aboutsummaryrefslogtreecommitdiff
path: root/WordPress/src/main/java
diff options
context:
space:
mode:
authorBeau Collins <beaucollins@gmail.com>2014-06-30 14:22:35 -0400
committerBeau Collins <beaucollins@gmail.com>2014-06-30 14:22:35 -0400
commitdd989429bd701a66bcba911de08f2e8d336798ef (patch)
tree7dc218e5b9ab283f566da6e5214611f7ed6fb30d /WordPress/src/main/java
parentcac104eee059124f27374e6085918f6254355b10 (diff)
downloadgradle-perf-android-medium-dd989429bd701a66bcba911de08f2e8d336798ef.tar.gz
Moved to gradle multiproject build structure
Diffstat (limited to 'WordPress/src/main/java')
-rw-r--r--WordPress/src/main/java/org/wordpress/android/Constants.java25
-rw-r--r--WordPress/src/main/java/org/wordpress/android/GCMIntentService.java277
-rw-r--r--WordPress/src/main/java/org/wordpress/android/GCMReceiver.java24
-rw-r--r--WordPress/src/main/java/org/wordpress/android/WordPress.java717
-rw-r--r--WordPress/src/main/java/org/wordpress/android/WordPressDB.java1739
-rw-r--r--WordPress/src/main/java/org/wordpress/android/WordPressStatsDB.java86
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/CommentTable.java345
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/ReaderBlogTable.java323
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/ReaderCommentTable.java188
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/ReaderDatabase.java220
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/ReaderLikeTable.java130
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/ReaderPostTable.java647
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/ReaderTagTable.java406
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/ReaderThumbnailTable.java59
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/ReaderUserTable.java188
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/SQLTable.java68
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/StatsBarChartDataTable.java88
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/StatsClickGroupsTable.java112
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/StatsClicksTable.java102
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/StatsGeoviewsTable.java102
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/StatsMostCommentedTable.java79
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/StatsReferrerGroupsTable.java113
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/StatsReferrersTable.java102
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/StatsSearchEngineTermsTable.java99
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/StatsTagsAndCategoriesTable.java77
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/StatsTopAuthorsTable.java104
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/StatsTopCommentersTable.java79
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/StatsTopPostsAndPagesTable.java105
-rw-r--r--WordPress/src/main/java/org/wordpress/android/datasets/StatsVideosTable.java104
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/Blog.java467
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/BlogIdentifier.java52
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/BlogPairId.java25
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/CategoryNode.java110
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/Comment.java237
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/CommentList.java89
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/CommentStatus.java56
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/FeatureSet.java38
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/MediaFile.java292
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/MediaGallery.java85
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/Note.java462
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/Post.java476
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/PostLocation.java93
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/PostStatus.java65
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/PostsListPost.java94
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/ReaderBlog.java144
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/ReaderBlogList.java70
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/ReaderComment.java122
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/ReaderCommentList.java125
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/ReaderPost.java649
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/ReaderPostList.java72
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/ReaderRecommendBlogList.java49
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/ReaderRecommendedBlog.java79
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/ReaderTag.java157
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/ReaderTagList.java52
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/ReaderTagType.java33
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/ReaderUrlList.java36
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/ReaderUser.java119
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/ReaderUserIdList.java14
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/ReaderUserList.java36
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/StatsBarChartData.java73
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/StatsClick.java74
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/StatsClickGroup.java120
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/StatsGeoview.java73
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/StatsMostCommented.java71
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/StatsReferrer.java73
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/StatsReferrerGroup.java120
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/StatsSearchEngineTerm.java62
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/StatsSummary.java212
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/StatsTagsandCategories.java77
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/StatsTopAuthor.java86
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/StatsTopCommenter.java72
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/StatsTopPostsAndPages.java84
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/StatsVideo.java84
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/StatsVideoSummary.java70
-rw-r--r--WordPress/src/main/java/org/wordpress/android/models/Theme.java221
-rw-r--r--WordPress/src/main/java/org/wordpress/android/networking/Authenticator.java13
-rw-r--r--WordPress/src/main/java/org/wordpress/android/networking/AuthenticatorRequest.java93
-rw-r--r--WordPress/src/main/java/org/wordpress/android/networking/OAuthAuthenticator.java115
-rw-r--r--WordPress/src/main/java/org/wordpress/android/networking/OAuthAuthenticatorFactory.java16
-rw-r--r--WordPress/src/main/java/org/wordpress/android/networking/OAuthAuthenticatorFactoryAbstract.java5
-rw-r--r--WordPress/src/main/java/org/wordpress/android/networking/OAuthAuthenticatorFactoryDefault.java7
-rw-r--r--WordPress/src/main/java/org/wordpress/android/networking/RestClientFactory.java19
-rw-r--r--WordPress/src/main/java/org/wordpress/android/networking/RestClientFactoryAbstract.java8
-rw-r--r--WordPress/src/main/java/org/wordpress/android/networking/RestClientFactoryDefault.java10
-rw-r--r--WordPress/src/main/java/org/wordpress/android/networking/RestClientUtils.java256
-rw-r--r--WordPress/src/main/java/org/wordpress/android/networking/SSLCertsViewActivity.java42
-rw-r--r--WordPress/src/main/java/org/wordpress/android/networking/SelfSignedSSLCertsManager.java268
-rw-r--r--WordPress/src/main/java/org/wordpress/android/networking/WPDelayedHurlStack.java260
-rw-r--r--WordPress/src/main/java/org/wordpress/android/networking/WPTrustManager.java119
-rw-r--r--WordPress/src/main/java/org/wordpress/android/providers/StatsContentProvider.java153
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/AddQuickPressShortcutActivity.java232
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/AppLogViewerActivity.java76
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/AuthenticatedWebViewActivity.java150
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/CheckableFrameLayout.java60
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/CustomSpinner.java43
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/DashboardActivity.java176
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/DeepLinkingIntentReceiverActivity.java82
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/ExpandableHeightGridView.java59
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/FadeInNetworkImageView.java43
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/HorizontalTabView.java241
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/MenuDrawerItem.java99
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/MultiSelectGridView.java177
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/OnRearrangeListener.java18
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/PullToRefreshHeaderTransformer.java98
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/PullToRefreshHelper.java142
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/ShareIntentReceiverActivity.java316
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/ViewSiteActivity.java71
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/WPActionBarActivity.java1150
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/WebViewActivity.java103
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/accounts/CreateUserAndBlog.java260
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/accounts/ManageBlogsActivity.java190
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/accounts/NUXDialogFragment.java125
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/accounts/NewAccountAbstractPageFragment.java308
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/accounts/NewAccountActivity.java17
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/accounts/NewBlogActivity.java47
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/accounts/NewBlogFragment.java297
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/accounts/NewUserPageFragment.java394
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/accounts/NuxHelpActivity.java53
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/accounts/SetupBlog.java429
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/accounts/SetupBlogTask.java56
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/accounts/WPComLoginActivity.java265
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/accounts/WelcomeActivity.java59
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/accounts/WelcomeFragmentSignIn.java629
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/comments/CommentActions.java505
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/comments/CommentAdapter.java347
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailFragment.java867
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDialogs.java48
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/comments/CommentUtils.java57
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/comments/CommentsActivity.java383
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/comments/CommentsListFragment.java660
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/comments/EditCommentActivity.java320
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/media/MediaAddFragment.java288
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/media/MediaBrowserActivity.java745
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/media/MediaEditFragment.java395
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/media/MediaGalleryActivity.java192
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/media/MediaGalleryAdapter.java141
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/media/MediaGalleryEditFragment.java193
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/media/MediaGalleryPickerActivity.java270
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/media/MediaGallerySettingsFragment.java380
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/media/MediaGridAdapter.java531
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/media/MediaGridFragment.java693
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/media/MediaImageLoader.java42
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/media/MediaItemFragment.java356
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/notifications/BigBadgeFragment.java122
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/notifications/DetailHeader.java81
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/notifications/FollowListener.java73
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/notifications/FollowRow.java237
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteCommentLikeFragment.java102
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteFollowAdapter.java142
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteMatcherFragment.java77
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteSingleLineListFragment.java72
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/notifications/NotesAdapter.java145
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationFragment.java23
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationUtils.java204
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsActivity.java397
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragment.java232
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsWebViewActivity.java61
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/posts/AddCategoryActivity.java118
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/posts/CategoryArrayAdapter.java41
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/posts/EditLinkActivity.java76
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.java404
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostContentFragment.java1606
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostPreviewFragment.java110
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostSettingsFragment.java880
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/posts/PagesActivity.java5
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/posts/ParentCategorySpinnerAdapter.java70
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/posts/PostsActivity.java630
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListFragment.java437
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/posts/PreviewPostActivity.java77
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/posts/SelectCategoriesActivity.java432
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/posts/ViewPostActivity.java26
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/posts/ViewPostFragment.java371
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/posts/adapters/PostsListAdapter.java260
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/prefs/AboutActivity.java72
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/prefs/BlogPreferencesActivity.java328
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/prefs/LicensesActivity.java18
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/prefs/PreferencesActivity.java742
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/prefs/UserPrefs.java142
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java178
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderAnim.java197
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderBlogFragment.java177
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderBlogInfoView.java262
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderConstants.java30
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPhotoViewerActivity.java113
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.java1490
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListActivity.java351
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.java1219
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostPagerActivity.java333
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderReblogActivity.java374
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderSubsActivity.java600
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagFragment.java186
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTypes.java26
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderUserListActivity.java138
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderUtils.java152
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderWebView.java264
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderActions.java98
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderAuthActions.java114
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderBlogActions.java384
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderCommentActions.java165
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderPostActions.java634
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderTagActions.java272
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderUserActions.java69
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderActionBarTagAdapter.java171
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderBlogAdapter.java343
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderCommentAdapter.java320
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderPostAdapter.java692
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderReblogAdapter.java192
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderTagAdapter.java237
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderUserAdapter.java182
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/models/ReaderBlogIdPostId.java23
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/reader/models/ReaderBlogIdPostIdList.java38
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAbsPagedViewFragment.java207
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAbsViewFragment.java67
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsActivity.java718
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsBarChartUnit.java24
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsBarGraph.java112
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsBarGraphFragment.java218
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsClicksFragment.java137
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCommentsFragment.java199
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCursorFragment.java239
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCursorInterface.java12
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCursorLoaderCallback.java16
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCursorTreeFragment.java442
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsGeoviewsFragment.java86
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsReferrersFragment.java136
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsSearchEngineTermsFragment.java80
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTagsAndCategoriesFragment.java104
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTimeframe.java34
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTopAuthorsFragment.java85
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTopPostsAndPagesFragment.java85
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTotalsFollowersAndSharesFragment.java151
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsVideoFragment.java215
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsViewHolder.java71
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsViewType.java19
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsVisitorsAndViewsFragment.java192
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWPLinkMovementMethod.java93
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWebViewActivity.java128
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/stats/service/StatsService.java764
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeBrowserActivity.java514
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeDetailsFragment.java362
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/themes/ThemePreviewFragment.java196
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeSearchFragment.java150
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeTabAdapter.java128
-rw-r--r--WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeTabFragment.java248
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/AlertUtil.java101
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/AniUtils.java66
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/AppLog.java234
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/AuthErrorDialogFragment.java85
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/BitmapLruCache.java31
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/CheckedLinearLayout.java47
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/CommentBadgeTextView.java37
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/CrashlyticsUtils.java48
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/DateTimeUtils.java148
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/DeviceUtils.java94
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/DisplayUtils.java87
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/DrawableManager.java80
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/EditTextUtils.java77
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/Emoticons.java106
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/FlowLayout.java135
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/FormatUtils.java35
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/GeocoderUtils.java116
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/GravatarUtils.java22
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/HtmlUtils.java134
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/ImageHelper.java502
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/ImageUtils.java72
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/IntHashMap.java339
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/JSONUtil.java236
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/LinePageIndicator.java432
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java36
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/LocationHelper.java132
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/MapUtils.java79
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/MediaDeleteService.java120
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/MediaGalleryImageSpan.java24
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/MediaUploadService.java192
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/MediaUtils.java499
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/MessageBarUtils.java81
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/NetworkUtils.java81
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/NotificationDismissBroadcastReceiver.java17
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/PageIndicator.java63
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/PhotonUtils.java96
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/PostUploadService.java870
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/ProfilingUtils.java77
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/RateLimitedTask.java37
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/ReaderVideoUtils.java172
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/SimperiumUtils.java109
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/SqlUtils.java121
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/StatUtils.java222
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/StringUtils.java278
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/SystemServiceFactory.java17
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java7
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java9
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/ThemeHelper.java66
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/ToastUtils.java119
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/UrlUtils.java165
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/UserEmail.java30
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/Utils.java78
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/Version.java47
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/VolleyUtils.java117
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/WPAlertDialogFragment.java139
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/WPEditText.java56
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/WPEditTextPreference.java28
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/WPHtml.java1242
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java59
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/WPImageGetter.java158
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/WPImageSpan.java93
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/WPLinkMovementMethod.java69
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/WPMeShortlinks.java133
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/WPRestClient.java441
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/WPUnderlineSpan.java48
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/WPViewPager.java49
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/WPWebChromeClient.java29
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/WPWebViewClient.java83
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/stats/AnalyticsTracker.java145
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/stats/AnalyticsTrackerMixpanel.java488
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/stats/AnalyticsTrackerMixpanelInstructionsForStat.java109
-rw-r--r--WordPress/src/main/java/org/wordpress/android/util/stats/AnalyticsTrackerWPCom.java71
-rw-r--r--WordPress/src/main/java/org/wordpress/android/widgets/OpenSansButton.java22
-rw-r--r--WordPress/src/main/java/org/wordpress/android/widgets/OpenSansEditText.java22
-rw-r--r--WordPress/src/main/java/org/wordpress/android/widgets/TypefaceCache.java103
-rw-r--r--WordPress/src/main/java/org/wordpress/android/widgets/WPLinearLayoutSizeBound.java45
-rw-r--r--WordPress/src/main/java/org/wordpress/android/widgets/WPListView.java106
-rw-r--r--WordPress/src/main/java/org/wordpress/android/widgets/WPNetworkImageView.java376
-rw-r--r--WordPress/src/main/java/org/wordpress/android/widgets/WPTextView.java26
-rw-r--r--WordPress/src/main/java/org/xmlrpc/android/ApiHelper.java1176
-rw-r--r--WordPress/src/main/java/org/xmlrpc/android/LoggedInputStream.java118
-rw-r--r--WordPress/src/main/java/org/xmlrpc/android/NullOutputStream.java11
-rw-r--r--WordPress/src/main/java/org/xmlrpc/android/TrustUserSSLCertsSocketFactory.java162
-rw-r--r--WordPress/src/main/java/org/xmlrpc/android/XMLRPCCallback.java26
-rw-r--r--WordPress/src/main/java/org/xmlrpc/android/XMLRPCClient.java603
-rw-r--r--WordPress/src/main/java/org/xmlrpc/android/XMLRPCClientInterface.java16
-rw-r--r--WordPress/src/main/java/org/xmlrpc/android/XMLRPCException.java16
-rw-r--r--WordPress/src/main/java/org/xmlrpc/android/XMLRPCFactory.java18
-rw-r--r--WordPress/src/main/java/org/xmlrpc/android/XMLRPCFactoryAbstract.java7
-rw-r--r--WordPress/src/main/java/org/xmlrpc/android/XMLRPCFactoryDefault.java9
-rw-r--r--WordPress/src/main/java/org/xmlrpc/android/XMLRPCFault.java28
-rw-r--r--WordPress/src/main/java/org/xmlrpc/android/XMLRPCSerializer.java298
336 files changed, 66862 insertions, 0 deletions
diff --git a/WordPress/src/main/java/org/wordpress/android/Constants.java b/WordPress/src/main/java/org/wordpress/android/Constants.java
new file mode 100644
index 000000000..e5cec8868
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/Constants.java
@@ -0,0 +1,25 @@
+
+package org.wordpress.android;
+
+public class Constants {
+ public static String readerURL = "https://en.wordpress.com/reader/mobile/v2";
+ public static String readerLoginURL = "https://wordpress.com/wp-login.php";
+
+ public static String readerURL_v3 = "https://en.wordpress.com/reader/mobile/v2/?chrome=no";
+ public static String authorizedHybridHost = "en.wordpress.com";
+ public static String readerTopicsURL = "http://en.wordpress.com/reader/mobile/v2/?template=topics";
+ public static String readerDetailURL = "https://en.wordpress.com/wp-admin/admin-ajax.php?action=wpcom_load_mobile&template=details&v=2";
+
+ public static String wpcomXMLRPCURL = "https://wordpress.com/xmlrpc.php";
+ public static String wpcomLoginURL = "https://wordpress.com/wp-login.php";
+
+ public static final String URL_TOS = "http://en.wordpress.com/tos";
+ public static String videoPressURL = "http://videopress.com";
+
+ public static int QUICK_POST_PHOTO_CAMERA = 0;
+ public static int QUICK_POST_PHOTO_LIBRARY = 1;
+ public static int QUICK_POST_VIDEO_CAMERA = 2;
+ public static int QUICK_POST_VIDEO_LIBRARY = 3;
+
+ public static final int INTENT_COMMENT_EDITOR = 1010;
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/GCMIntentService.java b/WordPress/src/main/java/org/wordpress/android/GCMIntentService.java
new file mode 100644
index 000000000..0dbc98641
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/GCMIntentService.java
@@ -0,0 +1,277 @@
+
+package org.wordpress.android;
+
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import android.os.Bundle;
+import android.preference.PreferenceManager;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.content.LocalBroadcastManager;
+
+import com.google.android.gcm.GCMBaseIntentService;
+
+import org.apache.commons.lang.StringEscapeUtils;
+import org.wordpress.android.ui.notifications.NotificationUtils;
+import org.wordpress.android.ui.notifications.NotificationsActivity;
+import org.wordpress.android.ui.posts.PostsActivity;
+import org.wordpress.android.ui.prefs.UserPrefs;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.ImageHelper;
+import org.wordpress.android.util.NotificationDismissBroadcastReceiver;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+public class GCMIntentService extends GCMBaseIntentService {
+ public static final int PUSH_NOTIFICATION_ID = 1337;
+
+ private static final Map<String, Bundle> mActiveNotificationsMap = new HashMap<String, Bundle>();
+ private static String mPreviousNoteId = null;
+ private static long mPreviousNoteTime = 0L;
+
+ @Override
+ protected String[] getSenderIds(Context context) {
+ String[] senderIds = new String[1];
+ senderIds[0] = BuildConfig.GCM_ID;
+ return senderIds;
+ }
+
+ @Override
+ protected void onError(Context context, String errorId) {
+ AppLog.e(T.NOTIFS, "GCM Error: " + errorId);
+ }
+
+ @Override
+ protected void onMessage(Context context, Intent intent) {
+ AppLog.v(T.NOTIFS, "Received Message");
+
+ if (!WordPress.hasValidWPComCredentials(context))
+ return;
+
+ Bundle extras = intent.getExtras();
+
+ if (extras == null) {
+ AppLog.v(T.NOTIFS, "No notification message content received. Aborting.");
+ return;
+ }
+
+ long wpcomUserID = UserPrefs.getCurrentUserId();
+ String userIDFromPN = extras.getString("user");
+ if (userIDFromPN != null) { //It is always populated server side, but better to double check it here.
+ if (wpcomUserID <= 0) {
+ //TODO: Do not abort the execution here, at least for this release, since there might be an issue for users that update the app.
+ //If they have never used the Reader, then they won't have a userId.
+ //Code for next release is below:
+ /* AppLog.e(T.NOTIFS, "No wpcom userId found in the app. Aborting.");
+ return;*/
+ } else {
+ if (!String.valueOf(wpcomUserID).equals(userIDFromPN)) {
+ AppLog.e(T.NOTIFS, "wpcom userId found in the app doesn't match with the ID in the PN. Aborting.");
+ return;
+ }
+ }
+ }
+
+ String title = StringEscapeUtils.unescapeHtml(extras.getString("title"));
+ if (title == null)
+ title = "WordPress";
+ String message = StringEscapeUtils.unescapeHtml(extras.getString("msg"));
+ String note_id = extras.getString("note_id");
+
+ /*
+ * if this has the same note_id as the previous notification, and the previous notification
+ * was received within the last second, then skip showing it - this handles duplicate
+ * notifications being shown due to the device being registered multiple times with different tokens.
+ * (still investigating how this could happen - 21-Oct-13)
+ *
+ * this also handles the (rare) case where the user receives rapid-fire sub-second like notifications
+ * due to sudden popularity (post gets added to FP and is liked by many people all at once, etc.),
+ * which we also want to avoid since it would drain the battery and annoy the user
+ *
+ * NOTE: different comments on the same post will have a different note_id, but different likes
+ * on the same post will have the same note_id, so don't assume that the note_id is unique
+ */
+ long thisTime = System.currentTimeMillis();
+ if (mPreviousNoteId != null && mPreviousNoteId.equals(note_id)) {
+ long seconds = TimeUnit.MILLISECONDS.toSeconds(thisTime - mPreviousNoteTime);
+ if (seconds <= 1) {
+ AppLog.w(T.NOTIFS, "skipped potential duplicate notification");
+ return;
+ }
+ }
+
+ mPreviousNoteId = note_id;
+ mPreviousNoteTime = thisTime;
+
+ if (note_id != null && !mActiveNotificationsMap.containsKey(note_id)) {
+ mActiveNotificationsMap.put(note_id, extras);
+ }
+
+ String iconURL = extras.getString("icon");
+ Bitmap largeIconBitmap = null;
+ if (iconURL != null) {
+ try {
+ iconURL = URLDecoder.decode(iconURL, "UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ AppLog.e(T.NOTIFS, e);
+ }
+ float screenDensity = getResources().getDisplayMetrics().densityDpi;
+ int size = Math.round(64 * (screenDensity / 160));
+ String resizedURL = iconURL.replaceAll("(?<=[?&;])s=[0-9]*", "s=" + size);
+ largeIconBitmap = ImageHelper.downloadBitmap(resizedURL);
+ }
+
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
+ boolean sound, vibrate, light;
+
+ sound = prefs.getBoolean("wp_pref_notification_sound", false);
+ vibrate = prefs.getBoolean("wp_pref_notification_vibrate", false);
+ light = prefs.getBoolean("wp_pref_notification_light", false);
+
+ NotificationCompat.Builder mBuilder;
+
+ Intent resultIntent = new Intent(this, PostsActivity.class);
+ resultIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK
+ | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ resultIntent.setAction("android.intent.action.MAIN");
+ resultIntent.addCategory("android.intent.category.LAUNCHER");
+ resultIntent.putExtra(NotificationsActivity.FROM_NOTIFICATION_EXTRA, true);
+
+ if (mActiveNotificationsMap.size() <= 1) {
+ mBuilder = new NotificationCompat.Builder(this).setSmallIcon(R.drawable.notification_icon).setContentTitle(title)
+ .setContentText(message).setTicker(message).setAutoCancel(true)
+ .setStyle(new NotificationCompat.BigTextStyle().bigText(message));
+
+ if (note_id != null) {
+ resultIntent.putExtra(NotificationsActivity.NOTE_ID_EXTRA, note_id);
+ }
+
+ // Add some actions if this is a comment notification
+ String noteType = extras.getString("type");
+ if (noteType != null && noteType.equals("c")) {
+ Intent commentReplyIntent = new Intent(this, PostsActivity.class);
+ commentReplyIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK
+ | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ commentReplyIntent.setAction("android.intent.action.MAIN");
+ commentReplyIntent.addCategory("android.intent.category.LAUNCHER");
+ commentReplyIntent.addCategory("comment-reply");
+ commentReplyIntent.putExtra(NotificationsActivity.FROM_NOTIFICATION_EXTRA, true);
+ commentReplyIntent.putExtra(NotificationsActivity.NOTE_INSTANT_REPLY_EXTRA, true);
+ if (note_id != null) {
+ commentReplyIntent.putExtra(NotificationsActivity.NOTE_ID_EXTRA, note_id);
+ }
+ PendingIntent commentReplyPendingIntent = PendingIntent.getActivity(context, 0,
+ commentReplyIntent,
+ PendingIntent.FLAG_CANCEL_CURRENT);
+ mBuilder.addAction(R.drawable.ab_icon_reply,
+ getResources().getText(R.string.reply), commentReplyPendingIntent);
+ }
+
+ if (largeIconBitmap != null) {
+ mBuilder.setLargeIcon(largeIconBitmap);
+ }
+ } else {
+ NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle();
+
+ int noteCtr = 1;
+ for (Bundle wpPN : mActiveNotificationsMap.values()) {
+ if (noteCtr > 5) // InboxStyle notification is limited to 5 lines
+ break;
+ if (wpPN.getString("msg") == null)
+ continue;
+ if (wpPN.getString("type") != null && wpPN.getString("type").equals("c")) {
+ String pnTitle = StringEscapeUtils.unescapeHtml((wpPN.getString("title")));
+ String pnMessage = StringEscapeUtils.unescapeHtml((wpPN.getString("msg")));
+ inboxStyle.addLine(pnTitle + ": " + pnMessage);
+ } else {
+ String pnMessage = StringEscapeUtils.unescapeHtml((wpPN.getString("msg")));
+ inboxStyle.addLine(pnMessage);
+ }
+
+ noteCtr++;
+ }
+
+ if (mActiveNotificationsMap.size() > 5) {
+ inboxStyle.setSummaryText(String.format(getString(R.string.more_notifications),
+ mActiveNotificationsMap.size() - 5));
+ }
+
+ String subject = String.format(getString(R.string.new_notifications), mActiveNotificationsMap.size());
+
+ mBuilder = new NotificationCompat.Builder(this)
+ .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.drawable.notification_multi))
+ .setSmallIcon(R.drawable.notification_icon)
+ .setContentTitle("WordPress")
+ .setContentText(subject)
+ .setTicker(message)
+ .setAutoCancel(true)
+ .setStyle(inboxStyle);
+ }
+
+ // Call broadcast receiver when notification is dismissed
+ Intent notificationDeletedIntent = new Intent(this, NotificationDismissBroadcastReceiver.class);
+ PendingIntent pendingDeleteIntent = PendingIntent.getBroadcast(context, 0, notificationDeletedIntent, 0);
+ mBuilder.setDeleteIntent(pendingDeleteIntent);
+
+ if (sound) {
+ mBuilder.setSound(Uri.parse("android.resource://" + getPackageName() + "/" + R.raw.notification));
+ }
+ if (vibrate) {
+ mBuilder.setVibrate(new long[]{500, 500, 500});
+ }
+ if (light) {
+ mBuilder.setLights(0xff0000ff, 1000, 5000);
+ }
+
+ PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, resultIntent,
+ PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_UPDATE_CURRENT);
+ mBuilder.setContentIntent(pendingIntent);
+ NotificationManager mNotificationManager =
+ (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ mNotificationManager.notify(PUSH_NOTIFICATION_ID, mBuilder.build());
+ broadcastNewNotification(context);
+ }
+
+ public void broadcastNewNotification(Context context) {
+ Intent msgIntent = new Intent();
+ msgIntent.setAction(NotificationsActivity.NOTIFICATION_ACTION);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(msgIntent);
+ }
+
+ @Override
+ protected void onRegistered(Context context, String regId) {
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context);
+ if (regId != null && regId.length() > 0) {
+ // Get or create UUID for WP.com notes api
+ String uuid = settings.getString(NotificationUtils.WPCOM_PUSH_DEVICE_UUID, null);
+ if (uuid == null) {
+ uuid = UUID.randomUUID().toString();
+ SharedPreferences.Editor editor = settings.edit();
+ editor.putString(NotificationUtils.WPCOM_PUSH_DEVICE_UUID, uuid);
+ editor.commit();
+ }
+
+ NotificationUtils.registerDeviceForPushNotifications(context, regId);
+ }
+ }
+
+ @Override
+ protected void onUnregistered(Context context, String regId) {
+ AppLog.v(T.NOTIFS, "GCM Unregistered ID: " + regId);
+ }
+
+ public static void clearNotificationsMap() {
+ mActiveNotificationsMap.clear();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/GCMReceiver.java b/WordPress/src/main/java/org/wordpress/android/GCMReceiver.java
new file mode 100644
index 000000000..f234da1a7
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/GCMReceiver.java
@@ -0,0 +1,24 @@
+package org.wordpress.android;
+
+/*
+ * This class is used in "zbetagroup" buildType only, and it's a workaround to fix problems on starting the GCM service.
+ *
+ * The default implementation of GCMBroadcastReceiver, available in the GCM API - com.google.android.gcm.GCMBroadcastReceiver,
+ * was trying to start the service with a wrong package name (org.wordpress.android.beta/.GCMIntentService).
+ * Details here: http://dexxtr.com/post/28188228252/rename-or-change-package-of-gcmintentservice-class
+ *
+ * Error msg we were seeing:
+ * W/ActivityManager(1866): Unable to start service Intent { act=com.google.android.c2dm.intent.REGISTRATION flg=0x10 pkg=org.wordpress.android.beta cmp=org.wordpress.android.beta/.GCMIntentService (has extras) } U=0: not found
+ *
+ */
+
+import android.content.Context;
+
+import com.google.android.gcm.GCMBroadcastReceiver;
+
+public class GCMReceiver extends GCMBroadcastReceiver {
+ @Override
+ protected String getGCMIntentServiceClassName(Context context) {
+ return "org.wordpress.android.GCMIntentService";
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/WordPress.java b/WordPress/src/main/java/org/wordpress/android/WordPress.java
new file mode 100644
index 000000000..b7b12fd28
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/WordPress.java
@@ -0,0 +1,717 @@
+package org.wordpress.android;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.app.Application;
+import android.content.ComponentCallbacks2;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.res.Configuration;
+import android.database.sqlite.SQLiteException;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.StrictMode;
+import android.preference.PreferenceManager;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.TextUtils;
+
+import com.android.volley.RequestQueue;
+import com.android.volley.VolleyLog;
+import com.android.volley.toolbox.ImageLoader;
+import com.android.volley.toolbox.Volley;
+import com.crashlytics.android.Crashlytics;
+import com.google.android.gcm.GCMRegistrar;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+
+import org.wordpress.android.datasets.ReaderDatabase;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.models.Post;
+import org.wordpress.android.networking.OAuthAuthenticator;
+import org.wordpress.android.networking.OAuthAuthenticatorFactory;
+import org.wordpress.android.networking.RestClientUtils;
+import org.wordpress.android.networking.SelfSignedSSLCertsManager;
+import org.wordpress.android.ui.accounts.SetupBlogTask.GenericSetupBlogTask;
+import org.wordpress.android.ui.notifications.NotificationUtils;
+import org.wordpress.android.ui.prefs.UserPrefs;
+import org.wordpress.android.ui.stats.service.StatsService;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.BitmapLruCache;
+import org.wordpress.android.util.DateTimeUtils;
+import org.wordpress.android.util.NetworkUtils;
+import org.wordpress.android.util.ProfilingUtils;
+import org.wordpress.android.util.RateLimitedTask;
+import org.wordpress.android.util.SimperiumUtils;
+import org.wordpress.android.util.Utils;
+import org.wordpress.android.util.VolleyUtils;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+import org.wordpress.android.util.stats.AnalyticsTrackerMixpanel;
+import org.wordpress.android.util.stats.AnalyticsTrackerWPCom;
+import org.wordpress.passcodelock.AppLockManager;
+
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.security.GeneralSecurityException;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+public class WordPress extends Application {
+ public static final String ACCESS_TOKEN_PREFERENCE="wp_pref_wpcom_access_token";
+ public static final String WPCOM_USERNAME_PREFERENCE="wp_pref_wpcom_username";
+ public static final String WPCOM_PASSWORD_PREFERENCE="wp_pref_wpcom_password";
+
+ public static String versionName;
+ public static Blog currentBlog;
+ public static Post currentPost;
+ public static WordPressDB wpDB;
+ public static WordPressStatsDB wpStatsDB;
+ public static OnPostUploadedListener onPostUploadedListener = null;
+ public static boolean postsShouldRefresh;
+ public static boolean shouldRestoreSelectedActivity;
+ public static RestClientUtils mRestClientUtils;
+ public static RequestQueue requestQueue;
+ public static ImageLoader imageLoader;
+
+ public static final String BROADCAST_ACTION_SIGNOUT = "wp-signout";
+ public static final String BROADCAST_ACTION_XMLRPC_INVALID_CREDENTIALS = "XMLRPC_INVALID_CREDENTIALS";
+ public static final String BROADCAST_ACTION_XMLRPC_INVALID_SSL_CERTIFICATE = "INVALID_SSL_CERTIFICATE";
+ public static final String BROADCAST_ACTION_XMLRPC_TWO_FA_AUTH = "TWO_FA_AUTH";
+ public static final String BROADCAST_ACTION_XMLRPC_LOGIN_LIMIT = "LOGIN_LIMIT";
+ public static final String BROADCAST_ACTION_REFRESH_MENU_PRESSED = "REFRESH_MENU_PRESSED";
+ public static final String BROADCAST_ACTION_BLOG_LIST_CHANGED = "BLOG_LIST_CHANGED";
+
+ private static final int SECONDS_BETWEEN_STATS_UPDATE = 30 * 60;
+ private static final int SECONDS_BETWEEN_BLOGLIST_UPDATE = 6 * 60 * 60;
+
+ private static Context mContext;
+ private static BitmapLruCache mBitmapCache;
+
+ /**
+ * Updates the stats of the current blog in background. There is a timeout of 30 minutes that limits
+ * too frequent refreshes.
+ * User is not notified in case of errors.
+ */
+ public static RateLimitedTask sUpdateCurrentBlogStats = new RateLimitedTask(SECONDS_BETWEEN_STATS_UPDATE) {
+ protected boolean run() {
+ Blog currentBlog = WordPress.getCurrentBlog();
+ if (currentBlog != null) {
+ String blogID = null;
+ if (currentBlog.isDotcomFlag()) {
+ blogID = String.valueOf(currentBlog.getRemoteBlogId());
+ } else if (currentBlog.isJetpackPowered() && currentBlog.hasValidJetpackCredentials()) {
+ blogID = currentBlog.getApi_blogid(); // Can return null
+ }
+ if (blogID != null) {
+ // start service to get stats
+ Intent intent = new Intent(mContext, StatsService.class);
+ intent.putExtra(StatsService.ARG_BLOG_ID, blogID);
+ mContext.startService(intent);
+ return true;
+ }
+ }
+ return false;
+ }
+ };
+
+ /**
+ * Update blog list in a background task. Broadcast WordPress.BROADCAST_ACTION_BLOG_LIST_CHANGED if the
+ * list changed.
+ */
+ public static RateLimitedTask sUpdateWordPressComBlogList = new RateLimitedTask(SECONDS_BETWEEN_BLOGLIST_UPDATE) {
+ protected boolean run() {
+ new GenericSetupBlogTask(getContext()).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ return true;
+ }
+ };
+
+ public static BitmapLruCache getBitmapCache() {
+ if (mBitmapCache == null) {
+ // The cache size will be measured in kilobytes rather than
+ // number of items. See http://developer.android.com/training/displaying-bitmaps/cache-bitmap.html
+ int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
+ int cacheSize = maxMemory / 16; //Use 1/16th of the available memory for this memory cache.
+ mBitmapCache = new BitmapLruCache(cacheSize);
+ }
+ return mBitmapCache;
+ }
+
+ @Override
+ public void onCreate() {
+ ProfilingUtils.start("WordPress.onCreate");
+ // Enable log recording
+ AppLog.enableRecording(true);
+ if (!Utils.isDebugBuild()) {
+ Crashlytics.start(this);
+ }
+ versionName = getVersionName(this);
+ initWpDb();
+ wpStatsDB = new WordPressStatsDB(this);
+ mContext = this;
+
+ configureSimperium();
+
+ // Volley networking setup
+ setupVolleyQueue();
+
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this);
+ if (settings.getInt("wp_pref_last_activity", -1) >= 0) {
+ shouldRestoreSelectedActivity = true;
+ }
+ registerForCloudMessaging(this);
+
+ // Uncomment this line if you want to test the app locking feature
+ AppLockManager.getInstance().enableDefaultAppLockIfAvailable(this);
+ if (AppLockManager.getInstance().isAppLockFeatureEnabled()) {
+ AppLockManager.getInstance().getCurrentAppLock().setDisabledActivities(
+ new String[]{"org.wordpress.android.ui.ShareIntentReceiverActivity"});
+ }
+
+ AnalyticsTracker.init();
+ AnalyticsTracker.registerTracker(new AnalyticsTrackerMixpanel());
+ AnalyticsTracker.registerTracker(new AnalyticsTrackerWPCom());
+ AnalyticsTracker.beginSession();
+ AnalyticsTracker.track(AnalyticsTracker.Stat.APPLICATION_OPENED);
+
+ super.onCreate();
+
+ ApplicationLifecycleMonitor pnBackendMonitor = new ApplicationLifecycleMonitor();
+ registerComponentCallbacks(pnBackendMonitor);
+ registerActivityLifecycleCallbacks(pnBackendMonitor);
+
+ sUpdateCurrentBlogStats.runIfNotLimited();
+ sUpdateWordPressComBlogList.runIfNotLimited();
+ }
+
+ // Configure Simperium and start buckets if we are signed in to WP.com
+ private void configureSimperium() {
+ if (!TextUtils.isEmpty(getWPComAuthToken(this))) {
+ SimperiumUtils.configureSimperium(this, getWPComAuthToken(this));
+ }
+ }
+
+ public static void setupVolleyQueue() {
+ requestQueue = Volley.newRequestQueue(mContext, VolleyUtils.getHTTPClientStack(mContext));
+ imageLoader = new ImageLoader(requestQueue, getBitmapCache());
+ VolleyLog.setTag(AppLog.TAG);
+ // http://stackoverflow.com/a/17035814
+ imageLoader.setBatchedResponseDelay(0);
+ }
+
+ private void initWpDb() {
+ if (!createAndVerifyWpDb()) {
+ AppLog.e(T.DB, "Invalid database, sign out user and delete database");
+ SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(this).edit();
+ currentBlog = null;
+ editor.remove(WordPress.WPCOM_USERNAME_PREFERENCE);
+ editor.remove(WordPress.WPCOM_PASSWORD_PREFERENCE);
+ editor.remove(WordPress.ACCESS_TOKEN_PREFERENCE);
+ editor.commit();
+ if (wpDB != null) {
+ wpDB.updateLastBlogId(-1);
+ wpDB.deleteDatabase(this);
+ }
+ wpDB = new WordPressDB(this);
+ }
+ }
+
+ private boolean createAndVerifyWpDb() {
+ try {
+ wpDB = new WordPressDB(this);
+ // verify account data
+ List<Map<String, Object>> accounts = wpDB.getAllAccounts();
+ for (Map<String, Object> account : accounts) {
+ if (account == null || account.get("blogName") == null || account.get("url") == null) {
+ return false;
+ }
+ }
+ return true;
+ } catch (SQLiteException sqle) {
+ AppLog.e(T.DB, sqle);
+ return false;
+ } catch (RuntimeException re) {
+ AppLog.e(T.DB, re);
+ return false;
+ }
+ }
+
+ public static Context getContext() {
+ return mContext;
+ }
+
+ public static RestClientUtils getRestClientUtils() {
+ if (mRestClientUtils == null) {
+ OAuthAuthenticator authenticator = OAuthAuthenticatorFactory.instantiate();
+ mRestClientUtils = new RestClientUtils(requestQueue, authenticator);
+ }
+ return mRestClientUtils;
+ }
+
+ /**
+ * enables "strict mode" for testing - should NEVER be used in release builds
+ */
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
+ private static void enableStrictMode() {
+ // return if the build is not a debug build
+ if (!BuildConfig.DEBUG) {
+ AppLog.e(T.UTILS, "You should not call enableStrictMode() on a non debug build");
+ return;
+ }
+
+ StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
+ .detectDiskReads()
+ .detectDiskWrites()
+ .detectNetwork()
+ .penaltyLog()
+ .penaltyFlashScreen()
+ .build());
+
+ StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
+ .detectActivityLeaks()
+ .detectLeakedSqlLiteObjects()
+ .detectLeakedClosableObjects()
+ .detectLeakedRegistrationObjects() // <-- requires Jelly Bean
+ .penaltyLog()
+ .build());
+
+ AppLog.w(T.UTILS, "Strict mode enabled");
+ }
+
+ public static void registerForCloudMessaging(Context ctx) {
+ if (WordPress.hasValidWPComCredentials(ctx)) {
+ String token = null;
+ try {
+ // Register for Google Cloud Messaging
+ GCMRegistrar.checkDevice(ctx);
+ GCMRegistrar.checkManifest(ctx);
+ token = GCMRegistrar.getRegistrationId(ctx);
+ String gcmId = BuildConfig.GCM_ID;
+ if (gcmId != null && token.equals("")) {
+ GCMRegistrar.register(ctx, gcmId);
+ } else {
+ // Send the token to WP.com in case it was invalidated
+ NotificationUtils.registerDeviceForPushNotifications(ctx, token);
+ AppLog.v(T.NOTIFS, "Already registered for GCM");
+ }
+ } catch (Exception e) {
+ AppLog.e(T.NOTIFS, "Could not register for GCM: " + e.getMessage());
+ }
+ }
+ }
+
+ public interface OnPostUploadedListener {
+ public abstract void OnPostUploaded(int localBlogId, String postId, boolean isPage);
+ }
+
+ public static String getVersionName(Context context) {
+ PackageManager pm = context.getPackageManager();
+ try {
+ PackageInfo pi = pm.getPackageInfo(context.getPackageName(), 0);
+ return pi.versionName == null ? "" : pi.versionName;
+ } catch (NameNotFoundException e) {
+ return "";
+ }
+ }
+
+ public static void setOnPostUploadedListener(OnPostUploadedListener listener) {
+ onPostUploadedListener = listener;
+ }
+
+ public static void postUploaded(int localBlogId, String postId, boolean isPage) {
+ if (onPostUploadedListener != null) {
+ try {
+ onPostUploadedListener.OnPostUploaded(localBlogId, postId, isPage);
+ } catch (Exception e) {
+ postsShouldRefresh = true;
+ }
+ } else {
+ postsShouldRefresh = true;
+ }
+
+ }
+
+ /**
+ * Get the currently active blog.
+ * <p/>
+ * If the current blog is not already set, try and determine the last active blog from the last
+ * time the application was used. If we're not able to determine the last active blog, just
+ * select the first one.
+ */
+ public static Blog getCurrentBlog() {
+ if (currentBlog == null || !wpDB.isDotComAccountVisible(currentBlog.getRemoteBlogId())) {
+ // attempt to restore the last active blog
+ setCurrentBlogToLastActive();
+
+ // fallback to just using the first blog
+ List<Map<String, Object>> accounts = WordPress.wpDB.getVisibleAccounts();
+ if (currentBlog == null && accounts.size() > 0) {
+ int id = Integer.valueOf(accounts.get(0).get("id").toString());
+ setCurrentBlog(id);
+ wpDB.updateLastBlogId(id);
+ }
+ }
+
+ return currentBlog;
+ }
+
+ /**
+ * Get the blog with the specified ID.
+ *
+ * @param id ID of the blog to retrieve.
+ * @return the blog with the specified ID, or null if blog could not be retrieved.
+ */
+ public static Blog getBlog(int id) {
+ try {
+ Blog blog = wpDB.instantiateBlogByLocalId(id);
+ return blog;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * Set the last active blog as the current blog.
+ *
+ * @return the current blog
+ */
+ public static Blog setCurrentBlogToLastActive() {
+ List<Map<String, Object>> accounts = WordPress.wpDB.getVisibleAccounts();
+
+ int lastBlogId = WordPress.wpDB.getLastBlogId();
+ if (lastBlogId != -1) {
+ for (Map<String, Object> account : accounts) {
+ int id = Integer.valueOf(account.get("id").toString());
+ if (id == lastBlogId) {
+ setCurrentBlog(id);
+ return currentBlog;
+ }
+ }
+ }
+ // Previous active blog is hidden or deleted
+ currentBlog = null;
+ return null;
+ }
+
+ /**
+ * Set the blog with the specified id as the current blog.
+ *
+ * @param id id of the blog to set as current
+ * @return the current blog
+ */
+ public static Blog setCurrentBlog(int id) {
+ currentBlog = wpDB.instantiateBlogByLocalId(id);
+ return currentBlog;
+ }
+
+ /**
+ * returns the blogID of the current blog or -1 if current blog is null
+ */
+ public static int getCurrentRemoteBlogId() {
+ return (getCurrentBlog() != null ? getCurrentBlog().getRemoteBlogId() : -1);
+ }
+
+ public static int getCurrentLocalTableBlogId() {
+ return (getCurrentBlog() != null ? getCurrentBlog().getLocalTableBlogId() : -1);
+ }
+
+ /**
+ * Checks for WordPress.com credentials
+ *
+ * @return true if we have credentials or false if not
+ */
+ public static boolean hasValidWPComCredentials(Context context) {
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context);
+ String username = settings.getString(WPCOM_USERNAME_PREFERENCE, null);
+ String password = settings.getString(WPCOM_PASSWORD_PREFERENCE, null);
+ return username != null && password != null;
+ }
+
+ public static boolean isSignedIn(Context context) {
+ if (WordPress.hasValidWPComCredentials(context)) {
+ return true;
+ }
+ return WordPress.wpDB.getNumVisibleAccounts() != 0;
+ }
+
+ /**
+ * Returns WordPress.com Auth Token
+ *
+ * @return String - The wpcom Auth token, or null if not authenticated.
+ */
+ public static String getWPComAuthToken(Context context) {
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context);
+ return settings.getString(WordPress.ACCESS_TOKEN_PREFERENCE, null);
+
+ }
+
+ /**
+ * Sign out from all accounts by clearing out the password, which will require user to sign in
+ * again
+ */
+ public static void signOut(Context context) {
+ removeWpComUserRelatedData(context);
+
+ try {
+ SelfSignedSSLCertsManager.getInstance(context).emptyLocalKeyStoreFile();
+ } catch (GeneralSecurityException e) {
+ AppLog.e(T.UTILS, "Error while cleaning the Local KeyStore File", e);
+ } catch (IOException e) {
+ AppLog.e(T.UTILS, "Error while cleaning the Local KeyStore File", e);
+ }
+
+ wpDB.deleteAllAccounts();
+ wpDB.updateLastBlogId(-1);
+ currentBlog = null;
+ AnalyticsTracker.endSession(false);
+ AnalyticsTracker.clearAllData();
+
+ // send broadcast that user is signing out - this is received by WPActionBarActivity
+ // descendants
+ sendLocalBroadcast(context, BROADCAST_ACTION_SIGNOUT);
+ }
+
+ public static void removeWpComUserRelatedData(Context context) {
+ // cancel all Volley requests - do this before unregistering push since that uses
+ // a Volley request
+ VolleyUtils.cancelAllRequests(requestQueue);
+
+ NotificationUtils.unregisterDevicePushNotifications(context);
+ try {
+ GCMRegistrar.checkDevice(context);
+ GCMRegistrar.unregister(context);
+ } catch (Exception e) {
+ AppLog.v(T.NOTIFS, "Could not unregister for GCM: " + e.getMessage());
+ }
+
+ SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(context).edit();
+ editor.remove(WordPress.WPCOM_USERNAME_PREFERENCE);
+ editor.remove(WordPress.WPCOM_PASSWORD_PREFERENCE);
+ editor.remove(WordPress.ACCESS_TOKEN_PREFERENCE);
+ editor.commit();
+
+ // reset all reader-related prefs & data
+ UserPrefs.reset();
+ ReaderDatabase.reset();
+
+ // Reset Simperium buckets (removes local data)
+ SimperiumUtils.resetBucketsAndDeauthorize();
+
+ // send broadcast that user is signing out - this is received by WPActionBarActivity
+ // descendants
+ Intent broadcastIntent = new Intent();
+ broadcastIntent.setAction(BROADCAST_ACTION_SIGNOUT);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(broadcastIntent);
+ }
+
+ public static boolean sendLocalBroadcast(Context context, String action) {
+ if (context == null || TextUtils.isEmpty(action)) {
+ return false;
+ }
+ LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context);
+ Intent intent = new Intent();
+ intent.setAction(action);
+ return lbm.sendBroadcast(intent);
+ }
+
+ public static String getLoginUrl(Blog blog) {
+ String loginURL = null;
+ Gson gson = new Gson();
+ Type type = new TypeToken<Map<?, ?>>() {
+ }.getType();
+ Map<?, ?> blogOptions = gson.fromJson(blog.getBlogOptions(), type);
+ if (blogOptions != null) {
+ Map<?, ?> homeURLMap = (Map<?, ?>) blogOptions.get("login_url");
+ if (homeURLMap != null)
+ loginURL = homeURLMap.get("value").toString();
+ }
+ // Try to guess the login URL if blogOptions is null (blog not added to the app), or WP version is < 3.6
+ if (loginURL == null) {
+ if (blog.getUrl().lastIndexOf("/") != -1) {
+ return blog.getUrl().substring(0, blog.getUrl().lastIndexOf("/"))
+ + "/wp-login.php";
+ } else {
+ return blog.getUrl().replace("xmlrpc.php", "wp-login.php");
+ }
+ }
+
+ return loginURL;
+ }
+
+ /**
+ * User-Agent string when making HTTP connections, for both API traffic and WebViews.
+ * Follows the format detailed at http://tools.ietf.org/html/rfc2616#section-14.43,
+ * ie: "AppName/AppVersion (OS Version; Locale; Device)"
+ * "wp-android/2.6.4 (Android 4.3; en_US; samsung GT-I9505/jfltezh)"
+ * "wp-android/2.6.3 (Android 4.4.2; en_US; LGE Nexus 5/hammerhead)"
+ * Note that app versions prior to 2.7 simply used "wp-android" as the user agent
+ **/
+ private static final String USER_AGENT_APPNAME = "wp-android";
+ private static String mUserAgent;
+ public static String getUserAgent() {
+ if (mUserAgent == null) {
+ PackageInfo pkgInfo;
+ try {
+ String pkgName = getContext().getApplicationInfo().packageName;
+ pkgInfo = getContext().getPackageManager().getPackageInfo(pkgName, 0);
+ } catch (PackageManager.NameNotFoundException e) {
+ return USER_AGENT_APPNAME;
+ }
+
+ mUserAgent = USER_AGENT_APPNAME + "/" + pkgInfo.versionName
+ + " (Android " + Build.VERSION.RELEASE + "; "
+ + Locale.getDefault().toString() + "; "
+ + Build.MANUFACTURER + " " + Build.MODEL + "/" + Build.PRODUCT + ")";
+ }
+ return mUserAgent;
+ }
+
+ /**
+ * Detect when the app goes to the background and come back to the foreground.
+ *
+ * Turns out that when your app has no more visible UI, a callback is triggered.
+ * The callback, implemented in this custom class, is called ComponentCallbacks2 (yes, with a two).
+ *
+ * This class also uses ActivityLifecycleCallbacks and a timer used as guard,
+ * to make sure to detect the send to background event and not other events.
+ *
+ */
+ private class ApplicationLifecycleMonitor implements Application.ActivityLifecycleCallbacks, ComponentCallbacks2 {
+ private final int DEFAULT_TIMEOUT = 2 * 60; // 2 minutes
+ private Date lastPingDate;
+
+ boolean isInBackground = false;
+
+ @Override
+ public void onConfigurationChanged(final Configuration newConfig) {
+ }
+
+ @Override
+ public void onLowMemory() {
+ }
+
+ @Override
+ public void onTrimMemory(final int level) {
+ if (level == ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) {
+ // We're in the Background
+ isInBackground = true;
+ AnalyticsTracker.track(AnalyticsTracker.Stat.APPLICATION_CLOSED);
+ AnalyticsTracker.endSession(false);
+ } else {
+ isInBackground = false;
+ }
+
+ // Levels that we need to consider are TRIM_MEMORY_RUNNING_CRITICAL = 15;
+ // - TRIM_MEMORY_RUNNING_LOW = 10; - TRIM_MEMORY_RUNNING_MODERATE = 5;
+ if (level < ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN && mBitmapCache != null) {
+ mBitmapCache.evictAll();
+ }
+ }
+
+ /**
+ * @return true if a network connection is available and the app come from background to foreground.
+ */
+ private boolean isNetworkAvailableAndComeFromBackground() {
+ // The app wasn't in background. No need to ping the backend again.
+ if (isInBackground == false) {
+ return false;
+ }
+
+ // The app moved from background -> foreground. Set this flag to false for security reason.
+ isInBackground = false;
+ if (!NetworkUtils.isNetworkAvailable(mContext)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private boolean isPushNotificationPingNeeded() {
+ if (lastPingDate == null) {
+ // first startup
+ return false;
+ }
+
+ Date now = new Date();
+ if (DateTimeUtils.secondsBetween(now, lastPingDate) >= DEFAULT_TIMEOUT) {
+ lastPingDate = now;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Check if user has valid credentials, and that at least 2 minutes are passed
+ * since the last ping, then try to update the PN token.
+ */
+ private void updatePushNotificationTokenIfNotLimited() {
+ // Synch Push Notifications settings
+ if (isPushNotificationPingNeeded() && WordPress.hasValidWPComCredentials(mContext)) {
+ String token = null;
+ try {
+ // Register for Google Cloud Messaging
+ GCMRegistrar.checkDevice(mContext);
+ GCMRegistrar.checkManifest(mContext);
+ token = GCMRegistrar.getRegistrationId(mContext);
+ String gcmId = BuildConfig.GCM_ID;
+ if (gcmId == null || token == null || token.equals("") ) {
+ AppLog.e(T.NOTIFS, "Could not ping the PNs backend, Token or gmcID not found");
+ } else {
+ // Send the token to WP.com
+ NotificationUtils.registerDeviceForPushNotifications(mContext, token);
+ }
+ } catch (Exception e) {
+ AppLog.e(T.NOTIFS, "Could not ping the PNs backend: " + e.getMessage());
+ }
+ }
+ }
+
+ @Override
+ public void onActivityResumed(Activity activity) {
+ // isNetworkAvailableAndComeFromBackground return false on Application start (doesn't come from background)
+ if (!isNetworkAvailableAndComeFromBackground()) {
+ return;
+ }
+
+ // Rate limited PN Token Update
+ updatePushNotificationTokenIfNotLimited();
+
+ // Rate limited Stats Update
+ sUpdateCurrentBlogStats.runIfNotLimited();
+
+ // Rate limited WPCom blog list Update
+ sUpdateWordPressComBlogList.runIfNotLimited();
+ }
+
+ @Override
+ public void onActivityCreated(Activity arg0, Bundle arg1) {
+ }
+
+ @Override
+ public void onActivityDestroyed(Activity arg0) {
+ }
+
+ @Override
+ public void onActivityPaused(Activity arg0) {
+ lastPingDate = new Date();
+ }
+
+ @Override
+ public void onActivitySaveInstanceState(Activity arg0, Bundle arg1) {
+ }
+
+ @Override
+ public void onActivityStarted(Activity arg0) {
+ }
+
+ @Override
+ public void onActivityStopped(Activity arg0) {
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/WordPressDB.java b/WordPress/src/main/java/org/wordpress/android/WordPressDB.java
new file mode 100644
index 000000000..f395da036
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/WordPressDB.java
@@ -0,0 +1,1739 @@
+package org.wordpress.android;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.database.sqlite.SQLiteDatabase;
+import android.preference.PreferenceManager;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.TextUtils;
+import android.util.Base64;
+
+import org.apache.commons.lang.ArrayUtils;
+import org.json.JSONArray;
+import org.wordpress.android.datasets.CommentTable;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.models.MediaFile;
+import org.wordpress.android.models.Post;
+import org.wordpress.android.models.PostLocation;
+import org.wordpress.android.models.PostsListPost;
+import org.wordpress.android.models.Theme;
+import org.wordpress.android.ui.posts.EditPostActivity;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.MapUtils;
+import org.wordpress.android.util.SqlUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.Utils;
+
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Vector;
+
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.DESKeySpec;
+
+public class WordPressDB {
+ private static final int DATABASE_VERSION = 27;
+
+ private static final String CREATE_TABLE_SETTINGS = "create table if not exists accounts (id integer primary key autoincrement, "
+ + "url text, blogName text, username text, password text, imagePlacement text, centerThumbnail boolean, fullSizeImage boolean, maxImageWidth text, maxImageWidthId integer);";
+ private static final String CREATE_TABLE_MEDIA = "create table if not exists media (id integer primary key autoincrement, "
+ + "postID integer not null, filePath text default '', fileName text default '', title text default '', description text default '', caption text default '', horizontalAlignment integer default 0, width integer default 0, height integer default 0, mimeType text default '', featured boolean default false, isVideo boolean default false);";
+ public static final String SETTINGS_TABLE = "accounts";
+ private static final String DATABASE_NAME = "wordpress";
+ private static final String MEDIA_TABLE = "media";
+
+ private static final String CREATE_TABLE_POSTS = "create table if not exists posts (id integer primary key autoincrement, blogID text, "
+ + "postid text, title text default '', dateCreated date, date_created_gmt date, categories text default '', custom_fields text default '', "
+ + "description text default '', link text default '', mt_allow_comments boolean, mt_allow_pings boolean, "
+ + "mt_excerpt text default '', mt_keywords text default '', mt_text_more text default '', permaLink text default '', post_status text default '', userid integer default 0, "
+ + "wp_author_display_name text default '', wp_author_id text default '', wp_password text default '', wp_post_format text default '', wp_slug text default '', mediaPaths text default '', "
+ + "latitude real, longitude real, localDraft boolean default 0, uploaded boolean default 0, isPage boolean default 0, wp_page_parent_id text, wp_page_parent_title text);";
+
+ private static final String POSTS_TABLE = "posts";
+
+ private static final String THEMES_TABLE = "themes";
+ private static final String CREATE_TABLE_THEMES = "create table if not exists themes (_id integer primary key autoincrement, "
+ + "themeId text, name text, description text, screenshotURL text, trendingRank integer default 0, popularityRank integer default 0, launchDate date, previewURL text, blogId text, isCurrent boolean default false, isPremium boolean default false, features text);";
+
+ // categories
+ private static final String CREATE_TABLE_CATEGORIES = "create table if not exists cats (id integer primary key autoincrement, "
+ + "blog_id text, wp_id integer, category_name text not null);";
+ private static final String CATEGORIES_TABLE = "cats";
+
+ // for capturing blogID
+ private static final String ADD_BLOGID = "alter table accounts add blogId integer;";
+ private static final String UPDATE_BLOGID = "update accounts set blogId = 1;";
+
+ // for capturing blogID, trac ticket #
+ private static final String ADD_LOCATION_FLAG = "alter table accounts add location boolean default false;";
+
+ // add wordpress.com stats login info
+ private static final String ADD_DOTCOM_USERNAME = "alter table accounts add dotcom_username text;";
+ private static final String ADD_DOTCOM_PASSWORD = "alter table accounts add dotcom_password text;";
+ private static final String ADD_API_KEY = "alter table accounts add api_key text;";
+ private static final String ADD_API_BLOGID = "alter table accounts add api_blogid text;";
+
+ // add wordpress.com flag and version column
+ private static final String ADD_DOTCOM_FLAG = "alter table accounts add dotcomFlag boolean default false;";
+ private static final String ADD_WP_VERSION = "alter table accounts add wpVersion text;";
+
+ // add httpuser and httppassword
+ private static final String ADD_HTTPUSER = "alter table accounts add httpuser text;";
+ private static final String ADD_HTTPPASSWORD = "alter table accounts add httppassword text;";
+
+ // add new table for QuickPress homescreen shortcuts
+ private static final String CREATE_TABLE_QUICKPRESS_SHORTCUTS = "create table if not exists quickpress_shortcuts (id integer primary key autoincrement, accountId text, name text);";
+ private static final String QUICKPRESS_SHORTCUTS_TABLE = "quickpress_shortcuts";
+
+ // add field to store last used blog
+ private static final String ADD_POST_FORMATS = "alter table accounts add postFormats text default '';";
+
+ //add scaled image settings
+ private static final String ADD_SCALED_IMAGE = "alter table accounts add isScaledImage boolean default false;";
+ private static final String ADD_SCALED_IMAGE_IMG_WIDTH = "alter table accounts add scaledImgWidth integer default 1024;";
+
+ //add boolean to posts to check uploaded posts that have local changes
+ private static final String ADD_LOCAL_POST_CHANGES = "alter table posts add isLocalChange boolean default 0";
+
+ //add boolean to track if featured image should be included in the post content
+ private static final String ADD_FEATURED_IN_POST = "alter table media add isFeaturedInPost boolean default false;";
+
+ // add home url to blog settings
+ private static final String ADD_HOME_URL = "alter table accounts add homeURL text default '';";
+
+ private static final String ADD_BLOG_OPTIONS = "alter table accounts add blog_options text default '';";
+
+ // add category parent id to keep track of category hierarchy
+ private static final String ADD_PARENTID_IN_CATEGORIES = "alter table cats add parent_id integer default 0;";
+
+ // add admin flag to blog settings
+ private static final String ADD_ACCOUNTS_ADMIN_FLAG = "alter table accounts add isAdmin boolean default false;";
+
+ // add thumbnailURL, thumbnailPath and fileURL to media
+ private static final String ADD_MEDIA_THUMBNAIL_URL = "alter table media add thumbnailURL text default '';";
+ private static final String ADD_MEDIA_FILE_URL = "alter table media add fileURL text default '';";
+ private static final String ADD_MEDIA_UNIQUE_ID = "alter table media add mediaId text default '';";
+ private static final String ADD_MEDIA_BLOG_ID = "alter table media add blogId text default '';";
+ private static final String ADD_MEDIA_DATE_GMT = "alter table media add date_created_gmt date;";
+ private static final String ADD_MEDIA_UPLOAD_STATE = "alter table media add uploadState default '';";
+ private static final String ADD_MEDIA_VIDEOPRESS_SHORTCODE = "alter table media add videoPressShortcode text default '';";
+
+ // add hidden flag to blog settings (accounts)
+ private static final String ADD_ACCOUNTS_HIDDEN_FLAG = "alter table accounts add isHidden boolean default 0;";
+
+ private SQLiteDatabase db;
+
+ protected static final String PASSWORD_SECRET = BuildConfig.DB_SECRET;
+
+ private Context context;
+
+ public WordPressDB(Context ctx) {
+ this.context = ctx;
+ db = ctx.openOrCreateDatabase(DATABASE_NAME, 0, null);
+
+ // Create tables if they don't exist
+ db.execSQL(CREATE_TABLE_SETTINGS);
+ db.execSQL(CREATE_TABLE_POSTS);
+ db.execSQL(CREATE_TABLE_CATEGORIES);
+ db.execSQL(CREATE_TABLE_QUICKPRESS_SHORTCUTS);
+ db.execSQL(CREATE_TABLE_MEDIA);
+ db.execSQL(CREATE_TABLE_THEMES);
+ CommentTable.createTables(db);
+
+ // Update tables for new installs and app updates
+ int currentVersion = db.getVersion();
+ switch (currentVersion) {
+ case 0:
+ // New install
+ currentVersion++;
+ case 1:
+ // Add columns that were added in very early releases, then move on to version 9
+ db.execSQL(ADD_BLOGID);
+ db.execSQL(UPDATE_BLOGID);
+ db.execSQL(ADD_LOCATION_FLAG);
+ db.execSQL(ADD_DOTCOM_USERNAME);
+ db.execSQL(ADD_DOTCOM_PASSWORD);
+ db.execSQL(ADD_API_KEY);
+ db.execSQL(ADD_API_BLOGID);
+ db.execSQL(ADD_DOTCOM_FLAG);
+ db.execSQL(ADD_WP_VERSION);
+ currentVersion = 9;
+ case 9:
+ db.execSQL(ADD_HTTPUSER);
+ db.execSQL(ADD_HTTPPASSWORD);
+ migratePasswords();
+ currentVersion++;
+ case 10:
+ db.delete(POSTS_TABLE, null, null);
+ db.execSQL(CREATE_TABLE_POSTS);
+ db.execSQL(ADD_POST_FORMATS);
+ currentVersion++;
+ case 11:
+ db.execSQL(ADD_SCALED_IMAGE);
+ db.execSQL(ADD_SCALED_IMAGE_IMG_WIDTH);
+ db.execSQL(ADD_LOCAL_POST_CHANGES);
+ currentVersion++;
+ case 12:
+ db.execSQL(ADD_FEATURED_IN_POST);
+ currentVersion++;
+ case 13:
+ db.execSQL(ADD_HOME_URL);
+ currentVersion++;
+ case 14:
+ db.execSQL(ADD_BLOG_OPTIONS);
+ currentVersion++;
+ case 15:
+ // No longer used (preferences migration)
+ currentVersion++;
+ case 16:
+ migrateWPComAccount();
+ currentVersion++;
+ case 17:
+ db.execSQL(ADD_PARENTID_IN_CATEGORIES);
+ currentVersion++;
+ case 18:
+ db.execSQL(ADD_ACCOUNTS_ADMIN_FLAG);
+ db.execSQL(ADD_MEDIA_FILE_URL);
+ db.execSQL(ADD_MEDIA_THUMBNAIL_URL);
+ db.execSQL(ADD_MEDIA_UNIQUE_ID);
+ db.execSQL(ADD_MEDIA_BLOG_ID);
+ db.execSQL(ADD_MEDIA_DATE_GMT);
+ db.execSQL(ADD_MEDIA_UPLOAD_STATE);
+ currentVersion++;
+ case 19:
+ // revision 20: create table "notes"
+ currentVersion++;
+ case 20:
+ db.execSQL(ADD_ACCOUNTS_HIDDEN_FLAG);
+ currentVersion++;
+ case 21:
+ db.execSQL(ADD_MEDIA_VIDEOPRESS_SHORTCODE);
+ currentVersion++;
+ // version 23 added CommentTable.java, version 24 changed the comment table schema
+ case 22:
+ currentVersion++;
+ case 23:
+ CommentTable.reset(db);
+ currentVersion++;
+ case 24:
+ currentVersion++;
+ case 25:
+ //ver 26 "virtually" remove columns 'lastCommentId' and 'runService' from the DB
+ //SQLite supports a limited subset of ALTER TABLE.
+ //The ALTER TABLE command in SQLite allows the user to rename a table or to add a new column to an existing table.
+ //It is not possible to rename a column, remove a column, or add or remove constraints from a table.
+ currentVersion++;
+ case 26:
+ // Drop the notes table, no longer needed with Simperium.
+ db.execSQL("DROP TABLE IF EXISTS notes;");
+ currentVersion++;
+ }
+ db.setVersion(DATABASE_VERSION);
+ }
+
+ public SQLiteDatabase getDatabase() {
+ return db;
+ }
+
+ public void deleteDatabase(Context ctx) {
+ ctx.deleteDatabase(DATABASE_NAME);
+ }
+
+ private void migrateWPComAccount() {
+ Cursor c = db.query(SETTINGS_TABLE, new String[] { "username", "password" }, "dotcomFlag=1", null, null,
+ null, null);
+
+ if (c.getCount() > 0) {
+ c.moveToFirst();
+ String username = c.getString(0);
+ String password = c.getString(1);
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this.context);
+ SharedPreferences.Editor editor = settings.edit();
+ editor.putString(WordPress.WPCOM_USERNAME_PREFERENCE, username);
+ editor.putString(WordPress.WPCOM_PASSWORD_PREFERENCE, password);
+ editor.commit();
+ }
+
+ c.close();
+ }
+
+ public boolean addBlog(Blog blog) {
+ ContentValues values = new ContentValues();
+ values.put("url", blog.getUrl());
+ values.put("homeURL", blog.getHomeURL());
+ values.put("blogName", blog.getBlogName());
+ values.put("username", blog.getUsername());
+ values.put("password", encryptPassword(blog.getPassword()));
+ values.put("httpuser", blog.getHttpuser());
+ values.put("httppassword", encryptPassword(blog.getHttppassword()));
+ values.put("imagePlacement", blog.getImagePlacement());
+ values.put("centerThumbnail", false);
+ values.put("fullSizeImage", false);
+ values.put("maxImageWidth", blog.getMaxImageWidth());
+ values.put("maxImageWidthId", blog.getMaxImageWidthId());
+ values.put("blogId", blog.getRemoteBlogId());
+ values.put("dotcomFlag", blog.isDotcomFlag());
+ if (blog.getWpVersion() != null) {
+ values.put("wpVersion", blog.getWpVersion());
+ } else {
+ values.putNull("wpVersion");
+ }
+ values.put("isAdmin", blog.isAdmin());
+ return db.insert(SETTINGS_TABLE, null, values) > -1;
+ }
+
+ public List<Integer> getAllAccountIDs() {
+ Cursor c = db.rawQuery("SELECT DISTINCT id FROM " + SETTINGS_TABLE, null);
+ try {
+ List<Integer> ids = new ArrayList<Integer>();
+ if (c.moveToFirst()) {
+ do {
+ ids.add(c.getInt(0));
+ } while (c.moveToNext());
+ }
+ return ids;
+ } finally {
+ SqlUtils.closeCursor(c);
+ }
+ }
+
+ public List<Map<String, Object>> getAccountsBy(String byString, String[] extraFields) {
+ if (db == null) {
+ return new Vector<Map<String, Object>>();
+ }
+ String[] baseFields = new String[]{"id", "blogName", "username", "blogId", "url",
+ "password"};
+ String[] allFields = baseFields;
+ if (extraFields != null) {
+ allFields = (String[]) ArrayUtils.addAll(baseFields, extraFields);
+ }
+ Cursor c = db.query(SETTINGS_TABLE, allFields, byString, null, null, null, null);
+ int numRows = c.getCount();
+ c.moveToFirst();
+ List<Map<String, Object>> accounts = new Vector<Map<String, Object>>();
+ for (int i = 0; i < numRows; i++) {
+ int id = c.getInt(0);
+ String blogName = c.getString(1);
+ String username = c.getString(2);
+ int blogId = c.getInt(3);
+ String url = c.getString(4);
+ String password = c.getString(5);
+ if (password != null && !password.equals("") && id > 0) {
+ Map<String, Object> thisHash = new HashMap<String, Object>();
+ thisHash.put("id", id);
+ thisHash.put("blogName", blogName);
+ thisHash.put("username", username);
+ thisHash.put("blogId", blogId);
+ thisHash.put("url", url);
+ if (extraFields != null) {
+ for (int j = 0; j < extraFields.length; ++j) {
+ thisHash.put(extraFields[j], c.getString(6 + j));
+ }
+ }
+ accounts.add(thisHash);
+ }
+ c.moveToNext();
+ }
+ c.close();
+ Collections.sort(accounts, Utils.BlogNameComparator);
+ return accounts;
+ }
+
+ public List<Map<String, Object>> getVisibleAccounts() {
+ return getAccountsBy("isHidden = 0", null);
+ }
+
+ public List<Map<String, Object>> getVisibleDotComAccounts() {
+ return getAccountsBy("isHidden = 0 AND dotcomFlag = 1", null);
+ }
+
+ public int getNumVisibleAccounts() {
+ return SqlUtils.intForQuery(db, "SELECT COUNT(*) FROM " + SETTINGS_TABLE + " WHERE isHidden = 0", null);
+ }
+
+ public int getNumDotComAccounts() {
+ return SqlUtils.intForQuery(db, "SELECT COUNT(*) FROM " + SETTINGS_TABLE + " WHERE dotcomFlag = 1", null);
+ }
+
+ public List<Map<String, Object>> getAllAccounts() {
+ return getAccountsBy(null, null);
+ }
+
+ public int setAllDotComAccountsVisibility(boolean visible) {
+ ContentValues values = new ContentValues();
+ values.put("isHidden", !visible);
+ return db.update(SETTINGS_TABLE, values, "dotcomFlag = 1", null);
+ }
+
+ public int setDotComAccountsVisibility(int id, boolean visible) {
+ ContentValues values = new ContentValues();
+ values.put("isHidden", !visible);
+ return db.update(SETTINGS_TABLE, values, "dotcomFlag=1 AND id=" + id, null);
+ }
+
+ public boolean isDotComAccountVisible(int blogId) {
+ String[] args = {Integer.toString(blogId)};
+ return SqlUtils.boolForQuery(db, "SELECT 1 FROM " + SETTINGS_TABLE +
+ " WHERE isHidden = 0 AND blogId=?", args);
+ }
+
+ public boolean isBlogInDatabase(int blogId, String xmlRpcUrl) {
+ Cursor c = db.query(SETTINGS_TABLE, new String[]{"id"}, "blogId=? AND url=?",
+ new String[]{Integer.toString(blogId), xmlRpcUrl}, null, null, null, null);
+ boolean result = c.getCount() > 0;
+ c.close();
+ return result;
+ }
+
+ public boolean isLocalBlogIdInDatabase(int localBlogId) {
+ String[] args = {Integer.toString(localBlogId)};
+ return SqlUtils.boolForQuery(db, "SELECT 1 FROM " + SETTINGS_TABLE + " WHERE id=?", args);
+ }
+
+ public boolean saveBlog(Blog blog) {
+ if (blog.getLocalTableBlogId() == -1) {
+ return addBlog(blog);
+ }
+
+ ContentValues values = new ContentValues();
+ values.put("url", blog.getUrl());
+ values.put("homeURL", blog.getHomeURL());
+ values.put("username", blog.getUsername());
+ values.put("password", encryptPassword(blog.getPassword()));
+ values.put("httpuser", blog.getHttpuser());
+ values.put("httppassword", encryptPassword(blog.getHttppassword()));
+ values.put("imagePlacement", blog.getImagePlacement());
+ values.put("centerThumbnail", blog.isFeaturedImageCapable());
+ values.put("fullSizeImage", blog.isFullSizeImage());
+ values.put("maxImageWidth", blog.getMaxImageWidth());
+ values.put("maxImageWidthId", blog.getMaxImageWidthId());
+ values.put("postFormats", blog.getPostFormats());
+ values.put("dotcom_username", blog.getDotcom_username());
+ values.put("dotcom_password", encryptPassword(blog.getDotcom_password()));
+ values.put("api_blogid", blog.getApi_blogid());
+ values.put("api_key", blog.getApi_key());
+ values.put("isScaledImage", blog.isScaledImage());
+ values.put("scaledImgWidth", blog.getScaledImageWidth());
+ values.put("blog_options", blog.getBlogOptions());
+ values.put("isHidden", blog.isHidden());
+ values.put("blogName", blog.getBlogName());
+ values.put("isAdmin", blog.isAdmin());
+ if (blog.getWpVersion() != null) {
+ values.put("wpVersion", blog.getWpVersion());
+ } else {
+ values.putNull("wpVersion");
+ }
+ boolean returnValue = db.update(SETTINGS_TABLE, values, "id=" + blog.getLocalTableBlogId(),
+ null) > 0;
+ if (blog.isDotcomFlag()) {
+ returnValue = updateWPComCredentials(blog.getUsername(), blog.getPassword());
+ }
+
+ return (returnValue);
+ }
+
+ public boolean updateWPComCredentials(String username, String password) {
+ // update the login for wordpress.com blogs
+ ContentValues userPass = new ContentValues();
+ userPass.put("username", username);
+ userPass.put("password", encryptPassword(password));
+ return db.update(SETTINGS_TABLE, userPass, "username=\""
+ + username + "\" AND dotcomFlag=1", null) > 0;
+ }
+
+ public boolean deleteAccount(Context ctx, int id) {
+ // TODO: should this also delete posts and other related info?
+ int rowsAffected = db.delete(SETTINGS_TABLE, "id=?", new String[]{Integer.toString(id)});
+ deleteQuickPressShortcutsForAccount(ctx, id);
+ return (rowsAffected > 0);
+ }
+
+ public void deleteAllAccounts() {
+ List<Integer> ids = getAllAccountIDs();
+ if (ids.size() == 0)
+ return;
+
+ db.beginTransaction();
+ try {
+ for (int id: ids) {
+ deleteAccount(context, id);
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ /**
+ * Instantiate a new Blog object from it's local id
+ *
+ * @param localId local blog id
+ * @return a new Blog instance or null if the localId was not found
+ */
+ public Blog instantiateBlogByLocalId(int localId) {
+ String[] fields =
+ new String[]{"url", "blogName", "username", "password", "httpuser", "httppassword", "imagePlacement",
+ "centerThumbnail", "fullSizeImage", "maxImageWidth", "maxImageWidthId",
+ "blogId", "dotcomFlag", "dotcom_username", "dotcom_password", "api_key",
+ "api_blogid", "wpVersion", "postFormats", "isScaledImage",
+ "scaledImgWidth", "homeURL", "blog_options", "isAdmin", "isHidden"};
+ Cursor c = db.query(SETTINGS_TABLE, fields, "id=?", new String[]{Integer.toString(localId)}, null, null, null);
+
+ Blog blog = null;
+ if (c.moveToFirst()) {
+ if (c.getString(0) != null) {
+ blog = new Blog();
+ blog.setLocalTableBlogId(localId);
+ blog.setUrl(c.getString(c.getColumnIndex("url"))); // 0
+
+ blog.setBlogName(c.getString(c.getColumnIndex("blogName"))); // 1
+ blog.setUsername(c.getString(c.getColumnIndex("username"))); // 2
+ blog.setPassword(decryptPassword(c.getString(c.getColumnIndex("password")))); // 3
+ if (c.getString(c.getColumnIndex("httpuser")) == null) {
+ blog.setHttpuser("");
+ } else {
+ blog.setHttpuser(c.getString(c.getColumnIndex("httpuser")));
+ }
+ if (c.getString(c.getColumnIndex("httppassword")) == null) {
+ blog.setHttppassword("");
+ } else {
+ blog.setHttppassword(decryptPassword(c.getString(c.getColumnIndex("httppassword"))));
+ }
+ blog.setImagePlacement(c.getString(c.getColumnIndex("imagePlacement")));
+ blog.setFeaturedImageCapable(c.getInt(c.getColumnIndex("centerThumbnail")) > 0);
+ blog.setFullSizeImage(c.getInt(c.getColumnIndex("fullSizeImage")) > 0);
+ blog.setMaxImageWidth(c.getString(c.getColumnIndex("maxImageWidth")));
+ blog.setMaxImageWidthId(c.getInt(c.getColumnIndex("maxImageWidthId")));
+ blog.setRemoteBlogId(c.getInt(c.getColumnIndex("blogId")));
+ blog.setDotcomFlag(c.getInt(c.getColumnIndex("dotcomFlag")) > 0);
+ if (c.getString(c.getColumnIndex("dotcom_username")) != null) {
+ blog.setDotcom_username(c.getString(c.getColumnIndex("dotcom_username")));
+ }
+ if (c.getString(c.getColumnIndex("dotcom_password")) != null) {
+ blog.setDotcom_password(decryptPassword(c.getString(c.getColumnIndex("dotcom_password"))));
+ }
+ if (c.getString(c.getColumnIndex("api_key")) != null) {
+ blog.setApi_key(c.getString(c.getColumnIndex("api_key")));
+ }
+ if (c.getString(c.getColumnIndex("api_blogid")) != null) {
+ blog.setApi_blogid(c.getString(c.getColumnIndex("api_blogid")));
+ }
+ if (c.getString(c.getColumnIndex("wpVersion")) != null) {
+ blog.setWpVersion(c.getString(c.getColumnIndex("wpVersion")));
+ }
+ blog.setPostFormats(c.getString(c.getColumnIndex("postFormats")));
+ blog.setScaledImage(c.getInt(c.getColumnIndex("isScaledImage")) > 0);
+ blog.setScaledImageWidth(c.getInt(c.getColumnIndex("scaledImgWidth")));
+ blog.setHomeURL(c.getString(c.getColumnIndex("homeURL")));
+ if (c.getString(c.getColumnIndex("blog_options")) == null) {
+ blog.setBlogOptions("{}");
+ } else {
+ blog.setBlogOptions(c.getString(c.getColumnIndex("blog_options")));
+ }
+ blog.setAdmin(c.getInt(c.getColumnIndex("isAdmin")) > 0);
+ blog.setHidden(c.getInt(c.getColumnIndex("isHidden")) > 0);
+ }
+ }
+ c.close();
+ return blog;
+ }
+
+ /*
+ * returns true if the passed blog is wp.com or jetpack-enabled (ie: returns false for
+ * self-hosted blogs that don't use jetpack)
+ */
+ public boolean isRemoteBlogIdDotComOrJetpack(int remoteBlogId) {
+ int localId = getLocalTableBlogIdForRemoteBlogId(remoteBlogId);
+ Blog blog = instantiateBlogByLocalId(localId);
+ return blog != null && (blog.isDotcomFlag() || blog.isJetpackPowered());
+ }
+
+ public Blog getBlogForDotComBlogId(String dotComBlogId) {
+ Cursor c = db.query(SETTINGS_TABLE, new String[]{"id"}, "api_blogid=? OR (blogId=? AND dotcomFlag=1)",
+ new String[]{dotComBlogId, dotComBlogId}, null, null, null);
+ Blog blog = null;
+ if (c.moveToFirst()) {
+ blog = instantiateBlogByLocalId(c.getInt(0));
+ }
+ c.close();
+ return blog;
+ }
+
+ public List<String> loadStatsLogin(int id) {
+ Cursor c = db.query(SETTINGS_TABLE, new String[] { "dotcom_username",
+ "dotcom_password" }, "id=" + id, null, null, null, null);
+
+ c.moveToFirst();
+
+ List<String> returnVector = new Vector<String>();
+ if (c.getString(0) != null) {
+ returnVector.add(c.getString(0));
+ returnVector.add(decryptPassword(c.getString(1)));
+ } else {
+ returnVector = null;
+ }
+ c.close();
+
+ return returnVector;
+ }
+
+ /*
+ * Jetpack blogs have the "wpcom" blog_id stored in options->api_blogid. This is because self-hosted blogs have both
+ * a blogID (local to their network), and a unique blogID on wpcom.
+ */
+ private int getLocalTableBlogIdForJetpackRemoteID(int remoteBlogId, String xmlRpcUrl) {
+ if (TextUtils.isEmpty(xmlRpcUrl)) {
+ String sql = "SELECT id FROM " + SETTINGS_TABLE + " WHERE dotcomFlag=0 AND api_blogid=?";
+ String[] args = {Integer.toString(remoteBlogId)};
+ return SqlUtils.intForQuery(db, sql, args);
+ } else {
+ String sql = "SELECT id FROM " + SETTINGS_TABLE + " WHERE dotcomFlag=0 AND api_blogid=? AND url=?";
+ String[] args = {Integer.toString(remoteBlogId), xmlRpcUrl};
+ return SqlUtils.intForQuery(db, sql, args);
+ }
+ }
+
+ public int getLocalTableBlogIdForRemoteBlogId(int remoteBlogId) {
+ int localBlogID = SqlUtils.intForQuery(db, "SELECT id FROM accounts WHERE blogId=?",
+ new String[]{Integer.toString(remoteBlogId)});
+ if (localBlogID == 0) {
+ localBlogID = this.getLocalTableBlogIdForJetpackRemoteID(remoteBlogId, null);
+ }
+ return localBlogID;
+ }
+
+ public int getLocalTableBlogIdForRemoteBlogIdAndXmlRpcUrl(int remoteBlogId, String xmlRpcUrl) {
+ int localBlogID = SqlUtils.intForQuery(db, "SELECT id FROM accounts WHERE blogId=? AND url=?",
+ new String[]{Integer.toString(remoteBlogId), xmlRpcUrl});
+ if (localBlogID==0) {
+ localBlogID = this.getLocalTableBlogIdForJetpackRemoteID(remoteBlogId, xmlRpcUrl);
+ }
+ return localBlogID;
+ }
+
+ public int getRemoteBlogIdForLocalTableBlogId(int localBlogId) {
+ int remoteBlogID = SqlUtils.intForQuery(db, "SELECT blogId FROM accounts WHERE id=?", new String[]{Integer.toString(localBlogId)});
+ if (remoteBlogID<=1) { //Make sure we're not returning a wrong ID for jetpack blog.
+ List<Map<String,Object>> allAccounts = this.getAccountsBy("dotcomFlag=0", new String[]{"api_blogid"});
+ for (Map<String, Object> currentAccount : allAccounts) {
+ if (MapUtils.getMapInt(currentAccount, "id")==localBlogId) {
+ remoteBlogID = MapUtils.getMapInt(currentAccount, "api_blogid");
+ break;
+ }
+ }
+ }
+ return remoteBlogID;
+ }
+
+ /**
+ * Set the ID of the most recently active blog. This value will persist between application
+ * launches.
+ *
+ * @param id ID of the most recently active blog.
+ */
+ public void updateLastBlogId(int id) {
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
+ SharedPreferences.Editor editor = preferences.edit();
+ editor.putInt("last_blog_id", id);
+ editor.commit();
+ }
+
+ /**
+ * Delete the ID for the most recently active blog.
+ */
+ public void deleteLastBlogId() {
+ updateLastBlogId(-1);
+ // Clear the last selected activity
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context);
+ SharedPreferences.Editor editor = settings.edit();
+ editor.putInt("wp_pref_last_activity", -1);
+ editor.commit();
+ }
+
+ /**
+ * Get the ID of the most recently active blog. -1 is returned if there is no recently active
+ * blog.
+ */
+ public int getLastBlogId() {
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
+ return preferences.getInt("last_blog_id", -1);
+ }
+
+ public List<Map<String, Object>> loadDrafts(int blogID,
+ boolean loadPages) {
+ List<Map<String, Object>> returnVector = new Vector<Map<String, Object>>();
+ Cursor c;
+ if (loadPages)
+ c = db.query(POSTS_TABLE, new String[] { "id", "title",
+ "post_status", "uploaded", "date_created_gmt",
+ "post_status" }, "blogID=" + blogID
+ + " AND localDraft=1 AND uploaded=0 AND isPage=1", null,
+ null, null, null);
+ else
+ c = db.query(POSTS_TABLE, new String[] { "id", "title",
+ "post_status", "uploaded", "date_created_gmt",
+ "post_status" }, "blogID=" + blogID
+ + " AND localDraft=1 AND uploaded=0 AND isPage=0", null,
+ null, null, null);
+
+ int numRows = c.getCount();
+ c.moveToFirst();
+
+ for (int i = 0; i < numRows; ++i) {
+ if (c.getString(0) != null) {
+ Map<String, Object> returnHash = new HashMap<String, Object>();
+ returnHash.put("id", c.getString(0));
+ returnHash.put("title", c.getString(1));
+ returnHash.put("status", c.getString(2));
+ returnHash.put("uploaded", c.getInt(3));
+ returnHash.put("date_created_gmt", c.getLong(4));
+ returnHash.put("post_status", c.getString(5));
+ returnVector.add(i, returnHash);
+ }
+ c.moveToNext();
+ }
+ c.close();
+
+ if (numRows == 0) {
+ returnVector = null;
+ }
+
+ return returnVector;
+ }
+
+ public boolean deletePost(Post post) {
+ int result = db.delete(POSTS_TABLE,
+ "blogID=? AND id=?",
+ new String[]{String.valueOf(post.getLocalTableBlogId()), String.valueOf(post.getLocalTablePostId())});
+
+ return (result == 1);
+ }
+
+ public Object[] arrayListToArray(Object array) {
+ if (array instanceof ArrayList) {
+ return ((ArrayList) array).toArray();
+ }
+ return (Object[]) array;
+ }
+
+ /**
+ * Saves a list of posts to the db
+ * @param postsList: list of post objects
+ * @param localBlogId: the posts table blog id
+ * @param isPage: boolean to save as pages
+ */
+ public void savePosts(List<?> postsList, int localBlogId, boolean isPage, boolean shouldOverwrite) {
+ if (postsList != null && postsList.size() != 0) {
+ db.beginTransaction();
+ try {
+ for (Object post : postsList) {
+ ContentValues values = new ContentValues();
+
+ // Sanity checks
+ if (!(post instanceof Map)) {
+ continue;
+ }
+ Map<?, ?> postMap = (Map<?, ?>) post;
+ String postID = MapUtils.getMapStr(postMap, (isPage) ? "page_id" : "postid");
+ if (TextUtils.isEmpty(postID)) {
+ // If we don't have a post or page ID, move on
+ continue;
+ }
+
+ values.put("blogID", localBlogId);
+ values.put("postid", postID);
+ values.put("title", MapUtils.getMapStr(postMap, "title"));
+ Date dateCreated = MapUtils.getMapDate(postMap, "dateCreated");
+ if (dateCreated != null) {
+ values.put("dateCreated", dateCreated.getTime());
+ } else {
+ Date now = new Date();
+ values.put("dateCreated", now.getTime());
+ }
+
+ Date dateCreatedGmt = MapUtils.getMapDate(postMap, "date_created_gmt");
+ if (dateCreatedGmt != null) {
+ values.put("date_created_gmt", dateCreatedGmt.getTime());
+ } else {
+ dateCreatedGmt = new Date((Long) values.get("dateCreated"));
+ values.put("date_created_gmt", dateCreatedGmt.getTime() + (dateCreatedGmt.getTimezoneOffset() * 60000));
+ }
+
+ values.put("description", MapUtils.getMapStr(postMap, "description"));
+ values.put("link", MapUtils.getMapStr(postMap, "link"));
+ values.put("permaLink", MapUtils.getMapStr(postMap, "permaLink"));
+
+ Object[] postCategories = (Object[]) postMap.get("categories");
+ JSONArray jsonCategoriesArray = new JSONArray();
+ if (postCategories != null) {
+ for (Object postCategory : postCategories) {
+ jsonCategoriesArray.put(postCategory.toString());
+ }
+ }
+ values.put("categories", jsonCategoriesArray.toString());
+
+ Object[] custom_fields = (Object[]) postMap.get("custom_fields");
+ JSONArray jsonCustomFieldsArray = new JSONArray();
+ if (custom_fields != null) {
+ for (Object custom_field : custom_fields) {
+ jsonCustomFieldsArray.put(custom_field.toString());
+ // Update geo_long and geo_lat from custom fields
+ if (!(custom_field instanceof Map))
+ continue;
+ Map<?, ?> customField = (Map<?, ?>) custom_field;
+ if (customField.get("key") != null && customField.get("value") != null) {
+ if (customField.get("key").equals("geo_longitude"))
+ values.put("longitude", customField.get("value").toString());
+ if (customField.get("key").equals("geo_latitude"))
+ values.put("latitude", customField.get("value").toString());
+ }
+ }
+ }
+ values.put("custom_fields", jsonCustomFieldsArray.toString());
+
+ values.put("mt_excerpt", MapUtils.getMapStr(postMap, (isPage) ? "excerpt" : "mt_excerpt"));
+ values.put("mt_text_more", MapUtils.getMapStr(postMap, (isPage) ? "text_more" : "mt_text_more"));
+ values.put("mt_allow_comments", MapUtils.getMapInt(postMap, "mt_allow_comments", 0));
+ values.put("mt_allow_pings", MapUtils.getMapInt(postMap, "mt_allow_pings", 0));
+ values.put("wp_slug", MapUtils.getMapStr(postMap, "wp_slug"));
+ values.put("wp_password", MapUtils.getMapStr(postMap, "wp_password"));
+ values.put("wp_author_id", MapUtils.getMapStr(postMap, "wp_author_id"));
+ values.put("wp_author_display_name", MapUtils.getMapStr(postMap, "wp_author_display_name"));
+ values.put("post_status", MapUtils.getMapStr(postMap, (isPage) ? "page_status" : "post_status"));
+ values.put("userid", MapUtils.getMapStr(postMap, "userid"));
+
+ if (isPage) {
+ values.put("isPage", true);
+ values.put("wp_page_parent_id", MapUtils.getMapStr(postMap, "wp_page_parent_id"));
+ values.put("wp_page_parent_title", MapUtils.getMapStr(postMap, "wp_page_parent_title"));
+ } else {
+ values.put("mt_keywords", MapUtils.getMapStr(postMap, "mt_keywords"));
+ values.put("wp_post_format", MapUtils.getMapStr(postMap, "wp_post_format"));
+ }
+
+ String whereClause = "blogID=? AND postID=? AND isPage=?";
+ if (!shouldOverwrite) {
+ whereClause += " AND NOT isLocalChange=1";
+ }
+
+ int result = db.update(POSTS_TABLE, values, whereClause,
+ new String[]{String.valueOf(localBlogId), postID, String.valueOf(SqlUtils.boolToSql(isPage))});
+ if (result == 0)
+ db.insert(POSTS_TABLE, null, values);
+ }
+
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
+ }
+
+ public List<PostsListPost> getPostsListPosts(int blogId, boolean loadPages) {
+ List<PostsListPost> posts = new ArrayList<PostsListPost>();
+ Cursor c;
+ c = db.query(POSTS_TABLE,
+ new String[] { "id", "blogID", "title",
+ "date_created_gmt", "post_status", "localDraft", "isLocalChange" },
+ "blogID=? AND isPage=? AND NOT (localDraft=1 AND uploaded=1)",
+ new String[] {String.valueOf(blogId), (loadPages) ? "1" : "0"}, null, null, "localDraft DESC, date_created_gmt DESC");
+ int numRows = c.getCount();
+ c.moveToFirst();
+
+ for (int i = 0; i < numRows; ++i) {
+ String postTitle = StringUtils.unescapeHTML(c.getString(c.getColumnIndex("title")));
+
+ // Create the PostsListPost and add it to the Array
+ PostsListPost post = new PostsListPost(
+ c.getInt(c.getColumnIndex("id")),
+ c.getInt(c.getColumnIndex("blogID")),
+ postTitle,
+ c.getLong(c.getColumnIndex("date_created_gmt")),
+ c.getString(c.getColumnIndex("post_status")),
+ SqlUtils.sqlToBool(c.getInt(c.getColumnIndex("localDraft"))),
+ SqlUtils.sqlToBool(c.getInt(c.getColumnIndex("isLocalChange")))
+ );
+ posts.add(i, post);
+ c.moveToNext();
+ }
+ c.close();
+
+ return posts;
+ }
+
+ public long savePost(Post post) {
+ long result = -1;
+ if (post != null) {
+ ContentValues values = new ContentValues();
+ values.put("blogID", post.getLocalTableBlogId());
+ values.put("title", post.getTitle());
+ values.put("date_created_gmt", post.getDate_created_gmt());
+ values.put("description", post.getDescription());
+ values.put("mt_text_more", post.getMoreText());
+
+ JSONArray categoriesJsonArray = post.getJSONCategories();
+ if (categoriesJsonArray != null) {
+ values.put("categories", categoriesJsonArray.toString());
+ }
+
+ values.put("localDraft", post.isLocalDraft());
+ values.put("mt_keywords", post.getKeywords());
+ values.put("wp_password", post.getPassword());
+ values.put("post_status", post.getPostStatus());
+ values.put("uploaded", post.isUploaded());
+ values.put("isPage", post.isPage());
+ values.put("wp_post_format", post.getPostFormat());
+ putPostLocation(post, values);
+ values.put("isLocalChange", post.isLocalChange());
+ values.put("mt_excerpt", post.getPostExcerpt());
+
+ result = db.insert(POSTS_TABLE, null, values);
+
+ if (result >= 0 && post.isLocalDraft() && !post.isUploaded()) {
+ post.setLocalTablePostId(result);
+ }
+ }
+
+ return (result);
+ }
+
+ public int updatePost(Post post) {
+ int result = 0;
+ if (post != null) {
+ ContentValues values = new ContentValues();
+ values.put("title", post.getTitle());
+ values.put("date_created_gmt", post.getDate_created_gmt());
+ values.put("description", post.getDescription());
+ values.put("mt_text_more", post.getMoreText());
+ values.put("uploaded", post.isUploaded());
+
+ JSONArray categoriesJsonArray = post.getJSONCategories();
+ if (categoriesJsonArray != null) {
+ values.put("categories", categoriesJsonArray.toString());
+ }
+
+ values.put("localDraft", post.isLocalDraft());
+ values.put("mediaPaths", post.getMediaPaths());
+ values.put("mt_keywords", post.getKeywords());
+ values.put("wp_password", post.getPassword());
+ values.put("post_status", post.getPostStatus());
+ values.put("isPage", post.isPage());
+ values.put("wp_post_format", post.getPostFormat());
+ values.put("isLocalChange", post.isLocalChange());
+ values.put("mt_excerpt", post.getPostExcerpt());
+ putPostLocation(post, values);
+
+ result = db.update(POSTS_TABLE, values, "blogID=? AND id=? AND isPage=?",
+ new String[]{
+ String.valueOf(post.getLocalTableBlogId()),
+ String.valueOf(post.getLocalTablePostId()),
+ String.valueOf(SqlUtils.boolToSql(post.isPage()))
+ });
+ }
+
+ return (result);
+ }
+
+ private void putPostLocation(Post post, ContentValues values) {
+ if (post.hasLocation()) {
+ PostLocation location = post.getLocation();
+ values.put("latitude", location.getLatitude());
+ values.put("longitude", location.getLongitude());
+ } else {
+ values.putNull("latitude");
+ values.putNull("longitude");
+ }
+ }
+
+ public List<Map<String, Object>> loadUploadedPosts(int blogID, boolean loadPages) {
+ List<Map<String, Object>> returnVector = new Vector<Map<String, Object>>();
+ Cursor c;
+ if (loadPages)
+ c = db.query(POSTS_TABLE,
+ new String[] { "id", "blogID", "postid", "title",
+ "date_created_gmt", "dateCreated", "post_status" },
+ "blogID=" + blogID + " AND localDraft != 1 AND isPage=1",
+ null, null, null, null);
+ else
+ c = db.query(POSTS_TABLE,
+ new String[] { "id", "blogID", "postid", "title",
+ "date_created_gmt", "dateCreated", "post_status" },
+ "blogID=" + blogID + " AND localDraft != 1 AND isPage=0",
+ null, null, null, null);
+
+ int numRows = c.getCount();
+ c.moveToFirst();
+
+ for (int i = 0; i < numRows; ++i) {
+ if (c.getString(0) != null) {
+ Map<String, Object> returnHash = new HashMap<String, Object>();
+ returnHash.put("id", c.getInt(0));
+ returnHash.put("blogID", c.getString(1));
+ returnHash.put("postID", c.getString(2));
+ returnHash.put("title", c.getString(3));
+ returnHash.put("date_created_gmt", c.getLong(4));
+ returnHash.put("dateCreated", c.getLong(5));
+ returnHash.put("post_status", c.getString(6));
+ returnVector.add(i, returnHash);
+ }
+ c.moveToNext();
+ }
+ c.close();
+
+ if (numRows == 0) {
+ returnVector = null;
+ }
+
+ return returnVector;
+ }
+
+ public void deleteUploadedPosts(int blogID, boolean isPage) {
+ if (isPage)
+ db.delete(POSTS_TABLE, "blogID=" + blogID
+ + " AND localDraft != 1 AND isPage=1", null);
+ else
+ db.delete(POSTS_TABLE, "blogID=" + blogID
+ + " AND localDraft != 1 AND isPage=0", null);
+
+ }
+
+ public Post getPostForLocalTablePostId(long localTablePostId) {
+ Cursor c = db.query(POSTS_TABLE, null, "id=?", new String[]{String.valueOf(localTablePostId)}, null, null, null);
+
+ Post post = new Post();
+ if (c.moveToFirst()) {
+ post.setLocalTablePostId(c.getLong(c.getColumnIndex("id")));
+ post.setLocalTableBlogId(Integer.valueOf(c.getString(c.getColumnIndex("blogID"))));
+ post.setRemotePostId(c.getString(c.getColumnIndex("postid")));
+ post.setTitle(c.getString(c.getColumnIndex("title")));
+ post.setDateCreated(c.getLong(c.getColumnIndex("dateCreated")));
+ post.setDate_created_gmt(c.getLong(c.getColumnIndex("date_created_gmt")));
+ post.setCategories(c.getString(c.getColumnIndex("categories")));
+ post.setCustomFields(c.getString(c.getColumnIndex("custom_fields")));
+ post.setDescription(c.getString(c.getColumnIndex("description")));
+ post.setLink(c.getString(c.getColumnIndex("link")));
+ post.setAllowComments(SqlUtils.sqlToBool(c.getInt(c.getColumnIndex("mt_allow_comments"))));
+ post.setAllowPings(SqlUtils.sqlToBool(c.getInt(c.getColumnIndex("mt_allow_pings"))));
+ post.setPostExcerpt(c.getString(c.getColumnIndex("mt_excerpt")));
+ post.setKeywords(c.getString(c.getColumnIndex("mt_keywords")));
+ post.setMoreText(c.getString(c.getColumnIndex("mt_text_more")));
+ post.setPermaLink(c.getString(c.getColumnIndex("permaLink")));
+ post.setPostStatus(c.getString(c.getColumnIndex("post_status")));
+ post.setUserId(c.getString(c.getColumnIndex("userid")));
+ post.setAuthorDisplayName(c.getString(c.getColumnIndex("wp_author_display_name")));
+ post.setAuthorId(c.getString(c.getColumnIndex("wp_author_id")));
+ post.setPassword(c.getString(c.getColumnIndex("wp_password")));
+ post.setPostFormat(c.getString(c.getColumnIndex("wp_post_format")));
+ post.setSlug(c.getString(c.getColumnIndex("wp_slug")));
+ post.setMediaPaths(c.getString(c.getColumnIndex("mediaPaths")));
+
+ int latColumnIndex = c.getColumnIndex("latitude");
+ int lngColumnIndex = c.getColumnIndex("longitude");
+ if (!c.isNull(latColumnIndex) && !c.isNull(lngColumnIndex)) {
+ post.setLocation(c.getDouble(latColumnIndex), c.getDouble(lngColumnIndex));
+ }
+
+ post.setLocalDraft(SqlUtils.sqlToBool(c.getInt(c.getColumnIndex("localDraft"))));
+ post.setUploaded(SqlUtils.sqlToBool(c.getInt(c.getColumnIndex("uploaded"))));
+ post.setIsPage(SqlUtils.sqlToBool(c.getInt(c.getColumnIndex("isPage"))));
+ post.setPageParentId(c.getString(c.getColumnIndex("wp_page_parent_id")));
+ post.setPageParentTitle(c.getString(c.getColumnIndex("wp_page_parent_title")));
+ post.setLocalChange(SqlUtils.sqlToBool(c.getInt(c.getColumnIndex("isLocalChange"))));
+ } else {
+ post = null;
+ }
+
+ c.close();
+ return post;
+ }
+
+ // Categories
+ public boolean insertCategory(int id, int wp_id, int parent_id, String category_name) {
+ ContentValues values = new ContentValues();
+ values.put("blog_id", id);
+ values.put("wp_id", wp_id);
+ values.put("category_name", category_name.toString());
+ values.put("parent_id", parent_id);
+ boolean returnValue = false;
+ synchronized (this) {
+ returnValue = db.insert(CATEGORIES_TABLE, null, values) > 0;
+ }
+
+ return (returnValue);
+ }
+
+ public List<String> loadCategories(int id) {
+ Cursor c = db.query(CATEGORIES_TABLE, new String[] { "id", "wp_id",
+ "category_name" }, "blog_id=" + id, null, null, null, null);
+ int numRows = c.getCount();
+ c.moveToFirst();
+ List<String> returnVector = new Vector<String>();
+ for (int i = 0; i < numRows; ++i) {
+ String category_name = c.getString(2);
+ if (category_name != null) {
+ returnVector.add(category_name);
+ }
+ c.moveToNext();
+ }
+ c.close();
+
+ return returnVector;
+ }
+
+ public int getCategoryId(int id, String category) {
+ Cursor c = db.query(CATEGORIES_TABLE, new String[] { "wp_id" },
+ "category_name=? AND blog_id=?", new String[] {category, String.valueOf(id)},
+ null, null, null);
+ if (c.getCount() == 0)
+ return 0;
+ c.moveToFirst();
+ int categoryID = 0;
+ categoryID = c.getInt(0);
+
+ c.close();
+
+ return categoryID;
+ }
+
+ public int getCategoryParentId(int id, String category) {
+ Cursor c = db.query(CATEGORIES_TABLE, new String[] { "parent_id" },
+ "category_name=? AND blog_id=?", new String[] {category, String.valueOf(id)},
+ null, null, null);
+ if (c.getCount() == 0)
+ return -1;
+ c.moveToFirst();
+ int categoryParentID = c.getInt(0);
+
+ c.close();
+
+ return categoryParentID;
+ }
+
+ public void clearCategories(int id) {
+ // clear out the table since we are refreshing the whole enchilada
+ db.delete(CATEGORIES_TABLE, "blog_id=" + id, null);
+
+ }
+
+ public boolean addQuickPressShortcut(int accountId, String name) {
+ ContentValues values = new ContentValues();
+ values.put("accountId", accountId);
+ values.put("name", name);
+ boolean returnValue = false;
+ synchronized (this) {
+ returnValue = db.insert(QUICKPRESS_SHORTCUTS_TABLE, null, values) > 0;
+ }
+
+ return (returnValue);
+ }
+
+ /*
+ * return all QuickPress shortcuts connected with the passed account
+ */
+ public List<Map<String, Object>> getQuickPressShortcuts(int accountId) {
+ Cursor c = db.query(QUICKPRESS_SHORTCUTS_TABLE, new String[] { "id",
+ "accountId", "name" }, "accountId = " + accountId, null, null,
+ null, null);
+ String id, name;
+ int numRows = c.getCount();
+ c.moveToFirst();
+ List<Map<String, Object>> accounts = new Vector<Map<String, Object>>();
+ for (int i = 0; i < numRows; i++) {
+ id = c.getString(0);
+ name = c.getString(2);
+ if (id != null) {
+ Map<String, Object> thisHash = new HashMap<String, Object>();
+
+ thisHash.put("id", id);
+ thisHash.put("name", name);
+ accounts.add(thisHash);
+ }
+ c.moveToNext();
+ }
+ c.close();
+
+ return accounts;
+ }
+
+ /*
+ * delete QuickPress home screen shortcuts connected with the passed account
+ */
+ private void deleteQuickPressShortcutsForAccount(Context ctx, int accountId) {
+ List<Map<String, Object>> shortcuts = getQuickPressShortcuts(accountId);
+ if (shortcuts.size() == 0)
+ return;
+
+ String packageName = EditPostActivity.class.getPackage().getName();
+ String className = EditPostActivity.class.getName();
+ for (int i = 0; i < shortcuts.size(); i++) {
+ Map<String, Object> shortcutHash = shortcuts.get(i);
+
+ Intent shortcutIntent = new Intent();
+ shortcutIntent.setClassName(packageName, className);
+ shortcutIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ shortcutIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ shortcutIntent.setAction(Intent.ACTION_VIEW);
+ Intent broadcastShortcutIntent = new Intent();
+ broadcastShortcutIntent.putExtra(Intent.EXTRA_SHORTCUT_INTENT,
+ shortcutIntent);
+ broadcastShortcutIntent.putExtra(Intent.EXTRA_SHORTCUT_NAME,
+ shortcutHash.get("name").toString());
+ broadcastShortcutIntent.putExtra("duplicate", false);
+ broadcastShortcutIntent
+ .setAction("com.android.launcher.action.UNINSTALL_SHORTCUT");
+ LocalBroadcastManager.getInstance(ctx).sendBroadcast(broadcastShortcutIntent);
+
+ // remove from shortcuts table
+ String shortcutId = shortcutHash.get("id").toString();
+ db.delete(QUICKPRESS_SHORTCUTS_TABLE, "id=?", new String[]{shortcutId});
+ }
+ }
+
+ public static String encryptPassword(String clearText) {
+ try {
+ DESKeySpec keySpec = new DESKeySpec(
+ PASSWORD_SECRET.getBytes("UTF-8"));
+ SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("DES");
+ SecretKey key = keyFactory.generateSecret(keySpec);
+
+ Cipher cipher = Cipher.getInstance("DES");
+ cipher.init(Cipher.ENCRYPT_MODE, key);
+ String encrypedPwd = Base64.encodeToString(cipher.doFinal(clearText
+ .getBytes("UTF-8")), Base64.DEFAULT);
+ return encrypedPwd;
+ } catch (Exception e) {
+ }
+ return clearText;
+ }
+
+ public static String decryptPassword(String encryptedPwd) {
+ try {
+ DESKeySpec keySpec = new DESKeySpec(
+ PASSWORD_SECRET.getBytes("UTF-8"));
+ SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("DES");
+ SecretKey key = keyFactory.generateSecret(keySpec);
+
+ byte[] encryptedWithoutB64 = Base64.decode(encryptedPwd, Base64.DEFAULT);
+ Cipher cipher = Cipher.getInstance("DES");
+ cipher.init(Cipher.DECRYPT_MODE, key);
+ byte[] plainTextPwdBytes = cipher.doFinal(encryptedWithoutB64);
+ return new String(plainTextPwdBytes);
+ } catch (Exception e) {
+ }
+ return encryptedPwd;
+ }
+
+ private void migratePasswords() {
+ Cursor c = db.query(SETTINGS_TABLE, new String[] { "id", "password",
+ "httppassword", "dotcom_password" }, null, null, null, null,
+ null);
+ int numRows = c.getCount();
+ c.moveToFirst();
+
+ for (int i = 0; i < numRows; i++) {
+ ContentValues values = new ContentValues();
+
+ if (c.getString(1) != null) {
+ values.put("password", encryptPassword(c.getString(1)));
+ }
+ if (c.getString(2) != null) {
+ values.put("httppassword", encryptPassword(c.getString(2)));
+ }
+ if (c.getString(3) != null) {
+ values.put("dotcom_password", encryptPassword(c.getString(3)));
+ }
+
+ db.update(SETTINGS_TABLE, values, "id=" + c.getInt(0), null);
+
+ c.moveToNext();
+ }
+ c.close();
+ }
+
+ public int getUnmoderatedCommentCount(int blogID) {
+ int commentCount = 0;
+
+ Cursor c = db
+ .rawQuery(
+ "select count(*) from comments where blogID=? AND status='hold'",
+ new String[] { String.valueOf(blogID) });
+ int numRows = c.getCount();
+ c.moveToFirst();
+
+ if (numRows > 0) {
+ commentCount = c.getInt(0);
+ }
+
+ c.close();
+
+ return commentCount;
+ }
+
+ public void saveMediaFile(MediaFile mf) {
+ ContentValues values = new ContentValues();
+ values.put("postID", mf.getPostID());
+ values.put("filePath", mf.getFilePath());
+ values.put("fileName", mf.getFileName());
+ values.put("title", mf.getTitle());
+ values.put("description", mf.getDescription());
+ values.put("caption", mf.getCaption());
+ values.put("horizontalAlignment", mf.getHorizontalAlignment());
+ values.put("width", mf.getWidth());
+ values.put("height", mf.getHeight());
+ values.put("mimeType", mf.getMimeType());
+ values.put("featured", mf.isFeatured());
+ values.put("isVideo", mf.isVideo());
+ values.put("isFeaturedInPost", mf.isFeaturedInPost());
+ values.put("fileURL", mf.getFileURL());
+ values.put("thumbnailURL", mf.getThumbnailURL());
+ values.put("mediaId", mf.getMediaId());
+ values.put("blogId", mf.getBlogId());
+ values.put("date_created_gmt", mf.getDateCreatedGMT());
+ values.put("videoPressShortcode", mf.getVideoPressShortCode());
+ if (mf.getUploadState() != null)
+ values.put("uploadState", mf.getUploadState());
+ else
+ values.putNull("uploadState");
+
+ synchronized (this) {
+ int result = 0;
+ boolean isMarkedForDelete = false;
+ if (mf.getMediaId() != null) {
+ Cursor cursor = db.rawQuery("SELECT uploadState FROM " + MEDIA_TABLE + " WHERE mediaId=?",
+ new String[]{StringUtils.notNullStr(mf.getMediaId())});
+ if (cursor != null && cursor.moveToFirst()) {
+ isMarkedForDelete = "delete".equals(cursor.getString(0));
+ cursor.close();
+ }
+
+ if (!isMarkedForDelete)
+ result = db.update(MEDIA_TABLE, values, "blogId=? AND mediaId=?",
+ new String[]{StringUtils.notNullStr(mf.getBlogId()), StringUtils.notNullStr(mf.getMediaId())});
+ }
+
+ if (result == 0 && !isMarkedForDelete) {
+ result = db.update(MEDIA_TABLE, values, "postID=? AND filePath=?",
+ new String[]{String.valueOf(mf.getPostID()), StringUtils.notNullStr(mf.getFilePath())});
+ if (result == 0)
+ db.insert(MEDIA_TABLE, null, values);
+ }
+ }
+
+ }
+
+ /** For a given blogId, get the first media files **/
+ public Cursor getFirstMediaFileForBlog(String blogId) {
+ return db.rawQuery("SELECT id as _id, * FROM " + MEDIA_TABLE + " WHERE blogId=? AND mediaId <> '' AND " +
+ "(uploadState IS NULL OR uploadState IN ('uploaded', 'queued', 'failed', 'uploading')) ORDER BY (uploadState=?) DESC, date_created_gmt DESC LIMIT 1",
+ new String[]{blogId, "uploading"});
+ }
+
+ /** For a given blogId, get all the media files **/
+ public Cursor getMediaFilesForBlog(String blogId) {
+ return db.rawQuery("SELECT id as _id, * FROM " + MEDIA_TABLE + " WHERE blogId=? AND mediaId <> '' AND "
+ + "(uploadState IS NULL OR uploadState IN ('uploaded', 'queued', 'failed', 'uploading')) ORDER BY (uploadState=?) DESC, date_created_gmt DESC", new String[] { blogId, "uploading" });
+ }
+
+ /** For a given blogId, get all the media files with searchTerm **/
+ public Cursor getMediaFilesForBlog(String blogId, String searchTerm) {
+ // Currently on WordPress.com, the media search engine only searches the title.
+ // We'll match this.
+
+ String term = searchTerm.toLowerCase(Locale.getDefault());
+ return db.rawQuery("SELECT id as _id, * FROM " + MEDIA_TABLE + " WHERE blogId=? AND mediaId <> '' AND title LIKE ? AND (uploadState IS NULL OR uploadState ='uploaded') ORDER BY (uploadState=?) DESC, date_created_gmt DESC", new String[] { blogId, "%" + term + "%", "uploading" });
+ }
+
+ /** For a given blogId, get the media file with the given media_id **/
+ public Cursor getMediaFile(String blogId, String mediaId) {
+ return db.rawQuery("SELECT * FROM " + MEDIA_TABLE + " WHERE blogId=? AND mediaId=?", new String[] { blogId, mediaId });
+ }
+
+ public int getMediaCountAll(String blogId) {
+ Cursor cursor = getMediaFilesForBlog(blogId);
+ int count = cursor.getCount();
+ cursor.close();
+ return count;
+ }
+
+
+ public Cursor getMediaImagesForBlog(String blogId) {
+ return db.rawQuery("SELECT id as _id, * FROM " + MEDIA_TABLE + " WHERE blogId=? AND mediaId <> '' AND "
+ + "(uploadState IS NULL OR uploadState IN ('uploaded', 'queued', 'failed', 'uploading')) AND mimeType LIKE ? ORDER BY (uploadState=?) DESC, date_created_gmt DESC", new String[] { blogId, "image%", "uploading" });
+ }
+
+ /** Ids in the filteredIds will not be selected **/
+ public Cursor getMediaImagesForBlog(String blogId, ArrayList<String> filteredIds) {
+ String mediaIdsStr = "";
+
+ if (filteredIds != null && filteredIds.size() > 0) {
+ mediaIdsStr = "AND mediaId NOT IN (";
+ for (String mediaId : filteredIds) {
+ mediaIdsStr += "'" + mediaId + "',";
+ }
+ mediaIdsStr = mediaIdsStr.subSequence(0, mediaIdsStr.length() - 1) + ")";
+ }
+
+ return db.rawQuery("SELECT id as _id, * FROM " + MEDIA_TABLE + " WHERE blogId=? AND mediaId <> '' AND "
+ + "(uploadState IS NULL OR uploadState IN ('uploaded', 'queued', 'failed', 'uploading')) AND mimeType LIKE ? " + mediaIdsStr + " ORDER BY (uploadState=?) DESC, date_created_gmt DESC", new String[] { blogId, "image%", "uploading" });
+ }
+
+ public int getMediaCountImages(String blogId) {
+ return getMediaImagesForBlog(blogId).getCount();
+ }
+
+ public Cursor getMediaUnattachedForBlog(String blogId) {
+ return db.rawQuery("SELECT id as _id, * FROM " + MEDIA_TABLE + " WHERE blogId=? AND mediaId <> '' AND " +
+ "(uploadState IS NULL OR uploadState IN ('uploaded', 'queued', 'failed', 'uploading')) AND postId=0 ORDER BY (uploadState=?) DESC, date_created_gmt DESC", new String[] { blogId, "uploading" });
+ }
+
+ public int getMediaCountUnattached(String blogId) {
+ return getMediaUnattachedForBlog(blogId).getCount();
+ }
+
+ public Cursor getMediaFilesForBlog(String blogId, long startDate, long endDate) {
+ return db.rawQuery("SELECT id as _id, * FROM " + MEDIA_TABLE + " WHERE blogId=? AND mediaId <> '' AND (uploadState IS NULL OR uploadState ='uploaded') AND (date_created_gmt >= ? AND date_created_gmt <= ?) ", new String[] { blogId , String.valueOf(startDate), String.valueOf(endDate) });
+ }
+
+ public Cursor getMediaFiles(String blogId, ArrayList<String> mediaIds) {
+ if (mediaIds == null || mediaIds.size() == 0)
+ return null;
+
+ String mediaIdsStr = "(";
+ for (String mediaId : mediaIds) {
+ mediaIdsStr += "'" + mediaId + "',";
+ }
+ mediaIdsStr = mediaIdsStr.subSequence(0, mediaIdsStr.length() - 1) + ")";
+
+ return db.rawQuery("SELECT id as _id, * FROM " + MEDIA_TABLE + " WHERE blogId=? AND mediaId IN " + mediaIdsStr, new String[] { blogId });
+ }
+
+ public MediaFile getMediaFile(String src, Post post) {
+ Cursor c = db.query(MEDIA_TABLE, null, "postID=? AND filePath=?",
+ new String[]{String.valueOf(post.getLocalTablePostId()), src}, null, null, null);
+
+ try {
+ if (c.moveToFirst()) {
+ MediaFile mf = new MediaFile();
+ mf.setId(c.getInt(0));
+ mf.setPostID(c.getInt(1));
+ mf.setFilePath(c.getString(2));
+ mf.setFileName(c.getString(3));
+ mf.setTitle(c.getString(4));
+ mf.setDescription(c.getString(5));
+ mf.setCaption(c.getString(6));
+ mf.setHorizontalAlignment(c.getInt(7));
+ mf.setWidth(c.getInt(8));
+ mf.setHeight(c.getInt(9));
+ mf.setMimeType(c.getString(10));
+ mf.setFeatured(c.getInt(11) > 0);
+ mf.setVideo(c.getInt(12) > 0);
+ mf.setFeaturedInPost(c.getInt(13) > 0);
+ mf.setFileURL(c.getString(14));
+ mf.setThumbnailURL(c.getString(15));
+ mf.setMediaId(c.getString(16));
+ mf.setBlogId(c.getString(17));
+ mf.setDateCreatedGMT(c.getLong(18));
+ mf.setUploadState(c.getString(19));
+ mf.setVideoPressShortCode(c.getString(20));
+
+ return mf;
+ } else {
+ return null;
+ }
+ } finally {
+ c.close();
+ }
+ }
+
+ public void deleteMediaFilesForPost(Post post) {
+ db.delete(MEDIA_TABLE, "blogId='" + post.getLocalTableBlogId() + "' AND postID=" + post.getLocalTablePostId(), null);
+ }
+
+ /** Get the queued media files for upload for a given blogId **/
+ public Cursor getMediaUploadQueue(String blogId) {
+ return db.rawQuery("SELECT * FROM " + MEDIA_TABLE + " WHERE uploadState=? AND blogId=?", new String[] {"queued", blogId});
+ }
+
+ /** Update a media file to a new upload state **/
+ public void updateMediaUploadState(String blogId, String mediaId, String uploadState) {
+ if (blogId == null || blogId.equals(""))
+ return;
+
+ ContentValues values = new ContentValues();
+ if (uploadState == null) values.putNull("uploadState");
+ else values.put("uploadState", uploadState);
+
+ if (mediaId == null) {
+ db.update(MEDIA_TABLE, values, "blogId=? AND (uploadState IS NULL OR uploadState ='uploaded')", new String[] { blogId });
+ } else {
+ db.update(MEDIA_TABLE, values, "blogId=? AND mediaId=?", new String[] { blogId, mediaId });
+ }
+ }
+
+ public void updateMediaFile(String blogId, String mediaId, String title, String description, String caption) {
+ if (blogId == null || blogId.equals("")) {
+ return;
+ }
+
+ ContentValues values = new ContentValues();
+
+ if (title == null || title.equals("")) {
+ values.put("title", "");
+ } else {
+ values.put("title", title);
+ }
+
+ if (title == null || title.equals("")) {
+ values.put("description", "");
+ } else {
+ values.put("description", description);
+ }
+
+ if (caption == null || caption.equals("")) {
+ values.put("caption", "");
+ } else {
+ values.put("caption", caption);
+ }
+
+ db.update(MEDIA_TABLE, values, "blogId = ? AND mediaId=?", new String[] { blogId, mediaId });
+ }
+
+ /**
+ * For a given blogId, set all uploading states to failed.
+ * Useful for cleaning up files stuck in the "uploading" state.
+ **/
+ public void setMediaUploadingToFailed(String blogId) {
+ if (blogId == null || blogId.equals(""))
+ return;
+
+ ContentValues values = new ContentValues();
+ values.put("uploadState", "failed");
+ db.update(MEDIA_TABLE, values, "blogId=? AND uploadState=?", new String[] { blogId, "uploading" });
+ }
+
+ /** For a given blogId, clear the upload states in the upload queue **/
+ public void clearMediaUploaded(String blogId) {
+ if (blogId == null || blogId.equals(""))
+ return;
+
+ ContentValues values = new ContentValues();
+ values.putNull("uploadState");
+ db.update(MEDIA_TABLE, values, "blogId=? AND uploadState=?", new String[] { blogId, "uploaded" });
+ }
+
+ /** Delete a media item from a blog locally **/
+ public void deleteMediaFile(String blogId, String mediaId) {
+ db.delete(MEDIA_TABLE, "blogId=? AND mediaId=?", new String[] { blogId, mediaId });
+ }
+
+ /** Mark media files for deletion without actually deleting them. **/
+ public void setMediaFilesMarkedForDelete(String blogId, List<String> ids) {
+ // This is for queueing up files to delete on the server
+ for (String id : ids)
+ updateMediaUploadState(blogId, id, "delete");
+ }
+
+ /** Mark media files as deleted without actually deleting them **/
+ public void setMediaFilesMarkedForDeleted(String blogId) {
+ // This is for syncing our files to the server:
+ // when we pull from the server, everything that is still 'deleted'
+ // was not downloaded from the server and can be removed via deleteFilesMarkedForDeleted()
+ updateMediaUploadState(blogId, null, "deleted");
+ }
+
+ /** Delete files marked as deleted **/
+ public void deleteFilesMarkedForDeleted(String blogId) {
+ db.delete(MEDIA_TABLE, "blogId=? AND uploadState=?", new String[] { blogId, "deleted" });
+ }
+
+ /** Get a media file scheduled for delete for a given blogId **/
+ public Cursor getMediaDeleteQueueItem(String blogId) {
+ return db.rawQuery("SELECT blogId, mediaId FROM " + MEDIA_TABLE + " WHERE uploadState=? AND blogId=? LIMIT 1",
+ new String[]{"delete", blogId});
+ }
+
+
+ public int getWPCOMBlogID() {
+ int id = -1;
+ Cursor c = db.query(SETTINGS_TABLE, new String[] { "id" },
+ "dotcomFlag=1", null, null, null, null);
+ int numRows = c.getCount();
+ c.moveToFirst();
+ if (numRows > 0) {
+ id = c.getInt(0);
+ }
+
+ c.close();
+
+ return id;
+ }
+
+ public boolean findLocalChanges(int blogId, boolean isPage) {
+ Cursor c = db.query(POSTS_TABLE, null,
+ "isLocalChange=? AND blogID=? AND isPage=?", new String[]{"1", String.valueOf(blogId), (isPage) ? "1" : "0"}, null, null, null);
+ int numRows = c.getCount();
+ c.close();
+ if (numRows > 0) {
+ return true;
+ }
+
+ return false;
+ }
+
+ public boolean saveTheme(Theme theme) {
+ boolean returnValue = false;
+
+ ContentValues values = new ContentValues();
+ values.put("themeId", theme.getThemeId());
+ values.put("name", theme.getName());
+ values.put("description", theme.getDescription());
+ values.put("screenshotURL", theme.getScreenshotURL());
+ values.put("trendingRank", theme.getTrendingRank());
+ values.put("popularityRank", theme.getPopularityRank());
+ values.put("launchDate", theme.getLaunchDateMs());
+ values.put("previewURL", theme.getPreviewURL());
+ values.put("blogId", theme.getBlogId());
+ values.put("isCurrent", theme.isCurrent());
+ values.put("isPremium", theme.isPremium());
+ values.put("features", theme.getFeatures());
+
+ synchronized (this) {
+ int result = db.update(
+ THEMES_TABLE,
+ values,
+ "themeId=?",
+ new String[]{ theme.getThemeId() });
+ if (result == 0)
+ returnValue = db.insert(THEMES_TABLE, null, values) > 0;
+ }
+
+ return (returnValue);
+ }
+
+ public Cursor getThemesAtoZ(String blogId) {
+ return db.rawQuery("SELECT _id, themeId, name, screenshotURL, isCurrent, isPremium FROM " + THEMES_TABLE + " WHERE blogId=? ORDER BY name COLLATE NOCASE ASC", new String[] { blogId });
+ }
+
+ public Cursor getThemesTrending(String blogId) {
+ return db.rawQuery("SELECT _id, themeId, name, screenshotURL, isCurrent, isPremium FROM " + THEMES_TABLE + " WHERE blogId=? ORDER BY trendingRank ASC", new String[] { blogId });
+ }
+
+ public Cursor getThemesPopularity(String blogId) {
+ return db.rawQuery("SELECT _id, themeId, name, screenshotURL, isCurrent, isPremium FROM " + THEMES_TABLE + " WHERE blogId=? ORDER BY popularityRank ASC", new String[] { blogId });
+ }
+
+ public Cursor getThemesNewest(String blogId) {
+ return db.rawQuery("SELECT _id, themeId, name, screenshotURL, isCurrent, isPremium FROM " + THEMES_TABLE + " WHERE blogId=? ORDER BY launchDate DESC", new String[] { blogId });
+ }
+
+ /*public Cursor getThemesPremium(String blogId) {
+ return db.rawQuery("SELECT _id, themeId, name, screenshotURL, isCurrent, isPremium FROM " + THEMES_TABLE + " WHERE blogId=? AND price > 0 ORDER BY name ASC", new String[] { blogId });
+ }
+
+ public Cursor getThemesFriendsOfWP(String blogId) {
+ return db.rawQuery("SELECT _id, themeId, name, screenshotURL, isCurrent, isPremium FROM " + THEMES_TABLE + " WHERE blogId=? AND themeId LIKE ? ORDER BY popularityRank ASC", new String[] { blogId, "partner-%" });
+ }
+
+ public Cursor getCurrentTheme(String blogId) {
+ return db.rawQuery("SELECT _id, themeId, name, screenshotURL, isCurrent, isPremium FROM " + THEMES_TABLE + " WHERE blogId=? AND isCurrentTheme='true'", new String[] { blogId });
+ }*/
+
+ public String getCurrentThemeId(String blogId) {
+ return DatabaseUtils.stringForQuery(db, "SELECT themeId FROM " + THEMES_TABLE + " WHERE blogId=? AND isCurrent='1'", new String[] { blogId });
+ }
+
+ public void setCurrentTheme(String blogId, String themeId) {
+ // update any old themes that are set to true to false
+ ContentValues values = new ContentValues();
+ values.put("isCurrent", false);
+ db.update(THEMES_TABLE, values, "blogID=? AND isCurrent='1'", new String[] { blogId });
+
+ values = new ContentValues();
+ values.put("isCurrent", true);
+ db.update(THEMES_TABLE, values, "blogId=? AND themeId=?", new String[] { blogId, themeId });
+ }
+
+ public int getThemeCount(String blogId) {
+ return getThemesAtoZ(blogId).getCount();
+ }
+
+ public Cursor getThemes(String blogId, String searchTerm) {
+ return db.rawQuery("SELECT _id, themeId, name, screenshotURL, isCurrent, isPremium FROM " + THEMES_TABLE + " WHERE blogId=? AND (name LIKE ? OR description LIKE ?) ORDER BY name ASC", new String[] {blogId, "%" + searchTerm + "%", "%" + searchTerm + "%"});
+
+ }
+
+ public Theme getTheme(String blogId, String themeId) {
+ Cursor cursor = db.rawQuery("SELECT name, description, screenshotURL, previewURL, isCurrent, isPremium, features FROM " + THEMES_TABLE + " WHERE blogId=? AND themeId=?", new String[]{blogId, themeId});
+ if (cursor.moveToFirst()) {
+ String name = cursor.getString(0);
+ String description = cursor.getString(1);
+ String screenshotURL = cursor.getString(2);
+ String previewURL = cursor.getString(3);
+ boolean isCurrent = cursor.getInt(4) == 1;
+ boolean isPremium = cursor.getInt(5) == 1;
+ String features = cursor.getString(6);
+
+ Theme theme = new Theme();
+ theme.setThemeId(themeId);
+ theme.setName(name);
+ theme.setDescription(description);
+ theme.setScreenshotURL(screenshotURL);
+ theme.setPreviewURL(previewURL);
+ theme.setCurrent(isCurrent);
+ theme.setPremium(isPremium);
+ theme.setFeatures(features);
+
+ cursor.close();
+
+ return theme;
+ } else {
+ cursor.close();
+ return null;
+ }
+ }
+
+ /*
+ * used during development to copy database to SD card so we can access it via DDMS
+ */
+ protected void copyDatabase() {
+ String copyFrom = db.getPath();
+ String copyTo = WordPress.getContext().getExternalFilesDir(null).getAbsolutePath() + "/" + DATABASE_NAME + ".db";
+
+ try {
+ InputStream input = new FileInputStream(copyFrom);
+ OutputStream output = new FileOutputStream(copyTo);
+
+ byte[] buffer = new byte[1024];
+ int length;
+ while ((length = input.read(buffer)) > 0)
+ output.write(buffer, 0, length);
+
+ output.flush();
+ output.close();
+ input.close();
+ } catch (IOException e) {
+ AppLog.e(T.DB, "failed to copy database", e);
+ }
+ }
+
+ public boolean hasAnyJetpackBlogs() {
+ return SqlUtils.boolForQuery(db, "SELECT 1 FROM " + SETTINGS_TABLE + " WHERE api_blogid != 0 LIMIT 1", null);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/WordPressStatsDB.java b/WordPress/src/main/java/org/wordpress/android/WordPressStatsDB.java
new file mode 100644
index 000000000..155788898
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/WordPressStatsDB.java
@@ -0,0 +1,86 @@
+package org.wordpress.android;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+
+import org.wordpress.android.datasets.StatsBarChartDataTable;
+import org.wordpress.android.datasets.StatsClickGroupsTable;
+import org.wordpress.android.datasets.StatsClicksTable;
+import org.wordpress.android.datasets.StatsGeoviewsTable;
+import org.wordpress.android.datasets.StatsMostCommentedTable;
+import org.wordpress.android.datasets.StatsReferrerGroupsTable;
+import org.wordpress.android.datasets.StatsReferrersTable;
+import org.wordpress.android.datasets.StatsSearchEngineTermsTable;
+import org.wordpress.android.datasets.StatsTagsAndCategoriesTable;
+import org.wordpress.android.datasets.StatsTopAuthorsTable;
+import org.wordpress.android.datasets.StatsTopCommentersTable;
+import org.wordpress.android.datasets.StatsTopPostsAndPagesTable;
+import org.wordpress.android.datasets.StatsVideosTable;
+import org.wordpress.android.providers.StatsContentProvider;
+import org.wordpress.android.util.SqlUtils;
+
+/**
+ * A database for storing stats. Do not access this class directly.
+ * Instead, use a {@link ContentResolver} and the URIs listed in
+ * {@link StatsContentProvider} to perform inserts, updates, and deletes.
+ * See {@link org.wordpress.android.ui.stats.service.StatsService} for examples.
+ */
+public class WordPressStatsDB extends SQLiteOpenHelper{
+ private static final int DATABASE_VERSION = 1;
+ private static final String DATABASE_NAME = "wordpress_stats";
+
+ private Context mContext;
+
+ public WordPressStatsDB(Context ctx) {
+ super(ctx, DATABASE_NAME, null, DATABASE_VERSION);
+ mContext = ctx;
+ getWritableDatabase();
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase db) {
+ db.execSQL(StatsClickGroupsTable.getInstance().toCreateQuery());
+ db.execSQL(StatsClicksTable.getInstance().toCreateQuery());
+ db.execSQL(StatsGeoviewsTable.getInstance().toCreateQuery());
+ db.execSQL(StatsMostCommentedTable.getInstance().toCreateQuery());
+ db.execSQL(StatsReferrerGroupsTable.getInstance().toCreateQuery());
+ db.execSQL(StatsReferrersTable.getInstance().toCreateQuery());
+ db.execSQL(StatsSearchEngineTermsTable.getInstance().toCreateQuery());
+ db.execSQL(StatsTagsAndCategoriesTable.getInstance().toCreateQuery());
+ db.execSQL(StatsTopAuthorsTable.getInstance().toCreateQuery());
+ db.execSQL(StatsTopCommentersTable.getInstance().toCreateQuery());
+ db.execSQL(StatsTopPostsAndPagesTable.getInstance().toCreateQuery());
+ db.execSQL(StatsVideosTable.getInstance().toCreateQuery());
+ db.execSQL(StatsBarChartDataTable.getInstance().toCreateQuery());
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ onCreate(db);
+
+ StatsClickGroupsTable.getInstance().onUpgrade(db, oldVersion, newVersion);
+ StatsClicksTable.getInstance().onUpgrade(db, oldVersion, newVersion);
+ StatsGeoviewsTable.getInstance().onUpgrade(db, oldVersion, newVersion);
+ StatsMostCommentedTable.getInstance().onUpgrade(db, oldVersion, newVersion);
+ StatsReferrerGroupsTable.getInstance().onUpgrade(db, oldVersion, newVersion);
+ StatsReferrersTable.getInstance().onUpgrade(db, oldVersion, newVersion);
+ StatsSearchEngineTermsTable.getInstance().onUpgrade(db, oldVersion, newVersion);
+ StatsTagsAndCategoriesTable.getInstance().onUpgrade(db, oldVersion, newVersion);
+ StatsTopAuthorsTable.getInstance().onUpgrade(db, oldVersion, newVersion);
+ StatsTopCommentersTable.getInstance().onUpgrade(db, oldVersion, newVersion);
+ StatsTopPostsAndPagesTable.getInstance().onUpgrade(db, oldVersion, newVersion);
+ StatsVideosTable.getInstance().onUpgrade(db, oldVersion, newVersion);
+ StatsBarChartDataTable.getInstance().onUpgrade(db, oldVersion, newVersion);
+ }
+
+ /*
+ * nbradbury - used during testing to clear all stats tables
+ */
+ public void reset() {
+ SQLiteDatabase db = getWritableDatabase();
+ SqlUtils.dropAllTables(db);
+ onCreate(db);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/CommentTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/CommentTable.java
new file mode 100644
index 000000000..dd907713a
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/CommentTable.java
@@ -0,0 +1,345 @@
+package org.wordpress.android.datasets;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+import android.database.sqlite.SQLiteStatement;
+
+import org.wordpress.android.WordPress;
+import org.wordpress.android.WordPressDB;
+import org.wordpress.android.models.Comment;
+import org.wordpress.android.models.CommentList;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.SqlUtils;
+import org.wordpress.android.util.StringUtils;
+
+/**
+ * replaces the comments table used in versions prior to 2.6.1, which didn't use a primary key
+ * and missed a few important fields
+ */
+public class CommentTable {
+ private static final String COMMENTS_TABLE = "comments";
+
+ public static void createTables(SQLiteDatabase db) {
+ db.execSQL("CREATE TABLE IF NOT EXISTS " + COMMENTS_TABLE + " ("
+ + " blog_id INTEGER DEFAULT 0,"
+ + " post_id INTEGER DEFAULT 0,"
+ + " comment_id INTEGER DEFAULT 0,"
+ + " comment TEXT,"
+ + " published TEXT,"
+ + " status TEXT,"
+ + " author_name TEXT,"
+ + " author_url TEXT,"
+ + " author_email TEXT,"
+ + " post_title TEXT,"
+ + " profile_image_url TEXT,"
+ + " PRIMARY KEY (blog_id, post_id, comment_id)"
+ + " );");
+ }
+
+ private static void dropTables(SQLiteDatabase db) {
+ db.execSQL("DROP TABLE IF EXISTS " + COMMENTS_TABLE);
+ }
+
+ public static void reset(SQLiteDatabase db) {
+ AppLog.i(AppLog.T.COMMENTS, "resetting comment table");
+ dropTables(db);
+ createTables(db);
+ }
+
+ private static SQLiteDatabase getReadableDb() {
+ return WordPress.wpDB.getDatabase();
+ }
+ private static SQLiteDatabase getWritableDb() {
+ return WordPress.wpDB.getDatabase();
+ }
+
+ /*
+ * purge comments attached to blogs that no longer exist, and remove older comments
+ * TODO: call after hiding or deleting blogs
+ */
+ private static final int MAX_COMMENTS = 1000;
+ public static int purge(SQLiteDatabase db) {
+ int numDeleted = 0;
+
+ // get rid of comments on blogs that don't exist or are hidden
+ String sql = " blog_id NOT IN (SELECT DISTINCT id FROM " + WordPressDB.SETTINGS_TABLE
+ + " WHERE isHidden = 0)";
+ numDeleted += db.delete(COMMENTS_TABLE, sql, null);
+
+ // get rid of older comments if we've reached the max
+ int numExisting = (int)SqlUtils.getRowCount(db, COMMENTS_TABLE);
+ if (numExisting > MAX_COMMENTS) {
+ int numToPurge = numExisting - MAX_COMMENTS;
+ sql = " comment_id IN (SELECT DISTINCT comment_id FROM " + COMMENTS_TABLE
+ + " ORDER BY published LIMIT " + Integer.toString(numToPurge) + ")";
+ numDeleted += db.delete(COMMENTS_TABLE, sql, null);
+ }
+
+ return numDeleted;
+ }
+
+ /**
+ * nbradbury 11/15/13 - add a single comment - will update existing comment with same IDs
+ * @param localBlogId - unique id in account table for the blog the comment is from
+ * @param comment - comment object to store
+ */
+ public static void addComment(int localBlogId, final Comment comment) {
+ if (comment == null)
+ return;
+
+ ContentValues values = new ContentValues();
+ values.put("blog_id", localBlogId);
+ values.put("post_id", comment.postID);
+ values.put("comment_id", comment.commentID);
+ values.put("author_name", comment.getAuthorName());
+ values.put("author_url", comment.getAuthorUrl());
+ values.put("comment", comment.getCommentText());
+ values.put("status", comment.getStatus());
+ values.put("author_email", comment.getAuthorEmail());
+ values.put("post_title", comment.getPostTitle());
+ values.put("published", comment.getPublished());
+ values.put("profile_image_url", comment.getProfileImageUrl());
+
+ getWritableDb().insertWithOnConflict(COMMENTS_TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE);
+ }
+
+ /**
+ * nbradbury 11/11/13 - retrieve a single comment
+ * @param localBlogId - unique id in account table for the blog the comment is from
+ * @param commentId - commentId of the actual comment
+ * @return Comment if found, null otherwise
+ */
+ public static Comment getComment(int localBlogId, long commentId) {
+ String[] args = {Integer.toString(localBlogId), Long.toString(commentId)};
+ Cursor c = getReadableDb().rawQuery("SELECT * FROM " + COMMENTS_TABLE + " WHERE blog_id=? AND comment_id=?", args);
+ try {
+ if (!c.moveToFirst())
+ return null;
+ return getCommentFromCursor(c);
+ } finally {
+ SqlUtils.closeCursor(c);
+ }
+ }
+
+ /**
+ * nbradbury - get all comments for a blog
+ * @param localBlogId - unique id in account table for this blog
+ * @return list of comments for this blog
+ */
+ public static CommentList getCommentsForBlog(int localBlogId) {
+ CommentList comments = new CommentList();
+
+ String[] args = {Integer.toString(localBlogId)};
+ Cursor c = getReadableDb().rawQuery("SELECT * FROM " + COMMENTS_TABLE + " WHERE blog_id=? ORDER BY published DESC", args);
+
+ try {
+ if (c.moveToFirst()) {
+ do {
+ Comment comment = getCommentFromCursor(c);
+ comments.add(comment);
+ } while (c.moveToNext());
+ }
+
+ return comments;
+ } finally {
+ SqlUtils.closeCursor(c);
+ }
+ }
+
+ /**
+ * nbradbury - delete all comments for a blog
+ * @param localBlogId - unique id in account table for this blog
+ * @return number of comments deleted
+ */
+ public static int deleteCommentsForBlog(int localBlogId) {
+ return getWritableDb().delete(COMMENTS_TABLE, "blog_id=?", new String[]{Integer.toString(localBlogId)});
+ }
+
+ /**
+ * nbradbury - saves comments for passed blog to local db, overwriting existing ones if necessary
+ * @param localBlogId - unique id in account table for this blog
+ * @param comments - list of comments to save
+ * @return true if saved, false on failure
+ */
+ public static boolean saveComments(int localBlogId, final CommentList comments) {
+ if (comments == null || comments.size() == 0)
+ return false;
+
+ final String sql = " INSERT OR REPLACE INTO " + COMMENTS_TABLE + "("
+ + " blog_id," // 1
+ + " post_id," // 2
+ + " comment_id," // 3
+ + " comment," // 4
+ + " published," // 5
+ + " status," // 6
+ + " author_name," // 7
+ + " author_url," // 8
+ + " author_email," // 9
+ + " post_title," // 10
+ + " profile_image_url" // 11
+ + " ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)";
+
+ SQLiteDatabase db = getWritableDb();
+ SQLiteStatement stmt = db.compileStatement(sql);
+ db.beginTransaction();
+ try {
+ try {
+ for (Comment comment: comments) {
+ stmt.bindLong ( 1, localBlogId);
+ stmt.bindLong ( 2, comment.postID);
+ stmt.bindLong ( 3, comment.commentID);
+ stmt.bindString( 4, comment.getCommentText());
+ stmt.bindString( 5, comment.getPublished());
+ stmt.bindString( 6, comment.getStatus());
+ stmt.bindString( 7, comment.getAuthorName());
+ stmt.bindString( 8, comment.getAuthorUrl());
+ stmt.bindString( 9, comment.getAuthorEmail());
+ stmt.bindString(10, comment.getPostTitle());
+ stmt.bindString(11, comment.getProfileImageUrl());
+ stmt.execute();
+ }
+
+ db.setTransactionSuccessful();
+ return true;
+ } catch (SQLiteException e) {
+ AppLog.e(AppLog.T.COMMENTS, e);
+ return false;
+ }
+ } finally {
+ db.endTransaction();
+ SqlUtils.closeStatement(stmt);
+ }
+ }
+
+ /**
+ * nbradbury - updates the passed comment
+ * @param localBlogId - unique id in account table for this blog
+ * @param comment - comment to update
+ */
+ public static void updateComment(int localBlogId, final Comment comment) {
+ // this will replace the existing comment
+ addComment(localBlogId, comment);
+ }
+
+ /**
+ * nbradbury - updates the status for the passed comment
+ * @param localBlogId - unique id in account table for this blog
+ * @param commentId - id of comment (returned by api)
+ * @param newStatus - status to change to
+ */
+ public static void updateCommentStatus(int localBlogId, long commentId, String newStatus) {
+ ContentValues values = new ContentValues();
+ values.put("status", newStatus);
+ String[] args = {Integer.toString(localBlogId),
+ Long.toString(commentId)};
+ getWritableDb().update(COMMENTS_TABLE,
+ values,
+ "blog_id=? AND comment_id=?",
+ args);
+ }
+
+ /**
+ * nbradbury - updates the status for the passed list of comments
+ * @param localBlogId - unique id in account table for this blog
+ * @param comments - list of comments to update
+ * @param newStatus - status to change to
+ */
+ public static void updateCommentsStatus(int localBlogId, final CommentList comments, String newStatus) {
+ if (comments == null || comments.size() == 0)
+ return;
+ getWritableDb().beginTransaction();
+ try {
+ for (Comment comment: comments) {
+ updateCommentStatus(localBlogId, comment.commentID, newStatus);
+ }
+ getWritableDb().setTransactionSuccessful();
+ } finally {
+ getWritableDb().endTransaction();
+ }
+ }
+
+ /**
+ * nbradbury - updates the post title for the passed comment
+ * @param localBlogId - unique id in account table for this blog
+ * @param postTitle - title to update to
+ * @return true if title updated
+ */
+ public static boolean updateCommentPostTitle(int localBlogId, long commentId, String postTitle) {
+ ContentValues values = new ContentValues();
+ values.put("post_title", StringUtils.notNullStr(postTitle));
+ String[] args = {Integer.toString(localBlogId),
+ Long.toString(commentId)};
+ int count = getWritableDb().update(COMMENTS_TABLE, values, "blog_id=? AND comment_id=?", args);
+ return (count > 0);
+ }
+
+ /**
+ * nbradbury 11/12/13 - delete a single comment
+ * @param localBlogId - unique id in account table for this blog
+ * @param commentId - commentId of the actual comment
+ * @return true if comment deleted, false otherwise
+ */
+ public static boolean deleteComment(int localBlogId, long commentId) {
+ String[] args = {Integer.toString(localBlogId),
+ Long.toString(commentId)};
+ int count = getWritableDb().delete(COMMENTS_TABLE, "blog_id=? AND comment_id=?", args);
+ return (count > 0);
+ }
+
+ /**
+ * nbradbury - delete a list of comments
+ * @param localBlogId - unique id in account table for this blog
+ * @param comments - list of comments to delete
+ */
+ public static void deleteComments(int localBlogId, final CommentList comments) {
+ if (comments == null || comments.size() == 0)
+ return;
+ getWritableDb().beginTransaction();
+ try {
+ for (Comment comment: comments) {
+ deleteComment(localBlogId, comment.commentID);
+ }
+ getWritableDb().setTransactionSuccessful();
+ } finally {
+ getWritableDb().endTransaction();
+ }
+ }
+
+ /**
+ * nbradbury - returns the number of unmoderated comments for a specific blog
+ * @param localBlogId - unique id in account table for this blog
+ */
+ public static int getUnmoderatedCommentCount(int localBlogId) {
+ String sql = "SELECT COUNT(*) FROM " + COMMENTS_TABLE + " WHERE blog_id=? AND status=?";
+ String[] args = {Integer.toString(localBlogId), "hold"};
+ return SqlUtils.intForQuery(getReadableDb(), sql, args);
+ }
+
+ private static Comment getCommentFromCursor(Cursor c) {
+ final String authorName = c.getString(c.getColumnIndex("author_name"));
+ final String content = c.getString(c.getColumnIndex("comment"));
+ final String published = c.getString(c.getColumnIndex("published"));
+ final String status = c.getString(c.getColumnIndex("status"));
+ final String authorUrl = c.getString(c.getColumnIndex("author_url"));
+ final String authorEmail = c.getString(c.getColumnIndex("author_email"));
+ final String postTitle = c.getString(c.getColumnIndex("post_title"));
+ final String profileImageUrl = c.getString(c.getColumnIndex("profile_image_url"));
+
+ int postId = c.getInt(c.getColumnIndex("post_id"));
+ int commentId = c.getInt(c.getColumnIndex("comment_id"));
+
+ return new Comment(
+ postId,
+ commentId,
+ authorName,
+ published,
+ content,
+ status,
+ postTitle,
+ authorUrl,
+ authorEmail,
+ profileImageUrl);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/ReaderBlogTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderBlogTable.java
new file mode 100644
index 000000000..fa7193c73
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderBlogTable.java
@@ -0,0 +1,323 @@
+package org.wordpress.android.datasets;
+
+import android.database.Cursor;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteStatement;
+import android.text.TextUtils;
+
+import org.wordpress.android.models.ReaderBlog;
+import org.wordpress.android.models.ReaderBlogList;
+import org.wordpress.android.models.ReaderRecommendBlogList;
+import org.wordpress.android.models.ReaderRecommendedBlog;
+import org.wordpress.android.models.ReaderUrlList;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.SqlUtils;
+
+/**
+ * tbl_blog_info contains information about blogs viewed in the reader, and blogs the
+ * user is following. Note that this table is populated from two endpoints:
+ *
+ * 1. sites/{$siteId}
+ * 2. read/following/mine?meta=site,feed
+ *
+ * The first endpoint is called when the user views blog preview, the second endpoint is called
+ * at startup to get the full list of blogs the user is following
+ */
+public class ReaderBlogTable {
+
+ protected static void createTables(SQLiteDatabase db) {
+ db.execSQL("CREATE TABLE tbl_blog_info ("
+ + " blog_id INTEGER DEFAULT 0,"
+ + " feed_id INTEGER DEFAULT 0,"
+ + " blog_url TEXT NOT NULL COLLATE NOCASE,"
+ + " name TEXT,"
+ + " description TEXT,"
+ + " is_private INTEGER DEFAULT 0,"
+ + " is_jetpack INTEGER DEFAULT 0,"
+ + " is_following INTEGER DEFAULT 0,"
+ + " num_followers INTEGER DEFAULT 0,"
+ + " PRIMARY KEY (blog_id, feed_id, blog_url)"
+ + ")");
+
+ db.execSQL("CREATE TABLE tbl_recommended_blogs ("
+ + " blog_id INTEGER DEFAULT 0,"
+ + " follow_reco_id INTEGER DEFAULT 0,"
+ + " score INTEGER DEFAULT 0,"
+ + " title TEXT COLLATE NOCASE,"
+ + " blog_url TEXT COLLATE NOCASE,"
+ + " image_url TEXT,"
+ + " reason TEXT,"
+ + " PRIMARY KEY (blog_id)"
+ + ")");
+ }
+
+ protected static void dropTables(SQLiteDatabase db) {
+ db.execSQL("DROP TABLE IF EXISTS tbl_blog_info");
+ db.execSQL("DROP TABLE IF EXISTS tbl_recommended_blogs");
+ }
+
+ /*
+ * get a blog's info by either id or url
+ */
+ public static ReaderBlog getBlogInfo(long blogId, String blogUrl) {
+ boolean hasBlogId = (blogId != 0);
+ boolean hasBlogUrl = !TextUtils.isEmpty(blogUrl);
+
+ if (!hasBlogId && !hasBlogUrl) {
+ return null;
+ }
+
+ // search by id if it's passed (may be zero for feeds), otherwise search by url
+ final Cursor cursor;
+ SQLiteDatabase db = ReaderDatabase.getReadableDb();
+ if (hasBlogId) {
+ String[] args = {Long.toString(blogId)};
+ cursor = db.rawQuery("SELECT * FROM tbl_blog_info WHERE blog_id=?", args);
+ } else {
+ String[] args = {blogUrl};
+ cursor = db.rawQuery("SELECT * FROM tbl_blog_info WHERE blog_url=?", args);
+ }
+
+ try {
+ if (!cursor.moveToFirst()) {
+ return null;
+ }
+ return getBlogInfoFromCursor(cursor);
+ } finally {
+ SqlUtils.closeCursor(cursor);
+ }
+ }
+
+ private static ReaderBlog getBlogInfoFromCursor(Cursor c) {
+ if (c == null) {
+ return null;
+ }
+
+ ReaderBlog blogInfo = new ReaderBlog();
+ blogInfo.blogId = c.getLong(c.getColumnIndex("blog_id"));
+ blogInfo.feedId = c.getLong(c.getColumnIndex("feed_id"));
+ blogInfo.setUrl(c.getString(c.getColumnIndex("blog_url")));
+ blogInfo.setName(c.getString(c.getColumnIndex("name")));
+ blogInfo.setDescription(c.getString(c.getColumnIndex("description")));
+ blogInfo.isPrivate = SqlUtils.sqlToBool(c.getInt(c.getColumnIndex("is_private")));
+ blogInfo.isJetpack = SqlUtils.sqlToBool(c.getInt(c.getColumnIndex("is_jetpack")));
+ blogInfo.isFollowing = SqlUtils.sqlToBool(c.getInt(c.getColumnIndex("is_following")));
+ blogInfo.numSubscribers = c.getInt(c.getColumnIndex("num_followers"));
+
+ return blogInfo;
+ }
+
+ public static void addOrUpdateBlog(ReaderBlog blogInfo) {
+ if (blogInfo == null) {
+ return;
+ }
+ String sql = "INSERT OR REPLACE INTO tbl_blog_info"
+ + " (blog_id, feed_id, blog_url, name, description, is_private, is_jetpack, is_following, num_followers)"
+ + " VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)";
+ SQLiteStatement stmt = ReaderDatabase.getWritableDb().compileStatement(sql);
+ try {
+ stmt.bindLong (1, blogInfo.blogId);
+ stmt.bindLong (2, blogInfo.feedId);
+ stmt.bindString(3, blogInfo.getUrl());
+ stmt.bindString(4, blogInfo.getName());
+ stmt.bindString(5, blogInfo.getDescription());
+ stmt.bindLong (6, SqlUtils.boolToSql(blogInfo.isPrivate));
+ stmt.bindLong (7, SqlUtils.boolToSql(blogInfo.isJetpack));
+ stmt.bindLong (8, SqlUtils.boolToSql(blogInfo.isFollowing));
+ stmt.bindLong (9, blogInfo.numSubscribers);
+ stmt.execute();
+ stmt.clearBindings();
+ } finally {
+ SqlUtils.closeStatement(stmt);
+ }
+ }
+
+ /*
+ * returns blogInfo for all followed blogs
+ */
+ public static ReaderBlogList getFollowedBlogs() {
+ Cursor c = ReaderDatabase.getReadableDb().rawQuery(
+ "SELECT * FROM tbl_blog_info WHERE is_following!=0 ORDER BY name COLLATE NOCASE, blog_url",
+ null);
+ try {
+ ReaderBlogList blogs = new ReaderBlogList();
+ if (c.moveToFirst()) {
+ do {
+ ReaderBlog blogInfo = getBlogInfoFromCursor(c);
+ blogs.add(blogInfo);
+ } while (c.moveToNext());
+ }
+ return blogs;
+ } finally {
+ SqlUtils.closeCursor(c);
+ }
+ }
+
+ /*
+ * set followed blogs from the read/following/mine endpoint
+ */
+ public static void setFollowedBlogs(ReaderBlogList followedBlogs) {
+ SQLiteDatabase db = ReaderDatabase.getWritableDb();
+ db.beginTransaction();
+ try {
+ // first set all existing blogs to not followed
+ db.execSQL("UPDATE tbl_blog_info SET is_following=0");
+
+ // then insert passed ones
+ if (followedBlogs != null) {
+ for (ReaderBlog blog: followedBlogs) {
+ addOrUpdateBlog(blog);
+ }
+ }
+
+ db.setTransactionSuccessful();
+
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ /*
+ * return list of URLs of followed blogs
+ */
+ public static ReaderUrlList getFollowedBlogUrls() {
+ Cursor c = ReaderDatabase.getReadableDb().rawQuery("SELECT DISTINCT blog_url FROM tbl_blog_info WHERE is_following!=0", null);
+ try {
+ ReaderUrlList urls = new ReaderUrlList();
+ if (c.moveToFirst()) {
+ do {
+ urls.add(c.getString(0));
+ } while (c.moveToNext());
+ }
+ return urls;
+ } finally {
+ SqlUtils.closeCursor(c);
+ }
+ }
+
+ public static void setIsFollowedBlog(long blogId, String url, boolean isFollowed) {
+ if (TextUtils.isEmpty(url)) {
+ return;
+ }
+
+ // get existing info for this blog
+ ReaderBlog blogInfo = getBlogInfo(blogId, url);
+
+ if (blogInfo == null) {
+ // blogInfo doesn't exist, create it with just the passed id & url
+ blogInfo = new ReaderBlog();
+ blogInfo.blogId = blogId;
+ blogInfo.setUrl(url);
+ } else if (blogInfo.isFollowing == isFollowed) {
+ // blogInfo already has passed following status, so nothing more to do
+ return;
+ }
+
+ blogInfo.isFollowing = isFollowed;
+ addOrUpdateBlog(blogInfo);
+ }
+
+ public static boolean isFollowedBlogUrl(String blogUrl) {
+ return isFollowedBlog(0, blogUrl);
+ }
+
+ public static boolean isFollowedBlog(long blogId, String blogUrl) {
+ boolean hasBlogId = (blogId != 0);
+ boolean hasBlogUrl = !TextUtils.isEmpty(blogUrl);
+
+ if (!hasBlogId && !hasBlogUrl) {
+ return false;
+ }
+
+ String sql;
+ if (hasBlogId && hasBlogUrl) {
+ // both id and url were passed, match on either
+ sql = "SELECT 1 FROM tbl_blog_info WHERE is_following!=0 AND (blog_id=? OR blog_url=?)";
+ String[] args = {Long.toString(blogId), blogUrl};
+ return SqlUtils.boolForQuery(ReaderDatabase.getReadableDb(), sql, args);
+ } else if (hasBlogId) {
+ // only id passed, match on id
+ sql = "SELECT 1 FROM tbl_blog_info WHERE is_following!=0 AND blog_id=?";
+ String[] args = {Long.toString(blogId)};
+ return SqlUtils.boolForQuery(ReaderDatabase.getReadableDb(), sql, args);
+ } else {
+ // only url passed, match on url
+ sql = "SELECT 1 FROM tbl_blog_info WHERE is_following!=0 AND blog_url=?";
+ String[] args = {blogUrl};
+ return SqlUtils.boolForQuery(ReaderDatabase.getReadableDb(), sql, args);
+ }
+ }
+
+ public static ReaderRecommendBlogList getAllRecommendedBlogs() {
+ return getRecommendedBlogs(0, 0);
+ }
+ public static ReaderRecommendBlogList getRecommendedBlogs(int limit, int offset) {
+ String sql = " SELECT * FROM tbl_recommended_blogs ORDER BY title";
+
+ if (limit > 0) {
+ sql += " LIMIT " + Integer.toString(limit);
+ if (offset > 0) {
+ sql += " OFFSET " + Integer.toString(offset);
+ }
+ }
+
+ Cursor c = ReaderDatabase.getReadableDb().rawQuery(sql, null);
+ try {
+ ReaderRecommendBlogList blogs = new ReaderRecommendBlogList();
+ if (c.moveToFirst()) {
+ do {
+ ReaderRecommendedBlog blog = new ReaderRecommendedBlog();
+ blog.blogId = c.getLong(c.getColumnIndex("blog_id"));
+ blog.followRecoId = c.getLong(c.getColumnIndex("follow_reco_id"));
+ blog.score = c.getInt(c.getColumnIndex("score"));
+ blog.setTitle(c.getString(c.getColumnIndex("title")));
+ blog.setBlogUrl(c.getString(c.getColumnIndex("blog_url")));
+ blog.setImageUrl(c.getString(c.getColumnIndex("image_url")));
+ blog.setReason(c.getString(c.getColumnIndex("reason")));
+ blogs.add(blog);
+ } while (c.moveToNext());
+ }
+ return blogs;
+ } finally {
+ SqlUtils.closeCursor(c);
+ }
+ }
+
+ public static void setRecommendedBlogs(ReaderRecommendBlogList blogs) {
+ SQLiteDatabase db = ReaderDatabase.getWritableDb();
+ SQLiteStatement stmt = db.compileStatement(
+ "INSERT INTO tbl_recommended_blogs"
+ + " (blog_id, follow_reco_id, score, title, blog_url, image_url, reason)"
+ + " VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)");
+ db.beginTransaction();
+ try {
+ try {
+ // first delete all recommended blogs
+ SqlUtils.deleteAllRowsInTable(db, "tbl_recommended_blogs");
+
+ // then insert the passed ones
+ if (blogs != null && blogs.size() > 0) {
+ for (ReaderRecommendedBlog blog : blogs) {
+ stmt.bindLong(1, blog.blogId);
+ stmt.bindLong(2, blog.followRecoId);
+ stmt.bindLong(3, blog.score);
+ stmt.bindString(4, blog.getTitle());
+ stmt.bindString(5, blog.getBlogUrl());
+ stmt.bindString(6, blog.getImageUrl());
+ stmt.bindString(7, blog.getReason());
+ stmt.execute();
+ stmt.clearBindings();
+ }
+ }
+ db.setTransactionSuccessful();
+
+ } catch (SQLException e) {
+ AppLog.e(AppLog.T.READER, e);
+ }
+ } finally {
+ SqlUtils.closeStatement(stmt);
+ db.endTransaction();
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/ReaderCommentTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderCommentTable.java
new file mode 100644
index 000000000..21e29aa16
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderCommentTable.java
@@ -0,0 +1,188 @@
+package org.wordpress.android.datasets;
+
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteStatement;
+
+import org.wordpress.android.models.ReaderComment;
+import org.wordpress.android.models.ReaderCommentList;
+import org.wordpress.android.models.ReaderPost;
+import org.wordpress.android.util.SqlUtils;
+
+/**
+ * stores comments on reader posts
+ */
+public class ReaderCommentTable {
+ private static final String COLUMN_NAMES =
+ " blog_id,"
+ + " post_id,"
+ + " comment_id,"
+ + " parent_id,"
+ + " author_name,"
+ + " author_avatar,"
+ + " author_url,"
+ + " author_id,"
+ + " author_blog_id,"
+ + " published,"
+ + " timestamp,"
+ + " status,"
+ + " text";
+
+
+ protected static void createTables(SQLiteDatabase db) {
+ db.execSQL("CREATE TABLE tbl_comments ("
+ + " blog_id INTEGER DEFAULT 0,"
+ + " post_id INTEGER DEFAULT 0,"
+ + " comment_id INTEGER DEFAULT 0,"
+ + " parent_id INTEGER DEFAULT 0,"
+ + " author_name TEXT,"
+ + " author_avatar TEXT,"
+ + " author_url TEXT,"
+ + " author_id INTEGER DEFAULT 0,"
+ + " author_blog_id INTEGER DEFAULT 0,"
+ + " published TEXT,"
+ + " timestamp INTEGER DEFAULT 0,"
+ + " status TEXT,"
+ + " text TEXT,"
+ + " PRIMARY KEY (blog_id, post_id, comment_id))");
+ }
+
+ protected static void dropTables(SQLiteDatabase db) {
+ db.execSQL("DROP TABLE IF EXISTS tbl_comments");
+ }
+
+ protected static void reset(SQLiteDatabase db) {
+ dropTables(db);
+ createTables(db);
+ }
+
+ public static boolean isEmpty() {
+ return (getNumComments()==0);
+ }
+
+ public static int getNumComments() {
+ long count = SqlUtils.getRowCount(ReaderDatabase.getReadableDb(), "tbl_comments");
+ return (int)count;
+ }
+
+ /*
+ * returns the #comments stored locally for this post, which may differ from ReaderPostTable.getNumCommentsOnPost
+ * (which is the #comments the server says exist for this post)
+ */
+ public static int getNumCommentsForPost(ReaderPost post) {
+ if (post == null) {
+ return 0;
+ }
+ String[] args = {Long.toString(post.blogId), Long.toString(post.postId)};
+ return SqlUtils.intForQuery(ReaderDatabase.getReadableDb(), "SELECT count(*) FROM tbl_comments WHERE blog_id=? AND post_id=?", args);
+ }
+
+ public static ReaderCommentList getCommentsForPost(ReaderPost post) {
+ if (post == null) {
+ return new ReaderCommentList();
+ }
+
+ String[] args = {Long.toString(post.blogId), Long.toString(post.postId)};
+ Cursor c = ReaderDatabase.getReadableDb().rawQuery("SELECT * FROM tbl_comments WHERE blog_id=? AND post_id=? ORDER BY timestamp", args);
+ try {
+ ReaderCommentList comments = new ReaderCommentList();
+ if (c.moveToFirst()) {
+ do {
+ comments.add(getCommentFromCursor(c));
+ } while (c.moveToNext());
+ }
+ return comments;
+ } finally {
+ SqlUtils.closeCursor(c);
+ }
+ }
+
+ public static void addOrUpdateComment(ReaderComment comment) {
+ if (comment == null) {
+ return;
+ }
+ ReaderCommentList comments = new ReaderCommentList();
+ comments.add(comment);
+ addOrUpdateComments(comments);
+ }
+
+ public static void addOrUpdateComments(ReaderCommentList comments) {
+ if (comments == null || comments.size() == 0) {
+ return;
+ }
+
+ SQLiteDatabase db = ReaderDatabase.getWritableDb();
+ db.beginTransaction();
+ SQLiteStatement stmt = db.compileStatement("INSERT OR REPLACE INTO tbl_comments ("
+ + COLUMN_NAMES
+ + ") VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)");
+ try {
+ for (ReaderComment comment: comments) {
+ stmt.bindLong (1, comment.blogId);
+ stmt.bindLong (2, comment.postId);
+ stmt.bindLong (3, comment.commentId);
+ stmt.bindLong (4, comment.parentId);
+ stmt.bindString(5, comment.getAuthorName());
+ stmt.bindString(6, comment.getAuthorAvatar());
+ stmt.bindString(7, comment.getAuthorUrl());
+ stmt.bindLong (8, comment.authorId);
+ stmt.bindLong (9, comment.authorBlogId);
+ stmt.bindString(10, comment.getPublished());
+ stmt.bindLong (11, comment.timestamp);
+ stmt.bindString(12, comment.getStatus());
+ stmt.bindString(13, comment.getText());
+
+ stmt.execute();
+ stmt.clearBindings();
+ }
+
+ db.setTransactionSuccessful();
+
+ } finally {
+ db.endTransaction();
+ SqlUtils.closeStatement(stmt);
+ }
+ }
+
+ /*
+ * purge comments attached to posts that no longer exist
+ */
+ protected static int purge(SQLiteDatabase db) {
+ return db.delete("tbl_comments", "post_id NOT IN (SELECT DISTINCT post_id FROM tbl_posts)", null);
+ }
+
+ public static void deleteComment(ReaderPost post, long commentId) {
+ if (post == null) {
+ return;
+ }
+ String[] args = {Long.toString(post.blogId), Long.toString(post.postId), Long.toString(commentId)};
+ ReaderDatabase.getWritableDb().delete("tbl_comments", "blog_id=? AND post_id=? AND comment_id=?", args);
+ }
+
+ public static ReaderComment getCommentFromCursor(Cursor c) {
+ if (c == null) {
+ throw new IllegalArgumentException("null comment cursor");
+ }
+
+ ReaderComment comment = new ReaderComment();
+
+ comment.commentId = c.getLong(c.getColumnIndex("comment_id"));
+ comment.blogId = c.getLong(c.getColumnIndex("blog_id"));
+ comment.postId = c.getLong(c.getColumnIndex("post_id"));
+ comment.parentId = c.getLong(c.getColumnIndex("parent_id"));
+
+ comment.setPublished(c.getString(c.getColumnIndex("published")));
+ comment.timestamp = c.getLong(c.getColumnIndex("timestamp"));
+
+ comment.setAuthorAvatar(c.getString(c.getColumnIndex("author_avatar")));
+ comment.setAuthorName(c.getString(c.getColumnIndex("author_name")));
+ comment.setAuthorUrl(c.getString(c.getColumnIndex("author_url")));
+ comment.authorId = c.getLong(c.getColumnIndex("author_id"));
+ comment.authorBlogId = c.getLong(c.getColumnIndex("author_blog_id"));
+
+ comment.setStatus(c.getString(c.getColumnIndex("status")));
+ comment.setText(c.getString(c.getColumnIndex("text")));
+
+ return comment;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/ReaderDatabase.java b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderDatabase.java
new file mode 100644
index 000000000..9b5f2be12
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderDatabase.java
@@ -0,0 +1,220 @@
+package org.wordpress.android.datasets;
+
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * database for all reader information
+ */
+public class ReaderDatabase extends SQLiteOpenHelper {
+ protected static final String DB_NAME = "wpreader.db";
+ private static final int DB_VERSION = 78;
+
+ /*
+ * version history
+ * 67 - added tbl_blog_info to ReaderBlogTable
+ * 68 - added author_blog_id to ReaderCommentTable
+ * 69 - renamed tbl_blog_urls to tbl_followed_blogs in ReaderBlogTable
+ * 70 - added author_id to ReaderCommentTable and ReaderPostTable
+ * 71 - added blog_id to ReaderUserTable
+ * 72 - removed tbl_followed_blogs from ReaderBlogTable
+ * 73 - added tbl_recommended_blogs to ReaderBlogTable
+ * 74 - added primary_tag to ReaderPostTable
+ * 75 - added secondary_tag to ReaderPostTable
+ * 76 - added feed_id to ReaderBlogTable
+ * 77 - restructured tag tables (ReaderTagTable)
+ * 78 - added tag_type to ReaderPostTable.tbl_post_tags
+ */
+
+ /*
+ * database singleton
+ */
+ private static ReaderDatabase mReaderDb;
+ private final static Object mDbLock = new Object();
+ public static ReaderDatabase getDatabase() {
+ if (mReaderDb == null) {
+ synchronized(mDbLock) {
+ if (mReaderDb == null) {
+ mReaderDb = new ReaderDatabase(WordPress.getContext());
+ // this ensures that onOpen() is called with a writable database (open will fail if app calls getReadableDb() first)
+ mReaderDb.getWritableDatabase();
+ }
+ }
+ }
+ return mReaderDb;
+ }
+
+ public static SQLiteDatabase getReadableDb() {
+ return getDatabase().getReadableDatabase();
+ }
+ public static SQLiteDatabase getWritableDb() {
+ return getDatabase().getWritableDatabase();
+ }
+
+ @Override
+ public void onOpen(SQLiteDatabase db) {
+ super.onOpen(db);
+ //copyDatabase(db);
+ }
+
+ /*
+ * resets (clears) the reader database
+ */
+ public static void reset() {
+ // note that we must call getWritableDb() before getDatabase() in case the database
+ // object hasn't been created yet
+ SQLiteDatabase db = getWritableDb();
+ getDatabase().reset(db);
+ }
+
+ public ReaderDatabase(Context context) {
+ super(context, DB_NAME, null, DB_VERSION);
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase db) {
+ createAllTables(db);
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // for now just reset the db when upgrading, future versions may want to avoid this
+ // and modify table structures, etc., on upgrade while preserving data
+ AppLog.i(T.READER, "Upgrading database from version " + oldVersion + " to version " + newVersion);
+ reset(db);
+ }
+
+ @Override
+ public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // IMPORTANT: do NOT call super() here - doing so throws a SQLiteException
+ AppLog.w(T.READER, "Downgrading database from version " + oldVersion + " to version " + newVersion);
+ reset(db);
+ }
+
+ private void createAllTables(SQLiteDatabase db) {
+ ReaderCommentTable.createTables(db);
+ ReaderLikeTable.createTables(db);
+ ReaderPostTable.createTables(db);
+ ReaderTagTable.createTables(db);
+ ReaderUserTable.createTables(db);
+ ReaderThumbnailTable.createTables(db);
+ ReaderBlogTable.createTables(db);
+ }
+
+ private void dropAllTables(SQLiteDatabase db) {
+ ReaderCommentTable.dropTables(db);
+ ReaderLikeTable.dropTables(db);
+ ReaderPostTable.dropTables(db);
+ ReaderTagTable.dropTables(db);
+ ReaderUserTable.dropTables(db);
+ ReaderThumbnailTable.dropTables(db);
+ ReaderBlogTable.dropTables(db);
+ }
+
+ /*
+ * drop & recreate all tables (essentially clears the db of all data)
+ */
+ private void reset(SQLiteDatabase db) {
+ db.beginTransaction();
+ try {
+ dropAllTables(db);
+ createAllTables(db);
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ /*
+ * purge older/unattached data - use purgeAsync() to do this in the background
+ */
+ private static void purge() {
+ SQLiteDatabase db = getWritableDb();
+ db.beginTransaction();
+ try {
+ int numPostsDeleted = ReaderPostTable.purge(db);
+
+ // don't bother purging other data unless posts were purged
+ if (numPostsDeleted > 0) {
+ AppLog.i(T.READER, String.format("%d total posts purged", numPostsDeleted));
+
+ // purge unattached comments
+ int numCommentsDeleted = ReaderCommentTable.purge(db);
+ if (numCommentsDeleted > 0) {
+ AppLog.i(T.READER, String.format("%d comments purged", numCommentsDeleted));
+ }
+
+ // purge unattached likes
+ int numLikesDeleted = ReaderLikeTable.purge(db);
+ if (numLikesDeleted > 0) {
+ AppLog.i(T.READER, String.format("%d likes purged", numLikesDeleted));
+ }
+
+ // purge unattached thumbnails
+ int numThumbsPurged = ReaderThumbnailTable.purge(db);
+ if (numThumbsPurged > 0) {
+ AppLog.i(T.READER, String.format("%d thumbnails purged", numThumbsPurged));
+ }
+
+ // purge unattached tags
+ int numTagsPurged = ReaderTagTable.purge(db);
+ if (numTagsPurged > 0) {
+ AppLog.i(T.READER, String.format("%d tags purged", numTagsPurged));
+ }
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ /*
+ * async purge
+ */
+ public static void purgeAsync() {
+ new Thread() {
+ @Override
+ public void run() {
+ purge();
+ }
+ }.start();
+ }
+
+ /*
+ * used during development to copy database to external storage so we can access it via DDMS
+ */
+ private void copyDatabase(SQLiteDatabase db) {
+ String copyFrom = db.getPath();
+ String copyTo = WordPress.getContext().getExternalFilesDir(null).getAbsolutePath() + "/" + DB_NAME;
+
+ try {
+ InputStream input = new FileInputStream(copyFrom);
+ OutputStream output = new FileOutputStream(copyTo);
+
+ byte[] buffer = new byte[1024];
+ int length;
+ while ((length = input.read(buffer)) > 0) {
+ output.write(buffer, 0, length);
+ }
+
+ output.flush();
+ output.close();
+ input.close();
+ } catch (IOException e) {
+ AppLog.e(T.DB, "failed to copy reader database", e);
+ }
+ }
+
+
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/ReaderLikeTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderLikeTable.java
new file mode 100644
index 000000000..c43457bc1
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderLikeTable.java
@@ -0,0 +1,130 @@
+package org.wordpress.android.datasets;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteStatement;
+
+import org.wordpress.android.models.ReaderPost;
+import org.wordpress.android.models.ReaderUserIdList;
+import org.wordpress.android.ui.prefs.UserPrefs;
+import org.wordpress.android.util.SqlUtils;
+
+/**
+ * stores likes for Reader posts
+ */
+public class ReaderLikeTable {
+ protected static void createTables(SQLiteDatabase db) {
+ db.execSQL("CREATE TABLE tbl_post_likes ("
+ + " post_id INTEGER,"
+ + " blog_id INTEGER,"
+ + " user_id INTEGER,"
+ + " PRIMARY KEY (blog_id, post_id, user_id))");
+ }
+
+ protected static void dropTables(SQLiteDatabase db) {
+ db.execSQL("DROP TABLE IF EXISTS tbl_post_likes");
+ }
+
+ protected static void reset(SQLiteDatabase db) {
+ dropTables(db);
+ createTables(db);
+ }
+
+ /*
+ * purge likes attached to posts that no longer exist
+ */
+ protected static int purge(SQLiteDatabase db) {
+ return db.delete("tbl_post_likes", "post_id NOT IN (SELECT DISTINCT post_id FROM tbl_posts)", null);
+ }
+
+ /*
+ * returns userIds of users who like the passed post
+ */
+ public static ReaderUserIdList getLikesForPost(ReaderPost post) {
+ ReaderUserIdList userIds = new ReaderUserIdList();
+ if (post==null)
+ return userIds;
+
+ String[] args = {Long.toString(post.blogId), Long.toString(post.postId)};
+ Cursor c = ReaderDatabase.getReadableDb().rawQuery("SELECT user_id FROM tbl_post_likes WHERE blog_id=? AND post_id=?", args);
+ try {
+ if (c.moveToFirst()) {
+ do {
+ userIds.add(c.getLong(0));
+ } while (c.moveToNext());
+ }
+
+ return userIds;
+ } finally {
+ SqlUtils.closeCursor(c);
+ }
+ }
+
+ public static int getNumLikesForPost(ReaderPost post) {
+ if (post == null) {
+ return 0;
+ }
+ String[] args = {Long.toString(post.blogId), Long.toString(post.postId)};
+ return SqlUtils.intForQuery(ReaderDatabase.getReadableDb(), "SELECT count(*) FROM tbl_post_likes WHERE blog_id=? AND post_id=?", args);
+ }
+
+ /*
+ * returns true if the passed user likes the passed post
+ */
+ /*private static boolean isLikedByUser(ReaderPost post, long userId) {
+ if (post==null)
+ return false;
+ String[] args = {Long.toString(post.blogId), Long.toString(post.postId), Long.toString(userId)};
+ return SqlUtils.boolForQuery(ReaderDatabase.getReadableDb(), "SELECT 1 FROM tbl_post_likes WHERE blog_id=? AND post_id=? AND user_id=?", args);
+ }*/
+
+ public static void setCurrentUserLikesPost(ReaderPost post, boolean isLiked) {
+ if (post==null)
+ return;
+ long userId = UserPrefs.getCurrentUserId();
+ if (isLiked) {
+ ContentValues values = new ContentValues();
+ values.put("blog_id", post.blogId);
+ values.put("post_id", post.postId);
+ values.put("user_id", userId);
+ ReaderDatabase.getWritableDb().insert("tbl_post_likes", null, values);
+ } else {
+ String args[] = {Long.toString(post.blogId), Long.toString(post.postId), Long.toString(userId)};
+ ReaderDatabase.getWritableDb().delete("tbl_post_likes", "blog_id=? AND post_id=? AND user_id=?", args);
+ }
+ }
+
+ public static void setLikesForPost(ReaderPost post, ReaderUserIdList userIds) {
+ if (post==null)
+ return;
+
+ SQLiteDatabase db = ReaderDatabase.getWritableDb();
+ db.beginTransaction();
+ SQLiteStatement stmt = db.compileStatement("INSERT INTO tbl_post_likes (blog_id, post_id, user_id) VALUES (?1,?2,?3)");
+ try {
+ // first delete all likes for this post
+ String[] args = {Long.toString(post.blogId), Long.toString(post.postId)};
+ db.delete("tbl_post_likes", "blog_id=? AND post_id=?", args);
+
+ // now insert the passed likes
+ if (userIds!=null) {
+ for (Long userId: userIds) {
+ stmt.bindLong(1, post.blogId);
+ stmt.bindLong(2, post.postId);
+ stmt.bindLong(3, userId);
+ stmt.execute();
+ stmt.clearBindings();
+ }
+ }
+
+ db.setTransactionSuccessful();
+
+ } finally {
+ db.endTransaction();
+ SqlUtils.closeStatement(stmt);
+ }
+ }
+}
+
+
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/ReaderPostTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderPostTable.java
new file mode 100644
index 000000000..75da0bebd
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderPostTable.java
@@ -0,0 +1,647 @@
+package org.wordpress.android.datasets;
+
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteStatement;
+import android.text.TextUtils;
+
+import org.wordpress.android.models.ReaderPost;
+import org.wordpress.android.models.ReaderPostList;
+import org.wordpress.android.models.ReaderTag;
+import org.wordpress.android.models.ReaderTagType;
+import org.wordpress.android.util.SqlUtils;
+
+/**
+ * tbl_posts contains all reader posts
+ * tbl_post_tags stores the association between posts and tags (posts can exist in more than one tag)
+ *
+ */
+public class ReaderPostTable {
+ private static final String COLUMN_NAMES =
+ "post_id," // 1
+ + "blog_id," // 2
+ + "pseudo_id," // 3
+ + "author_name," // 4
+ + "author_id," // 5
+ + "title," // 6
+ + "text," // 7
+ + "excerpt," // 8
+ + "url," // 9
+ + "blog_url," // 10
+ + "blog_name," // 11
+ + "featured_image," // 12
+ + "featured_video," // 13
+ + "post_avatar," // 14
+ + "timestamp," // 15
+ + "published," // 16
+ + "num_replies," // 17
+ + "num_likes," // 18
+ + "is_liked," // 19
+ + "is_followed," // 20
+ + "is_comments_open," // 21
+ + "is_reblogged," // 22
+ + "is_external," // 23
+ + "is_private," // 24
+ + "is_videopress," // 25
+ + "tag_list," // 26
+ + "primary_tag," // 27
+ + "secondary_tag"; // 28
+
+
+ protected static void createTables(SQLiteDatabase db) {
+ db.execSQL("CREATE TABLE tbl_posts ("
+ + " post_id INTEGER," // post_id for WP blogs, feed_item_id for non-WP blogs
+ + " blog_id INTEGER," // blog_id for WP blogs, feed_id for non-WP blogs
+ + " pseudo_id TEXT NOT NULL,"
+ + " author_name TEXT,"
+ + " author_id INTEGER DEFAULT 0,"
+ + " title TEXT,"
+ + " text TEXT,"
+ + " excerpt TEXT,"
+ + " url TEXT,"
+ + " blog_url TEXT,"
+ + " blog_name TEXT,"
+ + " featured_image TEXT,"
+ + " featured_video TEXT,"
+ + " post_avatar TEXT,"
+ + " timestamp INTEGER DEFAULT 0,"
+ + " published TEXT,"
+ + " num_replies INTEGER DEFAULT 0,"
+ + " num_likes INTEGER DEFAULT 0,"
+ + " is_liked INTEGER DEFAULT 0,"
+ + " is_followed INTEGER DEFAULT 0,"
+ + " is_comments_open INTEGER DEFAULT 0,"
+ + " is_reblogged INTEGER DEFAULT 0,"
+ + " is_external INTEGER DEFAULT 0,"
+ + " is_private INTEGER DEFAULT 0,"
+ + " is_videopress INTEGER DEFAULT 0,"
+ + " tag_list TEXT,"
+ + " primary_tag TEXT,"
+ + " secondary_tag TEXT,"
+ + " PRIMARY KEY (post_id, blog_id)"
+ + ")");
+
+ db.execSQL("CREATE TABLE tbl_post_tags ("
+ + " post_id INTEGER NOT NULL,"
+ + " blog_id INTEGER NOT NULL,"
+ + " pseudo_id TEXT NOT NULL,"
+ + " tag_name TEXT NOT NULL COLLATE NOCASE,"
+ + " tag_type INTEGER DEFAULT 0,"
+ + " PRIMARY KEY (post_id, blog_id, tag_name, tag_type)"
+ + ")");
+ }
+
+ protected static void dropTables(SQLiteDatabase db) {
+ db.execSQL("DROP TABLE IF EXISTS tbl_posts");
+ db.execSQL("DROP TABLE IF EXISTS tbl_post_tags");
+ }
+
+ protected static void reset(SQLiteDatabase db) {
+ dropTables(db);
+ createTables(db);
+ }
+
+ /*
+ * purge table of unattached posts - no need to wrap this in a transaction since this
+ * is only called from ReaderDatabase.purge() which already creates a transaction
+ */
+ protected static int purge(SQLiteDatabase db) {
+ // delete posts in tbl_post_tags attached to tags that no longer exist
+ int numDeleted = db.delete("tbl_post_tags", "tag_name NOT IN (SELECT DISTINCT tag_name FROM tbl_tags)", null);
+
+ // delete posts in tbl_posts that no longer exist in tbl_post_tags
+ numDeleted += db.delete("tbl_posts", "pseudo_id NOT IN (SELECT DISTINCT pseudo_id FROM tbl_post_tags)", null);
+
+ return numDeleted;
+ }
+
+ public static boolean isEmpty() {
+ return (getNumPosts() == 0);
+ }
+
+ private static int getNumPosts() {
+ long count = SqlUtils.getRowCount(ReaderDatabase.getReadableDb(), "tbl_posts");
+ return (int)count;
+ }
+
+ public static int getNumPostsInBlog(long blogId) {
+ return SqlUtils.intForQuery(ReaderDatabase.getReadableDb(),
+ "SELECT count(*) FROM tbl_posts WHERE blog_id=?",
+ new String[]{Long.toString(blogId)});
+ }
+
+ public static int getNumPostsWithTag(ReaderTag tag) {
+ if (tag == null) {
+ return 0;
+ }
+ String[] args = {tag.getTagName(), Integer.toString(tag.tagType.toInt())};
+ return SqlUtils.intForQuery(ReaderDatabase.getReadableDb(),
+ "SELECT count(*) FROM tbl_post_tags WHERE tag_name=? AND tag_type=?",
+ args);
+ }
+
+ public static boolean hasPostsWithTag(ReaderTag tag) {
+ return (getNumPostsWithTag(tag) > 0);
+ }
+
+ public static void addOrUpdatePost(ReaderPost post) {
+ if (post == null) {
+ return;
+ }
+ ReaderPostList posts = new ReaderPostList();
+ posts.add(post);
+ addOrUpdatePosts(null, posts);
+ }
+
+ public static ReaderPost getPost(long blogId, long postId) {
+ String[] args = new String[] {Long.toString(blogId), Long.toString(postId)};
+ Cursor c = ReaderDatabase.getReadableDb().rawQuery("SELECT * FROM tbl_posts WHERE blog_id=? AND post_id=? LIMIT 1", args);
+ try {
+ if (!c.moveToFirst()) {
+ return null;
+ }
+ return getPostFromCursor(c, null);
+ } finally {
+ SqlUtils.closeCursor(c);
+ }
+ }
+
+ public static void deletePost(long blogId, long postId) {
+ String[] args = {Long.toString(blogId), Long.toString(postId)};
+ ReaderDatabase.getWritableDb().delete("tbl_posts", "blog_id=? AND post_id=?", args);
+ }
+
+ public static String getPostTitle(long blogId, long postId) {
+ String[] args = {Long.toString(blogId), Long.toString(postId)};
+ return SqlUtils.stringForQuery(ReaderDatabase.getReadableDb(),
+ "SELECT title FROM tbl_posts WHERE blog_id=? AND post_id=?",
+ args);
+ }
+
+ public static boolean postExists(long blogId, long postId) {
+ String[] args = {Long.toString(blogId), Long.toString(postId)};
+ return SqlUtils.boolForQuery(ReaderDatabase.getReadableDb(),
+ "SELECT 1 FROM tbl_posts WHERE blog_id=? AND post_id=?",
+ args);
+ }
+
+ /*
+ * returns a count of which posts in the passed list don't already exist in the db for the passed tag
+ */
+ public static int getNumNewPostsWithTag(ReaderTag tag, ReaderPostList posts) {
+ if (posts == null || posts.size() == 0 || tag == null) {
+ return 0;
+ }
+
+ // if there aren't any posts in this tag, then all passed posts are new
+ if (getNumPostsWithTag(tag) == 0) {
+ return posts.size();
+ }
+
+ StringBuilder sb = new StringBuilder(
+ "SELECT COUNT(*) FROM tbl_post_tags WHERE tag_name=? AND tag_type=? AND pseudo_id IN (");
+ boolean isFirst = true;
+ for (ReaderPost post: posts) {
+ if (isFirst) {
+ isFirst = false;
+ } else {
+ sb.append(",");
+ }
+ sb.append("'").append(post.getPseudoId()).append("'");
+ }
+ sb.append(")");
+
+ String[] args = {tag.getTagName(), Integer.toString(tag.tagType.toInt())};
+ int numExisting = SqlUtils.intForQuery(ReaderDatabase.getReadableDb(), sb.toString(), args);
+ return posts.size() - numExisting;
+ }
+
+ /*
+ * returns the #comments known to exist for this post (ie: #comments the server says this post has), which
+ * may differ from ReaderCommentTable.getNumCommentsForPost (which returns # local comments for this post)
+ */
+ public static int getNumCommentsForPost(ReaderPost post) {
+ if (post == null) {
+ return 0;
+ }
+ String[] args = new String[] {Long.toString(post.blogId), Long.toString(post.postId)};
+ return SqlUtils.intForQuery(ReaderDatabase.getReadableDb(),
+ "SELECT num_replies FROM tbl_posts WHERE blog_id=? AND post_id=?",
+ args);
+ }
+
+ /*
+ * returns the #likes known to exist for this post (ie: #likes the server says this post has), which
+ * may differ from ReaderPostTable.getNumLikesForPost (which returns # local likes for this post)
+ */
+ public static int getNumLikesForPost(long blogId, long postId) {
+ String[] args = {Long.toString(blogId), Long.toString(postId)};
+ return SqlUtils.intForQuery(ReaderDatabase.getReadableDb(),
+ "SELECT num_likes FROM tbl_posts WHERE blog_id=? AND post_id=?",
+ args);
+ }
+
+ public static boolean isPostLikedByCurrentUser(long blogId, long postId) {
+ String[] args = new String[] {Long.toString(blogId), Long.toString(postId)};
+ return SqlUtils.boolForQuery(ReaderDatabase.getReadableDb(),
+ "SELECT is_liked FROM tbl_posts WHERE blog_id=? AND post_id=?",
+ args);
+ }
+
+ public static boolean isPostFollowed(ReaderPost post) {
+ if (post == null) {
+ return false;
+ }
+ String[] args = new String[] {Long.toString(post.blogId), Long.toString(post.postId)};
+ return SqlUtils.boolForQuery(ReaderDatabase.getReadableDb(),
+ "SELECT is_followed FROM tbl_posts WHERE blog_id=? AND post_id=?",
+ args);
+ }
+
+ /*
+ * updates the follow status of all posts in the passed list, returns true if any changed
+ */
+ public static boolean checkFollowStatusOnPosts(ReaderPostList posts) {
+ if (posts == null || posts.size() == 0) {
+ return false;
+ }
+
+ boolean isChanged = false;
+ for (ReaderPost post: posts) {
+ boolean isFollowed = isPostFollowed(post);
+ if (isFollowed != post.isFollowedByCurrentUser) {
+ post.isFollowedByCurrentUser = isFollowed;
+ isChanged = true;
+ }
+ }
+ return isChanged;
+ }
+
+ public static int deletePostsWithTag(final ReaderTag tag) {
+ if (tag == null) {
+ return 0;
+ }
+
+ // first delete posts from tbl_post_tags, and if any were deleted next delete posts in tbl_posts that no longer exist in tbl_post_tags
+ String[] args = {tag.getTagName(), Integer.toString(tag.tagType.toInt())};
+ int numDeleted = ReaderDatabase.getWritableDb().delete("tbl_post_tags",
+ "tag_name=? AND tag_type=?",
+ args);
+
+ if (numDeleted > 0)
+ ReaderDatabase.getWritableDb().delete("tbl_posts",
+ "post_id NOT IN (SELECT DISTINCT post_id FROM tbl_post_tags)",
+ null);
+
+ return numDeleted;
+ }
+
+ /*
+ * returns the iso8601 published date of the oldest post with the passed tag
+ */
+ public static String getOldestPubDateWithTag(final ReaderTag tag) {
+ if (tag == null) {
+ return "";
+ }
+
+ String sql = "SELECT tbl_posts.published FROM tbl_posts, tbl_post_tags"
+ + " WHERE tbl_posts.post_id = tbl_post_tags.post_id AND tbl_posts.blog_id = tbl_post_tags.blog_id"
+ + " AND tbl_post_tags.tag_name=? AND tbl_post_tags.tag_type=?"
+ + " ORDER BY published LIMIT 1";
+ String[] args = {tag.getTagName(), Integer.toString(tag.tagType.toInt())};
+ return SqlUtils.stringForQuery(ReaderDatabase.getReadableDb(), sql, args);
+ }
+
+ /*
+ * returns the iso8601 published date of the oldest post in the passed blog
+ */
+ public static String getOldestPubDateInBlog(long blogId) {
+ String sql = "SELECT published FROM tbl_posts"
+ + " WHERE blog_id = ?"
+ + " ORDER BY published LIMIT 1";
+ return SqlUtils.stringForQuery(ReaderDatabase.getReadableDb(), sql, new String[]{Long.toString(blogId)});
+ }
+
+ /*
+ * sets the following status for all posts in the passed blog
+ */
+ public static void setFollowStatusForPostsInBlog(long blogId, String blogUrl, boolean isFollowed) {
+ if (blogId == 0 && TextUtils.isEmpty(blogUrl)) {
+ return;
+ }
+
+ SQLiteDatabase db = ReaderDatabase.getWritableDb();
+ db.beginTransaction();
+ try {
+ // change is_followed in tbl_posts for this blog - use blogId if we have it,
+ // otherwise use url
+ if (blogId != 0) {
+ String sql = "UPDATE tbl_posts SET is_followed=" + SqlUtils.boolToSql(isFollowed)
+ + " WHERE blog_id=?";
+ db.execSQL(sql, new String[]{Long.toString(blogId)});
+ } else {
+ String sql = "UPDATE tbl_posts SET is_followed=" + SqlUtils.boolToSql(isFollowed)
+ + " WHERE blog_url=?";
+ db.execSQL(sql, new String[]{blogUrl});
+ }
+
+ // if blog is no longer followed, remove its posts tagged with "Blogs I Follow" in
+ // tbl_post_tags - note that this requires the blogId
+ if (!isFollowed && blogId != 0) {
+ db.delete("tbl_post_tags", "blog_id=? AND tag_name=?",
+ new String[]{Long.toString(blogId), ReaderTag.TAG_NAME_FOLLOWING});
+ }
+
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ public static void addOrUpdatePosts(final ReaderTag tag, ReaderPostList posts) {
+ if (posts == null || posts.size() == 0) {
+ return;
+ }
+
+ SQLiteDatabase db = ReaderDatabase.getWritableDb();
+ SQLiteStatement stmtPosts = db.compileStatement(
+ "INSERT OR REPLACE INTO tbl_posts ("
+ + COLUMN_NAMES
+ + ") VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23,?24,?25,?26,?27,?28)");
+ SQLiteStatement stmtTags = db.compileStatement(
+ "INSERT OR REPLACE INTO tbl_post_tags (post_id, blog_id, pseudo_id, tag_name, tag_type) VALUES (?1,?2,?3,?4,?5)");
+
+ db.beginTransaction();
+ try {
+ // first insert into tbl_posts
+ for (ReaderPost post: posts) {
+ stmtPosts.bindLong (1, post.postId);
+ stmtPosts.bindLong (2, post.blogId);
+ stmtPosts.bindString(3, post.getPseudoId());
+ stmtPosts.bindString(4, post.getAuthorName());
+ stmtPosts.bindLong (5, post.authorId);
+ stmtPosts.bindString(6, post.getTitle());
+ stmtPosts.bindString(7, post.getText());
+ stmtPosts.bindString(8, post.getExcerpt());
+ stmtPosts.bindString(9, post.getUrl());
+ stmtPosts.bindString(10, post.getBlogUrl());
+ stmtPosts.bindString(11, post.getBlogName());
+ stmtPosts.bindString(12, post.getFeaturedImage());
+ stmtPosts.bindString(13, post.getFeaturedVideo());
+ stmtPosts.bindString(14, post.getPostAvatar());
+ stmtPosts.bindLong (15, post.timestamp);
+ stmtPosts.bindString(16, post.getPublished());
+ stmtPosts.bindLong (17, post.numReplies);
+ stmtPosts.bindLong (18, post.numLikes);
+ stmtPosts.bindLong (19, SqlUtils.boolToSql(post.isLikedByCurrentUser));
+ stmtPosts.bindLong (20, SqlUtils.boolToSql(post.isFollowedByCurrentUser));
+ stmtPosts.bindLong (21, SqlUtils.boolToSql(post.isCommentsOpen));
+ stmtPosts.bindLong (22, SqlUtils.boolToSql(post.isRebloggedByCurrentUser));
+ stmtPosts.bindLong (23, SqlUtils.boolToSql(post.isExternal));
+ stmtPosts.bindLong (24, SqlUtils.boolToSql(post.isPrivate));
+ stmtPosts.bindLong (25, SqlUtils.boolToSql(post.isVideoPress));
+ stmtPosts.bindString(26, post.getTags());
+ stmtPosts.bindString(27, post.getPrimaryTag());
+ stmtPosts.bindString(28, post.getSecondaryTag());
+ stmtPosts.execute();
+ stmtPosts.clearBindings();
+ }
+
+ // now add to tbl_post_tags - note that tagName will be null when updating a single
+ // post, in which case we skip it here
+ if (tag != null) {
+ String tagName = tag.getTagName();
+ int tagType = tag.tagType.toInt();
+ for (ReaderPost post: posts) {
+ stmtTags.bindLong (1, post.postId);
+ stmtTags.bindLong (2, post.blogId);
+ stmtTags.bindString(3, post.getPseudoId());
+ stmtTags.bindString(4, tagName);
+ stmtTags.bindLong (5, tagType);
+ stmtTags.execute();
+ stmtTags.clearBindings();
+ }
+ }
+
+ db.setTransactionSuccessful();
+
+ } finally {
+ db.endTransaction();
+ SqlUtils.closeStatement(stmtPosts);
+ SqlUtils.closeStatement(stmtTags);
+ }
+ }
+
+ public static ReaderPostList getPostsWithTag(ReaderTag tag, int maxPosts) {
+ if (tag == null) {
+ return new ReaderPostList();
+ }
+
+ String sql = "SELECT tbl_posts.* FROM tbl_posts, tbl_post_tags"
+ + " WHERE tbl_posts.post_id = tbl_post_tags.post_id"
+ + " AND tbl_posts.blog_id = tbl_post_tags.blog_id"
+ + " AND tbl_post_tags.tag_name=?"
+ + " AND tbl_post_tags.tag_type=?";
+
+ if (tag.tagType == ReaderTagType.DEFAULT) {
+ // skip posts that are no longer liked if this is "Posts I Like", skip posts that are no
+ // longer followed if this is "Blogs I Follow"
+ if (tag.getTagName().equals(ReaderTag.TAG_NAME_LIKED)) {
+ sql += " AND tbl_posts.is_liked != 0";
+ } else if (tag.getTagName().equals(ReaderTag.TAG_NAME_FOLLOWING)) {
+ sql += " AND tbl_posts.is_followed != 0";
+ }
+ }
+
+ sql += " ORDER BY tbl_posts.timestamp DESC";
+
+ if (maxPosts > 0) {
+ sql += " LIMIT " + Integer.toString(maxPosts);
+ }
+
+ String[] args = {tag.getTagName(), Integer.toString(tag.tagType.toInt())};
+ Cursor cursor = ReaderDatabase.getReadableDb().rawQuery(sql, args);
+ try {
+ ReaderPostList posts = new ReaderPostList();
+ if (cursor != null && cursor.moveToFirst()) {
+ // create column indexes object that can be used for every post in this cursor so
+ // getPostFromCursor() doesn't need to call "getColumnIndex()" for every row
+ final PostColumnIndexes cols = new PostColumnIndexes(cursor);
+ do {
+ posts.add(getPostFromCursor(cursor, cols));
+ } while (cursor.moveToNext());
+ }
+ return posts;
+ } finally {
+ SqlUtils.closeCursor(cursor);
+ }
+ }
+
+ public static ReaderPostList getPostsInBlog(long blogId, int maxPosts) {
+ String sql = "SELECT * FROM tbl_posts WHERE blog_id = ? ORDER BY tbl_posts.timestamp DESC";
+
+ if (maxPosts > 0) {
+ sql += " LIMIT " + Integer.toString(maxPosts);
+ }
+
+ Cursor cursor = ReaderDatabase.getReadableDb().rawQuery(sql, new String[]{Long.toString(blogId)});
+ try {
+ ReaderPostList posts = new ReaderPostList();
+ if (cursor == null || !cursor.moveToFirst()) {
+ return posts;
+ }
+
+ final PostColumnIndexes cols = new PostColumnIndexes(cursor);
+ do {
+ posts.add(getPostFromCursor(cursor, cols));
+ } while (cursor.moveToNext());
+
+ return posts;
+ } finally {
+ SqlUtils.closeCursor(cursor);
+ }
+ }
+
+
+ public static void setPostReblogged(ReaderPost post, boolean isReblogged) {
+ if (post == null) {
+ return;
+ }
+
+ String sql = "UPDATE tbl_posts SET is_reblogged=" + SqlUtils.boolToSql(isReblogged)
+ + " WHERE blog_id=? AND post_id=?";
+ String[] args = {Long.toString(post.blogId), Long.toString(post.postId)};
+ ReaderDatabase.getWritableDb().execSQL(sql, args);
+ }
+
+ /*
+ * stores column indexes for a specific cursor - used when loading multiple posts from
+ * a cursor to avoid having to call getColumnIndex() for every row
+ */
+ private static class PostColumnIndexes {
+ private final int idx_post_id;
+ private final int idx_blog_id;
+ private final int idx_pseudo_id;
+
+ private final int idx_author_name;
+ private final int idx_author_id;
+ private final int idx_blog_name;
+ private final int idx_blog_url;
+ private final int idx_excerpt;
+ private final int idx_featured_image;
+ private final int idx_featured_video;
+
+ private final int idx_title;
+ private final int idx_text;
+ private final int idx_url;
+ private final int idx_post_avatar;
+
+ private final int idx_timestamp;
+ private final int idx_published;
+
+ private final int idx_num_replies;
+ private final int idx_num_likes;
+
+ private final int idx_is_liked;
+ private final int idx_is_followed;
+ private final int idx_is_comments_open;
+ private final int idx_is_reblogged;
+ private final int idx_is_external;
+ private final int idx_is_private;
+ private final int idx_is_videopress;
+
+ private final int idx_tag_list;
+ private final int idx_primary_tag;
+ private final int idx_secondary_tag;
+
+ private PostColumnIndexes(Cursor c) {
+ if (c == null)
+ throw new IllegalArgumentException("PostColumnIndexes > null cursor");
+
+ idx_post_id = c.getColumnIndex("post_id");
+ idx_blog_id = c.getColumnIndex("blog_id");
+ idx_pseudo_id = c.getColumnIndex("pseudo_id");
+
+ idx_author_name = c.getColumnIndex("author_name");
+ idx_author_id = c.getColumnIndex("author_id");
+ idx_blog_name = c.getColumnIndex("blog_name");
+ idx_blog_url = c.getColumnIndex("blog_url");
+ idx_excerpt = c.getColumnIndex("excerpt");
+ idx_featured_image = c.getColumnIndex("featured_image");
+ idx_featured_video = c.getColumnIndex("featured_video");
+
+ idx_title = c.getColumnIndex("title");
+ idx_text = c.getColumnIndex("text");
+ idx_url = c.getColumnIndex("url");
+ idx_post_avatar = c.getColumnIndex("post_avatar");
+
+ idx_timestamp = c.getColumnIndex("timestamp");
+ idx_published = c.getColumnIndex("published");
+
+ idx_num_replies = c.getColumnIndex("num_replies");
+ idx_num_likes = c.getColumnIndex("num_likes");
+
+ idx_is_liked = c.getColumnIndex("is_liked");
+ idx_is_followed = c.getColumnIndex("is_followed");
+ idx_is_comments_open = c.getColumnIndex("is_comments_open");
+ idx_is_reblogged = c.getColumnIndex("is_reblogged");
+ idx_is_external = c.getColumnIndex("is_external");
+ idx_is_private = c.getColumnIndex("is_private");
+ idx_is_videopress = c.getColumnIndex("is_videopress");
+
+ idx_tag_list = c.getColumnIndex("tag_list");
+ idx_primary_tag = c.getColumnIndex("primary_tag");
+ idx_secondary_tag = c.getColumnIndex("secondary_tag");
+ }
+ }
+
+ private static ReaderPost getPostFromCursor(Cursor c, PostColumnIndexes cols) {
+ if (c == null) {
+ throw new IllegalArgumentException("getPostFromCursor > null cursor");
+ }
+
+ ReaderPost post = new ReaderPost();
+
+ // if column index object wasn't passed, create it now
+ if (cols == null) {
+ cols = new PostColumnIndexes(c);
+ }
+
+ post.postId = c.getLong(cols.idx_post_id);
+ post.blogId = c.getLong(cols.idx_blog_id);
+ post.authorId = c.getLong(cols.idx_author_id);
+ post.setPseudoId(c.getString(cols.idx_pseudo_id));
+
+ post.setAuthorName(c.getString(cols.idx_author_name));
+ post.setBlogName(c.getString(cols.idx_blog_name));
+ post.setBlogUrl(c.getString(cols.idx_blog_url));
+ post.setExcerpt(c.getString(cols.idx_excerpt));
+ post.setFeaturedImage(c.getString(cols.idx_featured_image));
+ post.setFeaturedVideo(c.getString(cols.idx_featured_video));
+
+ post.setTitle(c.getString(cols.idx_title));
+ post.setText(c.getString(cols.idx_text));
+ post.setUrl(c.getString(cols.idx_url));
+ post.setPostAvatar(c.getString(cols.idx_post_avatar));
+
+ post.timestamp = c.getLong(cols.idx_timestamp);
+ post.setPublished(c.getString(cols.idx_published));
+
+ post.numReplies = c.getInt(cols.idx_num_replies);
+ post.numLikes = c.getInt(cols.idx_num_likes);
+
+ post.isLikedByCurrentUser = SqlUtils.sqlToBool(c.getInt(cols.idx_is_liked));
+ post.isFollowedByCurrentUser = SqlUtils.sqlToBool(c.getInt(cols.idx_is_followed));
+ post.isCommentsOpen = SqlUtils.sqlToBool(c.getInt(cols.idx_is_comments_open));
+ post.isRebloggedByCurrentUser = SqlUtils.sqlToBool(c.getInt(cols.idx_is_reblogged));
+ post.isExternal = SqlUtils.sqlToBool(c.getInt(cols.idx_is_external));
+ post.isPrivate = SqlUtils.sqlToBool(c.getInt(cols.idx_is_private));
+ post.isVideoPress = SqlUtils.sqlToBool(c.getInt(cols.idx_is_videopress));
+
+ post.setTags(c.getString(cols.idx_tag_list));
+ post.setPrimaryTag(c.getString(cols.idx_primary_tag));
+ post.setSecondaryTag(c.getString(cols.idx_secondary_tag));
+
+ return post;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/ReaderTagTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderTagTable.java
new file mode 100644
index 000000000..25b988c81
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderTagTable.java
@@ -0,0 +1,406 @@
+package org.wordpress.android.datasets;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteStatement;
+import android.text.TextUtils;
+
+import org.wordpress.android.models.ReaderTag;
+import org.wordpress.android.models.ReaderTagList;
+import org.wordpress.android.models.ReaderTagType;
+import org.wordpress.android.ui.reader.ReaderConstants;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.DateTimeUtils;
+import org.wordpress.android.util.SqlUtils;
+
+import java.util.Date;
+
+/**
+ * tbl_tags stores the list of tags the user subscribed to or has by default
+ * tbl_tags_recommended stores the list of recommended tags returned by the api
+ * tbl_tag_updates stores the iso8601 dates each tag was updated by the app as follows:
+ * date_updated is the date the tag was last updated
+ * date_newest is used when retrieving new posts - only get posts newer than date_newest
+ * date_oldest is used when retrieving old posts - only get posts older than date_oldest
+ */
+public class ReaderTagTable {
+ private static final String COLUMN_NAMES = "tag_name, tag_type, endpoint";
+
+ protected static void createTables(SQLiteDatabase db) {
+ db.execSQL("CREATE TABLE tbl_tags ("
+ + " tag_name TEXT COLLATE NOCASE,"
+ + " tag_type INTEGER DEFAULT 0,"
+ + " endpoint TEXT,"
+ + " PRIMARY KEY (tag_name, tag_type)"
+ + ")");
+
+ db.execSQL("CREATE TABLE tbl_tags_recommended ("
+ + " tag_name TEXT COLLATE NOCASE,"
+ + " tag_type INTEGER DEFAULT 0,"
+ + " endpoint TEXT,"
+ + " PRIMARY KEY (tag_name, tag_type)"
+ + ")");
+
+ db.execSQL("CREATE TABLE tbl_tag_updates ("
+ + " tag_name TEXT COLLATE NOCASE,"
+ + " tag_type INTEGER DEFAULT 0,"
+ + " date_updated TEXT,"
+ + " date_oldest TEXT,"
+ + " date_newest TEXT,"
+ + " PRIMARY KEY (tag_name, tag_type)"
+ + ")");
+ }
+
+ protected static void dropTables(SQLiteDatabase db) {
+ db.execSQL("DROP TABLE IF EXISTS tbl_tags");
+ db.execSQL("DROP TABLE IF EXISTS tbl_tags_recommended");
+ db.execSQL("DROP TABLE IF EXISTS tbl_tag_updates");
+ }
+
+ /*
+ * remove update data for tags that no longer exist
+ */
+ protected static int purge(SQLiteDatabase db) {
+ return db.delete("tbl_tag_updates", "tag_name NOT IN (SELECT DISTINCT tag_name FROM tbl_tags)", null);
+ }
+
+ /*
+ * returns true if tbl_tags is empty
+ */
+ public static boolean isEmpty() {
+ return (SqlUtils.getRowCount(ReaderDatabase.getReadableDb(), "tbl_tags") == 0);
+ }
+
+ /*
+ * replaces all tags with the passed list
+ */
+ public static void replaceTags(ReaderTagList tags) {
+ if (tags == null || tags.size() == 0) {
+ return;
+ }
+
+ SQLiteDatabase db = ReaderDatabase.getWritableDb();
+ db.beginTransaction();
+ try {
+ try {
+ // first delete all existing tags, then insert the passed ones
+ db.execSQL("DELETE FROM tbl_tags");
+ addOrUpdateTags(tags);
+ db.setTransactionSuccessful();
+ } catch (SQLException e) {
+ AppLog.e(T.READER, e);
+ }
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ public static void addOrUpdateTag(ReaderTag tag) {
+ if (tag == null) {
+ return;
+ }
+ ReaderTagList tags = new ReaderTagList();
+ tags.add(tag);
+ addOrUpdateTags(tags);
+ }
+
+ private static void addOrUpdateTags(ReaderTagList tagList) {
+ if (tagList == null || tagList.size() == 0) {
+ return;
+ }
+ SQLiteStatement stmt = null;
+ try {
+ stmt = ReaderDatabase.getWritableDb().compileStatement(
+ "INSERT OR REPLACE INTO tbl_tags ("
+ + COLUMN_NAMES
+ + ") VALUES (?1,?2,?3)");
+
+ for (ReaderTag tag: tagList) {
+ stmt.bindString(1, tag.getTagName());
+ stmt.bindLong (2, tag.tagType.toInt());
+ stmt.bindString(3, tag.getEndpoint());
+ stmt.execute();
+ stmt.clearBindings();
+ }
+
+ } finally {
+ SqlUtils.closeStatement(stmt);
+ }
+ }
+
+ /*
+ * returns true if the passed tag exists, regardless of type
+ */
+ public static boolean tagExists(ReaderTag tag) {
+ if (tag == null) {
+ return false;
+ }
+ String[] args = {tag.getTagName(), Integer.toString(tag.tagType.toInt())};
+ return SqlUtils.boolForQuery(ReaderDatabase.getReadableDb(),
+ "SELECT 1 FROM tbl_tags WHERE tag_name=?1 AND tag_type=?2",
+ args);
+ }
+
+ /*
+ * returns true if the passed tag exists and it has the passed type
+ */
+ private static boolean tagExistsOfType(String tagName, ReaderTagType tagType) {
+ if (TextUtils.isEmpty(tagName) || tagType == null) {
+ return false;
+ }
+
+ String[] args = {tagName, Integer.toString(tagType.toInt())};
+ return SqlUtils.boolForQuery(ReaderDatabase.getReadableDb(),
+ "SELECT 1 FROM tbl_tags WHERE tag_name=?1 AND tag_type=?2",
+ args);
+ }
+
+ public static boolean isFollowedTagName(String tagName) {
+ return tagExistsOfType(tagName, ReaderTagType.FOLLOWED);
+ }
+
+ public static boolean isDefaultTagName(String tagName) {
+ return tagExistsOfType(tagName, ReaderTagType.DEFAULT);
+ }
+
+ private static ReaderTag getTagFromCursor(Cursor c) {
+ if (c == null) {
+ throw new IllegalArgumentException("null tag cursor");
+ }
+
+ String tagName = c.getString(c.getColumnIndex("tag_name"));
+ String endpoint = c.getString(c.getColumnIndex("endpoint"));
+ ReaderTagType tagType = ReaderTagType.fromInt(c.getInt(c.getColumnIndex("tag_type")));
+
+ return new ReaderTag(tagName, endpoint, tagType);
+ }
+
+ public static ReaderTag getTag(String tagName, ReaderTagType tagType) {
+ if (TextUtils.isEmpty(tagName)) {
+ return null;
+ }
+
+ String[] args = {tagName, Integer.toString(tagType.toInt())};
+ Cursor c = ReaderDatabase.getReadableDb().rawQuery("SELECT * FROM tbl_tags WHERE tag_name=? AND tag_type=? LIMIT 1", args);
+ try {
+ if (!c.moveToFirst()) {
+ return null;
+ }
+ return getTagFromCursor(c);
+ } finally {
+ SqlUtils.closeCursor(c);
+ }
+ }
+
+ public static String getEndpointForTag(ReaderTag tag) {
+ if (tag == null) {
+ return null;
+ }
+ String[] args = {tag.getTagName(), Integer.toString(tag.tagType.toInt())};
+ return SqlUtils.stringForQuery(ReaderDatabase.getReadableDb(),
+ "SELECT endpoint FROM tbl_tags WHERE tag_name=? AND tag_type=?",
+ args);
+ }
+
+ public static ReaderTagList getDefaultTags() {
+ return getTagsOfType(ReaderTagType.DEFAULT);
+ }
+
+ public static ReaderTagList getFollowedTags() {
+ return getTagsOfType(ReaderTagType.FOLLOWED);
+ }
+
+ private static ReaderTagList getTagsOfType(ReaderTagType tagType) {
+ String[] args = {Integer.toString(tagType.toInt())};
+ Cursor c = ReaderDatabase.getReadableDb().rawQuery("SELECT * FROM tbl_tags WHERE tag_type=? ORDER BY tag_name", args);
+ try {
+ ReaderTagList tagList = new ReaderTagList();
+ if (c.moveToFirst()) {
+ do {
+ tagList.add(getTagFromCursor(c));
+ } while (c.moveToNext());
+ }
+ return tagList;
+ } finally {
+ SqlUtils.closeCursor(c);
+ }
+ }
+
+ public static void deleteTag(ReaderTag tag) {
+ if (tag == null) {
+ return;
+ }
+ String[] args = {tag.getTagName(), Integer.toString(tag.tagType.toInt())};
+ ReaderDatabase.getWritableDb().delete("tbl_tags", "tag_name=? AND tag_type=?", args);
+ ReaderDatabase.getWritableDb().delete("tbl_tag_updates", "tag_name=? AND tag_type=?", args);
+ }
+
+ /**
+ * tbl_tag_updates routines
+ **/
+ public static String getTagNewestDate(ReaderTag tag) {
+ if (tag == null) {
+ return "";
+ }
+ String[] args = {tag.getTagName(), Integer.toString(tag.tagType.toInt())};
+ return SqlUtils.stringForQuery(ReaderDatabase.getReadableDb(),
+ "SELECT date_newest FROM tbl_tag_updates WHERE tag_name=? AND tag_type=?",
+ args);
+ }
+ public static void setTagNewestDate(ReaderTag tag, String date) {
+ if (tag == null) {
+ return;
+ }
+
+ ContentValues values = new ContentValues();
+ values.put("tag_name", tag.getTagName());
+ values.put("tag_type", tag.tagType.toInt());
+ values.put("date_newest", date);
+ try {
+ ReaderDatabase.getWritableDb().insertWithOnConflict("tbl_tag_updates", null, values, SQLiteDatabase.CONFLICT_REPLACE);
+ } catch (SQLException e) {
+ AppLog.e(T.READER, e);
+ }
+ }
+
+ public static String getTagOldestDate(ReaderTag tag) {
+ if (tag == null) {
+ return "";
+ }
+ String[] args = {tag.getTagName(), Integer.toString(tag.tagType.toInt())};
+ return SqlUtils.stringForQuery(ReaderDatabase.getReadableDb(),
+ "SELECT date_oldest FROM tbl_tag_updates WHERE tag_name=? AND tag_type=?",
+ args);
+ }
+ public static void setTagOldestDate(ReaderTag tag, String date) {
+ if (tag == null) {
+ return;
+ }
+
+ ContentValues values = new ContentValues();
+ values.put("tag_name", tag.getTagName());
+ values.put("tag_type", tag.tagType.toInt());
+ values.put("date_oldest", date);
+ try {
+ ReaderDatabase.getWritableDb().insertWithOnConflict("tbl_tag_updates", null, values, SQLiteDatabase.CONFLICT_REPLACE);
+ } catch (SQLException e) {
+ AppLog.e(T.READER, e);
+ }
+ }
+
+ private static String getTagLastUpdated(ReaderTag tag) {
+ if (tag == null) {
+ return "";
+ }
+ String[] args = {tag.getTagName(), Integer.toString(tag.tagType.toInt())};
+ return SqlUtils.stringForQuery(ReaderDatabase.getReadableDb(),
+ "SELECT date_updated FROM tbl_tag_updates WHERE tag_name=? AND tag_type=?",
+ args);
+ }
+
+ public static void setTagLastUpdated(ReaderTag tag, String date) {
+ if (tag == null) {
+ return;
+ }
+
+ ContentValues values = new ContentValues();
+ values.put("tag_name", tag.getTagName());
+ values.put("tag_type", tag.tagType.toInt());
+ values.put("date_updated", date);
+ try {
+ ReaderDatabase.getWritableDb().insertWithOnConflict("tbl_tag_updates", null, values, SQLiteDatabase.CONFLICT_REPLACE);
+ } catch (SQLException e) {
+ AppLog.e(T.READER, e);
+ }
+ }
+
+ /*
+ * determine whether the passed tag should be auto-updated based on when it was last updated
+ */
+ public static boolean shouldAutoUpdateTag(ReaderTag tag) {
+ int minutes = minutesSinceLastUpdate(tag);
+ if (minutes == NEVER_UPDATED) {
+ return true;
+ }
+ return (minutes >= ReaderConstants.READER_AUTO_UPDATE_DELAY_MINUTES);
+ }
+
+ private static final int NEVER_UPDATED = -1;
+ private static int minutesSinceLastUpdate(ReaderTag tag) {
+ if (tag == null) {
+ return 0;
+ }
+
+ String updated = getTagLastUpdated(tag);
+ if (TextUtils.isEmpty(updated)) {
+ return NEVER_UPDATED;
+ }
+
+ Date dtUpdated = DateTimeUtils.iso8601ToJavaDate(updated);
+ if (dtUpdated == null) {
+ return 0;
+ }
+
+ Date dtNow = new Date();
+ return DateTimeUtils.minutesBetween(dtUpdated, dtNow);
+ }
+
+ /**
+ * recommended tags - stored in a separate table from default/subscribed tags, but have the same column names
+ **/
+ public static ReaderTagList getRecommendedTags(boolean excludeSubscribed) {
+ Cursor c;
+ if (excludeSubscribed) {
+ c = ReaderDatabase.getReadableDb().rawQuery("SELECT * FROM tbl_tags_recommended WHERE tag_name NOT IN (SELECT tag_name FROM tbl_tags) ORDER BY tag_name", null);
+ } else {
+ c = ReaderDatabase.getReadableDb().rawQuery("SELECT * FROM tbl_tags_recommended ORDER BY tag_name", null);
+ }
+ try {
+ ReaderTagList tagList = new ReaderTagList();
+ if (c.moveToFirst()) {
+ do {
+ tagList.add(getTagFromCursor(c));
+ } while (c.moveToNext());
+ }
+ return tagList;
+ } finally {
+ SqlUtils.closeCursor(c);
+ }
+ }
+
+ public static void setRecommendedTags(ReaderTagList tagList) {
+ if (tagList == null) {
+ return;
+ }
+
+ SQLiteDatabase db = ReaderDatabase.getWritableDb();
+ SQLiteStatement stmt = db.compileStatement("INSERT INTO tbl_tags_recommended (" + COLUMN_NAMES + ") VALUES (?1,?2,?3)");
+ db.beginTransaction();
+ try {
+ try {
+ // first delete all recommended tags
+ db.execSQL("DELETE FROM tbl_tags_recommended");
+
+ // then insert the passed ones
+ for (ReaderTag tag: tagList) {
+ stmt.bindString(1, tag.getTagName());
+ stmt.bindLong (2, tag.tagType.toInt());
+ stmt.bindString(3, tag.getEndpoint());
+ stmt.execute();
+ stmt.clearBindings();
+ }
+
+ db.setTransactionSuccessful();
+
+ } catch (SQLException e) {
+ AppLog.e(T.READER, e);
+ }
+ } finally {
+ SqlUtils.closeStatement(stmt);
+ db.endTransaction();
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/ReaderThumbnailTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderThumbnailTable.java
new file mode 100644
index 000000000..9c6ae3f57
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderThumbnailTable.java
@@ -0,0 +1,59 @@
+package org.wordpress.android.datasets;
+
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteStatement;
+import android.text.TextUtils;
+
+import org.wordpress.android.util.ReaderVideoUtils;
+import org.wordpress.android.util.SqlUtils;
+
+/**
+ * stores thumbnail urls for videos embedded in Reader posts
+ */
+public class ReaderThumbnailTable {
+ protected static void createTables(SQLiteDatabase db) {
+ db.execSQL("CREATE TABLE tbl_thumbnails ("
+ + " full_url TEXT COLLATE NOCASE PRIMARY KEY,"
+ + " thumbnail_url TEXT NOT NULL,"
+ + " post_id INTEGER)");
+ }
+
+ protected static void dropTables(SQLiteDatabase db) {
+ db.execSQL("DROP TABLE IF EXISTS tbl_thumbnails");
+ }
+
+ /*
+ * purge table of thumbnails attached to posts that no longer exist
+ */
+ protected static int purge(SQLiteDatabase db) {
+ return db.delete("tbl_thumbnails", "post_id NOT IN (SELECT DISTINCT post_id FROM tbl_posts)", null);
+ }
+
+ public static void addThumbnail(long postId, String fullUrl, String thumbnailUrl) {
+ if (TextUtils.isEmpty(fullUrl) || TextUtils.isEmpty(thumbnailUrl))
+ return;
+
+ SQLiteStatement stmt = ReaderDatabase.getWritableDb().compileStatement("INSERT OR REPLACE INTO tbl_thumbnails (full_url, thumbnail_url, post_id) VALUES (?1,?2,?3)");
+ try {
+ stmt.bindString(1, fullUrl);
+ stmt.bindString(2, thumbnailUrl);
+ stmt.bindLong (3, postId);
+ stmt.execute();
+ } finally {
+ SqlUtils.closeStatement(stmt);
+ }
+ }
+
+ public static String getThumbnailUrl(String fullUrl) {
+ if (TextUtils.isEmpty(fullUrl))
+ return null;
+
+ // if this is a YouTube video we can determine the thumbnail url from the passed url, so we
+ // don't need to store the full url nor query for it
+ if (ReaderVideoUtils.isYouTubeVideoLink(fullUrl))
+ return ReaderVideoUtils.getYouTubeThumbnailUrl(fullUrl);
+
+ return SqlUtils.stringForQuery(ReaderDatabase.getReadableDb(), "SELECT thumbnail_url FROM tbl_thumbnails WHERE full_url=?", new String[]{fullUrl});
+ }
+
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/ReaderUserTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderUserTable.java
new file mode 100644
index 000000000..ed193b908
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderUserTable.java
@@ -0,0 +1,188 @@
+package org.wordpress.android.datasets;
+
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteStatement;
+
+import org.wordpress.android.models.ReaderPost;
+import org.wordpress.android.models.ReaderUser;
+import org.wordpress.android.models.ReaderUserIdList;
+import org.wordpress.android.models.ReaderUserList;
+import org.wordpress.android.ui.prefs.UserPrefs;
+import org.wordpress.android.util.PhotonUtils;
+import org.wordpress.android.util.SqlUtils;
+
+import java.util.ArrayList;
+
+/**
+ * stores info about the current user and liking users
+ */
+public class ReaderUserTable {
+ protected static void createTables(SQLiteDatabase db) {
+ db.execSQL("CREATE TABLE tbl_users ("
+ + " user_id INTEGER PRIMARY KEY,"
+ + " blog_id INTEGER DEFAULT 0,"
+ + " user_name TEXT,"
+ + " display_name TEXT COLLATE NOCASE,"
+ + " url TEXT,"
+ + " profile_url TEXT,"
+ + " avatar_url TEXT)");
+ }
+
+ protected static void dropTables(SQLiteDatabase db) {
+ db.execSQL("DROP TABLE IF EXISTS tbl_users");
+ }
+
+ public static void addOrUpdateUser(ReaderUser user) {
+ if (user==null)
+ return;
+
+ ReaderUserList users = new ReaderUserList();
+ users.add(user);
+ addOrUpdateUsers(users);
+ }
+
+ private static final String COLUMN_NAMES =
+ " user_id," // 1
+ + " blog_id," // 2
+ + " user_name," // 3
+ + " display_name," // 4
+ + " url," // 5
+ + " profile_url," // 6
+ + " avatar_url"; // 7
+
+ public static void addOrUpdateUsers(ReaderUserList users) {
+ if (users==null || users.size()==0)
+ return;
+
+ SQLiteDatabase db = ReaderDatabase.getWritableDb();
+ db.beginTransaction();
+ SQLiteStatement stmt = db.compileStatement("INSERT OR REPLACE INTO tbl_users (" + COLUMN_NAMES + ") VALUES (?1,?2,?3,?4,?5,?6,?7)");
+ try {
+ for (ReaderUser user: users) {
+ stmt.bindLong (1, user.userId);
+ stmt.bindLong (2, user.blogId);
+ stmt.bindString(3, user.getUserName());
+ stmt.bindString(4, user.getDisplayName());
+ stmt.bindString(5, user.getUrl());
+ stmt.bindString(6, user.getProfileUrl());
+ stmt.bindString(7, user.getAvatarUrl());
+ stmt.execute();
+ stmt.clearBindings();
+ }
+
+ db.setTransactionSuccessful();
+
+ } finally {
+ db.endTransaction();
+ SqlUtils.closeStatement(stmt);
+ }
+ }
+
+ /*
+ * returns avatar urls for the passed user ids - used by post detail to show avatars for liking users
+ */
+ public static ArrayList<String> getAvatarUrls(ReaderUserIdList userIds, int max, int avatarSz) {
+ ArrayList<String> avatars = new ArrayList<String>();
+ if (userIds==null || userIds.size()==0)
+ return avatars;
+
+ StringBuilder sb = new StringBuilder("SELECT user_id, avatar_url FROM tbl_users WHERE user_id IN (");
+
+ // make sure current user's avatar is returned if the passed list contains them - this is
+ // important since it may not otherwise be returned when a "max" is passed, and we want
+ // the current user to appear first in post detail when they like a post
+ long currentUserId = UserPrefs.getCurrentUserId();
+ boolean containsCurrentUser = userIds.contains(currentUserId);
+ if (containsCurrentUser)
+ sb.append(currentUserId);
+
+ int numAdded = (containsCurrentUser ? 1 : 0);
+ for (Long id: userIds) {
+ // skip current user since we added them already
+ if (id!=currentUserId) {
+ if (numAdded > 0)
+ sb.append(",");
+ sb.append(id);
+ numAdded++;
+ if (max > 0 && numAdded >= max)
+ break;
+ }
+ }
+ sb.append(")");
+
+ Cursor c = ReaderDatabase.getReadableDb().rawQuery(sb.toString(), null);
+ try {
+ if (c.moveToFirst()) {
+ do {
+ long userId = c.getLong(0);
+ String url = PhotonUtils.fixAvatar(c.getString(1), avatarSz);
+ // add current user to the top
+ if (userId==currentUserId) {
+ avatars.add(0, url);
+ } else {
+ avatars.add(url);
+ }
+ } while (c.moveToNext());
+ }
+ return avatars;
+ } finally {
+ SqlUtils.closeCursor(c);
+ }
+ }
+
+ public static ReaderUser getCurrentUser() {
+ return getUser(UserPrefs.getCurrentUserId());
+ }
+
+ private static ReaderUser getUser(long userId) {
+ String args[] = {Long.toString(userId)};
+ Cursor c = ReaderDatabase.getReadableDb().rawQuery("SELECT * FROM tbl_users WHERE user_id=?", args);
+ try {
+ if (!c.moveToFirst())
+ return null;
+ return getUserFromCursor(c);
+ } finally {
+ SqlUtils.closeCursor(c);
+ }
+ }
+
+ private static String getAvatarForUser(long userId) {
+ String args[] = {Long.toString(userId)};
+ return SqlUtils.stringForQuery(ReaderDatabase.getReadableDb(), "SELECT avatar_url FROM tbl_users WHERE user_id=?", args);
+ }
+
+ public static ReaderUserList getUsersWhoLikePost(long blogId, long postId, int max) {
+ String[] args = {Long.toString(blogId), Long.toString(postId)};
+ String sql = "SELECT * from tbl_users WHERE user_id IN (SELECT user_id FROM tbl_post_likes WHERE blog_id=? AND post_id=?) ORDER BY display_name";
+ if (max > 0)
+ sql += " LIMIT " + Integer.toString(max);
+
+ Cursor c = ReaderDatabase.getReadableDb().rawQuery(sql, args);
+ try {
+ ReaderUserList users = new ReaderUserList();
+ if (c.moveToFirst()) {
+ do {
+ users.add(getUserFromCursor(c));
+ } while (c.moveToNext());
+ }
+ return users;
+ } finally {
+ SqlUtils.closeCursor(c);
+ }
+ }
+
+ private static ReaderUser getUserFromCursor(Cursor c) {
+ ReaderUser user = new ReaderUser();
+
+ user.userId = c.getLong(c.getColumnIndex("user_id"));
+ user.blogId = c.getLong(c.getColumnIndex("blog_id"));
+ user.setUserName(c.getString(c.getColumnIndex("user_name")));
+ user.setDisplayName(c.getString(c.getColumnIndex("display_name")));
+ user.setUrl(c.getString(c.getColumnIndex("url")));
+ user.setProfileUrl(c.getString(c.getColumnIndex("profile_url")));
+ user.setAvatarUrl(c.getString(c.getColumnIndex("avatar_url")));
+
+ return user;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/SQLTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/SQLTable.java
new file mode 100644
index 000000000..ba73f9ca3
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/SQLTable.java
@@ -0,0 +1,68 @@
+package org.wordpress.android.datasets;
+
+import java.util.Map;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+/**
+ * A class to represent an database table.
+ */
+
+public abstract class SQLTable {
+ public abstract String getName();
+
+ protected abstract String getUniqueConstraint();
+
+ protected abstract Map<String, String> getColumnMapping();
+
+ protected static class BaseColumns {
+ protected final static String _ID = "_id";
+ }
+
+ public String toCreateQuery() {
+ String createQuery = "CREATE TABLE IF NOT EXISTS " + getName() + " (";
+
+ Map<String, String> columns = getColumnMapping();
+
+ for (String column : columns.keySet()) {
+ createQuery += column + " " + columns.get(column) + ", ";
+ }
+
+ createQuery += getUniqueConstraint() + ");";
+
+ return createQuery;
+ }
+
+ public abstract void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion);
+
+ public Cursor query(final SQLiteDatabase database, final Uri uri, final String[] projection, final String selection, final String[] selectionArgs, final String sortOrder) {
+ return database.query(getName(), projection, selection, selectionArgs, null, null, sortOrder);
+ }
+
+ public long insert(final SQLiteDatabase database, final Uri uri, final ContentValues values) {
+ return database.insert(getName(), null, values);
+ }
+
+ public long insert(final SQLiteDatabase database, final ContentValues values) {
+ return insert(database, null, values);
+ }
+
+ public int update(final SQLiteDatabase database, final Uri uri, final ContentValues values, final String selection, final String[] selectionArgs) {
+ return database.update(getName(), values, selection, selectionArgs);
+ }
+
+ public int update(final SQLiteDatabase database, final ContentValues values, final String selection, final String[] selectionArgs) {
+ return update(database, null, values, selection, selectionArgs);
+ }
+
+ public int delete(final SQLiteDatabase database, final Uri uri, final String selection, final String[] selectionArgs) {
+ return database.delete(getName(), selection, selectionArgs);
+ }
+
+ public int delete(final SQLiteDatabase database, final String selection, final String[] selectionArgs) {
+ return delete(database, null, selection, selectionArgs);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/StatsBarChartDataTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/StatsBarChartDataTable.java
new file mode 100644
index 000000000..d6650e7db
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/StatsBarChartDataTable.java
@@ -0,0 +1,88 @@
+package org.wordpress.android.datasets;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+import org.wordpress.android.models.StatsBarChartData;
+
+/**
+ * A database table for holding stats bar chart data.
+ * <p>
+ * As time of writing, this table holds data for three different timeframes:
+ * <ul>
+ * <li> days (unit="DAY", date e.g. "2013-08-11")
+ * <li> weeks (unit="WEEK", date e.g. "2013W26")
+ * <li> months (unit="MONTH", date e.g. "2013-06-01")
+ * </ul>
+ * </p>
+ */
+
+public class StatsBarChartDataTable extends SQLTable {
+ private static final String NAME = "bar_chart_data";
+
+ public static final class Columns {
+ public static final String BLOG_ID = "blogId";
+ public static final String DATE = "date";
+ public static final String VIEWS = "views";
+ public static final String VISITORS = "visitors";
+ public static final String UNIT = "unit";
+ }
+
+ private static final class Holder {
+ public static final StatsBarChartDataTable INSTANCE = new StatsBarChartDataTable();
+ }
+
+ public static synchronized StatsBarChartDataTable getInstance() {
+ return Holder.INSTANCE;
+ }
+
+ private StatsBarChartDataTable() {}
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ protected String getUniqueConstraint() {
+ return "UNIQUE (" + Columns.BLOG_ID + ", " + Columns.DATE + ", " + Columns.UNIT + ") ON CONFLICT REPLACE";
+ }
+
+ @Override
+ protected Map<String, String> getColumnMapping() {
+ final Map<String, String> map = new LinkedHashMap<String, String>();
+ map.put(BaseColumns._ID, "INTEGER PRIMARY KEY AUTOINCREMENT");
+ map.put(Columns.BLOG_ID, "TEXT");
+ map.put(Columns.DATE, "DATE");
+ map.put(Columns.VIEWS, "INTEGER");
+ map.put(Columns.VISITORS, "INTEGER");
+ map.put(Columns.UNIT, "TEXT");
+ return map;
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // TODO Auto-generated method stub
+
+ }
+
+ public static ContentValues getContentValues(StatsBarChartData item) {
+ ContentValues values = new ContentValues();
+ values.put(Columns.BLOG_ID, item.getBlogId());
+ values.put(Columns.DATE, item.getDate());
+ values.put(Columns.VIEWS, item.getViews());
+ values.put(Columns.VISITORS, item.getVisitors());
+ values.put(Columns.UNIT, item.getBarChartUnit().name());
+ return values;
+ }
+
+ @Override
+ public Cursor query(SQLiteDatabase database, Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ return super.query(database, uri, projection, selection, selectionArgs, Columns.DATE + " DESC");
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/StatsClickGroupsTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/StatsClickGroupsTable.java
new file mode 100644
index 000000000..213e3744a
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/StatsClickGroupsTable.java
@@ -0,0 +1,112 @@
+package org.wordpress.android.datasets;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+import org.wordpress.android.models.StatsClickGroup;
+import org.wordpress.android.ui.stats.StatsActivity;
+import org.wordpress.android.ui.stats.StatsTimeframe;
+
+/**
+ * A database table to represent groups in the stats for clicks.
+ * A group may or may not have children.
+ * See {@link StatsClicksTable} for the children table structure.
+ */
+public class StatsClickGroupsTable extends SQLTable {
+ private static final String NAME = "click_groups";
+
+ public static final class Columns {
+ public static final String BLOG_ID = "blogId";
+ public static final String DATE = "date";
+ public static final String GROUP_ID = "groupId";
+ public static final String NAME = "name";
+ public static final String TOTAL = "total";
+ public static final String URL = "url";
+ public static final String ICON = "icon";
+ public static final String CHILDREN = "children";
+ }
+
+ private static final class Holder {
+ public static final StatsClickGroupsTable INSTANCE = new StatsClickGroupsTable();
+ }
+
+ public static synchronized StatsClickGroupsTable getInstance() {
+ return Holder.INSTANCE;
+ }
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ protected String getUniqueConstraint() {
+ return "UNIQUE (" + Columns.BLOG_ID + ", " + Columns.DATE + ", " + Columns.GROUP_ID + ") ON CONFLICT REPLACE";
+ }
+
+ @Override
+ protected Map<String, String> getColumnMapping() {
+ final Map<String, String> map = new LinkedHashMap<String, String>();
+ map.put(BaseColumns._ID, "INTEGER PRIMARY KEY AUTOINCREMENT");
+ map.put(Columns.BLOG_ID, "TEXT");
+ map.put(Columns.DATE, "DATE");
+ map.put(Columns.GROUP_ID, "TEXT");
+ map.put(Columns.NAME, "TEXT");
+ map.put(Columns.TOTAL, "TOTAL");
+ map.put(Columns.URL, "TEXT");
+ map.put(Columns.ICON, "TEXT");
+ map.put(Columns.CHILDREN, "INTEGER");
+ return map;
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // TODO Auto-generated method stub
+
+ }
+
+
+ public static ContentValues getContentValues(StatsClickGroup item) {
+ ContentValues values = new ContentValues();
+ values.put(Columns.BLOG_ID, item.getBlogId());
+ values.put(Columns.DATE, item.getDate());
+ values.put(Columns.GROUP_ID, item.getGroupId());
+ values.put(Columns.NAME, item.getName());
+ values.put(Columns.TOTAL, item.getTotal());
+ values.put(Columns.URL, item.getUrl());
+ values.put(Columns.ICON, item.getIcon());
+ values.put(Columns.CHILDREN, item.getChildren());
+ return values;
+ }
+
+ @Override
+ public Cursor query(SQLiteDatabase database, Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ String sort = NAME + "." + Columns.TOTAL + " DESC, " + NAME + "." + Columns.NAME + " ASC LIMIT " + StatsActivity.STATS_GROUP_MAX_ITEMS;
+
+ String timeframe = uri.getQueryParameter("timeframe");
+ if (timeframe == null)
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+
+ // get the latest for "Today", and the next latest for "Yesterday"
+ if (timeframe.equals(StatsTimeframe.TODAY.name())) {
+ return database.rawQuery("SELECT * FROM " + NAME +", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ") AS temp " +
+ "WHERE temp.date = " + NAME + ".date AND " + selection + " ORDER BY " + sort, selectionArgs);
+
+ } else if (timeframe.equals(StatsTimeframe.YESTERDAY.name())) {
+ return database.rawQuery(
+ "SELECT * FROM " + NAME + ", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ", " +
+ "( SELECT MAX(date) AS max FROM " + NAME + ")" +
+ " WHERE " + NAME + ".date < max) AS temp " +
+ "WHERE " + NAME + ".date = temp.date AND " + selection + " ORDER BY " + sort, selectionArgs);
+ }
+
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/StatsClicksTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/StatsClicksTable.java
new file mode 100644
index 000000000..43580b929
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/StatsClicksTable.java
@@ -0,0 +1,102 @@
+package org.wordpress.android.datasets;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+import org.wordpress.android.models.StatsClick;
+import org.wordpress.android.ui.stats.StatsTimeframe;
+
+/**
+ * A database table to represent the stats for clicks children.
+ * See {@link StatsClickGroupsTable} for the parent table structure.
+ */
+public class StatsClicksTable extends SQLTable {
+ private static final String NAME = "clicks";
+
+ public static final class Columns {
+ public static final String BLOG_ID = "blogId";
+ public static final String DATE = "date";
+ public static final String GROUP_ID = "groupId";
+ public static final String NAME = "name";
+ public static final String TOTAL = "total";
+ }
+
+ private static final class Holder {
+ public static final StatsClicksTable INSTANCE = new StatsClicksTable();
+ }
+
+ public static synchronized StatsClicksTable getInstance() {
+ return Holder.INSTANCE;
+ }
+
+ private StatsClicksTable() {}
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ protected String getUniqueConstraint() {
+ return "UNIQUE (" + Columns.BLOG_ID + ", " + Columns.DATE + ", " + Columns.GROUP_ID + ", " + Columns.NAME + ") ON CONFLICT REPLACE";
+ }
+
+ @Override
+ protected Map<String, String> getColumnMapping() {
+ final Map<String, String> map = new LinkedHashMap<String, String>();
+ map.put(BaseColumns._ID, "INTEGER PRIMARY KEY AUTOINCREMENT");
+ map.put(Columns.BLOG_ID, "TEXT");
+ map.put(Columns.DATE, "DATE");
+ map.put(Columns.GROUP_ID, "TEXT");
+ map.put(Columns.NAME, "TEXT");
+ map.put(Columns.TOTAL, "INTEGER");
+ return map;
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // TODO Auto-generated method stub
+
+ }
+
+ public static ContentValues getContentValues(StatsClick item) {
+ ContentValues values = new ContentValues();
+ values.put(Columns.BLOG_ID, item.getBlogId());
+ values.put(Columns.DATE, item.getDate());
+ values.put(Columns.GROUP_ID, item.getGroupId());
+ values.put(Columns.NAME, item.getName());
+ values.put(Columns.TOTAL, item.getTotal());
+ return values;
+ }
+
+ @Override
+ public Cursor query(SQLiteDatabase database, Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ String sort = NAME + "." + Columns.TOTAL + " DESC, " + NAME + "." + Columns.NAME + " ASC";
+
+ String timeframe = uri.getQueryParameter("timeframe");
+ if (timeframe == null)
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+
+ // get the latest for "Today", and the next latest for "Yesterday"
+ if (timeframe.equals(StatsTimeframe.TODAY.name())) {
+ return database.rawQuery("SELECT * FROM " + NAME +", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ") AS temp " +
+ "WHERE temp.date = " + NAME + ".date AND " + selection + " ORDER BY " + sort, selectionArgs);
+
+ } else if (timeframe.equals(StatsTimeframe.YESTERDAY.name())) {
+ return database.rawQuery(
+ "SELECT * FROM " + NAME + ", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ", " +
+ "( SELECT MAX(date) AS max FROM " + NAME + ")" +
+ " WHERE " + NAME + ".date < max) AS temp " +
+ "WHERE " + NAME + ".date = temp.date AND " + selection + " ORDER BY " + sort, selectionArgs);
+ }
+
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/StatsGeoviewsTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/StatsGeoviewsTable.java
new file mode 100644
index 000000000..99bcfcd45
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/StatsGeoviewsTable.java
@@ -0,0 +1,102 @@
+package org.wordpress.android.datasets;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+import org.wordpress.android.models.StatsGeoview;
+import org.wordpress.android.ui.stats.StatsActivity;
+import org.wordpress.android.ui.stats.StatsTimeframe;
+
+/**
+ * A database table to represent the stats for geoviews.
+ */
+public class StatsGeoviewsTable extends SQLTable {
+ private static final String NAME = "geoviews";
+
+ public static final class Columns {
+ public static final String BLOG_ID = "blogId";
+ public static final String DATE = "date";
+ public static final String COUNTRY = "country";
+ public static final String VIEWS = "views";
+ public static final String IMAGE_URL = "imageUrl";
+ }
+
+ private static final class Holder {
+ public static final StatsGeoviewsTable INSTANCE = new StatsGeoviewsTable();
+ }
+
+ public static synchronized StatsGeoviewsTable getInstance() {
+ return Holder.INSTANCE;
+ }
+
+ private StatsGeoviewsTable() {}
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ protected String getUniqueConstraint() {
+ return "UNIQUE (" + Columns.BLOG_ID + ", " + Columns.DATE + ", " + Columns.COUNTRY + ") ON CONFLICT REPLACE";
+ }
+
+ @Override
+ protected Map<String, String> getColumnMapping() {
+ final Map<String, String> map = new LinkedHashMap<String, String>();
+ map.put(BaseColumns._ID, "INTEGER PRIMARY KEY AUTOINCREMENT");
+ map.put(Columns.BLOG_ID, "TEXT");
+ map.put(Columns.DATE, "DATE");
+ map.put(Columns.COUNTRY, "TEXT");
+ map.put(Columns.VIEWS, "INTEGER");
+ map.put(Columns.IMAGE_URL, "TEXT");
+ return map;
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // TODO Auto-generated method stub
+
+ }
+
+ public static ContentValues getContentValues(StatsGeoview item) {
+ ContentValues values = new ContentValues();
+ values.put(Columns.BLOG_ID, item.getBlogId());
+ values.put(Columns.DATE, item.getDate());
+ values.put(Columns.COUNTRY, item.getCountry());
+ values.put(Columns.VIEWS, item.getViews());
+ values.put(Columns.IMAGE_URL, item.getImageUrl());
+ return values;
+ }
+
+ @Override
+ public Cursor query(SQLiteDatabase database, Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ String sort = NAME + "." + Columns.VIEWS + " DESC, " + NAME + "." + Columns.COUNTRY + " ASC LIMIT " + StatsActivity.STATS_GROUP_MAX_ITEMS;
+
+ String timeframe = uri.getQueryParameter("timeframe");
+ if (timeframe == null)
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+
+ // get the latest for "Today", and the next latest for "Yesterday"
+ if (timeframe.equals(StatsTimeframe.TODAY.name())) {
+ return database.rawQuery("SELECT * FROM " + NAME +", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ") AS temp " +
+ "WHERE temp.date = " + NAME + ".date AND " + selection + " ORDER BY " + sort, selectionArgs);
+
+ } else if (timeframe.equals(StatsTimeframe.YESTERDAY.name())) {
+ return database.rawQuery(
+ "SELECT * FROM " + NAME + ", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ", " +
+ "( SELECT MAX(date) AS max FROM " + NAME + ")" +
+ " WHERE " + NAME + ".date < max) AS temp " +
+ "WHERE temp.date = " + NAME + ".date AND " + selection + " ORDER BY " + sort, selectionArgs);
+ }
+
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/StatsMostCommentedTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/StatsMostCommentedTable.java
new file mode 100644
index 000000000..67a30677b
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/StatsMostCommentedTable.java
@@ -0,0 +1,79 @@
+package org.wordpress.android.datasets;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+import org.wordpress.android.models.StatsMostCommented;
+
+/**
+ * A database table to represent the stats for the most commented posts.
+ */
+public class StatsMostCommentedTable extends SQLTable {
+ private static final String NAME = "most_commented";
+
+ public static final class Columns {
+ public static final String BLOG_ID = "blogId";
+ public static final String POST_ID = "postId";
+ public static final String POST = "post";
+ public static final String COMMENTS = "comments";
+ public static final String URL = "url";
+ }
+
+ private static final class Holder {
+ public static final StatsMostCommentedTable INSTANCE = new StatsMostCommentedTable();
+ }
+
+ public static synchronized StatsMostCommentedTable getInstance() {
+ return Holder.INSTANCE;
+ }
+
+ private StatsMostCommentedTable() {}
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ protected String getUniqueConstraint() {
+ return "UNIQUE (" + Columns.BLOG_ID + ", " + Columns.POST_ID + ") ON CONFLICT REPLACE";
+ }
+
+ @Override
+ protected Map<String, String> getColumnMapping() {
+ final Map<String, String> map = new LinkedHashMap<String, String>();
+ map.put(BaseColumns._ID, "INTEGER PRIMARY KEY AUTOINCREMENT");
+ map.put(Columns.BLOG_ID, "TEXT");
+ map.put(Columns.POST_ID, "INTEGER");
+ map.put(Columns.POST, "TEXT");
+ map.put(Columns.COMMENTS, "INTEGER");
+ map.put(Columns.URL, "TEXT");
+ return map;
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // TODO Auto-generated method stub
+
+ }
+
+ public static ContentValues getContentValues(StatsMostCommented item) {
+ ContentValues values = new ContentValues();
+ values.put(Columns.BLOG_ID, item.getBlogId());
+ values.put(Columns.POST_ID, item.getPostId());
+ values.put(Columns.POST, item.getPost());
+ values.put(Columns.COMMENTS, item.getComments());
+ values.put(Columns.URL, item.getUrl());
+ return values;
+ }
+
+ @Override
+ public Cursor query(SQLiteDatabase database, Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ return super.query(database, uri, projection, selection, selectionArgs, Columns.COMMENTS + " DESC, " + Columns.POST + " ASC");
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/StatsReferrerGroupsTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/StatsReferrerGroupsTable.java
new file mode 100644
index 000000000..a2ea93fcb
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/StatsReferrerGroupsTable.java
@@ -0,0 +1,113 @@
+package org.wordpress.android.datasets;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+import org.wordpress.android.models.StatsReferrerGroup;
+import org.wordpress.android.ui.stats.StatsActivity;
+import org.wordpress.android.ui.stats.StatsTimeframe;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * A database table to represent groups in the stats for referrers.
+ * A group may or may not have children.
+ * See {@link StatsReferrersTable} for the children table structure.
+ */
+public class StatsReferrerGroupsTable extends SQLTable {
+ private static final String NAME = "referrer_groups";
+
+ public static final class Columns {
+ public static final String BLOG_ID = "blogId";
+ public static final String DATE = "date";
+ public static final String GROUP_ID = "groupId";
+ public static final String NAME = "name";
+ public static final String TOTAL = "total";
+ public static final String URL = "url";
+ public static final String ICON = "icon";
+ public static final String CHILDREN = "children";
+ }
+
+ private static final class Holder {
+ public static final StatsReferrerGroupsTable INSTANCE = new StatsReferrerGroupsTable();
+ }
+
+ public static synchronized StatsReferrerGroupsTable getInstance() {
+ return Holder.INSTANCE;
+ }
+
+ private StatsReferrerGroupsTable() {}
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ protected String getUniqueConstraint() {
+ return "UNIQUE (" + Columns.BLOG_ID + ", " + Columns.DATE + ", " + Columns.GROUP_ID + ") ON CONFLICT REPLACE";
+ }
+
+ @Override
+ protected Map<String, String> getColumnMapping() {
+ final Map<String, String> map = new LinkedHashMap<String, String>();
+ map.put(BaseColumns._ID, "INTEGER PRIMARY KEY AUTOINCREMENT");
+ map.put(Columns.BLOG_ID, "TEXT");
+ map.put(Columns.DATE, "DATE");
+ map.put(Columns.GROUP_ID, "TEXT");
+ map.put(Columns.NAME, "TEXT");
+ map.put(Columns.TOTAL, "TOTAL");
+ map.put(Columns.URL, "TEXT");
+ map.put(Columns.ICON, "TEXT");
+ map.put(Columns.CHILDREN, "INTEGER");
+ return map;
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // TODO Auto-generated method stub
+
+ }
+
+ public static ContentValues getContentValues(StatsReferrerGroup item) {
+ ContentValues values = new ContentValues();
+ values.put(Columns.BLOG_ID, item.getBlogId());
+ values.put(Columns.DATE, item.getDate());
+ values.put(Columns.GROUP_ID, item.getGroupId());
+ values.put(Columns.NAME, item.getName());
+ values.put(Columns.TOTAL, item.getTotal());
+ values.put(Columns.URL, item.getUrl());
+ values.put(Columns.ICON, item.getIcon());
+ values.put(Columns.CHILDREN, item.getChildren());
+ return values;
+ }
+
+ @Override
+ public Cursor query(SQLiteDatabase database, Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ String sort = NAME + "." + Columns.TOTAL + " DESC, " + NAME + "." + Columns.NAME + " ASC LIMIT " + StatsActivity.STATS_GROUP_MAX_ITEMS;
+
+ String timeframe = uri.getQueryParameter("timeframe");
+ if (timeframe == null)
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+
+ // get the latest for "Today", and the next latest for "Yesterday"
+ if (timeframe.equals(StatsTimeframe.TODAY.name())) {
+ return database.rawQuery("SELECT * FROM " + NAME +", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ") AS temp " +
+ "WHERE temp.date = " + NAME + ".date AND " + selection + " ORDER BY " + sort, selectionArgs);
+
+ } else if (timeframe.equals(StatsTimeframe.YESTERDAY.name())) {
+ return database.rawQuery(
+ "SELECT * FROM " + NAME + ", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ", " +
+ "( SELECT MAX(date) AS max FROM " + NAME + ")" +
+ " WHERE " + NAME + ".date < max) AS temp " +
+ "WHERE " + NAME + ".date = temp.date AND " + selection + " ORDER BY " + sort, selectionArgs);
+ }
+
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/StatsReferrersTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/StatsReferrersTable.java
new file mode 100644
index 000000000..3c3e260be
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/StatsReferrersTable.java
@@ -0,0 +1,102 @@
+package org.wordpress.android.datasets;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+import org.wordpress.android.models.StatsReferrer;
+import org.wordpress.android.ui.stats.StatsTimeframe;
+
+/**
+ * A database table to represent the stats for referrers children.
+ * See {@link StatsReferrerGroupsTable} for the parent table structure.
+ */
+public class StatsReferrersTable extends SQLTable {
+ private static final String NAME = "referrers";
+
+ public static final class Columns {
+ public static final String BLOG_ID = "blogId";
+ public static final String DATE = "date";
+ public static final String GROUP_ID = "groupId";
+ public static final String NAME = "name";
+ public static final String TOTAL = "total";
+ }
+
+ private static final class Holder {
+ public static final StatsReferrersTable INSTANCE = new StatsReferrersTable();
+ }
+
+ public static synchronized StatsReferrersTable getInstance() {
+ return Holder.INSTANCE;
+ }
+
+ private StatsReferrersTable() {}
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ protected String getUniqueConstraint() {
+ return "UNIQUE (" + Columns.BLOG_ID + ", " + Columns.DATE + ", " + Columns.GROUP_ID + ", " + Columns.NAME + ") ON CONFLICT REPLACE";
+ }
+
+ @Override
+ protected Map<String, String> getColumnMapping() {
+ final Map<String, String> map = new LinkedHashMap<String, String>();
+ map.put(BaseColumns._ID, "INTEGER PRIMARY KEY AUTOINCREMENT");
+ map.put(Columns.BLOG_ID, "TEXT");
+ map.put(Columns.DATE, "DATE");
+ map.put(Columns.GROUP_ID, "TEXT");
+ map.put(Columns.NAME, "TEXT");
+ map.put(Columns.TOTAL, "INTEGER");
+ return map;
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // TODO Auto-generated method stub
+
+ }
+
+ public static ContentValues getContentValues(StatsReferrer item) {
+ ContentValues values = new ContentValues();
+ values.put(Columns.BLOG_ID, item.getBlogId());
+ values.put(Columns.DATE, item.getDate());
+ values.put(Columns.GROUP_ID, item.getGroupId());
+ values.put(Columns.NAME, item.getName());
+ values.put(Columns.TOTAL, item.getTotal());
+ return values;
+ }
+
+ @Override
+ public Cursor query(SQLiteDatabase database, Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ String sort = NAME + "." + Columns.TOTAL + " DESC, " + NAME + "." + Columns.NAME + " ASC";
+
+ String timeframe = uri.getQueryParameter("timeframe");
+ if (timeframe == null)
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+
+ // get the latest for "Today", and the next latest for "Yesterday"
+ if (timeframe.equals(StatsTimeframe.TODAY.name())) {
+ return database.rawQuery("SELECT * FROM " + NAME +", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ") AS temp " +
+ "WHERE temp.date = " + NAME + ".date AND " + selection + " ORDER BY " + sort, selectionArgs);
+
+ } else if (timeframe.equals(StatsTimeframe.YESTERDAY.name())) {
+ return database.rawQuery(
+ "SELECT * FROM " + NAME + ", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ", " +
+ "( SELECT MAX(date) AS max FROM " + NAME + ")" +
+ " WHERE " + NAME + ".date < max) AS temp " +
+ "WHERE " + NAME + ".date = temp.date AND " + selection + " ORDER BY " + sort, selectionArgs);
+ }
+
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/StatsSearchEngineTermsTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/StatsSearchEngineTermsTable.java
new file mode 100644
index 000000000..0b8090372
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/StatsSearchEngineTermsTable.java
@@ -0,0 +1,99 @@
+package org.wordpress.android.datasets;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+import org.wordpress.android.models.StatsSearchEngineTerm;
+import org.wordpress.android.ui.stats.StatsActivity;
+import org.wordpress.android.ui.stats.StatsTimeframe;
+
+/**
+ * A database table to represent the stats for search engine terms.
+ */
+public class StatsSearchEngineTermsTable extends SQLTable {
+ private static final String NAME = "search_engine_terms";
+
+ public static final class Columns {
+ public static final String BLOG_ID = "blogId";
+ public static final String DATE = "date";
+ public static final String SEARCH = "search";
+ public static final String VIEWS = "views";
+ }
+
+ private static final class Holder {
+ public static final StatsSearchEngineTermsTable INSTANCE = new StatsSearchEngineTermsTable();
+ }
+
+ public static synchronized StatsSearchEngineTermsTable getInstance() {
+ return Holder.INSTANCE;
+ }
+
+ private StatsSearchEngineTermsTable() {}
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ protected String getUniqueConstraint() {
+ return "UNIQUE (" + Columns.BLOG_ID + ", " + Columns.DATE + ", " + Columns.SEARCH + ") ON CONFLICT REPLACE";
+ }
+
+ @Override
+ protected Map<String, String> getColumnMapping() {
+ final Map<String, String> map = new LinkedHashMap<String, String>();
+ map.put(BaseColumns._ID, "INTEGER PRIMARY KEY AUTOINCREMENT");
+ map.put(Columns.BLOG_ID, "TEXT");
+ map.put(Columns.DATE, "DATE");
+ map.put(Columns.SEARCH, "TEXT");
+ map.put(Columns.VIEWS, "INTEGER");
+ return map;
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // TODO Auto-generated method stub
+
+ }
+
+ public static ContentValues getContentValues(StatsSearchEngineTerm item) {
+ ContentValues values = new ContentValues();
+ values.put(Columns.BLOG_ID, item.getBlogId());
+ values.put(Columns.DATE, item.getDate());
+ values.put(Columns.SEARCH, item.getSearch());
+ values.put(Columns.VIEWS, item.getViews());
+ return values;
+ }
+
+ @Override
+ public Cursor query(SQLiteDatabase database, Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ String sort = NAME + "." + Columns.VIEWS + " DESC, " + NAME + "." + Columns.SEARCH + " ASC LIMIT " + StatsActivity.STATS_GROUP_MAX_ITEMS;
+
+ String timeframe = uri.getQueryParameter("timeframe");
+ if (timeframe == null)
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+
+ // get the latest for "Today", and the next latest for "Yesterday"
+ if (timeframe.equals(StatsTimeframe.TODAY.name())) {
+ return database.rawQuery("SELECT * FROM " + NAME +", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ") AS temp " +
+ "WHERE temp.date = " + NAME + ".date AND " + selection + " ORDER BY " + sort, selectionArgs);
+
+ } else if (timeframe.equals(StatsTimeframe.YESTERDAY.name())) {
+ return database.rawQuery(
+ "SELECT * FROM " + NAME + ", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ", " +
+ "( SELECT MAX(date) AS max FROM " + NAME + ")" +
+ " WHERE " + NAME + ".date < max) AS temp " +
+ "WHERE " + NAME + ".date = temp.date AND " + selection + " ORDER BY " + sort, selectionArgs);
+ }
+
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/StatsTagsAndCategoriesTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/StatsTagsAndCategoriesTable.java
new file mode 100644
index 000000000..d1e58e366
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/StatsTagsAndCategoriesTable.java
@@ -0,0 +1,77 @@
+package org.wordpress.android.datasets;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+import org.wordpress.android.models.StatsTagsandCategories;
+
+/**
+ * A database table to represent the stats for tags and categories.
+ * The type is either "tag" or "category"
+ */
+public class StatsTagsAndCategoriesTable extends SQLTable {
+ private static final String NAME = "tags_and_categories";
+
+ public static final class Columns {
+ public static final String BLOG_ID = "blogId";
+ public static final String TOPIC = "topic";
+ public static final String TYPE = "type";
+ public static final String VIEWS = "views";
+ }
+
+ private static final class Holder {
+ public static final StatsTagsAndCategoriesTable INSTANCE = new StatsTagsAndCategoriesTable();
+ }
+
+ public static synchronized StatsTagsAndCategoriesTable getInstance() {
+ return Holder.INSTANCE;
+ }
+
+ private StatsTagsAndCategoriesTable() {}
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ protected String getUniqueConstraint() {
+ return "UNIQUE (" + Columns.BLOG_ID + ", " + Columns.TOPIC + ", " + Columns.TYPE + ") ON CONFLICT REPLACE";
+ }
+
+ @Override
+ protected Map<String, String> getColumnMapping() {
+ final Map<String, String> map = new LinkedHashMap<String, String>();
+ map.put(BaseColumns._ID, "INTEGER PRIMARY KEY AUTOINCREMENT");
+ map.put(Columns.BLOG_ID, "TEXT");
+ map.put(Columns.TOPIC, "TEXT");
+ map.put(Columns.TYPE, "TEXT");
+ map.put(Columns.VIEWS, "INTEGER");
+ return map;
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // TODO Auto-generated method stub
+
+ }
+
+ public static ContentValues getContentValues(StatsTagsandCategories item) {
+ ContentValues values = new ContentValues();
+ values.put(Columns.BLOG_ID, item.getBlogId());
+ values.put(Columns.TOPIC, item.getTopic());
+ values.put(Columns.TYPE, item.getType());
+ values.put(Columns.VIEWS, item.getViews());
+ return values;
+ }
+
+ @Override
+ public Cursor query(SQLiteDatabase database, Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ return super.query(database, uri, projection, selection, selectionArgs, Columns.VIEWS + " DESC, " + Columns.TOPIC + " ASC");
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/StatsTopAuthorsTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/StatsTopAuthorsTable.java
new file mode 100644
index 000000000..0285354a4
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/StatsTopAuthorsTable.java
@@ -0,0 +1,104 @@
+package org.wordpress.android.datasets;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+import org.wordpress.android.models.StatsTopAuthor;
+import org.wordpress.android.ui.stats.StatsTimeframe;
+
+/**
+ * A database table to represent the stats for the top authors.
+ */
+public class StatsTopAuthorsTable extends SQLTable {
+ private static final String NAME = "top_authors";
+
+ public static final class Columns {
+ public static final String BLOG_ID = "blogId";
+ public static final String DATE = "date";
+ public static final String USER_ID = "userId";
+ public static final String NAME = "name";
+ public static final String VIEWS = "views";
+ public static final String IMAGE_URL = "imageUrl";
+ }
+
+ private static final class Holder {
+ public static final StatsTopAuthorsTable INSTANCE = new StatsTopAuthorsTable();
+ }
+
+ public static synchronized StatsTopAuthorsTable getInstance() {
+ return Holder.INSTANCE;
+ }
+
+ private StatsTopAuthorsTable() {}
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ protected String getUniqueConstraint() {
+ return "UNIQUE (" + Columns.BLOG_ID + ", " + Columns.DATE + ", " + Columns.USER_ID + ") ON CONFLICT REPLACE";
+ }
+
+ @Override
+ protected Map<String, String> getColumnMapping() {
+ final Map<String, String> map = new LinkedHashMap<String, String>();
+ map.put(BaseColumns._ID, "INTEGER PRIMARY KEY AUTOINCREMENT");
+ map.put(Columns.BLOG_ID, "TEXT");
+ map.put(Columns.DATE, "DATE");
+ map.put(Columns.USER_ID, "TEXT");
+ map.put(Columns.NAME, "TEXT");
+ map.put(Columns.VIEWS, "INTEGER");
+ map.put(Columns.IMAGE_URL, "TEXT");
+ return map;
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // TODO Auto-generated method stub
+
+ }
+
+ public static ContentValues getContentValues(StatsTopAuthor item) {
+ ContentValues values = new ContentValues();
+ values.put(Columns.BLOG_ID, item.getBlogId());
+ values.put(Columns.DATE, item.getDate());
+ values.put(Columns.USER_ID, item.getUserId());
+ values.put(Columns.NAME, item.getName());
+ values.put(Columns.VIEWS, item.getViews());
+ values.put(Columns.IMAGE_URL, item.getImageUrl());
+ return values;
+ }
+
+ @Override
+ public Cursor query(SQLiteDatabase database, Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ String sort = NAME + "." + Columns.VIEWS + " DESC, " + NAME + "." + Columns.NAME + " ASC";
+
+ String timeframe = uri.getQueryParameter("timeframe");
+ if (timeframe == null)
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+
+ // get the latest for "Today", and the next latest for "Yesterday"
+ if (timeframe.equals(StatsTimeframe.TODAY.name())) {
+ return database.rawQuery("SELECT * FROM " + NAME +", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ") AS temp " +
+ "WHERE temp.date = " + NAME + ".date AND " + selection + " ORDER BY " + sort, selectionArgs);
+
+ } else if (timeframe.equals(StatsTimeframe.YESTERDAY.name())) {
+ return database.rawQuery(
+ "SELECT * FROM " + NAME + ", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ", " +
+ "( SELECT MAX(date) AS max FROM " + NAME + ")" +
+ " WHERE " + NAME + ".date < max) AS temp " +
+ "WHERE temp.date = " + NAME + ".date AND " + selection + " ORDER BY " + sort, selectionArgs);
+ }
+
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/StatsTopCommentersTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/StatsTopCommentersTable.java
new file mode 100644
index 000000000..c3ac819ef
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/StatsTopCommentersTable.java
@@ -0,0 +1,79 @@
+package org.wordpress.android.datasets;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+import org.wordpress.android.models.StatsTopCommenter;
+
+/**
+ * A database table to represent the stats for top commenters.
+ */
+public class StatsTopCommentersTable extends SQLTable {
+ private static final String NAME = "top_commenters";
+
+ public static final class Columns {
+ public static final String BLOG_ID = "blogId";
+ public static final String USER_ID = "userId";
+ public static final String NAME = "name";
+ public static final String COMMENTS = "comments";
+ public static final String IMAGE_URL = "imageUrl";
+ }
+
+ private static final class Holder {
+ public static final StatsTopCommentersTable INSTANCE = new StatsTopCommentersTable();
+ }
+
+ public static synchronized StatsTopCommentersTable getInstance() {
+ return Holder.INSTANCE;
+ }
+
+ private StatsTopCommentersTable() {}
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ protected String getUniqueConstraint() {
+ return "UNIQUE (" + Columns.BLOG_ID + ", " + Columns.USER_ID + ") ON CONFLICT REPLACE";
+ }
+
+ @Override
+ protected Map<String, String> getColumnMapping() {
+ final Map<String, String> map = new LinkedHashMap<String, String>();
+ map.put(BaseColumns._ID, "INTEGER PRIMARY KEY AUTOINCREMENT");
+ map.put(Columns.BLOG_ID, "TEXT");
+ map.put(Columns.USER_ID, "TEXT");
+ map.put(Columns.NAME, "TEXT");
+ map.put(Columns.COMMENTS, "INTEGER");
+ map.put(Columns.IMAGE_URL, "TEXT");
+ return map;
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // TODO Auto-generated method stub
+
+ }
+
+ public static ContentValues getContentValues(StatsTopCommenter item) {
+ ContentValues values = new ContentValues();
+ values.put(Columns.BLOG_ID, item.getBlogId());
+ values.put(Columns.USER_ID, item.getUserId());
+ values.put(Columns.NAME, item.getName());
+ values.put(Columns.COMMENTS, item.getComments());
+ values.put(Columns.IMAGE_URL, item.getImageUrl());
+ return values;
+ }
+
+ @Override
+ public Cursor query(SQLiteDatabase database, Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ return super.query(database, uri, projection, selection, selectionArgs, Columns.COMMENTS + " DESC, " + Columns.NAME + " ASC");
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/StatsTopPostsAndPagesTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/StatsTopPostsAndPagesTable.java
new file mode 100644
index 000000000..8fdfc0f4e
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/StatsTopPostsAndPagesTable.java
@@ -0,0 +1,105 @@
+package org.wordpress.android.datasets;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+import org.wordpress.android.models.StatsTopPostsAndPages;
+import org.wordpress.android.ui.stats.StatsActivity;
+import org.wordpress.android.ui.stats.StatsTimeframe;
+
+/**
+ * A database table to represent the stats for the top posts and pages.
+ */
+public class StatsTopPostsAndPagesTable extends SQLTable {
+ private static final String NAME = "top_post_and_pages";
+
+ public static final class Columns {
+ public static final String BLOG_ID = "blogId";
+ public static final String DATE = "date";
+ public static final String POST_ID = "postId";
+ public static final String TITLE = "title";
+ public static final String VIEWS = "views";
+ public static final String URL = "url";
+ }
+
+ private static final class Holder {
+ public static final StatsTopPostsAndPagesTable INSTANCE = new StatsTopPostsAndPagesTable();
+ }
+
+ public static synchronized StatsTopPostsAndPagesTable getInstance() {
+ return Holder.INSTANCE;
+ }
+
+ private StatsTopPostsAndPagesTable() {}
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ protected String getUniqueConstraint() {
+ return "UNIQUE (" + Columns.BLOG_ID + ", " + Columns.DATE + ", " + Columns.POST_ID + ") ON CONFLICT REPLACE";
+ }
+
+ @Override
+ protected Map<String, String> getColumnMapping() {
+ final Map<String, String> map = new LinkedHashMap<String, String>();
+ map.put(BaseColumns._ID, "INTEGER PRIMARY KEY AUTOINCREMENT");
+ map.put(Columns.BLOG_ID, "TEXT");
+ map.put(Columns.DATE, "DATE");
+ map.put(Columns.POST_ID, "INTEGER");
+ map.put(Columns.TITLE, "TEXT");
+ map.put(Columns.VIEWS, "INTEGER");
+ map.put(Columns.URL, "TEXT");
+ return map;
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // TODO Auto-generated method stub
+
+ }
+
+ public static ContentValues getContentValues(StatsTopPostsAndPages item) {
+ ContentValues values = new ContentValues();
+ values.put(Columns.BLOG_ID, item.getBlogId());
+ values.put(Columns.DATE, item.getDate());
+ values.put(Columns.POST_ID, item.getPostId());
+ values.put(Columns.TITLE, item.getTitle());
+ values.put(Columns.VIEWS, item.getViews());
+ values.put(Columns.URL, item.getUrl());
+ return values;
+ }
+
+ @Override
+ public Cursor query(SQLiteDatabase database, Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ String sort = NAME + "." + Columns.VIEWS + " DESC, " + NAME + "." + Columns.TITLE + " ASC LIMIT " + StatsActivity.STATS_GROUP_MAX_ITEMS;
+
+ String timeframe = uri.getQueryParameter("timeframe");
+ if (timeframe == null)
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+
+ // get the latest for "Today", and the next latest for "Yesterday"
+ if (timeframe.equals(StatsTimeframe.TODAY.name())) {
+ return database.rawQuery("SELECT * FROM " + NAME +", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ") AS temp " +
+ "WHERE temp.date = " + NAME + ".date AND " + selection + " ORDER BY " + sort, selectionArgs);
+
+ } else if (timeframe.equals(StatsTimeframe.YESTERDAY.name())) {
+ return database.rawQuery(
+ "SELECT * FROM " + NAME + ", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ", " +
+ "( SELECT MAX(date) AS max FROM " + NAME + ")" +
+ " WHERE " + NAME + ".date < max) AS temp " +
+ "WHERE " + NAME + ".date = temp.date AND " + selection + " ORDER BY " + sort, selectionArgs);
+ }
+
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/StatsVideosTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/StatsVideosTable.java
new file mode 100644
index 000000000..6b9208143
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/StatsVideosTable.java
@@ -0,0 +1,104 @@
+package org.wordpress.android.datasets;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+import org.wordpress.android.models.StatsVideo;
+import org.wordpress.android.ui.stats.StatsTimeframe;
+
+/**
+ * A database table to represent the stats for videos.
+ */
+public class StatsVideosTable extends SQLTable {
+ private static final String NAME = "videos";
+
+ public static final class Columns {
+ public static final String BLOG_ID = "blogId";
+ public static final String DATE = "date";
+ public static final String VIDEO_ID = "videoId";
+ public static final String NAME = "name";
+ public static final String PLAYS = "plays";
+ public static final String URL = "url";
+ }
+
+ private static final class Holder {
+ public static final StatsVideosTable INSTANCE = new StatsVideosTable();
+ }
+
+ public static synchronized StatsVideosTable getInstance() {
+ return Holder.INSTANCE;
+ }
+
+ private StatsVideosTable() {}
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ protected String getUniqueConstraint() {
+ return "UNIQUE (" + Columns.BLOG_ID + ", " + Columns.DATE + ", " + Columns.VIDEO_ID + ") ON CONFLICT REPLACE";
+ }
+
+ @Override
+ protected Map<String, String> getColumnMapping() {
+ final Map<String, String> map = new LinkedHashMap<String, String>();
+ map.put(BaseColumns._ID, "INTEGER PRIMARY KEY AUTOINCREMENT");
+ map.put(Columns.BLOG_ID, "TEXT");
+ map.put(Columns.DATE, "DATE");
+ map.put(Columns.VIDEO_ID, "INTEGER");
+ map.put(Columns.NAME, "TEXT");
+ map.put(Columns.PLAYS, "INTEGER");
+ map.put(Columns.URL, "TEXT");
+ return map;
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // TODO Auto-generated method stub
+
+ }
+
+ public static ContentValues getContentValues(StatsVideo item) {
+ ContentValues values = new ContentValues();
+ values.put(Columns.BLOG_ID, item.getBlogId());
+ values.put(Columns.DATE, item.getDate());
+ values.put(Columns.VIDEO_ID, item.getVideoId());
+ values.put(Columns.NAME, item.getName());
+ values.put(Columns.PLAYS, item.getPlays());
+ values.put(Columns.URL, item.getUrl());
+ return values;
+ }
+
+ @Override
+ public Cursor query(SQLiteDatabase database, Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ String sort = NAME + "." + Columns.PLAYS + " DESC, " + NAME + "." + Columns.NAME + " ASC";
+
+ String timeframe = uri.getQueryParameter("timeframe");
+ if (timeframe == null)
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+
+ // get the latest for "Today", and the next latest for "Yesterday"
+ if (timeframe.equals(StatsTimeframe.TODAY.name())) {
+ return database.rawQuery("SELECT * FROM " + NAME +", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ") AS temp " +
+ "WHERE temp.date = " + NAME + ".date ORDER BY " + sort, null);
+
+ } else if (timeframe.equals(StatsTimeframe.YESTERDAY.name())) {
+ return database.rawQuery(
+ "SELECT * FROM " + NAME + ", " +
+ "(SELECT MAX(date) AS date FROM " + NAME + ", " +
+ "( SELECT MAX(date) AS max FROM " + NAME + ")" +
+ " WHERE " + NAME + ".date < max) AS temp " +
+ "WHERE " + NAME + ".date = temp.date ORDER BY " + sort, null);
+ }
+
+ return super.query(database, uri, projection, selection, selectionArgs, sort);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/Blog.java b/WordPress/src/main/java/org/wordpress/android/models/Blog.java
new file mode 100644
index 000000000..8acceb0e9
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/Blog.java
@@ -0,0 +1,467 @@
+//Manages data for blog settings
+
+package org.wordpress.android.models;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+import android.text.TextUtils;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.CommentTable;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.StringUtils;
+
+public class Blog {
+ private int localTableBlogId;
+ private String url;
+ private String homeURL;
+ private String blogName;
+ private String username;
+ private String password;
+ private String imagePlacement;
+ private boolean featuredImageCapable;
+ private boolean fullSizeImage;
+ private boolean scaledImage;
+ private int scaledImageWidth;
+ private String maxImageWidth;
+ private int maxImageWidthId;
+ private int remoteBlogId;
+ private String dotcom_username;
+ private String dotcom_password;
+ private String api_key;
+ private String api_blogid;
+ private boolean dotcomFlag;
+ private String wpVersion;
+ private String httpuser = "";
+ private String httppassword = "";
+ private String postFormats;
+ private String blogOptions = "{}";
+ private boolean isAdmin;
+ private boolean isHidden;
+
+ public Blog() {
+ }
+
+ public Blog(int localTableBlogId, String url, String homeURL, String blogName, String username, String password, String imagePlacement, boolean featuredImageCapable, boolean fullSizeImage, boolean scaledImage, int scaledImageWidth, String maxImageWidth, int maxImageWidthId, int remoteBlogId, String dotcom_username, String dotcom_password, String api_key, String api_blogid, boolean dotcomFlag, String wpVersion, String httpuser, String httppassword, String postFormats, String blogOptions, boolean isAdmin, boolean isHidden) {
+ this.localTableBlogId = localTableBlogId;
+ this.url = url;
+ this.homeURL = homeURL;
+ this.blogName = blogName;
+ this.username = username;
+ this.password = password;
+ this.imagePlacement = imagePlacement;
+ this.featuredImageCapable = featuredImageCapable;
+ this.fullSizeImage = fullSizeImage;
+ this.scaledImage = scaledImage;
+ this.scaledImageWidth = scaledImageWidth;
+ this.maxImageWidth = maxImageWidth;
+ this.maxImageWidthId = maxImageWidthId;
+ this.remoteBlogId = remoteBlogId;
+ this.dotcom_username = dotcom_username;
+ this.dotcom_password = dotcom_password;
+ this.api_key = api_key;
+ this.api_blogid = api_blogid;
+ this.dotcomFlag = dotcomFlag;
+ this.wpVersion = wpVersion;
+ this.httpuser = httpuser;
+ this.httppassword = httppassword;
+ this.postFormats = postFormats;
+ this.blogOptions = blogOptions;
+ this.isAdmin = isAdmin;
+ this.isHidden = isHidden;
+ }
+
+ public Blog(String url, String username, String password) {
+ this.url = url;
+ this.username = username;
+ this.password = password;
+ this.localTableBlogId = -1;
+ }
+
+ public int getLocalTableBlogId() {
+ return localTableBlogId;
+ }
+
+ public void setLocalTableBlogId(int id) {
+ this.localTableBlogId = id;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public void setUrl(String url) {
+ this.url = url;
+ }
+
+ public URI getUri() {
+ try {
+ String url = getUrl();
+ if (url == null) {
+ AppLog.e(T.UTILS, "Blog url is null");
+ return null;
+ }
+ return new URI(url);
+ } catch (URISyntaxException e) {
+ AppLog.e(T.UTILS, "Blog url is invalid: " + getUrl());
+ return null;
+ }
+ }
+
+ public String getHomeURL() {
+ return homeURL;
+ }
+
+ public void setHomeURL(String homeURL) {
+ this.homeURL = homeURL;
+ }
+
+ public String getBlogName() {
+ return blogName;
+ }
+
+ public void setBlogName(String blogName) {
+ this.blogName = blogName;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public String getImagePlacement() {
+ return imagePlacement;
+ }
+
+ public void setImagePlacement(String imagePlacement) {
+ this.imagePlacement = imagePlacement;
+ }
+
+ public boolean isFeaturedImageCapable() {
+ return featuredImageCapable;
+ }
+
+ public void setFeaturedImageCapable(boolean isCapable) {
+ this.featuredImageCapable = isCapable;
+ }
+
+ public boolean bsetFeaturedImageCapable(boolean isCapable) {
+ if (featuredImageCapable == isCapable) {
+ return false;
+ }
+ setFeaturedImageCapable(isCapable);
+ return true;
+ }
+
+ public boolean isFullSizeImage() {
+ return fullSizeImage;
+ }
+
+ public void setFullSizeImage(boolean fullSizeImage) {
+ this.fullSizeImage = fullSizeImage;
+ }
+
+ public String getMaxImageWidth() {
+ return StringUtils.notNullStr(maxImageWidth);
+ }
+
+ public void setMaxImageWidth(String maxImageWidth) {
+ this.maxImageWidth = maxImageWidth;
+ }
+
+ public int getMaxImageWidthId() {
+ return maxImageWidthId;
+ }
+
+ public void setMaxImageWidthId(int maxImageWidthId) {
+ this.maxImageWidthId = maxImageWidthId;
+ }
+
+ public int getRemoteBlogId() {
+ return remoteBlogId;
+ }
+
+ public void setRemoteBlogId(int blogId) {
+ this.remoteBlogId = blogId;
+ }
+
+ public String getDotcom_username() {
+ return dotcom_username;
+ }
+
+ public void setDotcom_username(String dotcomUsername) {
+ dotcom_username = dotcomUsername;
+ }
+
+ public String getDotcom_password() {
+ return dotcom_password;
+ }
+
+ public void setDotcom_password(String dotcomPassword) {
+ dotcom_password = dotcomPassword;
+ }
+
+ public String getApi_key() {
+ return api_key;
+ }
+
+ public void setApi_key(String apiKey) {
+ api_key = apiKey;
+ }
+
+ public String getApi_blogid() {
+ if (api_blogid == null) {
+ JSONObject jsonOptions = getBlogOptionsJSONObject();
+ if (jsonOptions!=null && jsonOptions.has("jetpack_client_id")) {
+ try {
+ String jetpackBlogId = jsonOptions.getJSONObject("jetpack_client_id").getString("value");
+ if (!TextUtils.isEmpty(jetpackBlogId)) {
+ this.setApi_blogid(jetpackBlogId);
+ WordPress.wpDB.saveBlog(this);
+ }
+ } catch (JSONException e) {
+ AppLog.e(T.UTILS, "Cannot load jetpack_client_id from options: " + jsonOptions, e);
+ }
+ }
+ }
+ return api_blogid;
+ }
+
+ public void setApi_blogid(String apiBlogid) {
+ api_blogid = apiBlogid;
+ }
+
+ public boolean isDotcomFlag() {
+ return dotcomFlag;
+ }
+
+ public void setDotcomFlag(boolean dotcomFlag) {
+ this.dotcomFlag = dotcomFlag;
+ }
+
+ public String getWpVersion() {
+ return wpVersion;
+ }
+
+ public void setWpVersion(String wpVersion) {
+ this.wpVersion = wpVersion;
+ }
+
+ public boolean bsetWpVersion(String wpVersion) {
+ if (StringUtils.equals(this.wpVersion, wpVersion)) {
+ return false;
+ }
+ setWpVersion(wpVersion);
+ return true;
+ }
+
+ public String getHttpuser() {
+ return httpuser;
+ }
+
+ public void setHttpuser(String httpuser) {
+ this.httpuser = httpuser;
+ }
+
+ public String getHttppassword() {
+ return httppassword;
+ }
+
+ public void setHttppassword(String httppassword) {
+ this.httppassword = httppassword;
+ }
+
+ public boolean isHidden() {
+ return isHidden;
+ }
+
+ public void setHidden(boolean isHidden) {
+ this.isHidden = isHidden;
+ }
+
+ public String getPostFormats() {
+ return postFormats;
+ }
+
+ public void setPostFormats(String postFormats) {
+ this.postFormats = postFormats;
+ }
+
+ public boolean bsetPostFormats(String postFormats) {
+ if (StringUtils.equals(this.postFormats, postFormats)) {
+ return false;
+ }
+ setPostFormats(postFormats);
+ return true;
+ }
+
+ // FIXME - Move to DB
+ public int getUnmoderatedCommentCount() {
+ return CommentTable.getUnmoderatedCommentCount(this.localTableBlogId);
+ }
+
+ public boolean isScaledImage() {
+ return scaledImage;
+ }
+
+ public void setScaledImage(boolean scaledImage) {
+ this.scaledImage = scaledImage;
+ }
+
+ public int getScaledImageWidth() {
+ return scaledImageWidth;
+ }
+
+ public void setScaledImageWidth(int scaledImageWidth) {
+ this.scaledImageWidth = scaledImageWidth;
+ }
+
+ public String getBlogOptions() {
+ return blogOptions;
+ }
+
+ public JSONObject getBlogOptionsJSONObject() {
+ String optionsString = getBlogOptions();
+ if (TextUtils.isEmpty(optionsString)) {
+ return null;
+ }
+ try {
+ return new JSONObject(optionsString);
+ } catch (JSONException e) {
+ AppLog.e(T.UTILS, "invalid blogOptions json", e);
+ }
+ return null;
+ }
+
+ public void setBlogOptions(String blogOptions) {
+ this.blogOptions = blogOptions;
+ JSONObject options = getBlogOptionsJSONObject();
+ if (options == null) {
+ this.blogOptions = "{}";
+ options = getBlogOptionsJSONObject();
+ }
+
+ if (options.has("jetpack_client_id")) {
+ try {
+ String jetpackBlogId = options.getJSONObject("jetpack_client_id").getString("value");
+ if (!TextUtils.isEmpty(jetpackBlogId)) {
+ this.setApi_blogid(jetpackBlogId);
+ }
+ } catch (JSONException e) {
+ AppLog.e(T.UTILS, "Cannot load jetpack_client_id from options: " + blogOptions, e);
+ }
+ }
+ }
+
+ // TODO: it's ugly to compare json strings, we have to normalize both strings before
+ // comparison or compare JSON objects after parsing
+ public boolean bsetBlogOptions(String blogOptions) {
+ if (StringUtils.equals(this.blogOptions, blogOptions)) {
+ return false;
+ }
+ setBlogOptions(blogOptions);
+ return true;
+ }
+
+ public boolean isAdmin() {
+ return isAdmin;
+ }
+
+ public void setAdmin(boolean isAdmin) {
+ this.isAdmin = isAdmin;
+ }
+
+ public boolean bsetAdmin(boolean isAdmin) {
+ if (this.isAdmin == isAdmin) {
+ return false;
+ }
+ setAdmin(isAdmin);
+ return true;
+ }
+
+ public String getAdminUrl() {
+ String adminUrl = null;
+ JSONObject jsonOptions = getBlogOptionsJSONObject();
+ if (jsonOptions != null) {
+ try {
+ adminUrl = jsonOptions.getJSONObject("admin_url").getString("value");
+ } catch (JSONException e) {
+ AppLog.e(T.UTILS, "Cannot load admin_url from options: " + jsonOptions, e);
+ }
+ }
+
+ // Try to guess the URL of the dashboard if blogOptions is null (blog not added to the app), or WP version is < 3.6
+ if (TextUtils.isEmpty(adminUrl)) {
+ if (this.getUrl().lastIndexOf("/") != -1) {
+ adminUrl = this.getUrl().substring(0, this.getUrl().lastIndexOf("/")) + "/wp-admin";
+ } else {
+ adminUrl = this.getUrl().replace("xmlrpc.php", "wp-admin");
+ }
+ }
+ return adminUrl;
+ }
+
+ public boolean isPrivate() {
+ JSONObject jsonOptions = getBlogOptionsJSONObject();
+ if (jsonOptions != null && jsonOptions.has("blog_public")) {
+ try {
+ String blogPublicValue = jsonOptions.getJSONObject("blog_public").getString("value");
+ if (!TextUtils.isEmpty(blogPublicValue) && "-1".equals(blogPublicValue)) {
+ return true;
+ }
+ } catch (JSONException e) {
+ AppLog.e(T.UTILS, "Cannot load blog_public from options: " + jsonOptions, e);
+ }
+ }
+ return false;
+ }
+
+ public boolean isJetpackPowered() {
+ JSONObject jsonOptions = getBlogOptionsJSONObject();
+ if (jsonOptions != null && jsonOptions.has("jetpack_client_id")) {
+ return true;
+ }
+ return false;
+ }
+
+ public boolean isPhotonCapable() {
+ return ((isDotcomFlag() && !isPrivate()) || (isJetpackPowered() && !hasValidHTTPAuthCredentials()));
+ }
+
+ public boolean hasValidJetpackCredentials() {
+ return !TextUtils.isEmpty(getDotcom_username()) && !TextUtils.isEmpty(getDotcom_password());
+ }
+
+ public boolean hasValidHTTPAuthCredentials() {
+ return !TextUtils.isEmpty(getHttppassword()) && !TextUtils.isEmpty(getHttpuser());
+ }
+
+ /**
+ * Get the WordPress.com blog ID
+ * Stored in blogId for WP.com, api_blogId for Jetpack
+ *
+ * @return WP.com blogId string, potentially null for Jetpack sites
+ */
+ public String getDotComBlogId() {
+ if (isDotcomFlag())
+ return String.valueOf(getRemoteBlogId());
+ else
+ return getApi_blogid();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/BlogIdentifier.java b/WordPress/src/main/java/org/wordpress/android/models/BlogIdentifier.java
new file mode 100644
index 000000000..755614324
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/BlogIdentifier.java
@@ -0,0 +1,52 @@
+package org.wordpress.android.models;
+
+import org.apache.commons.lang.builder.HashCodeBuilder;
+
+/**
+ * A blog is uniquely identified by the combination of xmlRpcUrl and blogId
+ */
+public class BlogIdentifier {
+ private String mXmlRpcUrl;
+ private int mBlogId;
+
+ public BlogIdentifier(String mXmlRpcUrl, int mBlogId) {
+ this.mXmlRpcUrl = mXmlRpcUrl;
+ this.mBlogId = mBlogId;
+ }
+
+ public String getXmlRpcUrl() {
+ return mXmlRpcUrl;
+ }
+
+ public void setXmlRpcUrl(String mXmlRpcUrl) {
+ this.mXmlRpcUrl = mXmlRpcUrl;
+ }
+
+ public int getBlogId() {
+ return mBlogId;
+ }
+
+ public void setBlogId(int mBlogId) {
+ this.mBlogId = mBlogId;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (other == null) {
+ return false;
+ }
+ if (other == this) { // same instance
+ return true;
+ }
+ if (!(other instanceof BlogIdentifier)) {
+ return false;
+ }
+ BlogIdentifier o = (BlogIdentifier) other;
+ return mXmlRpcUrl.equals(o.getXmlRpcUrl()) && mBlogId == o.getBlogId();
+ }
+
+ @Override
+ public int hashCode() {
+ return new HashCodeBuilder(3739, 50989).append(mBlogId).append(mXmlRpcUrl).toHashCode();
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/models/BlogPairId.java b/WordPress/src/main/java/org/wordpress/android/models/BlogPairId.java
new file mode 100644
index 000000000..c458fe67b
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/BlogPairId.java
@@ -0,0 +1,25 @@
+package org.wordpress.android.models;
+
+import java.io.Serializable;
+
+/**
+ * Simple POJO to store a pair of ids: a remoteBlogId + a genericId.
+ * Could be used to identify a comment (remoteBlogId + commentId) or a post (remoteBlogId + postId)
+ */
+public class BlogPairId implements Serializable {
+ private long mId;
+ private long mRemoteBlogId;
+
+ public BlogPairId(long remoteBlogId, long id) {
+ mRemoteBlogId = remoteBlogId;
+ mId = id;
+ }
+
+ public long getId() {
+ return mId;
+ }
+
+ public long getRemoteBlogId() {
+ return mRemoteBlogId;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/CategoryNode.java b/WordPress/src/main/java/org/wordpress/android/models/CategoryNode.java
new file mode 100644
index 000000000..cd01d8c79
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/CategoryNode.java
@@ -0,0 +1,110 @@
+package org.wordpress.android.models;
+
+import android.util.SparseArray;
+import org.wordpress.android.WordPress;
+
+import java.util.*;
+
+public class CategoryNode {
+ private int categoryId;
+ private String name;
+ private int parentId;
+ private int level;
+ SortedMap<String, CategoryNode> children = new TreeMap<String, CategoryNode>(new Comparator<String>() {
+ @Override
+ public int compare(String s, String s2) {
+ return s.compareToIgnoreCase(s2);
+ }
+ });
+
+ public SortedMap<String, CategoryNode> getChildren() {
+ return children;
+ }
+
+ public void setChildren(SortedMap<String, CategoryNode> children) {
+ this.children = children;
+ }
+
+ public CategoryNode(int categoryId, int parentId, String name) {
+ this.categoryId = categoryId;
+ this.parentId = parentId;
+ this.name = name;
+ }
+
+ public int getCategoryId() {
+ return categoryId;
+ }
+
+ public void setCategoryId(int categoryId) {
+ this.categoryId = categoryId;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public int getParentId() {
+ return parentId;
+ }
+
+ public void setParentId(int parentId) {
+ this.parentId = parentId;
+ }
+
+ public int getLevel() {
+ return level;
+ }
+
+ public static CategoryNode createCategoryTreeFromDB(int blogId) {
+ CategoryNode rootCategory = new CategoryNode(-1, -1, "");
+ if (WordPress.wpDB == null) {
+ return rootCategory;
+ }
+ List<String> stringCategories = WordPress.wpDB.loadCategories(blogId);
+
+ // First pass instantiate CategoryNode objects
+ SparseArray<CategoryNode> categoryMap = new SparseArray<CategoryNode>();
+ CategoryNode currentRootNode;
+ for (String name : stringCategories) {
+ int categoryId = WordPress.wpDB.getCategoryId(blogId, name);
+ int parentId = WordPress.wpDB.getCategoryParentId(blogId, name);
+ CategoryNode node = new CategoryNode(categoryId, parentId, name);
+ categoryMap.put(categoryId, node);
+ }
+
+ // Second pass associate nodes to form a tree
+ for(int i = 0; i < categoryMap.size(); i++){
+ CategoryNode category = categoryMap.valueAt(i);
+ if (category.getParentId() == 0) { // root node
+ currentRootNode = rootCategory;
+ } else {
+ currentRootNode = categoryMap.get(category.getParentId(), rootCategory);
+ }
+ currentRootNode.children.put(category.getName(), categoryMap.get(category.getCategoryId()));
+ }
+ return rootCategory;
+ }
+
+ private static void preOrderTreeTraversal(CategoryNode node, int level, ArrayList<CategoryNode> returnValue) {
+ if (node == null) {
+ return ;
+ }
+ if (node.parentId != -1) {
+ node.level = level;
+ returnValue.add(node);
+ }
+ for (CategoryNode child : node.getChildren().values()) {
+ preOrderTreeTraversal(child, level + 1, returnValue);
+ }
+ }
+
+ public static ArrayList<CategoryNode> getSortedListOfCategoriesFromRoot(CategoryNode node) {
+ ArrayList<CategoryNode> sortedCategories = new ArrayList<CategoryNode>();
+ preOrderTreeTraversal(node, 0, sortedCategories);
+ return sortedCategories;
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/models/Comment.java b/WordPress/src/main/java/org/wordpress/android/models/Comment.java
new file mode 100644
index 000000000..626ed26d6
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/Comment.java
@@ -0,0 +1,237 @@
+package org.wordpress.android.models;
+
+import android.content.Context;
+import android.text.TextUtils;
+
+import org.json.JSONObject;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.DateTimeUtils;
+import org.wordpress.android.util.GravatarUtils;
+import org.wordpress.android.util.HtmlUtils;
+import org.wordpress.android.util.JSONUtil;
+import org.wordpress.android.util.PhotonUtils;
+import org.wordpress.android.util.StringUtils;
+
+public class Comment {
+ public long postID;
+ public long commentID;
+
+ private String authorName;
+ private String status;
+ private String comment;
+ private String postTitle;
+ private String authorUrl;
+ private String authorEmail;
+ private String published;
+ private String profileImageUrl;
+
+ public Comment(long postID,
+ long commentID,
+ String authorName,
+ String pubDateGmt,
+ String comment,
+ String status,
+ String postTitle,
+ String authorURL,
+ String authorEmail,
+ String profileImageUrl) {
+ this.postID = postID;
+ this.commentID = commentID;
+ this.authorName = authorName;
+ this.status = status;
+ this.comment = comment;
+ this.postTitle = postTitle;
+ this.authorUrl = authorURL;
+ this.authorEmail = authorEmail;
+ this.profileImageUrl = profileImageUrl;
+ this.published = pubDateGmt;
+ }
+
+ private Comment() {
+ // nop
+ }
+
+ /*
+ * nbradbury 11/14/13 - create a comment from JSON (REST response)
+ * https://developer.wordpress.com/docs/api/1/get/sites/%24site/comments/%24comment_ID/
+ */
+ public static Comment fromJSON(JSONObject json) {
+ if (json == null)
+ return null;
+
+ Comment comment = new Comment();
+ comment.commentID = json.optLong("ID");
+ comment.status = JSONUtil.getString(json, "status");
+ comment.published = JSONUtil.getString(json, "date");
+
+ // note that the content often contains html, and on rare occasions may contain
+ // script blocks that need to be removed (only seen with blogs that use the
+ // sociable plugin)
+ comment.comment = HtmlUtils.stripScript(JSONUtil.getString(json, "content"));
+
+ JSONObject jsonPost = json.optJSONObject("post");
+ if (jsonPost != null) {
+ comment.postID = jsonPost.optLong("ID");
+ // TODO: c.postTitle = ???
+ }
+
+ JSONObject jsonAuthor = json.optJSONObject("author");
+ if (jsonAuthor!=null) {
+ // author names may contain html entities (esp. pingbacks)
+ comment.authorName = JSONUtil.getStringDecoded(jsonAuthor, "name");
+ comment.authorUrl = JSONUtil.getString(jsonAuthor, "URL");
+
+ // email address will be set to "false" when there isn't an email address
+ comment.authorEmail = JSONUtil.getString(jsonAuthor, "email");
+ if (comment.authorEmail.equals("false"))
+ comment.authorEmail = "";
+
+ comment.profileImageUrl = JSONUtil.getString(jsonAuthor, "avatar_URL");
+ }
+
+ return comment;
+ }
+
+ public String getProfileImageUrl() {
+ return StringUtils.notNullStr(profileImageUrl);
+ }
+ public void setProfileImageUrl(String url) {
+ profileImageUrl = StringUtils.notNullStr(url);
+ }
+ public boolean hasProfileImageUrl() {
+ return !TextUtils.isEmpty(profileImageUrl);
+ }
+
+ public CommentStatus getStatusEnum() {
+ return CommentStatus.fromString(status);
+ }
+
+ public String getStatus() {
+ return StringUtils.notNullStr(status);
+ }
+ public void setStatus(String status) {
+ this.status = StringUtils.notNullStr(status);
+ }
+
+ public String getPublished() {
+ return StringUtils.notNullStr(published);
+ }
+ public void setPublished(String pubDate) {
+ published = StringUtils.notNullStr(pubDate);
+ }
+
+ public boolean hasAuthorName() {
+ return !TextUtils.isEmpty(authorName);
+ }
+ public String getAuthorName() {
+ return StringUtils.notNullStr(authorName);
+ }
+ public void setAuthorName(String name) {
+ authorName = StringUtils.notNullStr(name);
+ }
+
+ public boolean hasAuthorEmail() {
+ return !TextUtils.isEmpty(authorEmail);
+ }
+ public String getAuthorEmail() {
+ return StringUtils.notNullStr(authorEmail);
+ }
+ public void setAuthorEmail(String email) {
+ authorEmail = StringUtils.notNullStr(email);
+ }
+
+ public boolean hasAuthorUrl() {
+ return !TextUtils.isEmpty(authorUrl);
+ }
+ public String getAuthorUrl() {
+ return StringUtils.notNullStr(authorUrl);
+ }
+ public void setAuthorUrl(String url) {
+ authorUrl = StringUtils.notNullStr(url);
+ }
+
+ public String getCommentText() {
+ return StringUtils.notNullStr(comment);
+ }
+ public void setCommentText(String text) {
+ comment = StringUtils.notNullStr(text);
+ }
+
+ public boolean hasPostTitle() {
+ return !TextUtils.isEmpty(postTitle);
+ }
+ public String getPostTitle() {
+ return StringUtils.notNullStr(postTitle);
+ }
+ public void setPostTitle(String title) {
+ postTitle = StringUtils.notNullStr(title);
+ }
+
+ /****
+ * the following are transient variables whose sole purpose is to cache commonly-used values
+ * for the comment that speeds up accessing them inside adapters
+ ****/
+
+ /*
+ * converts iso8601 published date to an actual java date
+ */
+ private transient java.util.Date dtPublished;
+ public java.util.Date getDatePublished() {
+ if (dtPublished == null)
+ dtPublished = DateTimeUtils.iso8601ToJavaDate(published);
+ return dtPublished;
+ }
+
+ private transient String unescapedCommentText;
+ public String getUnescapedCommentText() {
+ if (unescapedCommentText == null)
+ unescapedCommentText = StringUtils.unescapeHTML(getCommentText()).trim();
+ return unescapedCommentText;
+ }
+
+ private transient String unescapedPostTitle;
+ public String getUnescapedPostTitle() {
+ if (unescapedPostTitle == null)
+ unescapedPostTitle = StringUtils.unescapeHTML(getPostTitle().trim());
+ return unescapedPostTitle;
+ }
+
+ /*
+ * returns the avatar url as a photon/gravatar url set to the passed size
+ */
+ private transient String avatarForDisplay;
+ public String getAvatarForDisplay(int avatarSize) {
+ if (avatarForDisplay==null) {
+ if (hasProfileImageUrl()) {
+ avatarForDisplay = PhotonUtils.fixAvatar(profileImageUrl, avatarSize);
+ } else if (hasAuthorEmail()) {
+ avatarForDisplay = GravatarUtils.gravatarUrlFromEmail(authorEmail, avatarSize);
+ } else {
+ avatarForDisplay = "";
+ }
+ }
+ return avatarForDisplay;
+ }
+
+ /*
+ * returns the author + post title as "Author Name on Post Title" - used by comment list
+ */
+ private transient String formattedTitle;
+ public String getFormattedTitle() {
+ if (formattedTitle == null) {
+ Context context = WordPress.getContext();
+ final String author = (hasAuthorName() ? getAuthorName() : context.getString(R.string.anonymous));
+ if (hasPostTitle()) {
+ formattedTitle = author
+ + "<font color=" + HtmlUtils.colorResToHtmlColor(context, R.color.grey_medium) + ">"
+ + " " + context.getString(R.string.on) + " "
+ + "</font>"
+ + getUnescapedPostTitle();
+ } else {
+ formattedTitle = author;
+ }
+ }
+ return formattedTitle;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/CommentList.java b/WordPress/src/main/java/org/wordpress/android/models/CommentList.java
new file mode 100644
index 000000000..613b4c92e
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/CommentList.java
@@ -0,0 +1,89 @@
+package org.wordpress.android.models;
+
+import java.util.ArrayList;
+
+public class CommentList extends ArrayList<Comment> {
+ public int indexOfCommentId(long commentId) {
+ for (int i=0; i < this.size(); i++) {
+ if (commentId==this.get(i).commentID)
+ return i;
+ }
+ return -1;
+ }
+
+ /*
+ * replace comments in this list that match the passed list
+ */
+ public void replaceComments(final CommentList comments) {
+ if (comments == null || comments.size() == 0)
+ return;
+ for (Comment comment: comments) {
+ int index = indexOfCommentId(comment.commentID);
+ if (index > -1)
+ set(index, comment);
+ }
+ }
+
+ /*
+ * delete comments in this list that match the passed list
+ */
+ public void deleteComments(final CommentList comments) {
+ if (comments == null || comments.size() == 0)
+ return;
+ for (Comment comment: comments) {
+ int index = indexOfCommentId(comment.commentID);
+ if (index > -1)
+ remove(index);
+ }
+ }
+
+ /*
+ * returns true if any comments in this list have the passed status
+ */
+ public boolean hasAnyWithStatus(CommentStatus status) {
+ for (Comment comment: this) {
+ if (comment.getStatusEnum().equals(status))
+ return true;
+ }
+ return false;
+ }
+
+ /*
+ * returns true if any comments in this list do NOT have the passed status
+ */
+ public boolean hasAnyWithoutStatus(CommentStatus status) {
+ for (Comment comment: this) {
+ if (!comment.getStatusEnum().equals(status))
+ return true;
+ }
+ return false;
+ }
+
+ /*
+ * does passed list contain the same comments as this list?
+ */
+ public boolean isSameList(CommentList comments) {
+ if (comments == null || comments.size() != this.size())
+ return false;
+
+ for (final Comment comment: comments) {
+ int index = this.indexOfCommentId(comment.commentID);
+ if (index == -1)
+ return false;
+ final Comment thisComment = this.get(index);
+ if (!thisComment.getStatus().equals(comment.getStatus()))
+ return false;
+ if (!thisComment.getCommentText().equals(comment.getCommentText()))
+ return false;
+ if (!thisComment.getAuthorName().equals(comment.getAuthorName()))
+ return false;
+ if (!thisComment.getAuthorEmail().equals(comment.getAuthorEmail()))
+ return false;
+ if (!thisComment.getAuthorUrl().equals(comment.getAuthorUrl()))
+ return false;
+
+ }
+
+ return true;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/CommentStatus.java b/WordPress/src/main/java/org/wordpress/android/models/CommentStatus.java
new file mode 100644
index 000000000..eaae551db
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/CommentStatus.java
@@ -0,0 +1,56 @@
+package org.wordpress.android.models;
+
+public enum CommentStatus {
+ UNKNOWN,
+ UNAPPROVED,
+ APPROVED,
+ TRASH, // <-- REST only
+ SPAM;
+
+ /*
+ * returns the string representation of the passed status, as used by the XMLRPC API
+ */
+ public static String toString(CommentStatus status) {
+ switch (status) {
+ case UNAPPROVED:
+ return "hold";
+ case APPROVED:
+ return "approve";
+ case SPAM:
+ return "spam";
+ default:
+ return "";
+ }
+
+ /* for future reference, REST API uses these strings:
+ switch (status) {
+ case UNAPPROVED:
+ return "unapproved";
+ case APPROVED:
+ return "approved";
+ case SPAM:
+ return "spam";
+ case TRASH:
+ return "trash";
+ default:
+ return "";
+ } */
+ };
+
+ /*
+ * returns the status associated with the passed strings - handles both XMLRPC and REST
+ */
+ public static CommentStatus fromString(String value) {
+ if (value == null)
+ return CommentStatus.UNKNOWN;
+ if (value.equals("approve") || value.equals("approved"))
+ return CommentStatus.APPROVED;
+ if (value.equals("hold") || value.equals("unapproved"))
+ return CommentStatus.UNAPPROVED;
+ if (value.equals("spam"))
+ return SPAM;
+ if (value.equals("trash"))
+ return TRASH;
+ return CommentStatus.UNKNOWN;
+ }
+}; \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/models/FeatureSet.java b/WordPress/src/main/java/org/wordpress/android/models/FeatureSet.java
new file mode 100644
index 000000000..a64445042
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/FeatureSet.java
@@ -0,0 +1,38 @@
+package org.wordpress.android.models;
+
+import java.util.Map;
+
+/**
+ * A Model for parsing the result of wpcom.getFeatures() to retrieve
+ * features for a hosted WordPress.com blog.
+ */
+public class FeatureSet {
+ private int mBlogId;
+
+ private boolean mIsVideopressEnabled = false;
+ // add future features here
+
+ public FeatureSet(int blogId, Map<?,?> map) {
+ setBlogId(blogId);
+
+ if (map.containsKey("videopress_enabled"))
+ setIsVideopressEnabled((Boolean) map.get("videopress_enabled"));
+
+ }
+
+ public boolean isVideopressEnabled() {
+ return mIsVideopressEnabled;
+ }
+
+ public void setIsVideopressEnabled(boolean enabled) {
+ this.mIsVideopressEnabled = enabled;
+ }
+
+ public int getBlogId() {
+ return mBlogId;
+ }
+
+ public void setBlogId(int blogId) {
+ this.mBlogId = blogId;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/MediaFile.java b/WordPress/src/main/java/org/wordpress/android/models/MediaFile.java
new file mode 100644
index 000000000..d1d9d7dd8
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/MediaFile.java
@@ -0,0 +1,292 @@
+package org.wordpress.android.models;
+
+import android.webkit.MimeTypeMap;
+
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.MapUtils;
+import org.wordpress.android.util.StringUtils;
+
+import java.util.Date;
+import java.util.Map;
+
+public class MediaFile {
+ protected int id;
+ protected long postID;
+ protected String filePath = null; //path of the file into disk
+ protected String fileName = null; //name of the file into the server
+ protected String title = null;
+ protected String description = null;
+ protected String caption = null;
+ protected int horizontalAlignment; //0 = none, 1 = left, 2 = center, 3 = right
+ protected boolean verticalAligment = false; //false = bottom, true = top
+ protected int width = 500, height;
+ protected String mimeType = "";
+ protected String videoPressShortCode = null;
+ protected boolean featured = false;
+ protected boolean isVideo = false;
+ protected boolean featuredInPost;
+ protected String fileURL = null; // url of the file to download
+ protected String thumbnailURL = null; // url of the thumbnail to download
+ private String blogId;
+ private long dateCreatedGmt;
+ private String uploadState = null;
+ private String mediaId;
+
+ public MediaFile(String blogId, Map<?, ?> resultMap) {
+ boolean isDotCom = (WordPress.getCurrentBlog() != null && WordPress.getCurrentBlog().isDotcomFlag());
+
+ setBlogId(blogId);
+ setMediaId(MapUtils.getMapStr(resultMap, "attachment_id"));
+ setPostID(MapUtils.getMapLong(resultMap, "parent"));
+ setTitle(MapUtils.getMapStr(resultMap, "title"));
+ setCaption(MapUtils.getMapStr(resultMap, "caption"));
+ setDescription(MapUtils.getMapStr(resultMap, "description"));
+ setVideoPressShortCode(MapUtils.getMapStr(resultMap, "videopress_shortcode"));
+
+ // get the file name from the link
+ String link = MapUtils.getMapStr(resultMap, "link");
+ setFileName(new String(link).replaceAll("^.*/([A-Za-z0-9_-]+)\\.\\w+$", "$1"));
+
+ String fileType = new String(link).replaceAll(".*\\.(\\w+)$", "$1").toLowerCase();
+ String fileMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileType);
+ setMimeType(fileMimeType);
+
+ // make the file urls be https://... so that we can get these images with oauth when the blogs are private
+ // assume no https for images in self-hosted blogs
+ String fileUrl = MapUtils.getMapStr(resultMap, "link");
+ if (isDotCom)
+ fileUrl = fileUrl.replace("http:", "https:");
+ setFileURL(fileUrl);
+
+ String thumbnailURL = MapUtils.getMapStr(resultMap, "thumbnail");
+ if (thumbnailURL.startsWith("http")) {
+ if (isDotCom)
+ thumbnailURL = thumbnailURL.replace("http:", "https:");
+ setThumbnailURL(thumbnailURL);
+ }
+
+ Date date = MapUtils.getMapDate(resultMap, "date_created_gmt");
+ if (date != null)
+ setDateCreatedGMT(date.getTime());
+
+ Object meta = resultMap.get("metadata");
+ if(meta != null && meta instanceof Map) {
+ Map<?, ?> metadata = (Map<?, ?>) meta;
+ setWidth(MapUtils.getMapInt(metadata, "width"));
+ setHeight(MapUtils.getMapInt(metadata, "height"));
+ }
+ }
+
+ public MediaFile() {
+ // default constructor
+ }
+
+ public MediaFile(MediaFile mediaFile) {
+ this.id = mediaFile.id;
+ this.postID = mediaFile.postID;
+ this.filePath = mediaFile.filePath;
+ this.fileName = mediaFile.fileName;
+ this.title = mediaFile.title;
+ this.description = mediaFile.description;
+ this.caption = mediaFile.caption;
+ this.horizontalAlignment = mediaFile.horizontalAlignment;
+ this.verticalAligment = mediaFile.verticalAligment;
+ this.width = mediaFile.width;
+ this.height = mediaFile.height;
+ this.mimeType = mediaFile.mimeType;
+ this.videoPressShortCode = mediaFile.videoPressShortCode;
+ this.featured = mediaFile.featured;
+ this.isVideo = mediaFile.isVideo;
+ this.featuredInPost = mediaFile.featuredInPost;
+ this.fileURL = mediaFile.fileURL;
+ this.thumbnailURL = mediaFile.thumbnailURL;
+ this.blogId = mediaFile.blogId;
+ this.dateCreatedGmt = mediaFile.dateCreatedGmt;
+ this.uploadState = mediaFile.uploadState;
+ this.mediaId = mediaFile.mediaId;
+ }
+
+
+ public int getId() {
+ return id;
+ }
+
+ public void setId(int id) {
+ this.id = id;
+ }
+
+ public String getMediaId() {
+ return mediaId;
+ }
+
+ public void setMediaId(String id) {
+ mediaId = id;
+ }
+
+ public boolean isFeatured() {
+ return featured;
+ }
+
+ public void setFeatured(boolean featured) {
+ this.featured = featured;
+ }
+
+ public long getPostID() {
+ return postID;
+ }
+
+ public void setPostID(long postID) {
+ this.postID = postID;
+ }
+
+ public String getFilePath() {
+ return filePath;
+ }
+
+ public void setFilePath(String filePath) {
+ this.filePath = filePath;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public String getCaption() {
+ return caption;
+ }
+
+ public void setCaption(String caption) {
+ this.caption = caption;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public String getFileURL() {
+ return fileURL;
+ }
+
+ public void setFileURL(String fileURL) {
+ this.fileURL = fileURL;
+ }
+
+ public String getThumbnailURL() {
+ return thumbnailURL;
+ }
+
+ public void setThumbnailURL(String thumbnailURL) {
+ this.thumbnailURL = thumbnailURL;
+ }
+
+ public boolean isVerticalAlignmentOnTop() {
+ return verticalAligment;
+ }
+
+ public void setVerticalAlignmentOnTop(boolean verticalAligment) {
+ this.verticalAligment = verticalAligment;
+ }
+
+ public int getWidth() {
+ return width;
+ }
+
+ public void setWidth(int width) {
+ this.width = width;
+ }
+
+ public int getHeight() {
+ return height;
+ }
+
+ public void setHeight(int height) {
+ this.height = height;
+ }
+
+ public String getFileName() {
+ return fileName;
+ }
+
+ public void setFileName(String fileName) {
+ this.fileName = fileName;
+ }
+
+ public String getMimeType() {
+ return StringUtils.notNullStr(mimeType);
+ }
+
+ public void setMimeType(String type) {
+ mimeType = StringUtils.notNullStr(type);
+ }
+
+ public String getVideoPressShortCode() {
+ return videoPressShortCode;
+ }
+
+ public void setVideoPressShortCode(String videoPressShortCode) {
+ this.videoPressShortCode = videoPressShortCode;
+ }
+
+ public int getHorizontalAlignment() {
+ return horizontalAlignment;
+ }
+
+ public void setHorizontalAlignment(int horizontalAlignment) {
+ this.horizontalAlignment = horizontalAlignment;
+ }
+
+ public boolean isVideo() {
+ return isVideo;
+ }
+
+ public void setVideo(boolean isVideo) {
+ this.isVideo = isVideo;
+ }
+
+ public boolean isFeaturedInPost() {
+ return featuredInPost;
+ }
+
+ public void setFeaturedInPost(boolean featuredInPost) {
+ this.featuredInPost = featuredInPost;
+ }
+
+ public void save() {
+ WordPress.wpDB.saveMediaFile(this);
+ }
+
+ public String getBlogId() {
+ return blogId;
+ }
+
+ public void setBlogId(String blogId) {
+ this.blogId = blogId;
+
+ }
+
+ public void setDateCreatedGMT(long date_created_gmt) {
+ this.dateCreatedGmt = date_created_gmt;
+ }
+
+
+ public long getDateCreatedGMT() {
+ return dateCreatedGmt;
+ }
+
+ public void setUploadState(String uploadState) {
+ this.uploadState = uploadState;
+ }
+
+ public String getUploadState() {
+ return uploadState;
+ }
+}
+
diff --git a/WordPress/src/main/java/org/wordpress/android/models/MediaGallery.java b/WordPress/src/main/java/org/wordpress/android/models/MediaGallery.java
new file mode 100644
index 000000000..4b4339e4f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/MediaGallery.java
@@ -0,0 +1,85 @@
+
+package org.wordpress.android.models;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+
+/**
+ * A model representing a Media Gallery.
+ * A unique id is not used on the website, but only in this app.
+ * It is used to uniquely determining the instance of the object, as it is
+ * passed between post and media gallery editor.
+ */
+public class MediaGallery implements Serializable{
+ private static final long serialVersionUID = 2359176987182027508L;
+
+ private long uniqueId;
+ private boolean isRandom;
+ private String type;
+ private int numColumns;
+ private ArrayList<String> ids;
+
+ public MediaGallery(boolean isRandom, String type, int numColumns, ArrayList<String> ids) {
+ this.isRandom = isRandom;
+ this.type = type;
+ this.numColumns = numColumns;
+ this.ids = ids;
+ this.uniqueId = System.currentTimeMillis();
+ }
+
+ public MediaGallery() {
+ isRandom = false;
+ type = "";
+ numColumns = 3;
+ ids = new ArrayList<String>();
+ this.uniqueId = System.currentTimeMillis();
+ }
+
+ public boolean isRandom() {
+ return isRandom;
+ }
+
+ public void setRandom(boolean isRandom) {
+ this.isRandom = isRandom;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ public int getNumColumns() {
+ return numColumns;
+ }
+
+ public void setNumColumns(int numColumns) {
+ this.numColumns = numColumns;
+ }
+
+ public ArrayList<String> getIds() {
+ return ids;
+ }
+
+ public String getIdsStr() {
+ String ids_str = "";
+ if (ids.size() > 0) {
+ for (String id : ids) {
+ ids_str += id + ",";
+ }
+ ids_str = ids_str.substring(0, ids_str.length() - 1);
+ }
+ return ids_str;
+ }
+
+ public void setIds(ArrayList<String> ids) {
+ this.ids = ids;
+ }
+
+ /** An id to uniquely identify a media gallery object, so that the same object can be edited in the post editor **/
+ public long getUniqueId() {
+ return uniqueId;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/Note.java b/WordPress/src/main/java/org/wordpress/android/models/Note.java
new file mode 100644
index 000000000..76c84bb85
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/Note.java
@@ -0,0 +1,462 @@
+/**
+ * Note represents a single WordPress.com notification
+ */
+package org.wordpress.android.models;
+
+import android.text.Html;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.simperium.client.BucketSchema;
+import com.simperium.client.Syncable;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.DateTimeUtils;
+import org.wordpress.android.util.HtmlUtils;
+import org.wordpress.android.util.JSONUtil;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class Note extends Syncable {
+
+ public static class Schema extends BucketSchema<Note> {
+
+ static public final String NAME = "note";
+ static public final String TIMESTAMP_INDEX = "timestamp";
+
+ private static final Indexer<Note> sTimestampIndexer = new Indexer<Note>() {
+
+ @Override
+ public List<Index> index(Note note) {
+ List<Index> indexes = new ArrayList<Index>(1);
+ try {
+ indexes.add(new Index(TIMESTAMP_INDEX, note.getTimestamp()));
+ } catch (NumberFormatException e) {
+ // note will not have an indexed timestamp so it will
+ // show up at the end of a query sorting by timestamp
+ android.util.Log.e("WordPress", "Failed to index timestamp", e);
+ }
+ return indexes;
+ }
+
+ };
+
+ public Schema() {
+ // save an index with a timestamp
+ addIndex(sTimestampIndexer);
+ }
+
+ @Override
+ public String getRemoteName() {
+ return NAME;
+ }
+
+ @Override
+ public Note build(String key, JSONObject properties) {
+ return new Note(properties);
+ }
+
+ public void update(Note note, JSONObject properties) {
+ note.updateJSON(properties);
+ }
+
+ }
+
+
+ private static final String TAG = "NoteModel";
+
+ // Maximum character length for a comment preview
+ static private final int MAX_COMMENT_PREVIEW_LENGTH = 200;
+
+ private static final String NOTE_UNKNOWN_TYPE = "unknown";
+ private static final String NOTE_COMMENT_TYPE = "comment";
+ public static final String NOTE_COMMENT_LIKE_TYPE = "comment_like";
+ public static final String NOTE_LIKE_TYPE = "like";
+ private static final String NOTE_MATCHER_TYPE = "automattcher";
+ private static final String NOTE_ACHIEVEMENT_TYPE = "achievement";
+
+ // Notes have different types of "templates" for displaying differently
+ // this is not a canonical list but covers all the types currently in use
+ private static final String SINGLE_LINE_LIST_TEMPLATE = "single-line-list";
+ private static final String MULTI_LINE_LIST_TEMPLATE = "multi-line-list";
+ private static final String BIG_BADGE_TEMPLATE = "big-badge";
+
+ // JSON action keys
+ private static final String ACTION_KEY_REPLY = "replyto-comment";
+ private static final String ACTION_KEY_APPROVE = "approve-comment";
+ private static final String ACTION_KEY_UNAPPROVE = "unapprove-comment";
+ private static final String ACTION_KEY_SPAM = "spam-comment";
+
+ public static enum EnabledActions {
+ ACTION_REPLY,
+ ACTION_APPROVE,
+ ACTION_UNAPPROVE,
+ ACTION_SPAM
+ }
+
+ private Map<String, JSONObject> mActions;
+ private JSONObject mNoteJSON;
+
+ private int mBlogId;
+ private int mPostId;
+ private long mCommentId;
+ private long mCommentParentId;
+ private long mTimestamp;
+
+ private transient String mCommentPreview;
+ private transient String mSubject;
+ private transient String mIconUrl;
+ private transient String mSnippet;
+ private transient String mNoteType;
+
+ /**
+ * Create a note using JSON from Simperium
+ */
+ public Note(JSONObject noteJSON) {
+ mNoteJSON = noteJSON;
+ preloadContent();
+ }
+
+ /**
+ * Simperium method @see Diffable
+ */
+ @Override
+ public JSONObject getDiffableValue() {
+ return mNoteJSON;
+ }
+
+ /**
+ * Simperium method for identifying bucket object @see Diffable
+ */
+ @Override
+ public String getSimperiumKey() {
+ return getId();
+ }
+
+ public JSONObject toJSONObject() {
+ return mNoteJSON;
+ }
+
+ public String getId() {
+ return queryJSON("id", "0");
+ }
+
+ public String getType() {
+ if (mNoteType == null) {
+ mNoteType = queryJSON("type", NOTE_UNKNOWN_TYPE);
+ if (mNoteType.contains(NOTE_ACHIEVEMENT_TYPE)) {
+ mNoteType = NOTE_ACHIEVEMENT_TYPE;
+ }
+ }
+
+ return mNoteType;
+ }
+
+ private Boolean isType(String type) {
+ return getType().equals(type);
+ }
+
+ public Boolean isCommentType() {
+ return isType(NOTE_COMMENT_TYPE);
+ }
+
+ public Boolean isCommentLikeType() {
+ return isType(NOTE_COMMENT_LIKE_TYPE);
+ }
+
+ public Boolean isAutomattcherType() {
+ return isType(NOTE_MATCHER_TYPE);
+ }
+
+ public String getSubject() {
+ if (mSubject == null) {
+ String text = queryJSON("subject.text", "").trim();
+ if (text.equals("")) {
+ text = queryJSON("subject.html", "");
+ }
+ mSubject = Html.fromHtml(text).toString();
+ }
+ return mSubject;
+ }
+
+ public String getIconURL() {
+ if (mIconUrl == null)
+ mIconUrl = queryJSON("subject.icon", "");
+ return mIconUrl;
+ }
+
+ /**
+ * Removes HTML and cleans up newlines and whitespace
+ */
+ public String getCommentPreview() {
+ if (mCommentPreview == null) {
+ mCommentPreview = HtmlUtils.fastStripHtml(getCommentText());
+
+ // Trim down the comment preview if the comment text is too large.
+ if (mCommentPreview.length() > MAX_COMMENT_PREVIEW_LENGTH) {
+ mCommentPreview = mCommentPreview.substring(0, MAX_COMMENT_PREVIEW_LENGTH - 1);
+ }
+
+ }
+ return mCommentPreview;
+ }
+
+ /**
+ * For a comment note the text is in the body object's last item. It currently
+ * is only provided in HTML format.
+ */
+ String getCommentText() {
+ return queryJSON("body.items[last].html", "");
+ }
+
+ /**
+ * The inverse of isRead
+ */
+ public Boolean isUnread() {
+ return !isRead();
+ }
+
+ /**
+ * A note can have an "unread" of 0 or more ("likes" can have unread of 2+) to indicate the
+ * quantity of likes that are "unread" within the single note. So for a note to be "read" it
+ * should have 0
+ */
+ Boolean isRead() {
+ return queryJSON("unread", 0) == 0;
+ }
+
+ /**
+ * Sets the note's 'unread' to 0 and saves it to sync with Simperium
+ */
+ public void markAsRead() {
+ try {
+ mNoteJSON.put("unread", 0);
+ } catch (JSONException e) {
+ Log.e(TAG, "Unable to update note unread property", e);
+ return;
+ }
+ save();
+ }
+
+ public Reply buildReply(String content) {
+ JSONObject replyAction = getActions().get(ACTION_KEY_REPLY);
+ String restPath = JSONUtil.queryJSON(replyAction, "params.rest_path", "");
+ AppLog.d(T.NOTIFS, String.format("Search actions %s", restPath));
+ return new Reply(this, String.format("%s/replies/new", restPath), content);
+ }
+
+ /**
+ * Get the timestamp provided by the API for the note - cached for performance
+ */
+ public long getTimestamp() {
+ if (mTimestamp == 0) {
+ mTimestamp = queryJSON("timestamp", 0);
+ }
+
+ return mTimestamp;
+ }
+
+ /*
+ * returns a string representing the timespan based on the note's timestamp - used for display
+ * in the notification list (ex: "3d")
+ */
+ public String getTimeSpan() {
+ return DateTimeUtils.timestampToTimeSpan(getTimestamp());
+ }
+
+ String getTemplate() {
+ return queryJSON("body.template", "");
+ }
+
+ public Boolean isMultiLineListTemplate() {
+ return getTemplate().equals(MULTI_LINE_LIST_TEMPLATE);
+ }
+
+ public Boolean isSingleLineListTemplate() {
+ return getTemplate().equals(SINGLE_LINE_LIST_TEMPLATE);
+ }
+
+ public Boolean isBigBadgeTemplate() {
+ return getTemplate().equals(BIG_BADGE_TEMPLATE);
+ }
+
+ Map<String, JSONObject> getActions() {
+ if (mActions == null) {
+ try {
+ JSONArray actions = queryJSON("body.actions", new JSONArray());
+ mActions = new HashMap<String, JSONObject>(actions.length());
+ for (int i = 0; i < actions.length(); i++) {
+ JSONObject action = actions.getJSONObject(i);
+ String actionType = JSONUtil.queryJSON(action, "type", "");
+ if (!actionType.equals("")) {
+ mActions.put(actionType, action);
+ }
+ }
+ } catch (JSONException e) {
+ AppLog.e(T.NOTIFS, "Could not find actions", e);
+ mActions = new HashMap<String, JSONObject>();
+ }
+ }
+ return mActions;
+ }
+
+
+ private void updateJSON(JSONObject json) {
+
+ mNoteJSON = json;
+
+ // clear out the preloaded content
+ mTimestamp = 0;
+ mCommentPreview = null;
+ mSubject = null;
+ mIconUrl = null;
+ mNoteType = null;
+
+ // preload content again
+ preloadContent();
+ }
+
+ /*
+ * returns the "meta" section of the note's JSON (not guaranteed to exist)
+ */
+ private JSONObject getJSONMeta() {
+ return JSONUtil.getJSONChild(this.toJSONObject(), "meta");
+ }
+
+ /*
+ * returns the value of the passed name in the meta section of the JSON
+ */
+ public int getMetaValueAsInt(String name, int defaultValue) {
+ JSONObject jsonMeta = getJSONMeta();
+ if (jsonMeta == null)
+ return defaultValue;
+ return jsonMeta.optInt(name, defaultValue);
+ }
+
+ /*
+ * returns the actions allowed on this note, assumes it's a comment notification
+ */
+ public EnumSet<EnabledActions> getEnabledActions() {
+ EnumSet<EnabledActions> actions = EnumSet.noneOf(EnabledActions.class);
+ Map<String, JSONObject> jsonActions = getActions();
+ if (jsonActions == null || jsonActions.size() == 0)
+ return actions;
+ if (jsonActions.containsKey(ACTION_KEY_REPLY))
+ actions.add(EnabledActions.ACTION_REPLY);
+ if (jsonActions.containsKey(ACTION_KEY_APPROVE))
+ actions.add(EnabledActions.ACTION_APPROVE);
+ if (jsonActions.containsKey(ACTION_KEY_UNAPPROVE))
+ actions.add(EnabledActions.ACTION_UNAPPROVE);
+ if (jsonActions.containsKey(ACTION_KEY_SPAM))
+ actions.add(EnabledActions.ACTION_SPAM);
+ return actions;
+ }
+
+ /**
+ * pre-loads commonly-accessed fields - avoids performance hit of loading these
+ * fields inside an adapter's getView()
+ */
+ void preloadContent() {
+ if (mNoteJSON == null || mNoteJSON.length() == 0) {
+ return;
+ }
+
+ if (isCommentType()) {
+ // pre-load the preview text
+ getCommentPreview();
+ }
+
+ // pre-load the subject, avatar url and type
+ getSubject();
+ getIconURL();
+ getType();
+
+ // pre-load site/post/comment IDs
+ preloadMetaIds();
+ }
+
+ /*
+ * nbradbury - preload the blog, post, & comment IDs from the meta section
+ * ids={"site":61509427,"self":993925505,"post":161,"comment":178,"comment_parent":0}
+ */
+ private void preloadMetaIds() {
+ JSONObject jsonMeta = getJSONMeta();
+ if (jsonMeta == null)
+ return;
+ JSONObject jsonIDs = jsonMeta.optJSONObject("ids");
+ if (jsonIDs == null)
+ return;
+ mBlogId = jsonIDs.optInt("site");
+ mPostId = jsonIDs.optInt("post");
+ mCommentId = jsonIDs.optLong("comment");
+ mCommentParentId = jsonIDs.optLong("comment_parent");
+ }
+
+ public int getBlogId() {
+ return mBlogId;
+ }
+
+ public int getPostId() {
+ return mPostId;
+ }
+
+ public long getCommentId() {
+ return mCommentId;
+ }
+
+ public long getCommentParentId() {
+ return mCommentParentId;
+ }
+
+ /*
+ * plain-text snippet returned by the server - currently shown only for comments
+ */
+ String getSnippet() {
+ if (mSnippet == null) {
+ mSnippet = queryJSON("snippet", "");
+ }
+ return mSnippet;
+ }
+
+ public boolean hasSnippet() {
+ return !TextUtils.isEmpty(getSnippet());
+ }
+
+ /**
+ * Rudimentary system for pulling an item out of a JSON object hierarchy
+ */
+ public <U> U queryJSON(String query, U defaultObject) {
+ return JSONUtil.queryJSON(this.toJSONObject(), query, defaultObject);
+ }
+
+ /**
+ * Represents a user replying to a note.
+ */
+ public static class Reply {
+ private final Note mNote;
+ private final String mContent;
+ private final String mRestPath;
+
+ Reply(Note note, String restPath, String content) {
+ mNote = note;
+ mRestPath = restPath;
+ mContent = content;
+ }
+
+ public String getContent() {
+ return mContent;
+ }
+
+ public String getRestPath() {
+ return mRestPath;
+ }
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/models/Post.java b/WordPress/src/main/java/org/wordpress/android/models/Post.java
new file mode 100644
index 000000000..5a5bcc7c7
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/Post.java
@@ -0,0 +1,476 @@
+package org.wordpress.android.models;
+
+import android.text.TextUtils;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.StringUtils;
+
+import java.io.Serializable;
+
+public class Post implements Serializable {
+ // Increment this value if this model changes
+ // See: http://www.javapractices.com/topic/TopicAction.do?Id=45
+ static final long serialVersionUID = 2L;
+
+ public static String QUICK_MEDIA_TYPE_PHOTO = "QuickPhoto";
+ public static String QUICK_MEDIA_TYPE_VIDEO = "QuickVideo";
+
+ private long localTablePostId;
+ private int localTableBlogId;
+ private String categories;
+ private String customFields;
+ private long dateCreated;
+ private long dateCreatedGmt;
+ private String description;
+ private String link;
+ private boolean allowComments;
+ private boolean allowPings;
+ private String excerpt;
+ private String keywords;
+ private String moreText;
+ private String permaLink;
+ private String status;
+ private String remotePostId;
+ private String title;
+ private String userId;
+ private String authorDisplayName;
+ private String authorId;
+ private String password;
+ private String postFormat;
+ private String slug;
+ private boolean localDraft;
+ private boolean uploaded;
+ private boolean mChangedFromLocalDraftToPublished;
+ private boolean isPage;
+ private String pageParentId;
+ private String pageParentTitle;
+ private boolean isLocalChange;
+ private String mediaPaths;
+ private String quickPostType;
+ private PostLocation mPostLocation;
+
+ public Post() {
+ }
+
+ public Post(int blogId, boolean isPage) {
+ // creates a new, empty post for the passed in blogId
+ this.localTableBlogId = blogId;
+ this.isPage = isPage;
+ this.localDraft = true;
+ }
+
+ public long getLocalTablePostId() {
+ return localTablePostId;
+ }
+
+ public long getDateCreated() {
+ return dateCreated;
+ }
+
+ public void setDateCreated(long dateCreated) {
+ this.dateCreated = dateCreated;
+ }
+
+ public long getDate_created_gmt() {
+ return dateCreatedGmt;
+ }
+
+ public void setDate_created_gmt(long dateCreatedGmt) {
+ this.dateCreatedGmt = dateCreatedGmt;
+ }
+
+ public void setCategories(String postCategories) {
+ this.categories = postCategories;
+ }
+
+ public void setCustomFields(String customFields) {
+ this.customFields = customFields;
+ }
+
+ public int getLocalTableBlogId() {
+ return localTableBlogId;
+ }
+
+ public void setLocalTableBlogId(int localTableBlogId) {
+ this.localTableBlogId = localTableBlogId;
+ }
+
+ public boolean isLocalDraft() {
+ return localDraft;
+ }
+
+ public void setLocalDraft(boolean localDraft) {
+ this.localDraft = localDraft;
+ }
+
+ public JSONArray getJSONCategories() {
+ JSONArray jArray = null;
+ if (categories == null) {
+ categories = "[]";
+ }
+ try {
+ categories = StringUtils.unescapeHTML(categories);
+ if (TextUtils.isEmpty(categories)) {
+ jArray = new JSONArray();
+ } else {
+ jArray = new JSONArray(categories);
+ }
+ } catch (JSONException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ return jArray;
+ }
+
+ public void setJSONCategories(JSONArray categories) {
+ this.categories = categories.toString();
+ }
+
+ public JSONArray getCustomFields() {
+ JSONArray jArray = null;
+ try {
+ jArray = new JSONArray(customFields);
+ } catch (JSONException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ return jArray;
+ }
+
+ public JSONObject getCustomField(String key) {
+ JSONArray customFieldsJson = getCustomFields();
+ if (customFieldsJson == null) {
+ return null;
+ }
+
+ for (int i = 0; i < customFieldsJson.length(); i++) {
+ try {
+ JSONObject jsonObject = new JSONObject(customFieldsJson.getString(i));
+ String curentKey = jsonObject.getString("key");
+ if (key.equals(curentKey)) {
+ return jsonObject;
+ }
+ } catch (JSONException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ }
+ return null;
+ }
+
+ public void setCustomFields(JSONArray customFields) {
+ this.customFields = customFields.toString();
+ }
+
+ public String getDescription() {
+ return StringUtils.notNullStr(description);
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public String getLink() {
+ return StringUtils.notNullStr(link);
+ }
+
+ public void setLink(String link) {
+ this.link = link;
+ }
+
+ public boolean isAllowComments() {
+ return allowComments;
+ }
+
+ public void setAllowComments(boolean mtAllowComments) {
+ allowComments = mtAllowComments;
+ }
+
+ public boolean isAllowPings() {
+ return allowPings;
+ }
+
+ public void setAllowPings(boolean mtAllowPings) {
+ allowPings = mtAllowPings;
+ }
+
+ public String getPostExcerpt() {
+ return StringUtils.notNullStr(excerpt);
+ }
+
+ public void setPostExcerpt(String mtExcerpt) {
+ excerpt = mtExcerpt;
+ }
+
+ public String getKeywords() {
+ return StringUtils.notNullStr(keywords);
+ }
+
+ public void setKeywords(String mtKeywords) {
+ keywords = mtKeywords;
+ }
+
+ public String getMoreText() {
+ return StringUtils.notNullStr(moreText);
+ }
+
+ public void setMoreText(String mtTextMore) {
+ moreText = mtTextMore;
+ }
+
+ public String getPermaLink() {
+ return StringUtils.notNullStr(permaLink);
+ }
+
+ public void setPermaLink(String permaLink) {
+ this.permaLink = permaLink;
+ }
+
+ public String getPostStatus() {
+ return StringUtils.notNullStr(status);
+ }
+
+ public void setPostStatus(String postStatus) {
+ status = postStatus;
+ }
+
+ public PostStatus getStatusEnum() {
+ return PostStatus.fromPost(this);
+ }
+
+ public String getRemotePostId() {
+ return StringUtils.notNullStr(remotePostId);
+ }
+
+ public void setRemotePostId(String postId) {
+ this.remotePostId = postId;
+ }
+
+ public String getTitle() {
+ return StringUtils.notNullStr(title);
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public String getUserId() {
+ return StringUtils.notNullStr(userId);
+ }
+
+ public void setUserId(String userid) {
+ this.userId = userid;
+ }
+
+ public String getAuthorDisplayName() {
+ return StringUtils.notNullStr(authorDisplayName);
+ }
+
+ public void setAuthorDisplayName(String wpAuthorDisplayName) {
+ authorDisplayName = wpAuthorDisplayName;
+ }
+
+ public String getAuthorId() {
+ return StringUtils.notNullStr(authorId);
+ }
+
+ public void setAuthorId(String wpAuthorId) {
+ authorId = wpAuthorId;
+ }
+
+ public String getPassword() {
+ return StringUtils.notNullStr(password);
+ }
+
+ public void setPassword(String wpPassword) {
+ password = wpPassword;
+ }
+
+ public String getPostFormat() {
+ return StringUtils.notNullStr(postFormat);
+ }
+
+ public void setPostFormat(String wpPostForm) {
+ postFormat = wpPostForm;
+ }
+
+ public String getSlug() {
+ return StringUtils.notNullStr(slug);
+ }
+
+ public void setSlug(String wpSlug) {
+ slug = wpSlug;
+ }
+
+ public String getMediaPaths() {
+ return StringUtils.notNullStr(mediaPaths);
+ }
+
+ public void setMediaPaths(String mediaPaths) {
+ this.mediaPaths = mediaPaths;
+ }
+
+ public boolean supportsLocation() {
+ // Right now, we only disable for pages.
+ return !isPage();
+ }
+
+ public boolean hasLocation() {
+ return mPostLocation != null && mPostLocation.isValid();
+ }
+
+ public PostLocation getLocation() {
+ return mPostLocation;
+ }
+
+ public void setLocation(PostLocation location) {
+ mPostLocation = location;
+ }
+
+ public void unsetLocation() {
+ mPostLocation = null;
+ }
+
+ public void setLocation(double latitude, double longitude) {
+ try {
+ mPostLocation = new PostLocation(latitude, longitude);
+ } catch (IllegalArgumentException e) {
+ mPostLocation = null;
+ AppLog.e(T.POSTS, e);
+ }
+ }
+
+ public boolean isPage() {
+ return isPage;
+ }
+
+ public void setIsPage(boolean isPage) {
+ this.isPage = isPage;
+ }
+
+ public String getPageParentId() {
+ return StringUtils.notNullStr(pageParentId);
+ }
+
+ public void setPageParentId(String wp_page_parent_id) {
+ this.pageParentId = wp_page_parent_id;
+ }
+
+ public String getPageParentTitle() {
+ return StringUtils.notNullStr(pageParentTitle);
+ }
+
+ public void setPageParentTitle(String wp_page_parent_title) {
+ this.pageParentTitle = wp_page_parent_title;
+ }
+
+ public boolean isUploaded() {
+ return uploaded;
+ }
+
+ public void setUploaded(boolean uploaded) {
+ this.uploaded = uploaded;
+ }
+
+ public boolean isLocalChange() {
+ return isLocalChange;
+ }
+
+ public void setLocalChange(boolean isLocalChange) {
+ this.isLocalChange = isLocalChange;
+ }
+
+ public void setLocalTablePostId(long id) {
+ this.localTablePostId = id;
+ }
+
+ public void setQuickPostType(String type) {
+ this.quickPostType = type;
+ }
+
+ public String getQuickPostType() {
+ return StringUtils.notNullStr(quickPostType);
+ }
+
+ /**
+ * This indicates if the post has changed from a draft to published. This is primarily used
+ * for stats tracking purposes as we want to ensure that we properly track certain things when
+ * the user first publishes a post
+ * @return
+ */
+ public boolean hasChangedFromLocalDraftToPublished() {
+ return mChangedFromLocalDraftToPublished;
+ }
+
+ public void setChangedFromLocalDraftToPublished(boolean changedFromLocalDraftToPublished) {
+ this.mChangedFromLocalDraftToPublished = changedFromLocalDraftToPublished;
+ }
+
+ /**
+ * Checks if this post currently has data differing from another post.
+ *
+ * @param otherPost The post to compare to this post's editable data.
+ * @return True if this post's data differs from otherPost's data, False otherwise.
+ */
+ public boolean hasChanges(Post otherPost) {
+ return otherPost == null || !(StringUtils.equals(title, otherPost.title) &&
+ StringUtils.equals(description, otherPost.description) &&
+ StringUtils.equals(excerpt, otherPost.excerpt) &&
+ StringUtils.equals(keywords, otherPost.keywords) &&
+ StringUtils.equals(categories, otherPost.categories) &&
+ StringUtils.equals(status, otherPost.status) &&
+ StringUtils.equals(password, otherPost.password) &&
+ StringUtils.equals(postFormat, otherPost.postFormat) &&
+ this.dateCreatedGmt == otherPost.dateCreatedGmt &&
+ PostLocation.equals(this.mPostLocation, otherPost.mPostLocation)
+ );
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + localTableBlogId;
+ result = prime * result + (int) (localTablePostId ^ (localTablePostId >>> 32));
+ result = prime * result + (isPage ? 1231 : 1237);
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (other == this)
+ return true;
+ if (other instanceof Post) {
+ Post otherPost = (Post) other;
+ return (this.localTablePostId == otherPost.localTablePostId &&
+ this.isPage == otherPost.isPage &&
+ this.localTableBlogId == otherPost.localTableBlogId
+ );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Get the entire post content
+ * Joins description and moreText fields if both are valid
+ * @return post content as String
+ */
+ public String getContent() {
+ String postContent;
+ if (!TextUtils.isEmpty(getMoreText())) {
+ if (isLocalDraft())
+ postContent = getDescription() + "\n&lt;!--more--&gt;\n" + getMoreText();
+ else
+ postContent = getDescription() + "\n<!--more-->\n" + getMoreText();
+ } else
+ postContent = getDescription();
+
+ return postContent;
+ }
+
+ public boolean isNew() {
+ return getLocalTablePostId() >= 0;
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/models/PostLocation.java b/WordPress/src/main/java/org/wordpress/android/models/PostLocation.java
new file mode 100644
index 000000000..fb48e0bf6
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/PostLocation.java
@@ -0,0 +1,93 @@
+package org.wordpress.android.models;
+
+import java.io.Serializable;
+
+public class PostLocation implements Serializable {
+ static final double INVALID_LATITUDE = 9999;
+ static final double INVALID_LONGITUDE = 9999;
+ static final double MIN_LATITUDE = -90;
+ static final double MAX_LATITUDE = 90;
+ static final double MIN_LONGITUDE = -180;
+ static final double MAX_LONGITUDE = 180;
+
+ private double mLatitude = INVALID_LATITUDE;
+ private double mLongitude = INVALID_LONGITUDE;
+
+ public PostLocation() { }
+
+ public PostLocation(double latitude, double longitude) {
+ setLatitude(latitude);
+ setLongitude(longitude);
+ }
+
+ public boolean isValid() {
+ return isValidLatitude(mLatitude) && isValidLongitude(mLongitude);
+ }
+
+ public double getLatitude() {
+ return mLatitude;
+ }
+
+ public void setLatitude(double latitude) {
+ if (!isValidLatitude(latitude)) {
+ throw new IllegalArgumentException(
+ "Invalid latitude; must be between the range " + MIN_LATITUDE + " and " + MAX_LATITUDE
+ );
+ }
+
+ mLatitude = latitude;
+ }
+
+ public double getLongitude() {
+ return mLongitude;
+ }
+
+ public void setLongitude(double longitude) {
+ if (!isValidLongitude(longitude)) {
+ throw new IllegalArgumentException(
+ "Invalid longitude; must be between the range " + MIN_LONGITUDE + " and " + MAX_LONGITUDE
+ );
+ }
+
+ mLongitude = longitude;
+ }
+
+ private boolean isValidLatitude(double latitude) {
+ return latitude >= MIN_LATITUDE && latitude <= MAX_LATITUDE;
+ }
+
+ private boolean isValidLongitude(double longitude) {
+ return longitude >= MIN_LONGITUDE && longitude <= MAX_LONGITUDE;
+ }
+
+ public int hashCode() {
+ final int prime = 31;
+ int hashCode = 1;
+
+ hashCode = prime * hashCode + (Double.valueOf(mLatitude).hashCode());
+ hashCode = prime * hashCode + (Double.valueOf(mLongitude).hashCode());
+
+ return hashCode;
+ }
+
+ public boolean equals(Object other) {
+ if (other == this) {
+ return true;
+ } else if (other instanceof PostLocation) {
+ PostLocation otherLocation = (PostLocation) other;
+ return this.mLatitude == otherLocation.mLatitude
+ && this.mLongitude == otherLocation.mLongitude;
+ }
+ return false;
+ }
+
+ public static boolean equals(Object a, Object b) {
+ if (a == b) {
+ return true;
+ } else if (a == null || b == null) {
+ return false;
+ } else {
+ return a.equals(b);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/PostStatus.java b/WordPress/src/main/java/org/wordpress/android/models/PostStatus.java
new file mode 100644
index 000000000..12da9c49a
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/PostStatus.java
@@ -0,0 +1,65 @@
+package org.wordpress.android.models;
+
+import java.util.Date;
+
+public enum PostStatus {
+ UNKNOWN,
+ PUBLISHED,
+ DRAFT,
+ PRIVATE,
+ PENDING,
+ SCHEDULED; //NOTE: Only used locally, WP has a 'future' status but it is not returned from the metaWeblog API
+
+ private synchronized static PostStatus fromStringAndDateGMT(String value, long dateCreatedGMT) {
+ if (value == null)
+ return PostStatus.UNKNOWN;
+ if (value.equals("publish")) {
+ // Check if post is scheduled
+ Date d = new Date();
+ // Subtract 10 seconds from the server GMT date, in case server and device time slightly differ
+ if (dateCreatedGMT - 10000 > d.getTime()) {
+ return SCHEDULED;
+ }
+ return PUBLISHED;
+ }
+ if (value.equals("draft"))
+ return PostStatus.DRAFT;
+ if (value.equals("private"))
+ return PostStatus.PRIVATE;
+ if (value.equals("pending"))
+ return PENDING;
+ if (value.equals("future"))
+ return SCHEDULED;
+
+ return PostStatus.UNKNOWN;
+ }
+
+ public synchronized static PostStatus fromPost(Post post) {
+ String value = post.getPostStatus();
+ long dateCreatedGMT = post.getDate_created_gmt();
+ return fromStringAndDateGMT(value, dateCreatedGMT);
+ }
+
+ public synchronized static PostStatus fromPostsListPost(PostsListPost post) {
+ String value = post.getOriginalStatus();
+ long dateCreatedGMT = post.getDateCreatedGmt();
+ return fromStringAndDateGMT(value, dateCreatedGMT);
+ }
+
+ public static String toString(PostStatus status) {
+ switch (status) {
+ case PUBLISHED:
+ return "publish";
+ case DRAFT:
+ return "draft";
+ case PRIVATE:
+ return "private";
+ case PENDING:
+ return "pending";
+ case SCHEDULED:
+ return "future";
+ default:
+ return "";
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/PostsListPost.java b/WordPress/src/main/java/org/wordpress/android/models/PostsListPost.java
new file mode 100644
index 000000000..6abd24b39
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/PostsListPost.java
@@ -0,0 +1,94 @@
+package org.wordpress.android.models;
+
+import android.text.format.DateUtils;
+
+import org.wordpress.android.util.StringUtils;
+
+import java.util.Date;
+
+/**
+ * Barebones post/page as listed in PostsListFragment
+ */
+public class PostsListPost {
+ private int postId;
+ private int blogId;
+ private String title;
+ private long dateCreatedGmt;
+ private String status;
+ private boolean isLocalDraft;
+ private boolean hasLocalChanges;
+
+ public PostsListPost(int postId, int blogId, String title, long dateCreatedGmt, String status, boolean localDraft, boolean localChanges) {
+ setPostId(postId);
+ setBlogId(blogId);
+ setTitle(title);
+ setDateCreatedGmt(dateCreatedGmt);
+ setStatus(status);
+ setLocalDraft(localDraft);
+ setHasLocalChanges(localChanges);
+ }
+
+ public int getPostId() {
+ return postId;
+ }
+
+ public void setPostId(int postId) {
+ this.postId = postId;
+ }
+
+ public int getBlogId() {
+ return blogId;
+ }
+
+ public void setBlogId(int blogId) {
+ this.blogId = blogId;
+ }
+
+ public String getTitle() {
+ return StringUtils.notNullStr(title);
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public long getDateCreatedGmt() {
+ return dateCreatedGmt;
+ }
+
+ public void setDateCreatedGmt(long dateCreatedGmt) {
+ this.dateCreatedGmt = dateCreatedGmt;
+ }
+
+ public String getOriginalStatus() {
+ return StringUtils.notNullStr(status);
+ }
+
+ public void setStatus(String status) {
+ this.status = status;
+ }
+
+ public PostStatus getStatusEnum() {
+ return PostStatus.fromPostsListPost(this);
+ }
+
+ public String getFormattedDate() {
+ return DateUtils.getRelativeTimeSpanString(getDateCreatedGmt(), new Date().getTime(), DateUtils.SECOND_IN_MILLIS).toString();
+ }
+
+ public boolean isLocalDraft() {
+ return isLocalDraft;
+ }
+
+ public void setLocalDraft(boolean isLocalDraft) {
+ this.isLocalDraft = isLocalDraft;
+ }
+
+ public boolean hasLocalChanges() {
+ return hasLocalChanges;
+ }
+
+ public void setHasLocalChanges(boolean localChanges) {
+ this.hasLocalChanges = localChanges;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderBlog.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderBlog.java
new file mode 100644
index 000000000..78ec120b3
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderBlog.java
@@ -0,0 +1,144 @@
+package org.wordpress.android.models;
+
+import android.text.TextUtils;
+
+import org.json.JSONObject;
+import org.wordpress.android.util.JSONUtil;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.UrlUtils;
+
+public class ReaderBlog {
+ public long blogId;
+ public long feedId;
+
+ public boolean isPrivate;
+ public boolean isJetpack;
+ public boolean isFollowing;
+ public int numSubscribers;
+
+ private String name;
+ private String description;
+ private String url;
+
+ /*{
+ "ID": 3584907,
+ "name": "WordPress.com News",
+ "description": "The latest news on WordPress.com and the WordPress community.",
+ "URL": "http:\/\/en.blog.wordpress.com",
+ "jetpack": false,
+ "subscribers_count": 9222924,
+ "is_private": false,
+ "is_following": false,
+ "meta": {
+ "links": {
+ "self": "https:\/\/public-api.wordpress.com\/rest\/v1\/sites\/3584907",
+ "help": "https:\/\/public-api.wordpress.com\/rest\/v1\/sites\/3584907\/help",
+ "posts": "https:\/\/public-api.wordpress.com\/rest\/v1\/sites\/3584907\/posts\/",
+ "comments": "https:\/\/public-api.wordpress.com\/rest\/v1\/sites\/3584907\/comments\/"
+ }
+ }
+ }*/
+
+ public static ReaderBlog fromJson(JSONObject json) {
+ ReaderBlog blog = new ReaderBlog();
+ if (json == null) {
+ return blog;
+ }
+
+ // if meta/data/site exists then JSON is for a read/following/mine?meta=site subscription,
+ // if meta/data/feed exists then JSON is for a read/following/mine?meta=feed subscription,
+ // otherwise JSON the response for a single site/$siteId
+ JSONObject jsonSite = JSONUtil.getJSONChild(json, "meta/data/site");
+ JSONObject jsonFeed = JSONUtil.getJSONChild(json, "meta/data/feed");
+ if (jsonSite != null) {
+ blog.blogId = jsonSite.optLong("ID");
+ blog.setName(JSONUtil.getStringDecoded(jsonSite, "name"));
+ blog.setDescription(JSONUtil.getStringDecoded(jsonSite, "description"));
+ blog.setUrl(JSONUtil.getString(jsonSite, "URL"));
+
+ blog.isJetpack = JSONUtil.getBool(jsonSite, "jetpack");
+ blog.isPrivate = JSONUtil.getBool(jsonSite, "is_private");
+ blog.isFollowing = JSONUtil.getBool(jsonSite, "is_following");
+ blog.numSubscribers = jsonSite.optInt("subscribers_count");
+ } else if (jsonFeed != null) {
+ blog.feedId = jsonFeed.optLong("feed_ID");
+ blog.setName(JSONUtil.getStringDecoded(jsonFeed, "name"));
+ blog.setUrl(JSONUtil.getString(jsonFeed, "URL"));
+ blog.numSubscribers = jsonFeed.optInt("subscribers_count");
+ // TODO: read/following/mine doesn't include is_following for feeds, so assume to be true
+ blog.isFollowing = true;
+ } else {
+ blog.blogId = json.optLong("ID");
+ blog.setName(JSONUtil.getStringDecoded(json, "name"));
+ blog.setDescription(JSONUtil.getStringDecoded(json, "description"));
+ blog.setUrl(JSONUtil.getString(json, "URL"));
+
+ blog.isJetpack = JSONUtil.getBool(json, "jetpack");
+ blog.isPrivate = JSONUtil.getBool(json, "is_private");
+ blog.isFollowing = JSONUtil.getBool(json, "is_following");
+ blog.numSubscribers = json.optInt("subscribers_count");
+ }
+
+ return blog;
+ }
+
+ public String getName() {
+ return StringUtils.notNullStr(name);
+ }
+ public void setName(String blogName) {
+ this.name = StringUtils.notNullStr(blogName).trim();
+ }
+
+ public String getDescription() {
+ return StringUtils.notNullStr(description);
+ }
+ public void setDescription(String description) {
+ this.description = StringUtils.notNullStr(description).trim();
+ }
+
+ public String getUrl() {
+ return StringUtils.notNullStr(url);
+ }
+ public void setUrl(String url) {
+ this.url = StringUtils.notNullStr(url);
+ }
+
+ public boolean hasUrl() {
+ return !TextUtils.isEmpty(url);
+ }
+ public boolean hasName() {
+ return !TextUtils.isEmpty(name);
+ }
+ public boolean hasDescription() {
+ return !TextUtils.isEmpty(description);
+ }
+
+ // returns true if this is a feed rather than wp blog
+ public boolean isExternal() {
+ return (feedId != 0 || blogId == 0);
+ }
+ /*
+ * returns the mshot url to use for this blog, ex:
+ * http://s.wordpress.com/mshots/v1/http%3A%2F%2Fnickbradbury.com?w=600
+ * note that while mshots support a "h=" parameter, this crops rather than
+ * scales the image to that height
+ * https://github.com/Automattic/mShots
+ */
+ public String getMshotsUrl(int width) {
+ return "http://s.wordpress.com/mshots/v1/"
+ + UrlUtils.urlEncode(getUrl())
+ + String.format("?w=%d", width);
+ }
+
+ public boolean isSameAs(ReaderBlog blogInfo) {
+ return blogInfo != null
+ && this.blogId == blogInfo.blogId
+ && this.feedId == blogInfo.feedId
+ && this.isFollowing == blogInfo.isFollowing
+ && this.isPrivate == blogInfo.isPrivate
+ && this.numSubscribers == blogInfo.numSubscribers
+ && this.getName().equals(blogInfo.getName())
+ && this.getDescription().equals(blogInfo.getDescription())
+ && this.getUrl().equals(blogInfo.getUrl());
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderBlogList.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderBlogList.java
new file mode 100644
index 000000000..a5b895683
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderBlogList.java
@@ -0,0 +1,70 @@
+package org.wordpress.android.models;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+
+public class ReaderBlogList extends ArrayList<ReaderBlog> {
+
+ public static ReaderBlogList fromJson(JSONObject json) {
+ ReaderBlogList blogs = new ReaderBlogList();
+ if (json == null) {
+ return blogs;
+ }
+
+ // read/following/mine response
+ JSONArray jsonBlogs = json.optJSONArray("subscriptions");
+ if (jsonBlogs != null) {
+ for (int i = 0; i < jsonBlogs.length(); i++) {
+ ReaderBlog blog = ReaderBlog.fromJson(jsonBlogs.optJSONObject(i));
+ // make sure blog is valid before adding to the list - this can happen if user
+ // added a URL that isn't a feed or a blog since as of 29-May-2014 the API
+ // will let you follow any URL regardless if it's valid
+ if (blog.hasName() || blog.hasDescription() || blog.hasUrl()) {
+ blogs.add(blog);
+ }
+ }
+ }
+
+ return blogs;
+ }
+
+ private int indexOfBlogId(long blogId) {
+ for (int i = 0; i < size(); i++) {
+ if (this.get(i).blogId == blogId) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private int indexOfFeedId(long feedId) {
+ for (int i = 0; i < size(); i++) {
+ if (this.get(i).feedId == feedId) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ public boolean isSameList(ReaderBlogList blogs) {
+ if (blogs == null || blogs.size() != this.size()) {
+ return false;
+ }
+
+ for (ReaderBlog blogInfo: blogs) {
+ int index;
+ if (blogInfo.isExternal()) {
+ index = indexOfFeedId(blogInfo.feedId);
+ } else {
+ index = indexOfBlogId(blogInfo.blogId);
+ }
+ if (index == -1 || !this.get(index).isSameAs(blogInfo)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderComment.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderComment.java
new file mode 100644
index 000000000..6bc57e3dc
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderComment.java
@@ -0,0 +1,122 @@
+package org.wordpress.android.models;
+
+import android.text.TextUtils;
+
+import org.json.JSONObject;
+import org.wordpress.android.util.DateTimeUtils;
+import org.wordpress.android.util.HtmlUtils;
+import org.wordpress.android.util.JSONUtil;
+import org.wordpress.android.util.StringUtils;
+
+/**
+ * TODO: unify this with Comment.java
+ */
+public class ReaderComment {
+ public long commentId;
+ public long blogId;
+ public long postId;
+ public long parentId;
+
+ private String authorName;
+ private String authorAvatar;
+ private String authorUrl;
+ public long authorId;
+ public long authorBlogId;
+
+ private String status;
+ private String text;
+
+ public long timestamp;
+ private String published;
+
+ // not stored in db - denotes the indentation level when displaying this comment
+ public transient int level = 0;
+
+ public static ReaderComment fromJson(JSONObject json, long blogId) {
+ if (json == null)
+ throw new IllegalArgumentException("null json comment");
+
+ ReaderComment comment = new ReaderComment();
+
+ comment.blogId = blogId;
+ comment.commentId = json.optLong("ID");
+ comment.status = JSONUtil.getString(json, "status");
+
+ // note that content may contain html, adapter needs to handle it
+ comment.text = HtmlUtils.stripScript(JSONUtil.getString(json, "content"));
+
+ comment.published = JSONUtil.getString(json, "date");
+ comment.timestamp = DateTimeUtils.iso8601ToTimestamp(comment.published);
+
+ JSONObject jsonPost = json.optJSONObject("post");
+ if (jsonPost!=null)
+ comment.postId = jsonPost.optLong("ID");
+
+ JSONObject jsonAuthor = json.optJSONObject("author");
+ if (jsonAuthor!=null) {
+ // author names may contain html entities (esp. pingbacks)
+ comment.authorName = JSONUtil.getStringDecoded(jsonAuthor, "name");
+ comment.authorAvatar = JSONUtil.getString(jsonAuthor, "avatar_URL");
+ comment.authorUrl = JSONUtil.getString(jsonAuthor, "URL");
+ comment.authorId = jsonAuthor.optLong("ID");
+ comment.authorBlogId = jsonAuthor.optLong("site_ID");
+ }
+
+ JSONObject jsonParent = json.optJSONObject("parent");
+ if (jsonParent!=null)
+ comment.parentId = jsonParent.optLong("ID");
+
+ return comment;
+ }
+
+ public String getAuthorName() {
+ return StringUtils.notNullStr(authorName);
+ }
+
+ public void setAuthorName(String authorName) {
+ this.authorName = StringUtils.notNullStr(authorName);
+ }
+
+ public String getAuthorAvatar() {
+ return StringUtils.notNullStr(authorAvatar);
+ }
+ public void setAuthorAvatar(String authorAvatar) {
+ this.authorAvatar = StringUtils.notNullStr(authorAvatar);
+ }
+
+ public String getAuthorUrl() {
+ return StringUtils.notNullStr(authorUrl);
+ }
+ public void setAuthorUrl(String authorUrl) {
+ this.authorUrl = StringUtils.notNullStr(authorUrl);
+ }
+
+ public String getText() {
+ return StringUtils.notNullStr(text);
+ }
+ public void setText(String text) {
+ this.text = StringUtils.notNullStr(text);
+ }
+
+ public String getStatus() {
+ return StringUtils.notNullStr(status);
+ }
+ public void setStatus(String status) {
+ this.status = StringUtils.notNullStr(status);
+ }
+
+ public String getPublished() {
+ return StringUtils.notNullStr(published);
+ }
+ public void setPublished(String published) {
+ this.published = StringUtils.notNullStr(published);
+ }
+
+ public boolean hasAuthorUrl() {
+ return !TextUtils.isEmpty(authorUrl);
+ }
+
+ public boolean hasAuthorBlogId() {
+ return (authorBlogId != 0);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderCommentList.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderCommentList.java
new file mode 100644
index 000000000..b7553556f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderCommentList.java
@@ -0,0 +1,125 @@
+package org.wordpress.android.models;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+
+public class ReaderCommentList extends ArrayList<ReaderComment> {
+ public static ReaderCommentList fromJson(JSONObject json, long blogId) {
+ if (json==null)
+ throw new IllegalArgumentException("null json comment list");
+
+ ReaderCommentList comments = new ReaderCommentList();
+
+ JSONArray jsonComments = json.optJSONArray("comments");
+ if (jsonComments!=null) {
+ for (int i=0; i < jsonComments.length(); i++)
+ comments.add(ReaderComment.fromJson(jsonComments.optJSONObject(i), blogId));
+ }
+
+ return comments;
+ }
+
+ private boolean commentIdExists(long commentId) {
+ return (indexOfCommentId(commentId) > -1);
+ }
+
+ public int indexOfCommentId(long commentId) {
+ for (int i=0; i < this.size(); i++) {
+ if (commentId==this.get(i).commentId)
+ return i;
+ }
+ return -1;
+ }
+
+ /*
+ * does passed list contain the same comments as this list?
+ */
+ public boolean isSameList(ReaderCommentList comments) {
+ if (comments==null || comments.size()!=this.size())
+ return false;
+
+ for (ReaderComment comment: comments) {
+ if (!commentIdExists(comment.commentId))
+ return false;
+ }
+
+ return true;
+ }
+
+ public boolean replaceComment(long commentId, ReaderComment comment) {
+ if (comment==null)
+ return false;
+ int index = indexOfCommentId(commentId);
+ if (index == -1)
+ return false;
+ this.set(index, comment);
+ return true;
+ }
+
+ /*
+ * builds a new list from the passed one with child comments placed under their parents and indent levels applied
+ */
+ public static ReaderCommentList getLevelList(ReaderCommentList thisList) {
+ if (thisList==null)
+ return new ReaderCommentList();
+
+ // first check if there are any child comments - if not, just return the passed list
+ boolean hasChildComments = false;
+ for (ReaderComment comment: thisList) {
+ if (comment.parentId!=0) {
+ hasChildComments = true;
+ break;
+ }
+ }
+ if (!hasChildComments)
+ return thisList;
+
+ ReaderCommentList result = new ReaderCommentList();
+
+ // reset all levels, and add root comments to result
+ for (ReaderComment comment: thisList) {
+ comment.level = 0;
+ if (comment.parentId==0)
+ result.add(comment);
+ }
+
+ // add child comments under their parents
+ boolean done;
+ do {
+ done = true;
+ for (ReaderComment comment: thisList) {
+ // only look at comments that have a parentId but no level assigned yet
+ if (comment.parentId!=0 && comment.level==0) {
+ int parentIndex = result.indexOfCommentId(comment.parentId);
+ if (parentIndex > -1) {
+ comment.level = result.get(parentIndex).level + 1;
+
+ // insert this comment after the last comment of this level that has this parent
+ int commentIndex=parentIndex+1;
+ while (commentIndex < result.size()) {
+ if (result.get(commentIndex).level!=comment.level || result.get(commentIndex).parentId!=comment.parentId)
+ break;
+ commentIndex++;
+ }
+ result.add(commentIndex, comment);
+
+
+ done = false;
+ }
+ }
+ }
+ } while (!done);
+
+ // handle orphans (child comments whose parents weren't found above)
+ for (ReaderComment comment: thisList) {
+ if (comment.level==0 && comment.parentId!=0) {
+ comment.level = 1; // give it a non-zero level so it's indented by ReaderCommentAdapter
+ result.add(comment);
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderPost.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderPost.java
new file mode 100644
index 000000000..8406c479d
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderPost.java
@@ -0,0 +1,649 @@
+package org.wordpress.android.models;
+
+import android.net.Uri;
+import android.text.TextUtils;
+
+import org.json.JSONObject;
+import org.wordpress.android.ui.reader.ReaderUtils;
+import org.wordpress.android.util.DateTimeUtils;
+import org.wordpress.android.util.HtmlUtils;
+import org.wordpress.android.util.JSONUtil;
+import org.wordpress.android.util.PhotonUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.UrlUtils;
+
+import java.text.BreakIterator;
+import java.util.Iterator;
+
+public class ReaderPost {
+ private String pseudoId;
+ public long postId;
+ public long blogId;
+ public long authorId;
+
+ private String title;
+ private String text;
+ private String excerpt;
+ private String authorName;
+ private String blogName;
+ private String blogUrl;
+ private String postAvatar;
+
+ private String tags; // comma-separated list of tags
+ private String primaryTag; // most popular tag on this post based on usage in blog
+ private String secondaryTag; // second most popular tag on this post based on usage in blog
+
+ public long timestamp; // used for sorting
+ private String published;
+
+ private String url;
+ private String featuredImage;
+ private String featuredVideo;
+
+ public int numReplies; // includes comments, trackbacks & pingbacks
+ public int numLikes;
+
+ public boolean isLikedByCurrentUser;
+ public boolean isFollowedByCurrentUser;
+ public boolean isRebloggedByCurrentUser;
+ public boolean isCommentsOpen;
+ public boolean isExternal;
+ public boolean isPrivate;
+ public boolean isVideoPress;
+
+ public static ReaderPost fromJson(JSONObject json) {
+ if (json == null) {
+ throw new IllegalArgumentException("null json post");
+ }
+
+ ReaderPost post = new ReaderPost();
+
+ post.postId = json.optLong("ID");
+ post.blogId = json.optLong("site_ID");
+
+ if (json.has("pseudo_ID")) {
+ post.pseudoId = JSONUtil.getString(json, "pseudo_ID"); // read/ endpoint
+ } else {
+ post.pseudoId = JSONUtil.getString(json, "global_ID"); // sites/ endpoint
+ }
+
+ // remove HTML from the excerpt
+ post.excerpt = HtmlUtils.fastStripHtml(JSONUtil.getString(json, "excerpt"));
+
+ post.text = JSONUtil.getString(json, "content");
+ post.title = JSONUtil.getStringDecoded(json, "title");
+ post.url = JSONUtil.getString(json, "URL");
+ post.setBlogUrl(JSONUtil.getString(json, "site_URL"));
+
+ post.numReplies = json.optInt("comment_count");
+ post.numLikes = json.optInt("like_count");
+ post.isLikedByCurrentUser = JSONUtil.getBool(json, "i_like");
+ post.isFollowedByCurrentUser = JSONUtil.getBool(json, "is_following");
+ post.isRebloggedByCurrentUser = JSONUtil.getBool(json, "is_reblogged");
+ post.isCommentsOpen = JSONUtil.getBool(json, "comments_open");
+ post.isExternal = JSONUtil.getBool(json, "is_external");
+ post.isPrivate = JSONUtil.getBool(json, "site_is_private");
+
+ // parse the author section
+ assignAuthorFromJson(post, json.optJSONObject("author"));
+
+ // only freshly-pressed posts have the "editorial" section
+ JSONObject jsonEditorial = json.optJSONObject("editorial");
+ if (jsonEditorial != null) {
+ post.blogId = jsonEditorial.optLong("blog_id");
+ post.blogName = JSONUtil.getStringDecoded(jsonEditorial, "blog_name");
+ post.featuredImage = getImageUrlFromFeaturedImageUrl(JSONUtil.getString(jsonEditorial, "image"));
+ post.setPrimaryTag(JSONUtil.getString(jsonEditorial, "highlight_topic_title")); // highlight_topic?
+ // we want freshly-pressed posts to show & store the date they were chosen rather than the day they were published
+ post.published = JSONUtil.getString(jsonEditorial, "displayed_on");
+ } else {
+ post.featuredImage = JSONUtil.getString(json, "featured_image");
+ post.blogName = JSONUtil.getStringDecoded(json, "site_name");
+ post.published = JSONUtil.getString(json, "date");
+ }
+
+ // the date a post was liked is only returned by the read/liked/ endpoint - if this exists,
+ // set it as the timestamp so posts are sorted by the date they were liked rather than the
+ // date they were published (the timestamp is used to sort posts when querying)
+ String likeDate = JSONUtil.getString(json, "date_liked");
+ if (!TextUtils.isEmpty(likeDate)) {
+ post.timestamp = DateTimeUtils.iso8601ToTimestamp(likeDate);
+ } else {
+ post.timestamp = DateTimeUtils.iso8601ToTimestamp(post.published);
+ }
+
+ // if there's no featured thumbnail, check if featured media has been set - this is sometimes
+ // a YouTube or Vimeo video, in which case store it as the featured video so we can treat
+ // it as a video
+ if (!post.hasFeaturedImage()) {
+ JSONObject jsonMedia = json.optJSONObject("featured_media");
+ if (jsonMedia != null) {
+ String mediaUrl = JSONUtil.getString(jsonMedia, "uri");
+ if (!TextUtils.isEmpty(mediaUrl)) {
+ String type = JSONUtil.getString(jsonMedia, "type");
+ boolean isVideo = (type != null && type.equals("video"));
+ if (isVideo) {
+ post.featuredVideo = mediaUrl;
+ } else {
+ post.featuredImage = mediaUrl;
+ }
+ }
+ }
+
+ // if we still don't have a featured image, parse the content for an image that's
+ // suitable as a featured image - this is done since featured_media seems to miss
+ // some images that would work well as featured images on mobile
+ if (!post.hasFeaturedImage()) {
+ post.featuredImage = findFeaturedImage(post.text);
+ }
+ }
+
+ // if the post is untitled, make up a title from the excerpt
+ if (!post.hasTitle() && post.hasExcerpt()) {
+ post.title = extractTitle(post.excerpt, 50);
+ }
+
+ // remove html from title (rare, but does happen)
+ if (post.hasTitle() && post.title.contains("<") && post.title.contains(">")) {
+ post.title = HtmlUtils.stripHtml(post.title);
+ }
+
+ // parse the tags section
+ assignTagsFromJson(post, json.optJSONObject("tags"));
+
+ // the single-post sites/$site/posts/$post endpoint returns all site metadata
+ // under meta/data/site (assuming ?meta=site was added to the request)
+ JSONObject jsonSite = JSONUtil.getJSONChild(json, "meta/data/site");
+ if (jsonSite != null) {
+ post.blogId = jsonSite.optInt("ID");
+ post.blogName = JSONUtil.getString(jsonSite, "name");
+ post.setBlogUrl(JSONUtil.getString(jsonSite, "URL"));
+ post.isPrivate = JSONUtil.getBool(jsonSite, "is_private");
+ }
+
+ return post;
+ }
+
+ /*
+ * assigns author-related info to the passed post from the passed JSON "author" object
+ */
+ private static void assignAuthorFromJson(ReaderPost post, JSONObject jsonAuthor) {
+ if (jsonAuthor == null) {
+ return;
+ }
+
+ post.authorName = JSONUtil.getString(jsonAuthor, "name");
+ post.postAvatar = JSONUtil.getString(jsonAuthor, "avatar_URL");
+ post.authorId = jsonAuthor.optLong("ID");
+
+ // site_URL doesn't exist for /sites/ endpoints, so get it from the author
+ if (TextUtils.isEmpty(post.blogUrl)) {
+ post.setBlogUrl(JSONUtil.getString(jsonAuthor, "URL"));
+ }
+ }
+
+ /*
+ * assigns tag-related info to the passed post from the passed JSON "tags" object
+ */
+ private static void assignTagsFromJson(ReaderPost post, JSONObject jsonTags) {
+ if (jsonTags == null) {
+ return;
+ }
+
+ Iterator<String> it = jsonTags.keys();
+ if (!it.hasNext()) {
+ return;
+ }
+
+ // most popular tag & second most popular tag, based on usage count on this blog
+ String mostPopularTag = null;
+ String nextMostPopularTag = null;
+ int popularCount = 0;
+
+ StringBuilder sbAllTags = new StringBuilder();
+ boolean isFirst = true;
+
+ while (it.hasNext()) {
+ JSONObject jsonThisTag = jsonTags.optJSONObject(it.next());
+ String tagName = JSONUtil.getString(jsonThisTag, "name");
+
+ // if the number of posts on this blog that use this tag is higher than previous,
+ // set this as the most popular tag, and set the second most popular tag to
+ // the current most popular tag
+ int postCount = jsonThisTag.optInt("post_count");
+ if (postCount > popularCount) {
+ nextMostPopularTag = mostPopularTag;
+ mostPopularTag = tagName;
+ popularCount = postCount;
+ }
+
+ // add to list of all tags
+ if (isFirst) {
+ isFirst = false;
+ } else {
+ sbAllTags.append(",");
+ }
+ sbAllTags.append(tagName);
+ }
+
+ // don't set primary tag if one is already set (may have been set from the editorial
+ // section if this is a Freshly Pressed post)
+ if (!post.hasPrimaryTag()) {
+ post.setPrimaryTag(mostPopularTag);
+ }
+ post.setSecondaryTag(nextMostPopularTag);
+
+ post.setTags(sbAllTags.toString());
+ }
+
+ /*
+ * extracts a title from a post's excerpt
+ */
+ private static String extractTitle(final String excerpt, int maxLen) {
+ if (TextUtils.isEmpty(excerpt))
+ return null;
+
+ if (excerpt.length() < maxLen)
+ return excerpt.trim();
+
+ StringBuilder result = new StringBuilder();
+ BreakIterator wordIterator = BreakIterator.getWordInstance();
+ wordIterator.setText(excerpt);
+ int start = wordIterator.first();
+ int end = wordIterator.next();
+ int totalLen = 0;
+ while (end != BreakIterator.DONE) {
+ String word = excerpt.substring(start, end);
+ result.append(word);
+ totalLen += word.length();
+ if (totalLen >= maxLen)
+ break;
+ start = end;
+ end = wordIterator.next();
+ }
+
+ if (totalLen==0)
+ return null;
+ return result.toString().trim() + "...";
+ }
+
+ /*
+ * called when a post doesn't have a featured image, searches post's content for an image that
+ * may still be suitable as a featured image - only works with WP posts due to the search for
+ * specific WP image classes (but will also work with RSS posts that come from WP blogs)
+ */
+ private static String findFeaturedImage(final String text) {
+ if (text==null || !text.contains("<img "))
+ return null;
+
+ final String className;
+ if (text.contains("size-full")) {
+ className = "size-full";
+ } else if (text.contains("size-large")) {
+ className = "size-large";
+ } else if (text.contains("size-medium")) {
+ className = "size-medium";
+ } else {
+ return null;
+ }
+
+ // determine whether attributes are single- or double- quoted
+ boolean usesSingleQuotes = text.contains("src='");
+
+ int imgStart = text.indexOf("<img ");
+ while (imgStart > -1) {
+ int imgEnd = text.indexOf(">", imgStart);
+ if (imgEnd == -1)
+ return null;
+
+ String img = text.substring(imgStart, imgEnd+1);
+ if (img.contains(className)) {
+ int srcStart = img.indexOf(usesSingleQuotes ? "src='" : "src=\"");
+ if (srcStart == -1)
+ return null;
+ int srcEnd = img.indexOf(usesSingleQuotes ? "'" : "\"", srcStart+5);
+ if (srcEnd == -1)
+ return null;
+ return img.substring(srcStart+5, srcEnd);
+ }
+
+ imgStart = text.indexOf("<img ", imgEnd);
+ }
+
+ // if we get this far, no suitable image was found
+ return null;
+ }
+
+ /*
+ returns the actual image url from a Freshly Pressed featured image url - this is necessary because the
+ featured image returned by the API is often an ImagePress url that formats the actual image url for a
+ specific size, and we want to define the size in the app when the image is requested.
+ here's an example of an ImagePress featured image url from a freshly-pressed post:
+ https://s1.wp.com/imgpress?crop=0px%2C0px%2C252px%2C160px&url=https%3A%2F%2Fs2.wp.com%2Fimgpress%3Fw%3D252%26url%3Dhttp%253A%252F%252Fmostlybrightideas.files.wordpress.com%252F2013%252F08%252Ftablet.png&unsharpmask=80,0.5,3
+ */
+ private static String getImageUrlFromFeaturedImageUrl(final String featuredImageUrl) {
+ if (TextUtils.isEmpty(featuredImageUrl))
+ return null;
+
+ // if this is an mshots image, return the actual url without the query string (?h=n&w=n),
+ // and change it from https: to http: so it can be cached (it's only https because it's
+ // being returned by an authenticated REST endpoint - these images are found only in
+ // FP posts so they don't require https)
+ if (PhotonUtils.isMshotsUrl(featuredImageUrl))
+ return UrlUtils.removeQuery(featuredImageUrl).replaceFirst("https", "http");
+
+ if (featuredImageUrl.contains("imgpress")) {
+ // parse the url parameter
+ String actualImageUrl = Uri.parse(featuredImageUrl).getQueryParameter("url");
+ if (actualImageUrl==null)
+ return featuredImageUrl;
+
+ // at this point the imageUrl may still be an ImagePress url, so check the url param again (see above example)
+ if (actualImageUrl.contains("url=")) {
+ return Uri.parse(actualImageUrl).getQueryParameter("url");
+ } else {
+ return actualImageUrl;
+ }
+ }
+
+ // for all other featured images, return the passed url w/o the query string (since the query string
+ // often contains Photon sizing params that we don't want here)
+ int pos = featuredImageUrl.lastIndexOf("?");
+ if (pos == -1)
+ return featuredImageUrl;
+
+ return featuredImageUrl.substring(0, pos);
+ }
+
+ /*
+ * This is necessary to get VideoPress videos to work in the Reader since the v1
+ * REST API returns VideoPress videos in a script block that relies on jQuery - which obviously
+ * fails on mobile - here we extract the video thumbnail and insert a DIV at the top of the
+ * post content which links the thumbnail IMG to the video so the user can tap the thumb to
+ * play the video
+ * iOS: https://github.com/wordpress-mobile/WordPress-iOS/blob/develop/WordPress/Classes/ReaderPost.m#L702
+ */
+ /*private static void cleanupVideoPress(ReaderPost post) {
+ if (post==null || !post.hasText() || !post.hasFeaturedVideo())
+ return;
+
+ // extract the video thumbnail from them "videopress-poster" image class
+ String text = post.getText();
+ int pos = text.indexOf("videopress-poster");
+ if (pos == -1)
+ return;
+ int srcStart = text.indexOf("src=\"", pos);
+ if (srcStart == -1)
+ return;
+ srcStart += 5;
+ int srcEnd = text.indexOf("\"", srcStart);
+ if (srcEnd == -1)
+ return;
+
+ // set the featured image to the thumbnail if a featured image isn't already assigned
+ String thumb = text.substring(srcStart, srcEnd);
+ if (!post.hasFeaturedImage())
+ post.featuredImage = thumb;
+
+ // add the thumbnail linked to the actual video to the top of the content
+ String videoLink = String.format("<div><a href='%s'><img src='%s'/></a></div>", post.getFeaturedVideo(), thumb);
+ post.text = videoLink + text;
+ }*/
+
+ // --------------------------------------------------------------------------------------------
+
+ public String getAuthorName() {
+ return StringUtils.notNullStr(authorName);
+ }
+ public void setAuthorName(String authorName) {
+ this.authorName = StringUtils.notNullStr(authorName);
+ }
+
+ public String getTitle() {
+ return StringUtils.notNullStr(title);
+ }
+ public void setTitle(String title) {
+ this.title = StringUtils.notNullStr(title);
+ }
+
+ public String getText() {
+ return StringUtils.notNullStr(text);
+ }
+ public void setText(String text) {
+ this.text = StringUtils.notNullStr(text);
+ }
+
+ public String getExcerpt() {
+ return StringUtils.notNullStr(excerpt);
+ }
+ public void setExcerpt(String excerpt) {
+ this.excerpt = StringUtils.notNullStr(excerpt);
+ }
+
+ public String getUrl() {
+ return StringUtils.notNullStr(url);
+ }
+ public void setUrl(String url) {
+ this.url = StringUtils.notNullStr(url);
+ }
+
+ public String getFeaturedImage() {
+ return StringUtils.notNullStr(featuredImage);
+ }
+ public void setFeaturedImage(String featuredImage) {
+ this.featuredImage = StringUtils.notNullStr(featuredImage);
+ }
+
+ public String getFeaturedVideo() {
+ return StringUtils.notNullStr(featuredVideo);
+ }
+ public void setFeaturedVideo(String featuredVideo) {
+ this.featuredVideo = StringUtils.notNullStr(featuredVideo);
+ }
+
+ public String getBlogName() {
+ return StringUtils.notNullStr(blogName);
+ }
+ public void setBlogName(String blogName) {
+ this.blogName = StringUtils.notNullStr(blogName);
+ }
+
+ public String getBlogUrl() {
+ return StringUtils.notNullStr(blogUrl);
+ }
+ public void setBlogUrl(String blogUrl) {
+ this.blogUrl = StringUtils.notNullStr(blogUrl);
+ }
+
+ public String getPostAvatar() {
+ return StringUtils.notNullStr(postAvatar);
+ }
+ public void setPostAvatar(String postAvatar) {
+ this.postAvatar = StringUtils.notNullStr(postAvatar);
+ }
+
+ public String getPseudoId() {
+ return StringUtils.notNullStr(pseudoId);
+ }
+ public void setPseudoId(String pseudoId) {
+ this.pseudoId = StringUtils.notNullStr(pseudoId);
+ }
+
+ public String getPublished() {
+ return StringUtils.notNullStr(published);
+ }
+ public void setPublished(String published) {
+ this.published = StringUtils.notNullStr(published);
+ }
+
+ // --------------------------------------------------------------------------------------------
+
+ /*
+ * comma-separated tags
+ */
+ public String getTags() {
+ return StringUtils.notNullStr(tags);
+ }
+ public void setTags(String tags) {
+ this.tags = StringUtils.notNullStr(tags);
+ }
+
+ public String getPrimaryTag() {
+ return StringUtils.notNullStr(primaryTag);
+ }
+ public void setPrimaryTag(String tagName) {
+ // this is a bit of a hack to avoid setting the primary tag to one of the default
+ // tag names ("Freshly Pressed", etc.)
+ if (!ReaderTag.isDefaultTagName(tagName)) {
+ this.primaryTag = StringUtils.notNullStr(tagName);
+ }
+ }
+ public boolean hasPrimaryTag() {
+ return !TextUtils.isEmpty(primaryTag);
+ }
+
+ public String getSecondaryTag() {
+ return StringUtils.notNullStr(secondaryTag);
+ }
+ public void setSecondaryTag(String tagName) {
+ if (!ReaderTag.isDefaultTagName(tagName)) {
+ this.secondaryTag = StringUtils.notNullStr(tagName);
+ }
+ }
+
+ // --------------------------------------------------------------------------------------------
+
+ public boolean hasText() {
+ return !TextUtils.isEmpty(text);
+ }
+
+ public boolean hasExcerpt() {
+ return !TextUtils.isEmpty(excerpt);
+ }
+
+ public boolean hasFeaturedImage() {
+ return !TextUtils.isEmpty(featuredImage);
+ }
+
+ public boolean hasFeaturedVideo() {
+ return !TextUtils.isEmpty(featuredVideo);
+ }
+
+ public boolean hasPostAvatar() {
+ return !TextUtils.isEmpty(postAvatar);
+ }
+
+ public boolean hasBlogName() {
+ return !TextUtils.isEmpty(blogName);
+ }
+
+ public boolean hasAuthorName() {
+ return !TextUtils.isEmpty(authorName);
+ }
+
+ public boolean hasTitle() {
+ return !TextUtils.isEmpty(title);
+ }
+
+ public boolean hasBlogUrl() {
+ return !TextUtils.isEmpty(blogUrl);
+ }
+
+ /*
+ * only public wp posts can be reblogged
+ */
+ public boolean canReblog() {
+ return !isExternal && !isPrivate;
+ }
+
+ /*
+ * returns true if this post is from a WordPress blog
+ */
+ public boolean isWP() {
+ return !isExternal;
+ }
+
+ /****
+ * the following are transient variables - not stored in the db or returned in the json - whose
+ * sole purpose is to cache commonly-used values for the post that speeds up using them inside
+ * adapters
+ ****/
+
+ /*
+ * returns the featured image url as a photon url set to the passed width/height
+ */
+ private transient String featuredImageForDisplay;
+ public String getFeaturedImageForDisplay(int width, int height) {
+ if (featuredImageForDisplay == null) {
+ if (!hasFeaturedImage()) {
+ featuredImageForDisplay = "";
+ } else if (isPrivate) {
+ // images in private posts can't use photon, so handle separately
+ featuredImageForDisplay = ReaderUtils.getPrivateImageForDisplay(featuredImage, width, height);
+ } else {
+ // not private, so set to correctly sized photon url
+ featuredImageForDisplay = PhotonUtils.getPhotonImageUrl(featuredImage, width, height);
+ }
+ }
+ return featuredImageForDisplay;
+ }
+
+ /*
+ * returns the avatar url as a photon url set to the passed size
+ */
+ private transient String avatarForDisplay;
+ public String getPostAvatarForDisplay(int avatarSize) {
+ if (avatarForDisplay == null) {
+ if (!hasPostAvatar()) {
+ return "";
+ }
+ avatarForDisplay = PhotonUtils.fixAvatar(postAvatar, avatarSize);
+ }
+ return avatarForDisplay;
+ }
+
+ /*
+ * converts iso8601 published date to an actual java date
+ */
+ private transient java.util.Date dtPublished;
+ public java.util.Date getDatePublished() {
+ if (dtPublished == null) {
+ dtPublished = DateTimeUtils.iso8601ToJavaDate(published);
+ }
+ return dtPublished;
+ }
+
+ /*
+ * determine which tag to display for this post
+ * - no tag if this is a private blog or there is no primary tag for this post
+ * - primary tag, unless it's the same as the currently selected tag
+ * - secondary tag if primary tag is the same as the currently selected tag
+ */
+ private transient String tagForDisplay;
+ public String getTagForDisplay(final String currentTagName) {
+ if (tagForDisplay == null) {
+ if (!isPrivate && hasPrimaryTag()) {
+ if (getPrimaryTag().equalsIgnoreCase(currentTagName)) {
+ tagForDisplay = getSecondaryTag();
+ } else {
+ tagForDisplay = getPrimaryTag();
+ }
+ } else {
+ tagForDisplay = "";
+ }
+ }
+ return tagForDisplay;
+ }
+
+ /*
+ * used when a unique numeric id is required by an adapter (when hasStableIds() = true)
+ */
+ private transient long stableId;
+ public long getStableId() {
+ if (stableId == 0) {
+ stableId = (pseudoId != null ? pseudoId.hashCode() : 0);
+ }
+ return stableId;
+ }
+
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderPostList.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderPostList.java
new file mode 100644
index 000000000..39f69a441
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderPostList.java
@@ -0,0 +1,72 @@
+package org.wordpress.android.models;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.Date;
+
+public class ReaderPostList extends ArrayList<ReaderPost> {
+ public static ReaderPostList fromJson(JSONObject json) {
+ if (json==null)
+ throw new IllegalArgumentException("null json post list");
+
+ ReaderPostList posts = new ReaderPostList();
+ JSONArray jsonPosts = json.optJSONArray("posts");
+ if (jsonPosts!=null) {
+ for (int i=0; i < jsonPosts.length(); i++)
+ posts.add(ReaderPost.fromJson(jsonPosts.optJSONObject(i)));
+ }
+
+ return posts;
+ }
+
+ private int indexOfPost(long blogId, long postId) {
+ for (int i=0; i < size(); i++) {
+ if (this.get(i).blogId==blogId && this.get(i).postId==postId)
+ return i;
+ }
+ return -1;
+ }
+
+ public int indexOfPost(ReaderPost post) {
+ if (post==null)
+ return -1;
+ return indexOfPost(post.blogId, post.postId);
+ }
+
+ /*
+ * does passed list contain the same posts as this list?
+ */
+ public boolean isSameList(ReaderPostList posts) {
+ if (posts==null || posts.size()!=this.size())
+ return false;
+
+ for (ReaderPost post: posts) {
+ if (indexOfPost(post.blogId, post.postId) == -1)
+ return false;
+ }
+
+ return true;
+ }
+
+ /*
+ * returns the oldest pubDate of posts in this list
+ */
+ public Date getOldestPubDate() {
+ Date oldestDate = null;
+ for (ReaderPost post: this) {
+ Date dtPublished = post.getDatePublished();
+ if (dtPublished != null) {
+ if (oldestDate == null) {
+ oldestDate = dtPublished;
+ } else if (oldestDate.after(dtPublished)) {
+ oldestDate = dtPublished;
+ }
+ }
+ }
+
+ return oldestDate;
+ }
+
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderRecommendBlogList.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderRecommendBlogList.java
new file mode 100644
index 000000000..3f6bd7284
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderRecommendBlogList.java
@@ -0,0 +1,49 @@
+package org.wordpress.android.models;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+
+public class ReaderRecommendBlogList extends ArrayList<ReaderRecommendedBlog> {
+
+ public static ReaderRecommendBlogList fromJson(JSONObject json) {
+ ReaderRecommendBlogList blogs = new ReaderRecommendBlogList();
+
+ if (json == null) {
+ return blogs;
+ }
+
+ JSONArray jsonBlogs = json.optJSONArray("blogs");
+ if (jsonBlogs != null) {
+ for (int i = 0; i < jsonBlogs.length(); i++)
+ blogs.add(ReaderRecommendedBlog.fromJson(jsonBlogs.optJSONObject(i)));
+ }
+
+ return blogs;
+ }
+
+ private int indexOfBlogId(long blogId) {
+ for (int i = 0; i < size(); i++) {
+ if (this.get(i).blogId == blogId)
+ return i;
+ }
+ return -1;
+ }
+
+ public boolean isSameList(ReaderRecommendBlogList blogs) {
+ if (blogs == null || blogs.size() != this.size()) {
+ return false;
+ }
+
+ for (ReaderRecommendedBlog blog: blogs) {
+ int index = indexOfBlogId(blog.blogId);
+ if (index == -1 || !this.get(index).isSameAs(blog)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderRecommendedBlog.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderRecommendedBlog.java
new file mode 100644
index 000000000..381771a98
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderRecommendedBlog.java
@@ -0,0 +1,79 @@
+package org.wordpress.android.models;
+
+import org.json.JSONObject;
+import org.wordpress.android.util.JSONUtil;
+import org.wordpress.android.util.StringUtils;
+
+public class ReaderRecommendedBlog {
+ public long blogId;
+ public long followRecoId;
+ public int score;
+
+ private String title;
+ private String blogUrl;
+ private String imageUrl;
+ private String reason;
+
+ /*
+ * populated by response from get/read/recommendations/mine/
+ */
+ public static ReaderRecommendedBlog fromJson(JSONObject json) {
+ if (json == null) {
+ return null;
+ }
+
+ ReaderRecommendedBlog blog = new ReaderRecommendedBlog();
+
+ blog.blogId = json.optLong("blog_id");
+ blog.followRecoId = json.optLong("follow_reco_id");
+ blog.score = json.optInt("score");
+
+ blog.setTitle(JSONUtil.getString(json, "title"));
+ blog.setImageUrl(JSONUtil.getString(json, "image"));
+ blog.setReason(JSONUtil.getStringDecoded(json, "reason"));
+
+ // the "url" field points to an API endpoint, "blog_domain" contains the actual url
+ blog.setBlogUrl(JSONUtil.getString(json, "blog_domain"));
+
+ return blog;
+ }
+
+ public String getTitle() {
+ return StringUtils.notNullStr(title);
+ }
+ public void setTitle(String title) {
+ this.title = StringUtils.notNullStr(title);
+ }
+
+ public String getReason() {
+ return StringUtils.notNullStr(reason);
+ }
+ public void setReason(String reason) {
+ this.reason = StringUtils.notNullStr(reason);
+ }
+
+ public String getBlogUrl() {
+ return StringUtils.notNullStr(blogUrl);
+ }
+ public void setBlogUrl(String blogUrl) {
+ this.blogUrl = StringUtils.notNullStr(blogUrl);
+ }
+
+ public String getImageUrl() {
+ return StringUtils.notNullStr(imageUrl);
+ }
+ public void setImageUrl(String imageUrl) {
+ this.imageUrl = StringUtils.notNullStr(imageUrl);
+ }
+
+ protected boolean isSameAs(ReaderRecommendedBlog blog) {
+ if (blog == null) {
+ return false;
+ }
+ return (blog.blogId == this.blogId
+ && blog.score == this.score
+ && blog.followRecoId == this.followRecoId);
+ }
+
+
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderTag.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderTag.java
new file mode 100644
index 000000000..99342bf88
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderTag.java
@@ -0,0 +1,157 @@
+package org.wordpress.android.models;
+
+import android.text.TextUtils;
+
+import org.wordpress.android.util.StringUtils;
+
+import java.io.Serializable;
+import java.util.regex.Pattern;
+
+public class ReaderTag implements Serializable {
+ private String tagName;
+ private String endpoint;
+ public ReaderTagType tagType;
+
+ public static String TAG_ID_FOLLOWING = "following";
+ public static String TAG_ID_LIKED = "liked";
+
+ // these are the default tag names, which aren't localized in the /read/menu/ response
+ public static final String TAG_NAME_LIKED = "Posts I Like";
+ public static final String TAG_NAME_FOLLOWING = "Blogs I Follow";
+ public static final String TAG_NAME_FRESHLY_PRESSED = "Freshly Pressed";
+ private static final String TAG_NAME_DEFAULT = TAG_NAME_FRESHLY_PRESSED;
+
+ public ReaderTag(String tagName, String endpoint, ReaderTagType tagType) {
+ if (TextUtils.isEmpty(tagName)) {
+ this.setTagName(getTagNameFromEndpoint(endpoint));
+ } else {
+ this.setTagName(tagName);
+ }
+ this.setEndpoint(endpoint);
+ this.tagType = tagType;
+ }
+
+ public ReaderTag(String tagName, ReaderTagType tagType) {
+ this.setTagName(tagName);
+ this.tagType = tagType;
+ }
+
+ public static ReaderTag getDefaultTag() {
+ return new ReaderTag(TAG_NAME_DEFAULT, ReaderTagType.DEFAULT);
+ }
+
+ public String getEndpoint() {
+ return StringUtils.notNullStr(endpoint);
+ }
+ public void setEndpoint(String endpoint) {
+ this.endpoint = StringUtils.notNullStr(endpoint);
+ }
+
+ /**
+ * Extract tag Id from endpoint, only works for ReaderTagType.DEFAULT
+ *
+ * @return a string Id if tagType is ReaderTagType.DEFAULT, empty string else
+ */
+ public String getStringIdFromEndpoint() {
+ if (tagType != ReaderTagType.DEFAULT) {
+ return "";
+ }
+ String[] splitted = endpoint.split("/");
+ if (splitted != null && splitted.length > 0) {
+ return splitted[splitted.length - 1];
+ }
+ return "";
+ }
+
+ public String getTagName() {
+ return StringUtils.notNullStr(tagName);
+ }
+ public void setTagName(String name) {
+ this.tagName = StringUtils.notNullStr(name);
+ }
+ public String getCapitalizedTagName() {
+ if (tagName == null) {
+ return "";
+ }
+ // HACK to allow iPhone, iPad, iEverything else
+ if (tagName.startsWith("iP")) {
+ return tagName;
+ }
+ return StringUtils.capitalize(tagName);
+ }
+
+ /*
+ * returns the tag name for use in the application log - if this is a default tag it returns
+ * the full tag name, otherwise it abbreviates the tag name since exposing followed tags
+ * in the log could be considered a privacy issue
+ */
+ public String getTagNameForLog() {
+ String tagName = getTagName();
+ if (tagType == ReaderTagType.DEFAULT) {
+ return tagName;
+ } else if (tagName.length() >= 6) {
+ return tagName.substring(0, 3) + "...";
+ } else if (tagName.length() >= 4) {
+ return tagName.substring(0, 2) + "...";
+ } else if (tagName.length() >= 2) {
+ return tagName.substring(0, 1) + "...";
+ } else {
+ return "...";
+ }
+ }
+
+ /*
+ * used to ensure a tag name is valid before adding it
+ */
+ private static final Pattern INVALID_CHARS = Pattern.compile("^.*[~#@*+%{}<>\\[\\]|\"\\_].*$");
+ public static boolean isValidTagName(String tagName) {
+ if (TextUtils.isEmpty(tagName))
+ return false;
+ if (INVALID_CHARS.matcher(tagName).matches())
+ return false;
+ return true;
+ }
+
+ /*
+ * extracts the tag name from a valid read/tags/[tagName]/posts endpoint
+ */
+ private static String getTagNameFromEndpoint(final String endpoint) {
+ if (TextUtils.isEmpty(endpoint))
+ return "";
+
+ // make sure passed endpoint is valid
+ if (!endpoint.endsWith("/posts"))
+ return "";
+ int start = endpoint.indexOf("/read/tags/");
+ if (start == -1)
+ return "";
+
+ // skip "/read/tags/" then find the next "/"
+ start += 11;
+ int end = endpoint.indexOf("/", start);
+ if (end == -1)
+ return "";
+
+ return endpoint.substring(start, end);
+ }
+
+ /*
+ * is the passed tag name one of the default tags?
+ */
+ public static boolean isDefaultTagName(String tagName) {
+ if (TextUtils.isEmpty(tagName)) {
+ return false;
+ }
+ return (tagName.equalsIgnoreCase(TAG_NAME_FOLLOWING)
+ || tagName.equalsIgnoreCase(TAG_NAME_FRESHLY_PRESSED)
+ || tagName.equalsIgnoreCase(TAG_NAME_LIKED));
+ }
+
+ public static boolean isSameTag(ReaderTag tag1, ReaderTag tag2) {
+ if (tag1 == null || tag2 == null) {
+ return false;
+ }
+ return (tag1.getTagName().equalsIgnoreCase(tag2.getTagName())
+ && tag1.tagType.equals(tag2.tagType));
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderTagList.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderTagList.java
new file mode 100644
index 000000000..971b08d09
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderTagList.java
@@ -0,0 +1,52 @@
+package org.wordpress.android.models;
+
+import java.util.ArrayList;
+
+public class ReaderTagList extends ArrayList<ReaderTag> {
+
+ public int indexOfTag(ReaderTag tag) {
+ if (tag == null || isEmpty()) {
+ return -1;
+ }
+
+ for (int i = 0; i < size(); i++) {
+ if (ReaderTag.isSameTag(tag, this.get(i))) {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ public boolean isSameList(ReaderTagList tagList) {
+ if (tagList == null || tagList.size() != this.size()) {
+ return false;
+ }
+
+ for (ReaderTag thisTag: tagList) {
+ if (indexOfTag(thisTag) == -1) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /*
+ * returns a list of tags that are in this list but not in the passed list
+ */
+ public ReaderTagList getDeletions(ReaderTagList tagList) {
+ ReaderTagList deletions = new ReaderTagList();
+ if (tagList == null) {
+ return deletions;
+ }
+
+ for (ReaderTag thisTag: this) {
+ if (tagList.indexOfTag(thisTag) == -1) {
+ deletions.add(thisTag);
+ }
+ }
+
+ return deletions;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderTagType.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderTagType.java
new file mode 100644
index 000000000..b49081911
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderTagType.java
@@ -0,0 +1,33 @@
+package org.wordpress.android.models;
+
+public enum ReaderTagType {
+ FOLLOWED,
+ DEFAULT,
+ RECOMMENDED;
+
+ private static final int INT_DEFAULT = 0;
+ private static final int INT_FOLLOWED = 1;
+ private static final int INT_RECOMMENDED = 2;
+
+ public static ReaderTagType fromInt(int value) {
+ switch (value) {
+ case INT_RECOMMENDED :
+ return RECOMMENDED;
+ case INT_FOLLOWED :
+ return FOLLOWED;
+ default :
+ return DEFAULT;
+ }
+ }
+
+ public int toInt() {
+ switch (this) {
+ case FOLLOWED:
+ return INT_FOLLOWED;
+ case RECOMMENDED:
+ return INT_RECOMMENDED;
+ default :
+ return INT_DEFAULT;
+ }
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderUrlList.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderUrlList.java
new file mode 100644
index 000000000..17c97f1eb
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderUrlList.java
@@ -0,0 +1,36 @@
+package org.wordpress.android.models;
+
+import org.wordpress.android.util.UrlUtils;
+
+import java.util.HashSet;
+
+/**
+ * URLs are normalized before being added and during comparison to ensure better comparison
+ * of URLs that may be different strings but point to the same URL
+ */
+public class ReaderUrlList extends HashSet<String> {
+ @Override
+ public boolean add(String url) {
+ return super.add(UrlUtils.normalizeUrl(url));
+ }
+
+ @Override
+ public boolean remove(Object object) {
+ if (object instanceof String) {
+ return super.remove(UrlUtils.normalizeUrl((String) object));
+ } else {
+ return super.remove(object);
+ }
+ }
+
+ @Override
+ public boolean contains(Object object) {
+ if (object instanceof String) {
+ return super.contains(UrlUtils.normalizeUrl((String) object));
+ } else {
+ return super.contains(object);
+ }
+ }
+
+
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderUser.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderUser.java
new file mode 100644
index 000000000..34a39cc2f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderUser.java
@@ -0,0 +1,119 @@
+package org.wordpress.android.models;
+
+import android.text.TextUtils;
+
+import org.json.JSONObject;
+import org.wordpress.android.util.JSONUtil;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.UrlUtils;
+
+public class ReaderUser {
+ public long userId;
+ public long blogId;
+ private String userName;
+ private String displayName;
+ private String url;
+ private String profileUrl;
+ private String avatarUrl;
+
+ // isFollowed isn't read from json or stored in db - used by ReaderUserAdapter to mark followed users
+ public transient boolean isFollowed;
+
+ public static ReaderUser fromJson(JSONObject json) {
+ ReaderUser user = new ReaderUser();
+ if (json==null)
+ return user;
+
+ user.userId = json.optLong("ID");
+ user.blogId = json.optLong("site_ID");
+
+ user.userName = JSONUtil.getString(json, "username");
+ user.url = JSONUtil.getString(json, "URL"); // <-- this isn't necessarily a wp blog
+ user.profileUrl = JSONUtil.getString(json, "profile_URL");
+ user.avatarUrl = JSONUtil.getString(json, "avatar_URL");
+
+ // "me" api call (current user) has "display_name", others have "name"
+ if (json.has("display_name")) {
+ user.displayName = JSONUtil.getStringDecoded(json, "display_name");
+ } else {
+ user.displayName = JSONUtil.getStringDecoded(json, "name");
+ }
+
+ return user;
+ }
+
+ public String getUserName() {
+ return StringUtils.notNullStr(userName);
+ }
+ public void setUserName(String userName) {
+ this.userName = StringUtils.notNullStr(userName);
+ }
+
+ public String getDisplayName() {
+ return StringUtils.notNullStr(displayName);
+ }
+ public void setDisplayName(String displayName) {
+ this.displayName = StringUtils.notNullStr(displayName);
+ }
+
+ public String getUrl() {
+ return StringUtils.notNullStr(url);
+ }
+ public void setUrl(String url) {
+ this.url = StringUtils.notNullStr(url);
+ }
+
+ public String getProfileUrl() {
+ return StringUtils.notNullStr(profileUrl);
+ }
+ public void setProfileUrl(String profileUrl) {
+ this.profileUrl = StringUtils.notNullStr(profileUrl);
+ }
+
+ public String getAvatarUrl() {
+ return StringUtils.notNullStr(avatarUrl);
+ }
+ public void setAvatarUrl(String avatarUrl) {
+ this.avatarUrl = StringUtils.notNullStr(avatarUrl);
+ }
+
+ public boolean hasUrl() {
+ return !TextUtils.isEmpty(url);
+ }
+
+ public boolean hasBlogId() {
+ return (blogId != 0);
+ }
+ /*
+ * not stored - used by ReaderUserAdapter for performance
+ */
+ private transient String urlDomain;
+ public String getUrlDomain() {
+ if (urlDomain == null) {
+ if (hasUrl()) {
+ urlDomain = UrlUtils.getDomainFromUrl(getUrl());
+ } else {
+ urlDomain = "";
+ }
+ }
+ return urlDomain;
+ }
+
+ public boolean isSameUser(ReaderUser user) {
+ if (user==null)
+ return false;
+ if (this.userId != user.userId)
+ return false;
+ if (!this.getAvatarUrl().equals(user.getAvatarUrl()))
+ return false;
+ if (!this.getDisplayName().equals(user.getDisplayName()))
+ return false;
+ if (!this.getUserName().equals(user.getUserName()))
+ return false;
+ if (!this.getUrl().equals(user.getUrl()))
+ return false;
+ if (!this.getProfileUrl().equals(user.getProfileUrl()))
+ return false;
+ return true;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderUserIdList.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderUserIdList.java
new file mode 100644
index 000000000..8073e1c61
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderUserIdList.java
@@ -0,0 +1,14 @@
+package org.wordpress.android.models;
+
+import java.util.HashSet;
+
+public class ReaderUserIdList extends HashSet<Long> {
+ /*
+ * returns true if passed list contains the same userIds as this list
+ */
+ public boolean isSameList(ReaderUserIdList compareIds) {
+ if (compareIds==null || compareIds.size()!=this.size())
+ return false;
+ return this.containsAll(compareIds);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderUserList.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderUserList.java
new file mode 100644
index 000000000..d6b889748
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderUserList.java
@@ -0,0 +1,36 @@
+package org.wordpress.android.models;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+
+public class ReaderUserList extends ArrayList<ReaderUser> {
+ /*
+ * returns all userIds in this list
+ */
+ public ReaderUserIdList getUserIds() {
+ ReaderUserIdList ids = new ReaderUserIdList();
+ for (ReaderUser user: this)
+ ids.add(user.userId);
+ return ids;
+ }
+
+
+ /*
+ * passed json is response from getting likes for a post
+ */
+ public static ReaderUserList fromJsonLikes(JSONObject json) {
+ ReaderUserList users = new ReaderUserList();
+ if (json==null)
+ return users;
+
+ JSONArray jsonLikes = json.optJSONArray("likes");
+ if (jsonLikes!=null) {
+ for (int i=0; i < jsonLikes.length(); i++)
+ users.add(ReaderUser.fromJson(jsonLikes.optJSONObject(i)));
+ }
+
+ return users;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/StatsBarChartData.java b/WordPress/src/main/java/org/wordpress/android/models/StatsBarChartData.java
new file mode 100644
index 000000000..4691e2ad8
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/StatsBarChartData.java
@@ -0,0 +1,73 @@
+package org.wordpress.android.models;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+
+import org.wordpress.android.ui.stats.StatsBarChartUnit;
+
+/**
+ * A model to represent the bar chart data.
+ * <p>
+ * The bar chart unit is either:
+ * <ul>
+ * <li> "DAY" where the date looks like "2013-09-12"
+ * <li> "WEEK" where the date looks like "2013W26"
+ * <li> "MONTH" where the date looks like "2013-09-01"
+ * </ul>
+ * </p>
+ */
+public class StatsBarChartData {
+ private String mBlogId;
+ private String mDate;
+ private int mViews;
+ private int mVisitors;
+ private StatsBarChartUnit mBarChartUnit;
+
+ public StatsBarChartData(String blogId, StatsBarChartUnit unit, JSONArray result) throws JSONException {
+ setBlogId(blogId);
+ setBarChartUnit(unit);
+ setDate(result.getString(0));
+ setViews(result.getInt(1));
+ setVisitors(result.getInt(2));
+ }
+
+ public String getBlogId() {
+ return mBlogId;
+ }
+
+ public void setBlogId(String blogId) {
+ this.mBlogId = blogId;
+ }
+
+ public String getDate() {
+ return mDate;
+ }
+
+ public void setDate(String date) {
+ this.mDate = date;
+ }
+
+ public int getViews() {
+ return mViews;
+ }
+
+ public void setViews(int views) {
+ this.mViews = views;
+ }
+
+ public int getVisitors() {
+ return mVisitors;
+ }
+
+ public void setVisitors(int visitors) {
+ this.mVisitors = visitors;
+ }
+
+ public StatsBarChartUnit getBarChartUnit() {
+ return mBarChartUnit;
+ }
+
+ public void setBarChartUnit(StatsBarChartUnit unit) {
+ this.mBarChartUnit = unit;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/StatsClick.java b/WordPress/src/main/java/org/wordpress/android/models/StatsClick.java
new file mode 100644
index 000000000..d783abc2b
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/StatsClick.java
@@ -0,0 +1,74 @@
+package org.wordpress.android.models;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+
+import org.wordpress.android.util.StatUtils;
+
+/**
+ * A model to represent a click child stat.
+ */
+public class StatsClick {
+ private String mBlogId;
+ private long mDate;
+ private String mGroupId;
+ private String mName;
+ private int mTotal;
+
+ public StatsClick(String blogId, long date, String groupId, String name, int total) {
+ this.setBlogId(blogId);
+ this.setDate(date);
+ this.setGroupId(groupId);
+ this.setName(name);
+ this.setTotal(total);
+ }
+
+ public StatsClick(String blogId, String date, String groupId, JSONArray result) throws JSONException {
+ setBlogId(blogId);
+ setDate(StatUtils.toMs(date));
+ setGroupId(groupId);
+
+ setName(result.getString(0));
+ setTotal(result.getInt(1));
+ }
+
+ public String getBlogId() {
+ return mBlogId;
+ }
+
+ public void setBlogId(String blogId) {
+ this.mBlogId = blogId;
+ }
+
+ public long getDate() {
+ return mDate;
+ }
+
+ public void setDate(long date) {
+ this.mDate = date;
+ }
+
+ public String getGroupId() {
+ return mGroupId;
+ }
+
+ public void setGroupId(String groupId) {
+ this.mGroupId = groupId;
+ }
+
+ public int getTotal() {
+ return mTotal;
+ }
+
+ public void setTotal(int total) {
+ this.mTotal = total;
+ }
+
+ public String getName() {
+ return mName;
+ }
+
+ public void setName(String name) {
+ this.mName = name;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/StatsClickGroup.java b/WordPress/src/main/java/org/wordpress/android/models/StatsClickGroup.java
new file mode 100644
index 000000000..b4faea1c4
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/StatsClickGroup.java
@@ -0,0 +1,120 @@
+package org.wordpress.android.models;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.wordpress.android.util.StatUtils;
+
+/**
+ * A model to represent a click group stat.
+ */
+public class StatsClickGroup {
+ private String mBlogId;
+ private long mDate;
+ private String mGroupId;
+ private String mName;
+ private int mTotal;
+ private String mUrl;
+ private String mIcon;
+ private int mChildren;
+
+ public StatsClickGroup(String blogId, long date, String name, String groupId, int total, String url, String icon, int children) {
+ this.setBlogId(blogId);
+ this.setDate(date);
+ this.setGroupId(groupId);
+ this.setName(name);
+ this.setTotal(total);
+ this.setUrl(url);
+ this.setIcon(icon);
+ this.setChildren(children);
+ }
+
+ public StatsClickGroup(String blogId, String date, JSONObject result) throws JSONException {
+ setBlogId(blogId);
+ setDate(StatUtils.toMs(date));
+ setGroupId(result.getString("group"));
+ setName(result.getString("name"));
+ setTotal(result.getInt("total"));
+ if (result.has("icon") && !result.getString("icon").equals("null"))
+ setIcon(result.getString("icon"));
+
+ // Set a url only if there is one result, and this result starts with http
+ // If there are more, the urls will be set in the children
+ JSONArray array = result.getJSONArray("results");
+ if (array.length() == 1) {
+ setChildren(0); // the child won't be stored if there's only one child
+
+ JSONArray firstEntry = array.getJSONArray(0);
+ String url = firstEntry.getString(0);
+ if (url.startsWith("http"))
+ setUrl(url);
+ } else {
+ setChildren(array.length());
+ }
+ }
+
+ public String getBlogId() {
+ return mBlogId;
+ }
+
+ public void setBlogId(String blogId) {
+ this.mBlogId = blogId;
+ }
+
+ public long getDate() {
+ return mDate;
+ }
+
+ public void setDate(long date) {
+ this.mDate = date;
+ }
+
+ public String getGroupId() {
+ return mGroupId;
+ }
+
+ public void setGroupId(String groupId) {
+ this.mGroupId = groupId;
+ }
+
+ public String getName() {
+ return mName;
+ }
+
+ public void setName(String name) {
+ this.mName = name;
+ }
+
+ public int getTotal() {
+ return mTotal;
+ }
+
+ public void setTotal(int total) {
+ this.mTotal = total;
+ }
+
+ public String getUrl() {
+ return mUrl;
+ }
+
+ public void setUrl(String url) {
+ this.mUrl = url;
+ }
+
+ public String getIcon() {
+ return mIcon;
+ }
+
+ public void setIcon(String icon) {
+ this.mIcon = icon;
+ }
+
+ public int getChildren() {
+ return mChildren;
+ }
+
+ public void setChildren(int children) {
+ this.mChildren = children;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/StatsGeoview.java b/WordPress/src/main/java/org/wordpress/android/models/StatsGeoview.java
new file mode 100644
index 000000000..5f2cc35dc
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/StatsGeoview.java
@@ -0,0 +1,73 @@
+package org.wordpress.android.models;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.wordpress.android.util.StatUtils;
+
+/**
+ * A model to represent a geoview stat.
+ */
+public class StatsGeoview {
+ private String mBlogId;
+ private long mDate;
+ private String mCountry;
+ private int mViews;
+ private String mImageUrl;
+
+ public StatsGeoview(String blogId, long date, String country, int views, String imageUrl) {
+ this.mBlogId = blogId;
+ this.mDate = date;
+ this.mCountry = country;
+ this.mViews = views;
+ this.mImageUrl = imageUrl;
+ }
+
+ public StatsGeoview(String blogId, JSONObject result) throws JSONException {
+ setBlogId(blogId);
+ setDate(StatUtils.toMs(result.getString("date")));
+ setCountry(result.getString("country"));
+ setViews(result.getInt("views"));
+ setImageUrl(result.getString("imageUrl"));
+ }
+
+ public String getBlogId() {
+ return mBlogId;
+ }
+
+ public void setBlogId(String blogId) {
+ this.mBlogId = blogId;
+ }
+
+ public long getDate() {
+ return mDate;
+ }
+
+ public void setDate(long date) {
+ this.mDate = date;
+ }
+
+ public String getCountry() {
+ return mCountry;
+ }
+
+ public void setCountry(String country) {
+ this.mCountry = country;
+ }
+
+ public int getViews() {
+ return mViews;
+ }
+
+ public void setViews(int views) {
+ this.mViews = views;
+ }
+
+ public String getImageUrl() {
+ return mImageUrl;
+ }
+
+ public void setImageUrl(String imageUrl) {
+ this.mImageUrl = imageUrl;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/StatsMostCommented.java b/WordPress/src/main/java/org/wordpress/android/models/StatsMostCommented.java
new file mode 100644
index 000000000..54ad5b55c
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/StatsMostCommented.java
@@ -0,0 +1,71 @@
+package org.wordpress.android.models;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * A model to represent a most commented post stat
+ */
+public class StatsMostCommented {
+ private String mBlogId;
+ private int mPostId;
+ private String mPost;
+ private int mComments;
+ private String mUrl;
+
+ public StatsMostCommented(String blogId, int postId, String post, int comments, String url) {
+ this.mBlogId = blogId;
+ this.mPostId = postId;
+ this.mPost = post;
+ this.mComments = comments;
+ this.mUrl = url;
+ }
+
+ public StatsMostCommented(String blogId, JSONObject result) throws JSONException {
+ setBlogId(blogId);
+ setPostId(result.getInt("postId"));
+ setPost(result.getString("post"));
+ setComments(result.getInt("comments"));
+ setUrl(result.getString("url"));
+ }
+
+ public String getBlogId() {
+ return mBlogId;
+ }
+
+ public void setBlogId(String blogId) {
+ this.mBlogId = blogId;
+ }
+
+ public int getPostId() {
+ return mPostId;
+ }
+
+ public void setPostId(int postId) {
+ this.mPostId = postId;
+ }
+
+ public String getPost() {
+ return mPost;
+ }
+
+ public void setPost(String post) {
+ this.mPost = post;
+ }
+
+ public int getComments() {
+ return mComments;
+ }
+
+ public void setComments(int comments) {
+ this.mComments = comments;
+ }
+
+ public String getUrl() {
+ return mUrl;
+ }
+
+ public void setUrl(String url) {
+ this.mUrl = url;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/StatsReferrer.java b/WordPress/src/main/java/org/wordpress/android/models/StatsReferrer.java
new file mode 100644
index 000000000..2c4ce32f8
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/StatsReferrer.java
@@ -0,0 +1,73 @@
+package org.wordpress.android.models;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.wordpress.android.util.StatUtils;
+
+/**
+ * A model to represent a referrer child stat.
+ */
+public class StatsReferrer {
+ private String mBlogId;
+ private long mDate;
+ private String mGroupId;
+ private String mName;
+ private int mTotal;
+
+ public StatsReferrer(String blogId, long date, String groupId, String name, int total) {
+ this.setBlogId(blogId);
+ this.setDate(date);
+ this.setGroupId(groupId);
+ this.setName(name);
+ this.setTotal(total);
+ }
+
+ public StatsReferrer(String blogId, String date, String groupId, JSONArray result) throws JSONException {
+ setBlogId(blogId);
+ setDate(StatUtils.toMs(date));
+ setGroupId(groupId);
+
+ setName(result.getString(0));
+ setTotal(result.getInt(1));
+ }
+
+ public String getBlogId() {
+ return mBlogId;
+ }
+
+ public void setBlogId(String blogId) {
+ this.mBlogId = blogId;
+ }
+
+ public long getDate() {
+ return mDate;
+ }
+
+ public void setDate(long date) {
+ this.mDate = date;
+ }
+
+ public String getGroupId() {
+ return mGroupId;
+ }
+
+ public void setGroupId(String groupId) {
+ this.mGroupId = groupId;
+ }
+
+ public int getTotal() {
+ return mTotal;
+ }
+
+ public void setTotal(int total) {
+ this.mTotal = total;
+ }
+
+ public String getName() {
+ return mName;
+ }
+
+ public void setName(String name) {
+ this.mName = name;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/StatsReferrerGroup.java b/WordPress/src/main/java/org/wordpress/android/models/StatsReferrerGroup.java
new file mode 100644
index 000000000..e3db74283
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/StatsReferrerGroup.java
@@ -0,0 +1,120 @@
+package org.wordpress.android.models;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.wordpress.android.util.StatUtils;
+
+/**
+ * A model to represent a referrer group stat
+ */
+public class StatsReferrerGroup {
+ private String mBlogId;
+ private long mDate;
+ private String mGroupId;
+ private String mName;
+ private int mTotal;
+ private String mUrl;
+ private String mIcon;
+ private int mChildren;
+
+ public StatsReferrerGroup(String blogId, long date, String name, String groupId, int total, String url, String icon, int children) {
+ this.setBlogId(blogId);
+ this.setDate(date);
+ this.setGroupId(groupId);
+ this.setName(name);
+ this.setTotal(total);
+ this.setUrl(url);
+ this.setIcon(icon);
+ this.setChildren(children);
+ }
+
+ public StatsReferrerGroup(String blogId, String date, JSONObject result) throws JSONException {
+ setBlogId(blogId);
+ setDate(StatUtils.toMs(date));
+ setGroupId(result.getString("group"));
+ setName(result.getString("name"));
+ setTotal(result.getInt("total"));
+ if (result.has("icon") && !result.getString("icon").equals("null"))
+ setIcon(result.getString("icon"));
+
+ // Set a url only if there is one result, and this result starts with http
+ // If there are more, the urls will be set in the children
+ JSONArray array = result.getJSONArray("results");
+ if (array.length() == 1) {
+ setChildren(0); // the child won't be stored if there's only one child
+
+ JSONArray firstEntry = array.getJSONArray(0);
+ String url = firstEntry.getString(0);
+ if (url.startsWith("http"))
+ setUrl(url);
+ } else {
+ setChildren(array.length());
+ }
+ }
+
+ public String getBlogId() {
+ return mBlogId;
+ }
+
+ public void setBlogId(String blogId) {
+ this.mBlogId = blogId;
+ }
+
+ public long getDate() {
+ return mDate;
+ }
+
+ public void setDate(long date) {
+ this.mDate = date;
+ }
+
+ public String getGroupId() {
+ return mGroupId;
+ }
+
+ public void setGroupId(String groupId) {
+ this.mGroupId = groupId;
+ }
+
+ public String getName() {
+ return mName;
+ }
+
+ public void setName(String name) {
+ this.mName = name;
+ }
+
+ public int getTotal() {
+ return mTotal;
+ }
+
+ public void setTotal(int total) {
+ this.mTotal = total;
+ }
+
+ public String getUrl() {
+ return mUrl;
+ }
+
+ public void setUrl(String url) {
+ this.mUrl = url;
+ }
+
+ public String getIcon() {
+ return mIcon;
+ }
+
+ public void setIcon(String icon) {
+ this.mIcon = icon;
+ }
+
+ public int getChildren() {
+ return mChildren;
+ }
+
+ public void setChildren(int children) {
+ this.mChildren = children;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/StatsSearchEngineTerm.java b/WordPress/src/main/java/org/wordpress/android/models/StatsSearchEngineTerm.java
new file mode 100644
index 000000000..bc8e6f9d8
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/StatsSearchEngineTerm.java
@@ -0,0 +1,62 @@
+package org.wordpress.android.models;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+
+import org.wordpress.android.util.StatUtils;
+
+/**
+ * A model to represent a search engine term stat
+ */
+public class StatsSearchEngineTerm {
+ private String mBlogId;
+ private long mDate;
+ private String mSearch;
+ private int mViews;
+
+ public StatsSearchEngineTerm(String blogId, long date, String search, int views) {
+ this.mBlogId = blogId;
+ this.mDate = date;
+ this.mSearch = search;
+ this.mViews = views;
+ }
+
+ public StatsSearchEngineTerm(String blogId, String date, JSONArray result) throws JSONException {
+ setBlogId(blogId);
+ setDate(StatUtils.toMs(date));
+ setSearch(result.getString(0));
+ setViews(result.getInt(1));
+ }
+
+ public String getBlogId() {
+ return mBlogId;
+ }
+
+ public void setBlogId(String blogId) {
+ this.mBlogId = blogId;
+ }
+
+ public long getDate() {
+ return mDate;
+ }
+
+ public void setDate(long date) {
+ this.mDate = date;
+ }
+
+ public String getSearch() {
+ return mSearch;
+ }
+
+ public void setSearch(String search) {
+ this.mSearch = search;
+ }
+
+ public int getViews() {
+ return mViews;
+ }
+
+ public void setViews(int views) {
+ this.mViews = views;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/StatsSummary.java b/WordPress/src/main/java/org/wordpress/android/models/StatsSummary.java
new file mode 100644
index 000000000..9a1f7133c
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/StatsSummary.java
@@ -0,0 +1,212 @@
+package org.wordpress.android.models;
+
+import java.io.Serializable;
+
+import com.google.gson.annotations.SerializedName;
+
+import org.wordpress.android.util.StatUtils;
+
+/**
+ * A model to represent the summary of a blog's stats.
+ */
+public class StatsSummary implements Serializable{
+ private static final long serialVersionUID = 1951520106663020694L;
+
+ @SerializedName("visitors_today")
+ private int visitorsToday;
+
+ @SerializedName("visitors_yesterday")
+ private int visitorsYesterday;
+
+ @SerializedName("views_today")
+ private int viewsToday;
+
+ @SerializedName("views_yesterday")
+ private int viewsYesterday;
+
+ @SerializedName("views_best_day")
+ private String viewsBestDay;
+
+ @SerializedName("views_best_day_total")
+ private int viewsBestDayTotal;
+
+ @SerializedName("views")
+ private int viewsAllTime;
+
+ @SerializedName("comments")
+ private int commentsAllTime;
+
+ @SerializedName("posts")
+ private int posts;
+
+ @SerializedName("followers_blog")
+ private int followersBlog;
+
+ @SerializedName("followers_comments")
+ private int followersComments;
+
+ @SerializedName("comments_per_month")
+ private int commentsPerMonth;
+
+ @SerializedName("comments_most_active_recent_day")
+ private String commentsMostActiveRecentDay;
+
+ @SerializedName("comments_most_active_time")
+ private String commentsMostActiveTime;
+
+ @SerializedName("comments_spam")
+ private int commentsSpam;
+
+ @SerializedName("categories")
+ private int categories;
+
+ @SerializedName("tags")
+ private int tags;
+
+ @SerializedName("shares")
+ private int shares;
+
+ public int getVisitorsToday() {
+ return visitorsToday;
+ }
+
+ public void setVisitorsToday(int visitorsToday) {
+ this.visitorsToday = visitorsToday;
+ }
+
+ public int getVisitorsYesterday() {
+ return visitorsYesterday;
+ }
+
+ public void setVisitorsYesterday(int visitorsYesterday) {
+ this.visitorsYesterday = visitorsYesterday;
+ }
+
+ public int getViewsToday() {
+ return viewsToday;
+ }
+
+ public void setViewsToday(int viewsToday) {
+ this.viewsToday = viewsToday;
+ }
+
+ public int getViewsYesterday() {
+ return viewsYesterday;
+ }
+
+ public void setViewsYesterday(int viewsYesterday) {
+ this.viewsYesterday = viewsYesterday;
+ }
+
+ public String getBestDay() {
+ return viewsBestDay;
+ }
+
+ public void setViewsBestDay(String viewsBestDay) {
+ this.viewsBestDay = viewsBestDay;
+ }
+
+ public int getViewsBestDayTotal() {
+ return viewsBestDayTotal;
+ }
+
+ public void setBestDayTotal(int viewsBestDayTotal) {
+ this.viewsBestDayTotal = viewsBestDayTotal;
+ }
+
+ public int getViewsAllTime() {
+ return viewsAllTime;
+ }
+
+ public void setViewsAllTime(int viewsAllTime) {
+ this.viewsAllTime = viewsAllTime;
+ }
+
+ public int getCommentsAllTime() {
+ return commentsAllTime;
+ }
+
+ public void setCommentsAllTime(int commentsAllTime) {
+ this.commentsAllTime = commentsAllTime;
+ }
+
+ public int getPosts() {
+ return posts;
+ }
+
+ public void setPosts(int posts) {
+ this.posts = posts;
+ }
+
+ public int getFollowersBlog() {
+ return followersBlog;
+ }
+
+ public void setFollowersBlog(int followersBlog) {
+ this.followersBlog = followersBlog;
+ }
+
+ public int getFollowersComments() {
+ return followersComments;
+ }
+
+ public void setFollowersComments(int followersComments) {
+ this.followersComments = followersComments;
+ }
+
+ public int getCommentsPerMonth() {
+ return commentsPerMonth;
+ }
+
+ public void setCommentsPerMonth(int commentsPerMonth) {
+ this.commentsPerMonth = commentsPerMonth;
+ }
+
+ public String getCommentsMostActiveRecentDay() {
+ return StatUtils.parseDate(commentsMostActiveRecentDay, "yyyy-MM-dd", "MMMMM d, yyyy");
+ }
+
+ public void setCommentsMostActiveRecentDay(String commentsMostActiveRecentDay) {
+ this.commentsMostActiveRecentDay = commentsMostActiveRecentDay;
+ }
+
+ public String getCommentsMostActiveTime() {
+ return commentsMostActiveTime;
+ }
+
+ public void setCommentsMostActiveTime(String commentsMostActiveTime) {
+ this.commentsMostActiveTime = commentsMostActiveTime;
+ }
+
+ public int getCommentsSpam() {
+ return commentsSpam;
+ }
+
+ public void setCommentsSpam(int commentsSpam) {
+ this.commentsSpam = commentsSpam;
+ }
+
+ public int getCategories() {
+ return categories;
+ }
+
+ public void setCategories(int categories) {
+ this.categories = categories;
+ }
+
+ public int getTags() {
+ return tags;
+ }
+
+ public void setTags(int tags) {
+ this.tags = tags;
+ }
+
+ public int getShares() {
+ return shares;
+ }
+
+ public void setShares(int shares) {
+ this.shares = shares;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/StatsTagsandCategories.java b/WordPress/src/main/java/org/wordpress/android/models/StatsTagsandCategories.java
new file mode 100644
index 000000000..b89a44232
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/StatsTagsandCategories.java
@@ -0,0 +1,77 @@
+package org.wordpress.android.models;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * A model to represent a for a tag or category stat
+ */
+public class StatsTagsandCategories {
+ private String mBlogId;
+ private String mTopic;
+ private String mType;
+ private int mViews;
+
+ public enum Type {
+ TAG("tag"), CATEGORY("category");
+
+ private String mLabel;
+
+ private Type (String label) {
+ mLabel = label;
+ }
+
+ public String getLabel() {
+ return mLabel;
+ }
+ }
+
+ public StatsTagsandCategories(String blogId, String topic, Type type, int views) {
+ this.mBlogId = blogId;
+ this.mTopic = topic;
+ this.mType = type.getLabel();
+ this.mViews = views;
+ }
+
+ public StatsTagsandCategories(String blogId, JSONObject result) throws JSONException {
+ setBlogId(blogId);
+ setTopic(result.getString("topic"));
+ if (result.get("type").equals(Type.CATEGORY.getLabel()))
+ setType(Type.CATEGORY);
+ else
+ setType(Type.TAG);
+ setViews(result.getInt("views"));
+ }
+
+ public String getBlogId() {
+ return mBlogId;
+ }
+
+ public void setBlogId(String blogId) {
+ this.mBlogId = blogId;
+ }
+
+ public String getTopic() {
+ return mTopic;
+ }
+
+ public void setTopic(String topic) {
+ this.mTopic = topic;
+ }
+
+ public String getType() {
+ return mType;
+ }
+
+ public void setType(Type type) {
+ this.mType = type.getLabel();
+ }
+
+ public int getViews() {
+ return mViews;
+ }
+
+ public void setViews(int views) {
+ this.mViews = views;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/StatsTopAuthor.java b/WordPress/src/main/java/org/wordpress/android/models/StatsTopAuthor.java
new file mode 100644
index 000000000..27c17f126
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/StatsTopAuthor.java
@@ -0,0 +1,86 @@
+package org.wordpress.android.models;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.wordpress.android.util.StatUtils;
+
+/**
+ * A model to represent a top author stat
+ */
+public class StatsTopAuthor {
+ private String mBlogId;
+ private long mDate;
+ private int mUserId;
+ private String mName;
+ private int mViews;
+ private String mImageUrl;
+
+ public StatsTopAuthor(String blogId, long date, int userId, String name, int views, String imageUrl) {
+ this.mBlogId = blogId;
+ this.mDate = date;
+ this.mUserId = userId;
+ this.mName = name;
+ this.mViews = views;
+ this.mImageUrl = imageUrl;
+ }
+
+ public StatsTopAuthor(String blogId, JSONObject result) throws JSONException {
+ setBlogId(blogId);
+ setDate(StatUtils.toMs(result.getString("date")));
+ setUserId(result.getInt("userId"));
+ setName(result.getString("name"));
+ setViews(result.getInt("views"));
+
+ if (result.has("imageUrl") && !result.getString("imageUrl").equals("null"))
+ setImageUrl(result.getString("imageUrl"));
+ }
+
+ public String getBlogId() {
+ return mBlogId;
+ }
+
+ public void setBlogId(String blogId) {
+ this.mBlogId = blogId;
+ }
+
+ public long getDate() {
+ return mDate;
+ }
+
+ public void setDate(long date) {
+ this.mDate = date;
+ }
+
+ public int getUserId() {
+ return mUserId;
+ }
+
+ public void setUserId(int userId) {
+ this.mUserId = userId;
+ }
+
+ public String getName() {
+ return mName;
+ }
+
+ public void setName(String name) {
+ this.mName = name;
+ }
+
+ public int getViews() {
+ return mViews;
+ }
+
+ public void setViews(int views) {
+ this.mViews = views;
+ }
+
+ public String getImageUrl() {
+ return mImageUrl;
+ }
+
+ public void setImageUrl(String imageUrl) {
+ this.mImageUrl = imageUrl;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/StatsTopCommenter.java b/WordPress/src/main/java/org/wordpress/android/models/StatsTopCommenter.java
new file mode 100644
index 000000000..0af6c23ad
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/StatsTopCommenter.java
@@ -0,0 +1,72 @@
+package org.wordpress.android.models;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * A model to represent a top commenter stat
+ */
+public class StatsTopCommenter {
+ private String mBlogId;
+ private int mUserId;
+ private String mName;
+ private int mComments;
+ private String mImageUrl;
+
+ public StatsTopCommenter(String blogId, int userId, String name, int comments, String imageUrl) {
+ this.mBlogId = blogId;
+ this.mUserId = userId;
+ this.mName = name;
+ this.mComments = comments;
+ this.mImageUrl = imageUrl;
+ }
+
+ public StatsTopCommenter(String blogId, JSONObject result) throws JSONException {
+ setBlogId(blogId);
+ setUserId(result.getInt("userId"));
+ setName(result.getString("name"));
+ setComments(result.getInt("comments"));
+ if (result.has("imageUrl") && !result.getString("imageUrl").equals("null"))
+ setImageUrl(result.getString("imageUrl"));
+ }
+
+ public String getBlogId() {
+ return mBlogId;
+ }
+
+ public void setBlogId(String blogId) {
+ this.mBlogId = blogId;
+ }
+
+ public int getUserId() {
+ return mUserId;
+ }
+
+ public void setUserId(int userId) {
+ this.mUserId = userId;
+ }
+
+ public String getName() {
+ return mName;
+ }
+
+ public void setName(String name) {
+ this.mName = name;
+ }
+
+ public int getComments() {
+ return mComments;
+ }
+
+ public void setComments(int comments) {
+ this.mComments = comments;
+ }
+
+ public String getImageUrl() {
+ return mImageUrl;
+ }
+
+ public void setImageUrl(String imageUrl) {
+ this.mImageUrl = imageUrl;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/StatsTopPostsAndPages.java b/WordPress/src/main/java/org/wordpress/android/models/StatsTopPostsAndPages.java
new file mode 100644
index 000000000..0b5fdc993
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/StatsTopPostsAndPages.java
@@ -0,0 +1,84 @@
+package org.wordpress.android.models;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.wordpress.android.util.StatUtils;
+
+/**
+ * A model to represent a top post or page stat
+ */
+public class StatsTopPostsAndPages {
+ private String mBlogId;
+ private long mDate;
+ private int mPostId;
+ private String mTitle;
+ private int mViews;
+ private String mUrl;
+
+ public StatsTopPostsAndPages(String blogId, long date, int postId, String title, int views, String url) {
+ this.mBlogId = blogId;
+ this.mDate = date;
+ this.mPostId = postId;
+ this.mTitle = title;
+ this.mViews = views;
+ this.mUrl = url;
+ }
+
+ public StatsTopPostsAndPages(String blogId, JSONObject result) throws JSONException {
+ setBlogId(blogId);
+ setDate(StatUtils.toMs(result.getString("date")));
+ setPostId(result.getInt("postId"));
+ setTitle(result.getString("title"));
+ setViews(result.getInt("views"));
+ setUrl(result.getString("url"));
+ }
+
+ public String getBlogId() {
+ return mBlogId;
+ }
+
+ public void setBlogId(String blogId) {
+ this.mBlogId = blogId;
+ }
+
+ public long getDate() {
+ return mDate;
+ }
+
+ public void setDate(long date) {
+ this.mDate = date;
+ }
+
+ public int getPostId() {
+ return mPostId;
+ }
+
+ public void setPostId(int postId) {
+ this.mPostId = postId;
+ }
+
+ public String getTitle() {
+ return mTitle;
+ }
+
+ public void setTitle(String title) {
+ this.mTitle = title;
+ }
+
+ public int getViews() {
+ return mViews;
+ }
+
+ public void setViews(int views) {
+ this.mViews = views;
+ }
+
+ public String getUrl() {
+ return mUrl;
+ }
+
+ public void setUrl(String url) {
+ this.mUrl = url;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/StatsVideo.java b/WordPress/src/main/java/org/wordpress/android/models/StatsVideo.java
new file mode 100644
index 000000000..56b512e53
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/StatsVideo.java
@@ -0,0 +1,84 @@
+package org.wordpress.android.models;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.wordpress.android.util.StatUtils;
+
+/**
+ * A model to represent a video stat
+ */
+public class StatsVideo {
+ private String mBlogId;
+ private long mDate;
+ private int mVideoId;
+ private String mName;
+ private int mPlays;
+ private String mUrl;
+
+ public StatsVideo(String blogId, long date, int videoId, String name, int plays, String url) {
+ this.mBlogId = blogId;
+ this.mDate = date;
+ this.mVideoId = videoId;
+ this.mName = name;
+ this.mPlays = plays;
+ this.mUrl = url;
+ }
+
+ public StatsVideo(String blogId, JSONObject result) throws JSONException {
+ setBlogId(blogId);
+ setDate(StatUtils.toMs(result.getString("date")));
+ setVideoId(result.getInt("videoId"));
+ setName(result.getString("name"));
+ setPlays(result.getInt("plays"));
+ setUrl(result.getString("url"));
+ }
+
+ public String getBlogId() {
+ return mBlogId;
+ }
+
+ public void setBlogId(String blogId) {
+ this.mBlogId = blogId;
+ }
+
+ public long getDate() {
+ return mDate;
+ }
+
+ public void setDate(long date) {
+ this.mDate = date;
+ }
+
+ public int getVideoId() {
+ return mVideoId;
+ }
+
+ public void setVideoId(int videoId) {
+ this.mVideoId = videoId;
+ }
+
+ public String getName() {
+ return mName;
+ }
+
+ public void setName(String name) {
+ this.mName = name;
+ }
+
+ public int getPlays() {
+ return mPlays;
+ }
+
+ public void setPlays(int plays) {
+ this.mPlays = plays;
+ }
+
+ public String getUrl() {
+ return mUrl;
+ }
+
+ public void setUrl(String url) {
+ this.mUrl = url;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/StatsVideoSummary.java b/WordPress/src/main/java/org/wordpress/android/models/StatsVideoSummary.java
new file mode 100644
index 000000000..cf40ab7da
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/StatsVideoSummary.java
@@ -0,0 +1,70 @@
+package org.wordpress.android.models;
+
+/**
+ * A model representing the summary for video views
+ */
+public class StatsVideoSummary {
+ private String mTimeframe;
+ private int mPlays;
+ private int mImpressions;
+ private int mMinutes;
+ private String mBandwidth;
+ private String mDate;
+
+ public StatsVideoSummary(String timeframe, int plays, int impressions, int minutes, String bandwidth, String date) {
+ this.setTimeframe(timeframe);
+ this.setPlays(plays);
+ this.setImpressions(impressions);
+ this.setMinutes(minutes);
+ this.setBandwidth(bandwidth);
+ this.setDate(date);
+ }
+
+ public String getTimeframe() {
+ return mTimeframe;
+ }
+
+ public void setTimeframe(String timeframe) {
+ this.mTimeframe = timeframe;
+ }
+
+ public int getPlays() {
+ return mPlays;
+ }
+
+ public void setPlays(int plays) {
+ this.mPlays = plays;
+ }
+
+ public int getImpressions() {
+ return mImpressions;
+ }
+
+ public void setImpressions(int impressions) {
+ this.mImpressions = impressions;
+ }
+
+ public int getMinutes() {
+ return mMinutes;
+ }
+
+ public void setMinutes(int minutes) {
+ this.mMinutes = minutes;
+ }
+
+ public String getBandwidth() {
+ return mBandwidth;
+ }
+
+ public void setBandwidth(String bandwidth) {
+ this.mBandwidth = bandwidth;
+ }
+
+ public String getDate() {
+ return mDate;
+ }
+
+ public void setDate(String date) {
+ this.mDate = date;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/models/Theme.java b/WordPress/src/main/java/org/wordpress/android/models/Theme.java
new file mode 100644
index 000000000..d2e58801e
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/models/Theme.java
@@ -0,0 +1,221 @@
+package org.wordpress.android.models;
+
+import android.text.TextUtils;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.ThemeHelper;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Locale;
+
+/**
+ * A model to represent a theme
+ */
+public class Theme {
+ private String themeId = null;
+ private String screenshotURL = "";
+ private String name = "";
+ private String description = "";
+ private int trendingRank = 0;
+ private int popularityRank = 0;
+ private String launchDate = "";
+ private long launchDateMs = 0;
+ private String blogId;
+ private String previewURL = "";
+ private boolean isCurrent = false;
+ private boolean isPremium = false;
+ private String features;
+
+ public Theme() {
+ }
+
+ public Theme(String themeId, String screenshotURL, String name, String description, int trendingRank, int popularityRank, String launchDate, String blogId, String previewURL, boolean isPremium, String features) {
+ setThemeId(themeId);
+ setScreenshotURL(screenshotURL);
+ setName(name);
+ setDescription(description);
+ setTrendingRank(trendingRank);
+ setPopularityRank(popularityRank);
+ setLaunchDate(launchDate);
+ setBlogId(blogId);
+ setPreviewURL(previewURL);
+ setPremium(isPremium);
+ setFeatures(features);
+ }
+
+ public void setFeatures(String features) {
+ this.features = features;
+ }
+
+ public ArrayList<String> getFeaturesArray() {
+ ArrayList<String> features = new ArrayList<String>();
+ if (!TextUtils.isEmpty(this.features)) {
+ String [] arr = this.features.split(",");
+ Collections.addAll(features, arr);
+ }
+ return features;
+ }
+
+ public String getFeatures() {
+ return this.features;
+ }
+
+ public String getThemeId() {
+ return themeId;
+ }
+
+ public void setThemeId(String themeId) {
+ this.themeId = themeId;
+ }
+
+ public String getScreenshotURL() {
+ return screenshotURL;
+ }
+
+ public void setScreenshotURL(String screenshotURL) {
+ this.screenshotURL = screenshotURL;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public int getTrendingRank() {
+ return trendingRank;
+ }
+
+ public void setTrendingRank(int trendingRank) {
+ this.trendingRank = trendingRank;
+ }
+
+ public int getPopularityRank() {
+ return popularityRank;
+ }
+
+ public void setPopularityRank(int popularityRank) {
+ this.popularityRank = popularityRank;
+ }
+
+ public String getLaunchDate() {
+ return launchDate;
+ }
+
+ public long getLaunchDateMs() {
+ return launchDateMs;
+ }
+
+ public void setLaunchDate(String launchDate) {
+ this.launchDate = launchDate;
+ try {
+ Date date = new SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH).parse(launchDate);
+ this.launchDateMs = date.getTime();
+ } catch (ParseException e) {
+ AppLog.e(T.THEMES, e);
+ }
+ }
+
+ public String getBlogId() {
+ return blogId;
+ }
+
+ public void setBlogId(String blogId) {
+ this.blogId = blogId;
+ }
+
+ public String getPreviewURL() {
+ return previewURL;
+ }
+
+ public void setPreviewURL(String previewURL) {
+ this.previewURL = previewURL;
+ }
+
+ public void save() {
+ WordPress.wpDB.saveTheme(this);
+ }
+
+ public static Theme fromJSON(JSONObject object) throws JSONException {
+ if (object == null)
+ return null;
+
+ String themeId = object.getString("id");
+ String screenshotURL = object.getString("screenshot") ;
+ String name = object.getString("name");
+ String description = object.getString("description");
+ int trendingRank = object.getInt("trending_rank");
+ int popularityRank = object.getInt("popularity_rank");
+ String launchDate = object.getString("launch_date");
+ String previewURL = object.has("preview_url") ? object.getString("preview_url") : ""; // we don't receive preview_url when we fetch current theme
+
+ // parse cost, e.g
+ // "cost": {
+ // "display": "$80",
+ // "number": 80,
+ // "currency": "USD"
+ // },
+ JSONObject costObject = object.getJSONObject("cost");
+ boolean isPremium = costObject.getInt("number") > 0;
+
+ // if the theme is free, set the blogId to be empty
+ // if the theme is not free, set the blogId to the current blog
+ String blogId = String.valueOf(WordPress.getCurrentBlog().getRemoteBlogId());
+
+ // build comma-separated list of features
+ StringBuilder sbFeatures = new StringBuilder();
+ JSONArray tags = object.optJSONArray("tags");
+ if (tags != null && tags.length() > 0) {
+ boolean isFirst = true;
+ for (int i = 0; i < tags.length(); i++ ) {
+ String label = ThemeHelper.getLabel(tags.getString(i));
+ if (!TextUtils.isEmpty(label)) {
+ if (isFirst) {
+ isFirst = false;
+ } else {
+ sbFeatures.append(",");
+ }
+ sbFeatures.append(label);
+ }
+ }
+ }
+ String features = sbFeatures.toString();
+
+ return new Theme(themeId, screenshotURL, name, description, trendingRank, popularityRank, launchDate, blogId, previewURL, isPremium, features);
+ }
+
+ public void setCurrent(boolean isCurrent) {
+ this.isCurrent = isCurrent;
+ }
+
+ public boolean isCurrent() {
+ return isCurrent;
+ }
+
+ public boolean isPremium() {
+ return isPremium;
+ }
+
+ public void setPremium(boolean isPremium) {
+ this.isPremium = isPremium;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/networking/Authenticator.java b/WordPress/src/main/java/org/wordpress/android/networking/Authenticator.java
new file mode 100644
index 000000000..24b63a6b8
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/networking/Authenticator.java
@@ -0,0 +1,13 @@
+package org.wordpress.android.networking;
+
+/**
+ * Interface that provides a method that should perform the necessary task to make sure
+ * the provided AuthenticatorRequest will be authenticated.
+ *
+ * The Authenticator must call AuthenticatorRequest.send() when it has completed its operations. For
+ * convenience the AuthenticatorRequest class provides AuthenticatorRequest.setAccessToken so the Authenticator can
+ * easily update the access token.
+ */
+public interface Authenticator {
+ void authenticate(AuthenticatorRequest authenticatorRequest);
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/networking/AuthenticatorRequest.java b/WordPress/src/main/java/org/wordpress/android/networking/AuthenticatorRequest.java
new file mode 100644
index 000000000..3e1e668ae
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/networking/AuthenticatorRequest.java
@@ -0,0 +1,93 @@
+package org.wordpress.android.networking;
+
+import com.android.volley.VolleyError;
+import com.wordpress.rest.Oauth;
+import com.wordpress.rest.RestClient;
+import com.wordpress.rest.RestRequest;
+import com.wordpress.rest.RestRequest.ErrorListener;
+
+/**
+ * Encapsulates the behaviour for asking the Authenticator for an access token. This
+ * allows the request maker to disregard the authentication state when making requests.
+ */
+public class AuthenticatorRequest {
+ static private final String SITE_PREFIX = "https://public-api.wordpress.com/rest/v1/sites/";
+ static private final String BATCH_CALL_PREFIX = "https://public-api.wordpress.com/rest/v1/batch/?urls%5B%5D=%2Fsites%2F";
+ private RestRequest mRequest;
+ private RestRequest.ErrorListener mListener;
+ private RestClient mRestClient;
+ private Authenticator mAuthenticator;
+
+ protected AuthenticatorRequest(RestRequest request, ErrorListener listener, RestClient restClient,
+ Authenticator authenticator) {
+ mRequest = request;
+ mListener = listener;
+ mRestClient = restClient;
+ mAuthenticator = authenticator;
+ }
+
+ public String getSiteId() {
+ return extractSiteIdFromUrl(mRequest.getUrl());
+ }
+
+ /**
+ * Parse out the site ID from an URL.
+ * Note: For batch REST API calls, only the first siteID is returned
+ *
+ * @return The site ID
+ */
+ public static String extractSiteIdFromUrl(String url) {
+ if (url == null) {
+ return null;
+ }
+ if (url.startsWith(SITE_PREFIX) && !SITE_PREFIX.equals(url)) {
+ int marker = SITE_PREFIX.length();
+ if (url.indexOf("/", marker) < marker) {
+ return null;
+ }
+ return url.substring(marker, url.indexOf("/", marker));
+ } else if (url.startsWith(BATCH_CALL_PREFIX) && !BATCH_CALL_PREFIX.equals(url)) {
+ int marker = BATCH_CALL_PREFIX.length();
+ if (url.indexOf("%2F", marker) < marker) {
+ return null;
+ }
+ return url.substring(marker, url.indexOf("%2F", marker));
+ }
+
+ // not a sites/$siteId request or a batch request
+ return null;
+ }
+
+ /**
+ * Attempt to send the request, checks to see if we have an access token and if not
+ * asks the Authenticator to authenticate the request.
+ *
+ * If no Authenticator is provided the request is always sent.
+ */
+ protected void send(){
+ if (mAuthenticator == null) {
+ mRestClient.send(mRequest);
+ } else {
+ mAuthenticator.authenticate(this);
+ }
+ }
+
+ public void sendWithAccessToken(String token){
+ mRequest.setAccessToken(token.toString());
+ mRestClient.send(mRequest);
+ }
+
+ public void sendWithAccessToken(Oauth.Token token){
+ sendWithAccessToken(token.toString());
+ }
+
+ /**
+ * If an access token cannot be obtained the request can be aborted and the
+ * handler's onFailure method is called
+ */
+ public void abort(VolleyError error){
+ if (mListener != null) {
+ mListener.onErrorResponse(error);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/networking/OAuthAuthenticator.java b/WordPress/src/main/java/org/wordpress/android/networking/OAuthAuthenticator.java
new file mode 100644
index 000000000..db68eb2a4
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/networking/OAuthAuthenticator.java
@@ -0,0 +1,115 @@
+package org.wordpress.android.networking;
+
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+
+import com.android.volley.Request;
+import com.android.volley.VolleyError;
+import com.wordpress.rest.Oauth;
+
+import org.wordpress.android.BuildConfig;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.WordPressDB;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.util.SimperiumUtils;
+
+public class OAuthAuthenticator implements Authenticator {
+ @Override
+ public void authenticate(AuthenticatorRequest request) {
+ String siteId = request.getSiteId();
+ String token = null;
+ Blog blog = null;
+
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(WordPress.getContext());
+ if (siteId == null) {
+ // Use the global access token
+ token = settings.getString(WordPress.ACCESS_TOKEN_PREFERENCE, null);
+ } else {
+ blog = WordPress.wpDB.getBlogForDotComBlogId(siteId);
+
+ if (blog != null) {
+ // get the access token from api key field. Jetpack blogs linked with a different wpcom
+ // account have the token stored here.
+ token = blog.getApi_key();
+
+ // valid oauth tokens are 64 chars
+ if (token != null && token.length() < 64 && !blog.isDotcomFlag()) {
+ token = null;
+ }
+
+ // if there is no access token, we need to check if it is a dotcom blog, or a jetpack
+ // blog linked with the main wpcom account.
+ if (token == null) {
+ if (blog.isDotcomFlag() && blog.getUsername().equals(settings.getString(
+ WordPress.WPCOM_USERNAME_PREFERENCE, ""))) {
+ token = settings.getString(WordPress.ACCESS_TOKEN_PREFERENCE, null);
+ } else if (blog.isJetpackPowered()) {
+ if (blog.getDotcom_username() == null || blog.getDotcom_username().equals(settings.getString(
+ WordPress.WPCOM_USERNAME_PREFERENCE, ""))) {
+ token = settings.getString(WordPress.ACCESS_TOKEN_PREFERENCE, null);
+ }
+ }
+ }
+ }
+ }
+ if (token != null) {
+ // we have an access token, set the request and send it
+ request.sendWithAccessToken(token);
+ } else {
+ // we don't have an access token, let's request one
+ requestAccessToken(request, blog);
+ }
+ }
+
+ public void requestAccessToken(final AuthenticatorRequest request, final Blog blog) {
+ Oauth oauth = new Oauth(BuildConfig.OAUTH_APP_ID, BuildConfig.OAUTH_APP_SECRET, BuildConfig.OAUTH_REDIRECT_URI);
+ String username;
+ String password;
+ final SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(WordPress.getContext());
+ if (blog == null) {
+ // We weren't give a specific blog, so we're going to user the username/password
+ // from the "global" dotcom user account
+ username = settings.getString(WordPress.WPCOM_USERNAME_PREFERENCE, null);
+ password = WordPressDB.decryptPassword(settings.getString(WordPress.WPCOM_PASSWORD_PREFERENCE, null));
+ } else {
+ // use the requested blog's username password, if it's a dotcom blog, use the
+ // username and password for the blog. If it's a jetpack blog (not isDotcomFlag)
+ // then use the getDotcom_* methods for username/password
+ if (blog.isDotcomFlag()) {
+ username = blog.getUsername();
+ password = blog.getPassword();
+ } else {
+ username = blog.getDotcom_username();
+ password = blog.getDotcom_password();
+ }
+ }
+
+ Request oauthRequest = oauth.makeRequest(username, password,
+ new Oauth.Listener() {
+ @Override
+ public void onResponse(Oauth.Token token) {
+ if (blog == null) {
+ settings.edit().putString(WordPress.ACCESS_TOKEN_PREFERENCE, token.toString()).commit();
+ } else {
+ blog.setApi_key(token.toString());
+ WordPress.wpDB.saveBlog(blog);
+ }
+
+ // Once we have a token, start up Simperium
+ SimperiumUtils.configureSimperium(WordPress.getContext(), token.toString());
+
+ request.sendWithAccessToken(token);
+ }
+ },
+
+ new Oauth.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError error) {
+ request.abort(error);
+ }
+ }
+ );
+ // add oauth request to the request queue
+ WordPress.requestQueue.add(oauthRequest);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/networking/OAuthAuthenticatorFactory.java b/WordPress/src/main/java/org/wordpress/android/networking/OAuthAuthenticatorFactory.java
new file mode 100644
index 000000000..b8dde11a3
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/networking/OAuthAuthenticatorFactory.java
@@ -0,0 +1,16 @@
+package org.wordpress.android.networking;
+
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+
+public class OAuthAuthenticatorFactory {
+ public static OAuthAuthenticatorFactoryAbstract sFactory;
+
+ public static OAuthAuthenticator instantiate() {
+ if (sFactory == null) {
+ sFactory = new OAuthAuthenticatorFactoryDefault();
+ }
+ AppLog.v(T.UTILS, "instantiate OAuth using sFactory: " + sFactory.getClass());
+ return sFactory.make();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/networking/OAuthAuthenticatorFactoryAbstract.java b/WordPress/src/main/java/org/wordpress/android/networking/OAuthAuthenticatorFactoryAbstract.java
new file mode 100644
index 000000000..85cff768c
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/networking/OAuthAuthenticatorFactoryAbstract.java
@@ -0,0 +1,5 @@
+package org.wordpress.android.networking;
+
+public interface OAuthAuthenticatorFactoryAbstract {
+ public OAuthAuthenticator make();
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/networking/OAuthAuthenticatorFactoryDefault.java b/WordPress/src/main/java/org/wordpress/android/networking/OAuthAuthenticatorFactoryDefault.java
new file mode 100644
index 000000000..4687d1dca
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/networking/OAuthAuthenticatorFactoryDefault.java
@@ -0,0 +1,7 @@
+package org.wordpress.android.networking;
+
+public class OAuthAuthenticatorFactoryDefault implements OAuthAuthenticatorFactoryAbstract {
+ public OAuthAuthenticator make() {
+ return new OAuthAuthenticator();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/networking/RestClientFactory.java b/WordPress/src/main/java/org/wordpress/android/networking/RestClientFactory.java
new file mode 100644
index 000000000..076d878e0
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/networking/RestClientFactory.java
@@ -0,0 +1,19 @@
+package org.wordpress.android.networking;
+
+import com.android.volley.RequestQueue;
+import com.wordpress.rest.RestClient;
+
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+
+public class RestClientFactory {
+ public static RestClientFactoryAbstract sFactory;
+
+ public static RestClient instantiate(RequestQueue queue) {
+ if (sFactory == null) {
+ sFactory = new RestClientFactoryDefault();
+ }
+ AppLog.v(T.UTILS, "instantiate RestClient using sFactory: " + sFactory.getClass());
+ return sFactory.make(queue);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/networking/RestClientFactoryAbstract.java b/WordPress/src/main/java/org/wordpress/android/networking/RestClientFactoryAbstract.java
new file mode 100644
index 000000000..2e5906e4a
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/networking/RestClientFactoryAbstract.java
@@ -0,0 +1,8 @@
+package org.wordpress.android.networking;
+
+import com.android.volley.RequestQueue;
+import com.wordpress.rest.RestClient;
+
+public interface RestClientFactoryAbstract {
+ public RestClient make(RequestQueue queue);
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/networking/RestClientFactoryDefault.java b/WordPress/src/main/java/org/wordpress/android/networking/RestClientFactoryDefault.java
new file mode 100644
index 000000000..79646bb79
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/networking/RestClientFactoryDefault.java
@@ -0,0 +1,10 @@
+package org.wordpress.android.networking;
+
+import com.android.volley.RequestQueue;
+import com.wordpress.rest.RestClient;
+
+public class RestClientFactoryDefault implements RestClientFactoryAbstract {
+ public RestClient make(RequestQueue queue) {
+ return new RestClient(queue);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/networking/RestClientUtils.java b/WordPress/src/main/java/org/wordpress/android/networking/RestClientUtils.java
new file mode 100644
index 000000000..69d325c9e
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/networking/RestClientUtils.java
@@ -0,0 +1,256 @@
+/**
+ * Interface to the WordPress.com REST API.
+ */
+package org.wordpress.android.networking;
+
+import com.android.volley.DefaultRetryPolicy;
+import com.android.volley.Request;
+import com.android.volley.Request.Method;
+import com.android.volley.RequestQueue;
+import com.android.volley.RetryPolicy;
+import com.android.volley.toolbox.RequestFuture;
+import com.wordpress.rest.RestClient;
+import com.wordpress.rest.RestRequest;
+import com.wordpress.rest.RestRequest.ErrorListener;
+import com.wordpress.rest.RestRequest.Listener;
+
+import org.json.JSONObject;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Note;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+public class RestClientUtils {
+ private static final String NOTIFICATION_FIELDS = "id,type,unread,body,subject,timestamp,meta";
+ private static final String COMMENT_REPLY_CONTENT_FIELD = "content";
+
+ private RestClient mRestClient;
+ private Authenticator mAuthenticator;
+
+ /**
+ * Socket timeout in milliseconds for rest requests
+ */
+ public static final int REST_TIMEOUT_MS = 30000;
+
+ /**
+ * Default number of retries for POST rest requests
+ */
+ public static final int REST_MAX_RETRIES_POST = 0;
+
+ /**
+ * Default number of retries for GET rest requests
+ */
+ public static final int REST_MAX_RETRIES_GET = 3;
+
+ /**
+ * Default backoff multiplier for rest requests
+ */
+ public static final float REST_BACKOFF_MULT = 2f;
+
+ public RestClientUtils(RequestQueue queue, Authenticator authenticator) {
+ // load an existing access token from prefs if we have one
+ mAuthenticator = authenticator;
+ mRestClient = RestClientFactory.instantiate(queue);
+ mRestClient.setUserAgent(WordPress.getUserAgent());
+ }
+
+ /**
+ * Reply to a comment using a Note.Reply object.
+ * <p/>
+ * https://developer.wordpress.com/docs/api/1/post/sites/%24site/posts/%24post_ID/replies/new/
+ */
+ public void replyToComment(Note.Reply reply, Listener listener, ErrorListener errorListener) {
+ Map<String, String> params = new HashMap<String, String>();
+ params.put(COMMENT_REPLY_CONTENT_FIELD, reply.getContent());
+ post(reply.getRestPath(), params, null, listener, errorListener);
+ }
+
+ /**
+ * Reply to a comment.
+ * <p/>
+ * https://developer.wordpress.com/docs/api/1/post/sites/%24site/posts/%24post_ID/replies/new/
+ */
+ public void replyToComment(String siteId, String commentId, String content, Listener listener,
+ ErrorListener errorListener) {
+ Map<String, String> params = new HashMap<String, String>();
+ params.put(COMMENT_REPLY_CONTENT_FIELD, content);
+ String path = String.format("sites/%s/comments/%s/replies/new", siteId, commentId);
+ post(path, params, null, listener, errorListener);
+ }
+
+ /**
+ * Follow a site given an ID or domain
+ * <p/>
+ * https://developer.wordpress.com/docs/api/1/post/sites/%24site/follows/new/
+ */
+ public void followSite(String siteId, Listener listener, ErrorListener errorListener) {
+ String path = String.format("sites/%s/follows/new", siteId);
+ post(path, listener, errorListener);
+ }
+
+ /**
+ * Unfollow a site given an ID or domain
+ * <p/>
+ * https://developer.wordpress.com/docs/api/1/post/sites/%24site/follows/mine/delete/
+ */
+ public void unfollowSite(String siteId, Listener listener, ErrorListener errorListener) {
+ String path = String.format("sites/%s/follows/mine/delete", siteId);
+ post(path, listener, errorListener);
+ }
+
+ /**
+ * Get notifications with the provided params.
+ * <p/>
+ * https://developer.wordpress.com/docs/api/1/get/notifications/
+ */
+ public void getNotifications(Map<String, String> params, Listener listener, ErrorListener errorListener) {
+ params.put("number", "40");
+ params.put("num_note_items", "20");
+ params.put("fields", NOTIFICATION_FIELDS);
+ get("notifications", params, null, listener, errorListener);
+ }
+
+ /**
+ * Get notifications with default params.
+ * <p/>
+ * https://developer.wordpress.com/docs/api/1/get/notifications/
+ */
+ public void getNotifications(Listener listener, ErrorListener errorListener) {
+ getNotifications(new HashMap<String, String>(), listener, errorListener);
+ }
+
+ /**
+ * Update the seen timestamp.
+ * <p/>
+ * https://developer.wordpress.com/docs/api/1/post/notifications/seen
+ */
+ public void markNotificationsSeen(String timestamp, Listener listener, ErrorListener errorListener) {
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("time", timestamp);
+ post("notifications/seen", params, null, listener, errorListener);
+ }
+
+ /**
+ * Moderate a comment.
+ * <p/>
+ * http://developer.wordpress.com/docs/api/1/sites/%24site/comments/%24comment_ID/
+ */
+ public void moderateComment(String siteId, String commentId, String status, Listener listener,
+ ErrorListener errorListener) {
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("status", status);
+ String path = String.format("sites/%s/comments/%s/", siteId, commentId);
+ post(path, params, null, listener, errorListener);
+ }
+
+ /**
+ * Get all a site's themes
+ */
+ public void getThemes(String siteId, int limit, int offset, Listener listener, ErrorListener errorListener) {
+ String path = String.format("sites/%s/themes?limit=%d&offset=%d", siteId, limit, offset);
+ get(path, listener, errorListener);
+ }
+
+ /**
+ * Set a site's theme
+ */
+ public void setTheme(String siteId, String themeId, Listener listener, ErrorListener errorListener) {
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("theme", themeId);
+ String path = String.format("sites/%s/themes/mine", siteId);
+ post(path, params, null, listener, errorListener);
+ }
+
+ /**
+ * Get a site's current theme
+ */
+ public void getCurrentTheme(String siteId, Listener listener, ErrorListener errorListener) {
+ String path = String.format("sites/%s/themes/mine", siteId);
+ get(path, listener, errorListener);
+ }
+
+ /**
+ * Make GET request
+ */
+ public Request<JSONObject> get(String path, Listener listener, ErrorListener errorListener) {
+ return get(path, null, null, listener, errorListener);
+ }
+
+ /**
+ * Make GET request with params
+ */
+ public Request<JSONObject> get(String path, Map<String, String> params, RetryPolicy retryPolicy, Listener listener,
+ ErrorListener errorListener) {
+ // turn params into querystring
+
+ RestRequest request = mRestClient.makeRequest(Method.GET, RestClient.getAbsoluteURL(path, params), null,
+ listener, errorListener);
+ if (retryPolicy == null) {
+ retryPolicy = new DefaultRetryPolicy(REST_TIMEOUT_MS, REST_MAX_RETRIES_GET, REST_BACKOFF_MULT);
+ }
+ request.setRetryPolicy(retryPolicy);
+ AuthenticatorRequest authCheck = new AuthenticatorRequest(request, errorListener, mRestClient, mAuthenticator);
+ authCheck.send();
+ return request;
+ }
+
+ /**
+ * Make Synchronous GET request
+ *
+ * @throws TimeoutException
+ * @throws ExecutionException
+ * @throws InterruptedException
+ */
+ public JSONObject getSynchronous(String path) throws InterruptedException, ExecutionException, TimeoutException {
+ return getSynchronous(path, null, null);
+ }
+
+ /**
+ * Make Synchronous GET request with params
+ *
+ * @throws TimeoutException
+ * @throws ExecutionException
+ * @throws InterruptedException
+ */
+ public JSONObject getSynchronous(String path, Map<String, String> params, RetryPolicy retryPolicy)
+ throws InterruptedException, ExecutionException, TimeoutException {
+ RequestFuture<JSONObject> future = RequestFuture.newFuture();
+ RestRequest request = mRestClient.makeRequest(Method.GET, RestClient.getAbsoluteURL(path, params), null, future, future);
+
+ if (retryPolicy == null) {
+ retryPolicy = new DefaultRetryPolicy(REST_TIMEOUT_MS, REST_MAX_RETRIES_GET, REST_BACKOFF_MULT);
+ }
+ request.setRetryPolicy(retryPolicy);
+
+ AuthenticatorRequest authCheck = new AuthenticatorRequest(request, null, mRestClient, mAuthenticator);
+ authCheck.send(); //this insert the request into the queue. //TODO: Verify that everything is OK on REST calls without a valid token
+ JSONObject response = future.get();
+ return response;
+ }
+
+ /**
+ * Make POST request
+ */
+ public void post(String path, Listener listener, ErrorListener errorListener) {
+ post(path, null, null, listener, errorListener);
+ }
+
+ /**
+ * Make POST request with params
+ */
+ public void post(final String path, Map<String, String> params, RetryPolicy retryPolicy, Listener listener,
+ ErrorListener errorListener) {
+ final RestRequest request = mRestClient.makeRequest(Method.POST, RestClient.getAbsoluteURL(path), params,
+ listener, errorListener);
+ if (retryPolicy == null) {
+ retryPolicy = new DefaultRetryPolicy(REST_TIMEOUT_MS, REST_MAX_RETRIES_POST,
+ REST_BACKOFF_MULT); //Do not retry on failure
+ }
+ request.setRetryPolicy(retryPolicy);
+ AuthenticatorRequest authCheck = new AuthenticatorRequest(request, errorListener, mRestClient, mAuthenticator);
+ authCheck.send();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/networking/SSLCertsViewActivity.java b/WordPress/src/main/java/org/wordpress/android/networking/SSLCertsViewActivity.java
new file mode 100644
index 000000000..5c5df39e2
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/networking/SSLCertsViewActivity.java
@@ -0,0 +1,42 @@
+package org.wordpress.android.networking;
+
+import android.app.ActionBar;
+import android.os.Bundle;
+import android.webkit.WebSettings;
+
+import org.wordpress.android.R;
+import org.wordpress.android.ui.WebViewActivity;
+
+/**
+ * Display details of a SSL cert
+ */
+public class SSLCertsViewActivity extends WebViewActivity {
+ public static final String CERT_DETAILS_KEYS = "CertDetails";
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setTitle(getResources().getText(R.string.ssl_certificate_details));
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(false);
+ }
+ mWebView.getSettings().setBuiltInZoomControls(false);
+
+ Bundle extras = getIntent().getExtras();
+ if (extras != null && extras.containsKey(CERT_DETAILS_KEYS)) {
+ String certDetails = extras.getString(CERT_DETAILS_KEYS);
+ StringBuilder sb = new StringBuilder("<html><body>");
+ sb.append(certDetails);
+ sb.append("</body></html>");
+ WebSettings settings = mWebView.getSettings();
+ settings.setDefaultTextEncodingName("utf-8");
+ mWebView.loadDataWithBaseURL(null, sb.toString(), "text/html", "utf-8", null);
+ }
+ }
+
+ protected void refreshMenuDrawer(){
+ //No need to refresh menu drawer here. Also fix an issue where the login screen is force-pushed on the stack.
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/networking/SelfSignedSSLCertsManager.java b/WordPress/src/main/java/org/wordpress/android/networking/SelfSignedSSLCertsManager.java
new file mode 100644
index 000000000..0c9a30b8f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/networking/SelfSignedSSLCertsManager.java
@@ -0,0 +1,268 @@
+package org.wordpress.android.networking;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.net.http.SslCertificate;
+import android.os.Bundle;
+
+import org.wordpress.android.BuildConfig;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+
+import javax.security.auth.x500.X500Principal;
+
+public class SelfSignedSSLCertsManager {
+ private static SelfSignedSSLCertsManager sInstance;
+ private File mLocalTrustStoreFile;
+ private KeyStore mLocalKeyStore;
+ // Used to hold the last self-signed certificate chain that doesn't pass trusting
+ private X509Certificate[] mLastFailureChain;
+
+ private SelfSignedSSLCertsManager(Context ctx) throws IOException, GeneralSecurityException {
+ mLocalTrustStoreFile = new File(ctx.getFilesDir(), "self_signed_certs_truststore.bks");
+ createLocalKeyStoreFile();
+ mLocalKeyStore = loadTrustStore(ctx);
+ }
+
+ public static void askForSslTrust(final Context ctx) {
+ AlertDialog.Builder alert = new AlertDialog.Builder(ctx);
+ alert.setTitle(ctx.getString(R.string.ssl_certificate_error));
+ alert.setMessage(ctx.getString(R.string.ssl_certificate_ask_trust));
+ alert.setPositiveButton(R.string.ssl_certificate_trust, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int which) {
+ SelfSignedSSLCertsManager selfSignedSSLCertsManager;
+ try {
+ selfSignedSSLCertsManager = SelfSignedSSLCertsManager.getInstance(ctx);
+ selfSignedSSLCertsManager.addCertificates(selfSignedSSLCertsManager.getLastFailureChain());
+ } catch (GeneralSecurityException e) {
+ AppLog.e(T.API, e);
+ } catch (IOException e) {
+ AppLog.e(T.API, e);
+ }
+ }
+ }
+ );
+ alert.setNeutralButton(R.string.ssl_certificate_details, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int which) {
+ Intent intent = new Intent(ctx, SSLCertsViewActivity.class);
+ try {
+ SelfSignedSSLCertsManager selfSignedSSLCertsManager = SelfSignedSSLCertsManager.getInstance(ctx);
+ String lastFailureChainDescription =
+ selfSignedSSLCertsManager.getLastFailureChainDescription().replaceAll("\n", "<br/>");
+ intent.putExtra(SSLCertsViewActivity.CERT_DETAILS_KEYS, lastFailureChainDescription);
+ ctx.startActivity(intent);
+ } catch (GeneralSecurityException e) {
+ AppLog.e(T.API, e);
+ } catch (IOException e) {
+ AppLog.e(T.API, e);
+ }
+ }
+ });
+ alert.setNegativeButton(R.string.ssl_certificate_do_not_trust, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int which) {
+ }
+ });
+ alert.show();
+ }
+
+ public static synchronized SelfSignedSSLCertsManager getInstance(Context ctx)
+ throws IOException, GeneralSecurityException {
+ if (sInstance == null) {
+ sInstance = new SelfSignedSSLCertsManager(ctx);
+ }
+ return sInstance;
+ }
+
+ public void addCertificates(X509Certificate[] certs) throws IOException, GeneralSecurityException {
+ if (certs == null || certs.length == 0) {
+ return;
+ }
+
+ for (X509Certificate cert : certs) {
+ String alias = hashName(cert.getSubjectX500Principal());
+ mLocalKeyStore.setCertificateEntry(alias, cert);
+ }
+ saveTrustStore();
+ // reset the Volley queue Otherwise new certs are not used
+ WordPress.setupVolleyQueue();
+ }
+
+ public void addCertificate(X509Certificate cert) throws IOException, GeneralSecurityException {
+ if (cert == null) {
+ return;
+ }
+
+ String alias = hashName(cert.getSubjectX500Principal());
+ mLocalKeyStore.setCertificateEntry(alias, cert);
+ saveTrustStore();
+ }
+
+ public KeyStore getLocalKeyStore() {
+ return mLocalKeyStore;
+ }
+
+ private KeyStore loadTrustStore(Context ctx) throws IOException, GeneralSecurityException {
+ KeyStore localTrustStore = KeyStore.getInstance("BKS");
+ InputStream in = new FileInputStream(mLocalTrustStoreFile);
+ try {
+ localTrustStore.load(in, BuildConfig.DB_SECRET.toCharArray());
+ } finally {
+ in.close();
+ }
+ return localTrustStore;
+ }
+
+ private void saveTrustStore() throws IOException, GeneralSecurityException {
+ FileOutputStream out = null;
+ try {
+ out = new FileOutputStream(mLocalTrustStoreFile);
+ mLocalKeyStore.store(out, BuildConfig.DB_SECRET.toCharArray());
+ } finally {
+ if (out!=null){
+ try {
+ out.close();
+ } catch (IOException e) {
+ AppLog.e(T.UTILS, e);
+ }
+ }
+ }
+ }
+
+ //Create an empty trust store file if missing
+ private void createLocalKeyStoreFile() throws GeneralSecurityException, IOException {
+ if (!mLocalTrustStoreFile.exists()) {
+ FileOutputStream out = null;
+ try {
+ out = new FileOutputStream(mLocalTrustStoreFile);
+ KeyStore localTrustStore = KeyStore.getInstance("BKS");
+ localTrustStore.load(null, BuildConfig.DB_SECRET.toCharArray());
+ localTrustStore.store(out, BuildConfig.DB_SECRET.toCharArray());
+ } finally {
+ if (out!=null){
+ try {
+ out.close();
+ } catch (IOException e) {
+ AppLog.e(T.UTILS, e);
+ }
+ }
+ }
+ }
+ }
+
+ public void emptyLocalKeyStoreFile() {
+ if (mLocalTrustStoreFile.exists()) {
+ mLocalTrustStoreFile.delete();
+ }
+ try {
+ createLocalKeyStoreFile();
+ } catch (GeneralSecurityException e) {
+ AppLog.e(T.API, "Cannot create/initialize local Keystore", e);
+ } catch (IOException e) {
+ AppLog.e(T.API, "Cannot create/initialize local Keystore", e);
+ }
+ }
+
+ private static String hashName(X500Principal principal) {
+ try {
+ byte[] digest = MessageDigest.getInstance("MD5").digest(principal.getEncoded());
+ String result = Integer.toString(leInt(digest), 16);
+ if (result.length() > 8) {
+ StringBuilder buff = new StringBuilder();
+ int padding = 8 - result.length();
+ for (int i = 0; i < padding; i++) {
+ buff.append("0");
+ }
+ buff.append(result);
+
+ return buff.toString();
+ }
+
+ return result;
+ } catch (NoSuchAlgorithmException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private static int leInt(byte[] bytes) {
+ int offset = 0;
+ return ((bytes[offset++] & 0xff) << 0)
+ | ((bytes[offset++] & 0xff) << 8)
+ | ((bytes[offset++] & 0xff) << 16)
+ | ((bytes[offset] & 0xff) << 24);
+ }
+
+ public X509Certificate[] getLastFailureChain() {
+ return mLastFailureChain;
+ }
+
+ public void setLastFailureChain(X509Certificate[] lastFaiulreChain) {
+ mLastFailureChain = lastFaiulreChain;
+ }
+
+ public String getLastFailureChainDescription() {
+ return (mLastFailureChain == null || mLastFailureChain.length == 0) ? "" : mLastFailureChain[0].toString();
+ }
+
+ public boolean isCertificateTrusted(SslCertificate cert){
+ if (cert==null)
+ return false;
+
+ Bundle bundle = SslCertificate.saveState(cert);
+ X509Certificate x509Certificate;
+ byte[] bytes = bundle.getByteArray("x509-certificate");
+ if (bytes == null) {
+ AppLog.e(T.API, "Cannot load the SSLCertificate bytes from the bundle!");
+ x509Certificate = null;
+ } else {
+ try {
+ CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
+ Certificate certX509 = certFactory.generateCertificate(new ByteArrayInputStream(bytes));
+ x509Certificate = (X509Certificate) certX509;
+ } catch (CertificateException e) {
+ AppLog.e(T.API, "Cannot generate the X509Certificate with the bytes provided", e);
+ x509Certificate = null;
+ }
+ }
+
+ return isCertificateTrusted(x509Certificate);
+ }
+
+ public boolean isCertificateTrusted(X509Certificate x509Certificate){
+ if (x509Certificate==null)
+ return false;
+
+ // Now I have an X509Certificate I can pass to an X509TrustManager for validation.
+ try {
+ String certificateAlias = this.getLocalKeyStore().getCertificateAlias(x509Certificate);
+ if(certificateAlias != null ) {
+ AppLog.w(T.API, "Current certificate " + x509Certificate.getSubjectDN().getName() +" is in KeyStore.");
+ return true;
+ }
+ } catch (KeyStoreException e) {
+ AppLog.e(T.API, "Cannot check if the certificate is in KeyStore. Seems that Keystore is not initialized.", e);
+ }
+
+ AppLog.w(T.API, "Current certificate " + x509Certificate.getSubjectDN().getName() +" is NOT in KeyStore.");
+ return false;
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/networking/WPDelayedHurlStack.java b/WordPress/src/main/java/org/wordpress/android/networking/WPDelayedHurlStack.java
new file mode 100644
index 000000000..22c9b003b
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/networking/WPDelayedHurlStack.java
@@ -0,0 +1,260 @@
+package org.wordpress.android.networking;
+
+import android.content.Context;
+import android.util.Base64;
+
+import com.android.volley.AuthFailureError;
+import com.android.volley.Request;
+import com.android.volley.Request.Method;
+import com.android.volley.toolbox.HttpStack;
+
+import org.apache.http.Header;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpResponse;
+import org.apache.http.ProtocolVersion;
+import org.apache.http.StatusLine;
+import org.apache.http.entity.BasicHttpEntity;
+import org.apache.http.message.BasicHeader;
+import org.apache.http.message.BasicHttpResponse;
+import org.apache.http.message.BasicStatusLine;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.StringUtils;
+
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.security.GeneralSecurityException;
+import java.security.KeyManagementException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSocketFactory;
+import javax.net.ssl.TrustManager;
+
+/**
+ * An {@link HttpStack} based on the code of {@link com.android.volley.toolbox.HurlStack} that internally
+ * uses a {@link HttpURLConnection}.
+ *
+ * This implementation of {@link HttpStack} internally initializes {@link SelfSignedSSLCertsManager} in a secondary
+ * thread since initialization could take a few seconds.
+ */
+public class WPDelayedHurlStack implements HttpStack {
+ private static final String HEADER_CONTENT_TYPE = "Content-Type";
+
+ private SSLSocketFactory mSslSocketFactory;
+ private final Blog mCurrentBlog;
+ private final Context mCtx;
+ private final Object monitor = new Object();
+
+ public WPDelayedHurlStack(final Context ctx, final Blog currentBlog) {
+ mCurrentBlog = currentBlog;
+ mCtx = ctx;
+
+ // initializes SelfSignedSSLCertsManager in a separate thread.
+ Thread sslContextInitializer = new Thread() {
+ @Override
+ public void run() {
+ try {
+ TrustManager[] trustAllowedCerts = new TrustManager[]{
+ new WPTrustManager(SelfSignedSSLCertsManager.getInstance(ctx).getLocalKeyStore())
+ };
+ SSLContext context = SSLContext.getInstance("SSL");
+ context.init(null, trustAllowedCerts, new SecureRandom());
+ mSslSocketFactory = context.getSocketFactory();
+ } catch (NoSuchAlgorithmException e) {
+ AppLog.e(T.API, e);
+ } catch (KeyManagementException e) {
+ AppLog.e(T.API, e);
+ } catch (GeneralSecurityException e) {
+ AppLog.e(T.API, e);
+ } catch (IOException e) {
+ AppLog.e(T.API, e);
+ }
+ }
+ };
+ sslContextInitializer.start();
+ }
+
+ @Override
+ public HttpResponse performRequest(Request<?> request, Map<String, String> additionalHeaders)
+ throws IOException, AuthFailureError {
+ if (request.getUrl() != null) {
+ if (!StringUtils.getHost(request.getUrl()).endsWith("wordpress.com") && mCurrentBlog != null
+ && mCurrentBlog.hasValidHTTPAuthCredentials()) {
+ String creds = String.format("%s:%s", mCurrentBlog.getHttpuser(), mCurrentBlog.getHttppassword());
+ String auth = "Basic " + Base64.encodeToString(creds.getBytes(), Base64.DEFAULT);
+ additionalHeaders.put("Authorization", auth);
+ }
+
+ if (StringUtils.getHost(request.getUrl()).endsWith("files.wordpress.com") && mCtx != null
+ && WordPress.getWPComAuthToken(mCtx) != null) {
+ // Add the auth header to access private WP.com files
+ additionalHeaders.put("Authorization", "Bearer " + WordPress.getWPComAuthToken(mCtx));
+ }
+ }
+
+ additionalHeaders.put("User-Agent", WordPress.getUserAgent());
+
+ String url = request.getUrl();
+ HashMap<String, String> map = new HashMap<String, String>();
+ map.putAll(request.getHeaders());
+ map.putAll(additionalHeaders);
+
+ URL parsedUrl = new URL(url);
+ HttpURLConnection connection = openConnection(parsedUrl, request);
+ for (String headerName : map.keySet()) {
+ connection.addRequestProperty(headerName, map.get(headerName));
+ }
+ setConnectionParametersForRequest(connection, request);
+ // Initialize HttpResponse with data from the HttpURLConnection.
+ ProtocolVersion protocolVersion = new ProtocolVersion("HTTP", 1, 1);
+ int responseCode = connection.getResponseCode();
+ if (responseCode == -1) {
+ // -1 is returned by getResponseCode() if the response code could not be retrieved.
+ // Signal to the caller that something was wrong with the connection.
+ throw new IOException("Could not retrieve response code from HttpUrlConnection.");
+ }
+ StatusLine responseStatus = new BasicStatusLine(protocolVersion,
+ connection.getResponseCode(), connection.getResponseMessage());
+ BasicHttpResponse response = new BasicHttpResponse(responseStatus);
+ response.setEntity(entityFromConnection(connection));
+ for (Entry<String, List<String>> header : connection.getHeaderFields().entrySet()) {
+ if (header.getKey() != null) {
+ Header h = new BasicHeader(header.getKey(), header.getValue().get(0));
+ response.addHeader(h);
+ }
+ }
+ return response;
+ }
+
+ /**
+ * Initializes an {@link HttpEntity} from the given {@link HttpURLConnection}.
+ * @param connection
+ * @return an HttpEntity populated with data from <code>connection</code>.
+ */
+ private static HttpEntity entityFromConnection(HttpURLConnection connection) {
+ BasicHttpEntity entity = new BasicHttpEntity();
+ InputStream inputStream;
+ try {
+ inputStream = connection.getInputStream();
+ } catch (IOException ioe) {
+ inputStream = connection.getErrorStream();
+ }
+ entity.setContent(inputStream);
+ entity.setContentLength(connection.getContentLength());
+ entity.setContentEncoding(connection.getContentEncoding());
+ entity.setContentType(connection.getContentType());
+ return entity;
+ }
+
+ /**
+ * Create an {@link HttpURLConnection} for the specified {@code url}.
+ */
+ protected HttpURLConnection createConnection(URL url) throws IOException {
+ // Check that the custom SslSocketFactory is not null on HTTPS connections
+ if ("https".equals(url.getProtocol()) && !url.getHost().endsWith("wordpress.com")
+ && !url.getHost().endsWith("gravatar.com")) {
+ // WordPress.com doesn't need the custom mSslSocketFactory
+ synchronized (monitor) {
+ while (mSslSocketFactory == null) {
+ try {
+ monitor.wait(500);
+ } catch (InterruptedException e) {
+ // we can't do much here.
+ }
+ }
+ }
+ }
+
+ return (HttpURLConnection) url.openConnection();
+ }
+
+ /**
+ * Opens an {@link HttpURLConnection} with parameters.
+ * @param url
+ * @return an open connection
+ * @throws IOException
+ */
+ private HttpURLConnection openConnection(URL url, Request<?> request) throws IOException {
+ HttpURLConnection connection = createConnection(url);
+
+ int timeoutMs = request.getTimeoutMs();
+ connection.setConnectTimeout(timeoutMs);
+ connection.setReadTimeout(timeoutMs);
+ connection.setUseCaches(false);
+ connection.setDoInput(true);
+
+ // use caller-provided custom SslSocketFactory, if any, for HTTPS
+ if ("https".equals(url.getProtocol()) && mSslSocketFactory != null) {
+ ((HttpsURLConnection) connection).setSSLSocketFactory(mSslSocketFactory);
+ }
+
+ return connection;
+ }
+
+ @SuppressWarnings("deprecation")
+ /* package */ static void setConnectionParametersForRequest(HttpURLConnection connection,
+ Request<?> request) throws IOException, AuthFailureError {
+ switch (request.getMethod()) {
+ case Method.DEPRECATED_GET_OR_POST:
+ // This is the deprecated way that needs to be handled for backwards compatibility.
+ // If the request's post body is null, then the assumption is that the request is
+ // GET. Otherwise, it is assumed that the request is a POST.
+ byte[] postBody = request.getPostBody();
+ if (postBody != null) {
+ // Prepare output. There is no need to set Content-Length explicitly,
+ // since this is handled by HttpURLConnection using the size of the prepared
+ // output stream.
+ connection.setDoOutput(true);
+ connection.setRequestMethod("POST");
+ connection.addRequestProperty(HEADER_CONTENT_TYPE,
+ request.getPostBodyContentType());
+ DataOutputStream out = new DataOutputStream(connection.getOutputStream());
+ out.write(postBody);
+ out.close();
+ }
+ break;
+ case Method.GET:
+ // Not necessary to set the request method because connection defaults to GET but
+ // being explicit here.
+ connection.setRequestMethod("GET");
+ break;
+ case Method.DELETE:
+ connection.setRequestMethod("DELETE");
+ break;
+ case Method.POST:
+ connection.setRequestMethod("POST");
+ addBodyIfExists(connection, request);
+ break;
+ case Method.PUT:
+ connection.setRequestMethod("PUT");
+ addBodyIfExists(connection, request);
+ break;
+ default:
+ throw new IllegalStateException("Unknown method type.");
+ }
+ }
+
+ private static void addBodyIfExists(HttpURLConnection connection, Request<?> request)
+ throws IOException, AuthFailureError {
+ byte[] body = request.getBody();
+ if (body != null) {
+ connection.setDoOutput(true);
+ connection.addRequestProperty(HEADER_CONTENT_TYPE, request.getBodyContentType());
+ DataOutputStream out = new DataOutputStream(connection.getOutputStream());
+ out.write(body);
+ out.close();
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/networking/WPTrustManager.java b/WordPress/src/main/java/org/wordpress/android/networking/WPTrustManager.java
new file mode 100644
index 000000000..8f7bb9104
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/networking/WPTrustManager.java
@@ -0,0 +1,119 @@
+package org.wordpress.android.networking;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509TrustManager;
+
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+
+public class WPTrustManager implements X509TrustManager {
+ private X509TrustManager defaultTrustManager;
+ private X509TrustManager localTrustManager;
+ private X509Certificate[] acceptedIssuers;
+
+ public WPTrustManager(KeyStore localKeyStore) {
+ try {
+ TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ tmf.init((KeyStore) null);
+
+ defaultTrustManager = findX509TrustManager(tmf);
+ if (defaultTrustManager == null) {
+ throw new IllegalStateException("Couldn't find X509TrustManager");
+ }
+
+ localTrustManager = new LocalStoreX509TrustManager(localKeyStore);
+
+ List<X509Certificate> allIssuers = new ArrayList<X509Certificate>();
+ Collections.addAll(allIssuers, defaultTrustManager.getAcceptedIssuers());
+ Collections.addAll(allIssuers, localTrustManager.getAcceptedIssuers());
+ acceptedIssuers = allIssuers.toArray(new X509Certificate[allIssuers.size()]);
+ } catch (GeneralSecurityException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+
+ private static X509TrustManager findX509TrustManager(TrustManagerFactory tmf) {
+ TrustManager tms[] = tmf.getTrustManagers();
+ for (int i = 0; i < tms.length; i++) {
+ if (tms[i] instanceof X509TrustManager) {
+ return (X509TrustManager) tms[i];
+ }
+ }
+ return null;
+ }
+
+
+ public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+ try {
+ defaultTrustManager.checkClientTrusted(chain, authType);
+ } catch (CertificateException ce) {
+ localTrustManager.checkClientTrusted(chain, authType);
+ }
+ }
+
+ public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+ try {
+ defaultTrustManager.checkServerTrusted(chain, authType);
+ } catch (CertificateException ce) {
+ localTrustManager.checkServerTrusted(chain, authType);
+ }
+ }
+
+ public X509Certificate[] getAcceptedIssuers() {
+ return acceptedIssuers;
+ }
+
+ static class LocalStoreX509TrustManager implements X509TrustManager {
+ private X509TrustManager trustManager;
+
+ LocalStoreX509TrustManager(KeyStore localKeyStore) {
+ try {
+ TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ tmf.init(localKeyStore);
+
+ trustManager = findX509TrustManager(tmf);
+ if (trustManager == null) {
+ throw new IllegalStateException("Couldn't find X509TrustManager");
+ }
+ } catch (GeneralSecurityException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+ trustManager.checkClientTrusted(chain, authType);
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+ try {
+ trustManager.checkServerTrusted(chain, authType);
+ } catch (CertificateException e) {
+ AppLog.e(T.API, "Cannot trust the certificate with the local trust manager...", e);
+ try {
+ SelfSignedSSLCertsManager.getInstance(null).setLastFailureChain(chain);
+ } catch (GeneralSecurityException e1) {
+ } catch (IOException e1) {
+ }
+ throw e;
+ }
+ }
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return trustManager.getAcceptedIssuers();
+ }
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/providers/StatsContentProvider.java b/WordPress/src/main/java/org/wordpress/android/providers/StatsContentProvider.java
new file mode 100644
index 000000000..11b6b0250
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/providers/StatsContentProvider.java
@@ -0,0 +1,153 @@
+package org.wordpress.android.providers;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.util.SparseArray;
+
+import org.wordpress.android.BuildConfig;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.WordPressStatsDB;
+import org.wordpress.android.datasets.SQLTable;
+import org.wordpress.android.datasets.StatsBarChartDataTable;
+import org.wordpress.android.datasets.StatsClickGroupsTable;
+import org.wordpress.android.datasets.StatsClicksTable;
+import org.wordpress.android.datasets.StatsGeoviewsTable;
+import org.wordpress.android.datasets.StatsMostCommentedTable;
+import org.wordpress.android.datasets.StatsReferrerGroupsTable;
+import org.wordpress.android.datasets.StatsReferrersTable;
+import org.wordpress.android.datasets.StatsSearchEngineTermsTable;
+import org.wordpress.android.datasets.StatsTagsAndCategoriesTable;
+import org.wordpress.android.datasets.StatsTopAuthorsTable;
+import org.wordpress.android.datasets.StatsTopCommentersTable;
+import org.wordpress.android.datasets.StatsTopPostsAndPagesTable;
+import org.wordpress.android.datasets.StatsVideosTable;
+
+/**
+ * Content Provider for stats. Provides a common interface to the stats database.
+ * See {@link WordPressStatsDB}
+ */
+public class StatsContentProvider extends ContentProvider {
+ public static final Uri STATS_CLICK_GROUP_URI = Uri.parse("content://" + BuildConfig.STATS_PROVIDER_AUTHORITY + "/" + Paths.CLICK_GROUPS);
+ public static final Uri STATS_CLICKS_URI = Uri.parse("content://" + BuildConfig.STATS_PROVIDER_AUTHORITY + "/" + Paths.CLICKS);
+ public static final Uri STATS_GEOVIEWS_URI = Uri.parse("content://" + BuildConfig.STATS_PROVIDER_AUTHORITY + "/" + Paths.GEOVIEWS);
+ public static final Uri STATS_MOST_COMMENTED_URI = Uri.parse("content://" + BuildConfig.STATS_PROVIDER_AUTHORITY + "/" + Paths.MOST_COMMENTED);
+ public static final Uri STATS_REFERRER_GROUP_URI = Uri.parse("content://" + BuildConfig.STATS_PROVIDER_AUTHORITY + "/" + Paths.REFERRER_GROUPS);
+ public static final Uri STATS_REFERRERS_URI = Uri.parse("content://" + BuildConfig.STATS_PROVIDER_AUTHORITY + "/" + Paths.REFERRERS);
+ public static final Uri STATS_SEARCH_ENGINE_TERMS_URI = Uri.parse("content://" + BuildConfig.STATS_PROVIDER_AUTHORITY + "/" + Paths.SEARCH_ENGINE_TERMS);
+ public static final Uri STATS_TAGS_AND_CATEGORIES_URI = Uri.parse("content://" + BuildConfig.STATS_PROVIDER_AUTHORITY + "/" + Paths.TAGS_AND_CATEGORIES);
+ public static final Uri STATS_TOP_AUTHORS_URI = Uri.parse("content://" + BuildConfig.STATS_PROVIDER_AUTHORITY + "/" + Paths.TOP_AUTHORS);
+ public static final Uri STATS_TOP_COMMENTERS_URI = Uri.parse("content://" + BuildConfig.STATS_PROVIDER_AUTHORITY + "/" + Paths.TOP_COMMENTERS);
+ public static final Uri STATS_TOP_POSTS_AND_PAGES_URI = Uri.parse("content://" + BuildConfig.STATS_PROVIDER_AUTHORITY + "/" + Paths.TOP_POSTS_AND_PAGES);
+ public static final Uri STATS_VIDEOS_URI = Uri.parse("content://" + BuildConfig.STATS_PROVIDER_AUTHORITY + "/" + Paths.VIDEOS);
+ public static final Uri STATS_BAR_CHART_DATA_URI = Uri.parse("content://" + BuildConfig.STATS_PROVIDER_AUTHORITY + "/" + Paths.BAR_CHART_DATA);
+
+ private static final class Paths {
+ private static final String CLICK_GROUPS = "click_groups";
+ private static final String CLICKS = "clicks";
+ private static final String GEOVIEWS = "geoviews";
+ private static final String MOST_COMMENTED = "most_commented";
+ private static final String REFERRER_GROUPS = "referrer_groups";
+ private static final String REFERRERS = "referrers";
+ private static final String SEARCH_ENGINE_TERMS = "search_engine_terms";
+ private static final String TAGS_AND_CATEGORIES = "tags_and_categories";
+ private static final String TOP_AUTHORS = "top_authors";
+ private static final String TOP_COMMENTERS = "top_commenters";
+ private static final String TOP_POSTS_AND_PAGES = "top_posts_and_pages";
+ private static final String VIDEOS = "videos";
+ private static final String BAR_CHART_DATA = "bar_chart_data";
+ }
+
+ private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
+ private static SparseArray<SQLTable> sUriMatchToSQLTableMap = new SparseArray<SQLTable>();
+ private int URI_MATCH = 0;
+
+ @Override
+ public synchronized boolean onCreate() {
+ registerTable(Paths.CLICK_GROUPS, StatsClickGroupsTable.getInstance());
+ registerTable(Paths.CLICKS, StatsClicksTable.getInstance());
+ registerTable(Paths.GEOVIEWS, StatsGeoviewsTable.getInstance());
+ registerTable(Paths.MOST_COMMENTED, StatsMostCommentedTable.getInstance());
+ registerTable(Paths.REFERRER_GROUPS, StatsReferrerGroupsTable.getInstance());
+ registerTable(Paths.REFERRERS, StatsReferrersTable.getInstance());
+ registerTable(Paths.SEARCH_ENGINE_TERMS, StatsSearchEngineTermsTable.getInstance());
+ registerTable(Paths.TAGS_AND_CATEGORIES, StatsTagsAndCategoriesTable.getInstance());
+ registerTable(Paths.TOP_AUTHORS, StatsTopAuthorsTable.getInstance());
+ registerTable(Paths.TOP_COMMENTERS, StatsTopCommentersTable.getInstance());
+ registerTable(Paths.TOP_POSTS_AND_PAGES, StatsTopPostsAndPagesTable.getInstance());
+ registerTable(Paths.VIDEOS, StatsVideosTable.getInstance());
+ registerTable(Paths.BAR_CHART_DATA, StatsBarChartDataTable.getInstance());
+ return true;
+ }
+
+ private void registerTable(String path, SQLTable table) {
+ final int match = URI_MATCH++;
+ sUriMatcher.addURI(BuildConfig.STATS_PROVIDER_AUTHORITY, path, match);
+ sUriMatchToSQLTableMap.put(match, table);
+ }
+
+ @Override
+ public synchronized String getType(Uri uri) {
+ return null;
+ }
+
+ @Override
+ public synchronized int delete(Uri uri, String selection, String[] selectionArgs) {
+ SQLTable table = getSQLTable(uri);
+ if (table != null) {
+ int count = table.delete(getDB(), uri, selection, selectionArgs);
+ return count;
+ }
+
+ return 0;
+ }
+
+ @Override
+ public synchronized Uri insert(Uri uri, ContentValues values) {
+ SQLTable table = getSQLTable(uri);
+ Uri result_uri = null;
+ if (table != null) {
+ long _id = table.insert(getDB(), uri, values);
+ result_uri = Uri.parse(uri.toString() + "/" + _id);
+ }
+
+ return result_uri;
+ }
+
+ @Override
+ public synchronized Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ SQLTable table = getSQLTable(uri);
+ if (table != null) {
+ return table.query(getDB(), uri, projection, selection, selectionArgs, sortOrder);
+ }
+
+ return null;
+ }
+
+ @Override
+ public synchronized int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+ SQLTable table = getSQLTable(uri);
+ if (table != null) {
+ int count = table.update(getDB(), uri, values, selection, selectionArgs);
+ return count;
+ }
+
+ return 0;
+ }
+
+ private static synchronized SQLTable getSQLTable(Uri uri) {
+ int uriMatch = sUriMatcher.match(uri);
+
+ if (sUriMatchToSQLTableMap.indexOfKey(uriMatch) >= 0)
+ return sUriMatchToSQLTableMap.get(uriMatch);
+
+ return null;
+ }
+
+ private synchronized SQLiteDatabase getDB() {
+ return WordPress.wpStatsDB.getWritableDatabase();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/AddQuickPressShortcutActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/AddQuickPressShortcutActivity.java
new file mode 100644
index 000000000..02747e583
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/AddQuickPressShortcutActivity.java
@@ -0,0 +1,232 @@
+package org.wordpress.android.ui;
+
+import android.app.AlertDialog;
+import android.app.ListActivity;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewGroup.LayoutParams;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemClickListener;
+import android.widget.BaseAdapter;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import android.widget.RelativeLayout;
+import android.widget.ScrollView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.android.volley.toolbox.NetworkImageView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.ui.accounts.WelcomeActivity;
+import org.wordpress.android.ui.posts.EditPostActivity;
+import org.wordpress.android.util.StringUtils;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Vector;
+
+public class AddQuickPressShortcutActivity extends ListActivity {
+ static final int ADD_ACCOUNT_REQUEST = 0;
+
+ public List<Map<String, Object>> accounts;
+ public String[] blogNames;
+ public int[] accountIDs;
+ public String[] accountUsers;
+ public String[] blavatars;
+ public List<String> accountNames = new Vector<String>();
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.add_quickpress_shortcut);
+ setTitle(getResources().getText(R.string.quickpress_window_title));
+
+ displayAccounts();
+ }
+
+ private void displayAccounts() {
+ accounts = WordPress.wpDB.getVisibleAccounts();
+
+ ListView listView = (ListView) findViewById(android.R.id.list);
+
+ ImageView iv = new ImageView(this);
+ iv.setBackgroundDrawable(getResources().getDrawable(R.drawable.list_divider));
+ listView.addFooterView(iv);
+ listView.setVerticalFadingEdgeEnabled(false);
+ listView.setVerticalScrollBarEnabled(true);
+
+ if (accounts.size() > 0) {
+ ScrollView sv = new ScrollView(this);
+ sv.setLayoutParams(new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.WRAP_CONTENT));
+ LinearLayout layout = new LinearLayout(this);
+ layout.setPadding(10, 10, 10, 0);
+ layout.setLayoutParams(new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.WRAP_CONTENT));
+
+ layout.setOrientation(LinearLayout.VERTICAL);
+
+ blogNames = new String[accounts.size()];
+ accountIDs = new int[accounts.size()];
+ accountUsers = new String[accounts.size()];
+ blavatars = new String[accounts.size()];
+ int validBlogCtr = 0;
+ for (int i = 0; i < accounts.size(); i++) {
+ Map<String, Object> curHash = accounts.get(i);
+ blogNames[validBlogCtr] = curHash.get("blogName").toString();
+ accountUsers[validBlogCtr] = curHash.get("username").toString();
+ accountIDs[validBlogCtr] = (Integer)curHash.get("id");
+ String url = curHash.get("url").toString();
+ url = url.replace("http://", "");
+ url = url.replace("https://", "");
+ String[] urlSplit = url.split("/");
+ url = urlSplit[0];
+ url = "http://gravatar.com/blavatar/"
+ + StringUtils.getMd5Hash(url.trim())
+ + "?s=60&d=404";
+ blavatars[validBlogCtr] = url;
+ accountNames.add(validBlogCtr, blogNames[i]);
+ validBlogCtr++;
+ }
+
+ if (validBlogCtr < accounts.size()){
+ accounts = WordPress.wpDB.getVisibleAccounts();
+ }
+
+ setListAdapter(new HomeListAdapter());
+
+ listView.setOnItemClickListener(new OnItemClickListener() {
+ public void onItemClick(AdapterView<?> arg0, View row, int position, long id) {
+ AddQuickPressShortcutActivity.this.buildDialog(position);
+ }
+ });
+
+ if(accounts.size() == 1) {
+ AddQuickPressShortcutActivity.this.buildDialog(0);
+ }
+
+ } else {
+ // no account, load new account view
+ Intent i = new Intent(AddQuickPressShortcutActivity.this, WelcomeActivity.class);
+ startActivityForResult(i, ADD_ACCOUNT_REQUEST);
+ }
+ }
+
+ private void buildDialog(int positionParam) {
+ final int position = positionParam;
+
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(AddQuickPressShortcutActivity.this);
+ dialogBuilder.setTitle(R.string.quickpress_add_alert_title);
+
+ final EditText quickPressShortcutName = new EditText(AddQuickPressShortcutActivity.this);
+ quickPressShortcutName.setText("QP " + StringUtils.unescapeHTML(accountNames.get(position)));
+ dialogBuilder.setView(quickPressShortcutName);
+
+ dialogBuilder.setPositiveButton(R.string.add, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int which) {
+ if (TextUtils.isEmpty(quickPressShortcutName.getText())) {
+ Toast t = Toast.makeText(AddQuickPressShortcutActivity.this, R.string.quickpress_add_error, Toast.LENGTH_LONG);
+ t.show();
+ } else {
+ Intent shortcutIntent = new Intent(AddQuickPressShortcutActivity.this, EditPostActivity.class);
+ shortcutIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ shortcutIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ shortcutIntent.putExtra(EditPostActivity.EXTRA_QUICKPRESS_BLOG_ID, accountIDs[position]);
+ shortcutIntent.putExtra(EditPostActivity.EXTRA_IS_QUICKPRESS, true);
+
+ Intent addIntent = new Intent();
+ addIntent.putExtra(Intent.EXTRA_SHORTCUT_INTENT, shortcutIntent);
+ addIntent.putExtra(Intent.EXTRA_SHORTCUT_NAME, quickPressShortcutName.getText().toString());
+ addIntent.putExtra(Intent.EXTRA_SHORTCUT_ICON_RESOURCE, Intent.ShortcutIconResource.fromContext(AddQuickPressShortcutActivity.this, R.drawable.app_icon));
+
+ WordPress.wpDB.addQuickPressShortcut(accountIDs[position], quickPressShortcutName.getText().toString());
+
+ if (WordPress.currentBlog == null) {
+ WordPress.currentBlog = WordPress.wpDB.instantiateBlogByLocalId(accountIDs[position]);
+ WordPress.wpDB.updateLastBlogId(accountIDs[position]);
+ }
+
+ addIntent.setAction("com.android.launcher.action.INSTALL_SHORTCUT");
+ LocalBroadcastManager.getInstance(AddQuickPressShortcutActivity.this).sendBroadcast(addIntent);
+ AddQuickPressShortcutActivity.this.finish();
+ }
+ }
+ });
+ dialogBuilder.setNegativeButton(R.string.cancel, new DialogInterface.OnClickListener() {
+ // just let the dialog close
+ public void onClick(DialogInterface dialog, int which) {}
+ });
+
+ dialogBuilder.setCancelable(false);
+ dialogBuilder.create().show();
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ switch (requestCode) {
+ case ADD_ACCOUNT_REQUEST:
+ if (resultCode == RESULT_OK) {
+ accounts = WordPress.wpDB.getVisibleAccounts();
+ if (accounts.size() > 0) {
+ displayAccounts();
+ break;
+ }
+ }
+ finish();
+ break;
+ }
+ }
+
+ protected class HomeListAdapter extends BaseAdapter {
+ public HomeListAdapter() {
+ }
+
+ public int getCount() {
+ return accounts.size();
+ }
+
+ public Object getItem(int position) {
+ return position;
+ }
+
+ public long getItemId(int position) {
+ return position;
+ }
+
+ public View getView(int position, View convertView, ViewGroup parent) {
+ RelativeLayout view = (RelativeLayout) convertView;
+ if (view == null) {
+ LayoutInflater inflater = getLayoutInflater();
+ view = (RelativeLayout)inflater.inflate(R.layout.home_row, parent, false);
+ }
+ String username = accountUsers[position];
+ view.setBackgroundDrawable(getResources().getDrawable(
+ R.drawable.list_bg_selector));
+ view.setId(Integer.valueOf(accountIDs[position]));
+
+ TextView blogName = (TextView)view.findViewById(R.id.blogName);
+ TextView blogUsername = (TextView)view.findViewById(R.id.blogUser);
+ NetworkImageView blavatar = (NetworkImageView)view.findViewById(R.id.blavatar);
+
+ blogName.setText(
+ StringUtils.unescapeHTML(blogNames[position]));
+ blogUsername.setText(
+ StringUtils.unescapeHTML(username));
+ blavatar.setImageUrl(blavatars[position], WordPress.imageLoader);
+
+ return view;
+
+ }
+
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/AppLogViewerActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/AppLogViewerActivity.java
new file mode 100644
index 000000000..77f6238f2
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/AppLogViewerActivity.java
@@ -0,0 +1,76 @@
+package org.wordpress.android.ui;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.Html;
+import android.text.method.ScrollingMovementMethod;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.ToastUtils;
+
+/**
+ * views the activity log (see utils/AppLog.java)
+ */
+public class AppLogViewerActivity extends Activity {
+ private TextView mTxtLogViewer;
+ private static final int ID_SHARE = 1;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.logviewer_activity);
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
+ actionBar.setDisplayShowTitleEnabled(true);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+ mTxtLogViewer = (TextView) findViewById(R.id.text_log);
+ mTxtLogViewer.setText(Html.fromHtml(AppLog.toHtml(this)));
+
+ // this is necessary to enable the textView to scroll vertically
+ mTxtLogViewer.setMovementMethod(ScrollingMovementMethod.getInstance());
+ }
+
+ private void shareAppLog() {
+ Intent intent = new Intent(Intent.ACTION_SEND);
+ intent.setType("text/plain");
+ intent.putExtra(Intent.EXTRA_TEXT, AppLog.toPlainText(this));
+ intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.app_name) + " " + getTitle());
+ try {
+ startActivity(Intent.createChooser(intent, getString(R.string.reader_btn_share)));
+ } catch (android.content.ActivityNotFoundException ex) {
+ ToastUtils.showToast(this, R.string.reader_toast_err_share_intent);
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ MenuItem item = menu.add(Menu.NONE, ID_SHARE, Menu.NONE, R.string.reader_btn_share);
+ item.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
+ item.setIcon(R.drawable.ab_icon_share);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ finish();
+ return true;
+ case ID_SHARE:
+ shareAppLog();
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/AuthenticatedWebViewActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/AuthenticatedWebViewActivity.java
new file mode 100644
index 000000000..71f4453d4
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/AuthenticatedWebViewActivity.java
@@ -0,0 +1,150 @@
+
+package org.wordpress.android.ui;
+
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.webkit.WebSettings;
+import android.widget.ProgressBar;
+import android.widget.Toast;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.WPWebChromeClient;
+import org.wordpress.android.util.WPWebViewClient;
+import org.wordpress.passcodelock.AppLockManager;
+
+import java.io.UnsupportedEncodingException;
+import java.lang.reflect.Type;
+import java.net.URLEncoder;
+import java.util.Map;
+
+/**
+ * Activity for displaying WordPress content in a webview which may require authentication.
+ * Currently, this activity can only load content for the {@link WordPress.currentBlog}.
+ */
+public class AuthenticatedWebViewActivity extends WebViewActivity {
+ public static final String LOAD_AUTHENTICATED_URL = "loadAuthenticatedUrl";
+
+ /**
+ * Blog for which this activity is loading content.
+ */
+ protected Blog mBlog;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ mBlog = WordPress.getCurrentBlog();
+ if (mBlog == null) {
+ Toast.makeText(this, getResources().getText(R.string.blog_not_found),
+ Toast.LENGTH_SHORT).show();
+ finish();
+ }
+
+ mWebView.setWebViewClient(new WPWebViewClient(mBlog));
+
+ mWebView.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE);
+ mWebView.getSettings().setSavePassword(false);
+
+ Bundle extras = getIntent().getExtras();
+ if (extras != null && extras.containsKey(LOAD_AUTHENTICATED_URL)) {
+ String authUrl = extras.getString(LOAD_AUTHENTICATED_URL);
+ loadAuthenticatedUrl(authUrl);
+ }
+ mWebView.setWebChromeClient(new WPWebChromeClient(this, (ProgressBar) findViewById(R.id.progress_bar)));
+ }
+
+ /**
+ * Get the URL of the WordPress login page.
+ *
+ * @return URL of the login page.
+ */
+ protected String getLoginUrl() {
+ String loginURL = null;
+ Gson gson = new Gson();
+ Type type = new TypeToken<Map<?, ?>>() {}.getType();
+ Map<?, ?> blogOptions = gson.fromJson(mBlog.getBlogOptions(), type);
+ if (blogOptions != null) {
+ Map<?, ?> homeURLMap = (Map<?, ?>) blogOptions.get("login_url");
+ if (homeURLMap != null)
+ loginURL = homeURLMap.get("value").toString();
+ }
+ // Try to guess the login URL if blogOptions is null (blog not added to the app), or WP version is < 3.6
+ if( loginURL == null ) {
+ if (mBlog.getUrl().lastIndexOf("/") != -1) {
+ return mBlog.getUrl().substring(0, mBlog.getUrl().lastIndexOf("/"))
+ + "/wp-login.php";
+ } else {
+ return mBlog.getUrl().replace("xmlrpc.php", "wp-login.php");
+ }
+ }
+
+ return loginURL;
+ }
+
+ /**
+ * Login to the WordPress blog and load the specified URL.
+ *
+ * @param url URL to be loaded in the webview.
+ */
+ protected void loadAuthenticatedUrl(String url) {
+ try {
+ String postData = String.format("log=%s&pwd=%s&redirect_to=%s",
+ URLEncoder.encode(mBlog.getUsername(), "UTF-8"), URLEncoder.encode(mBlog.getPassword(), "UTF-8"),
+ URLEncoder.encode(url, "UTF-8"));
+ mWebView.postUrl(getLoginUrl(), postData.getBytes());
+ } catch (UnsupportedEncodingException e) {
+ AppLog.e(T.UTILS, e);
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.webview, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ if (mWebView == null)
+ return false;
+
+ int itemID = item.getItemId();
+ if (itemID == R.id.menu_refresh) {
+ mWebView.reload();
+ return true;
+ } else if (itemID == R.id.menu_share) {
+ Intent share = new Intent(Intent.ACTION_SEND);
+ share.setType("text/plain");
+ share.putExtra(Intent.EXTRA_TEXT, mWebView.getUrl());
+ startActivity(Intent.createChooser(share, getResources().getText(R.string.share_link)));
+ return true;
+ } else if (itemID == R.id.menu_browser) {
+ String url = mWebView.getUrl();
+ if (url != null) {
+ Uri uri = Uri.parse(url);
+ if (uri != null) {
+ Intent i = new Intent(Intent.ACTION_VIEW);
+ i.setData(uri);
+ startActivity(i);
+ AppLockManager.getInstance().setExtendedTimeout();
+ }
+ }
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/CheckableFrameLayout.java b/WordPress/src/main/java/org/wordpress/android/ui/CheckableFrameLayout.java
new file mode 100644
index 000000000..2f58e1c1d
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/CheckableFrameLayout.java
@@ -0,0 +1,60 @@
+package org.wordpress.android.ui;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.CheckBox;
+import android.widget.Checkable;
+import android.widget.FrameLayout;
+
+import org.wordpress.android.R;
+
+public class CheckableFrameLayout extends FrameLayout implements Checkable {
+ private boolean mIsChecked;
+ private OnCheckedChangeListener mOnCheckedChangeListener;
+
+ public interface OnCheckedChangeListener {
+ public void onCheckedChanged(CheckableFrameLayout view, boolean isChecked);
+
+ }
+
+ public CheckableFrameLayout(Context context) {
+ super(context);
+ }
+
+ public CheckableFrameLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public CheckableFrameLayout(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ public boolean isChecked() {
+ return mIsChecked;
+ }
+
+ @Override
+ public void setChecked(boolean checked) {
+ CheckBox checkbox = (CheckBox) findViewById(R.id.media_grid_item_checkstate);
+ if (checkbox != null)
+ checkbox.setChecked(checked);
+
+ if (mIsChecked != checked) {
+ mIsChecked = checked;
+ refreshDrawableState();
+ if(mOnCheckedChangeListener != null) {
+ mOnCheckedChangeListener.onCheckedChanged((CheckableFrameLayout) this.findViewById(R.id.media_grid_frame_layout), checked);
+ }
+ }
+ }
+
+ @Override
+ public void toggle() {
+ setChecked(!mIsChecked);
+ }
+
+ public void setOnCheckedChangeListener(OnCheckedChangeListener onCheckChangeListener) {
+ mOnCheckedChangeListener = onCheckChangeListener;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/CustomSpinner.java b/WordPress/src/main/java/org/wordpress/android/ui/CustomSpinner.java
new file mode 100644
index 000000000..8a7f2a132
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/CustomSpinner.java
@@ -0,0 +1,43 @@
+package org.wordpress.android.ui;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.Spinner;
+
+import org.wordpress.android.ui.media.MediaGridFragment.Filter;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+
+import java.lang.reflect.Field;
+
+public class CustomSpinner extends Spinner {
+ OnItemSelectedListener listener;
+
+ public CustomSpinner(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public void setSelection(int position) {
+ //only ignore if the old selection is custom date since we may want to click on it again
+ if (position == Filter.CUSTOM_DATE.ordinal())
+ ignoreOldSelectionByReflection();
+ super.setSelection(position);
+ }
+
+ public void setOnItemSelectedEvenIfUnchangedListener(
+ OnItemSelectedListener listener) {
+ this.listener = listener;
+ }
+
+ private void ignoreOldSelectionByReflection() {
+ try {
+ Class<?> c = this.getClass().getSuperclass().getSuperclass().getSuperclass();
+ Field reqField = c.getDeclaredField("mOldSelectedPosition");
+ reqField.setAccessible(true);
+ reqField.setInt(this, -1);
+ } catch (Exception e) {
+ AppLog.e(T.MEDIA, e);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/DashboardActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/DashboardActivity.java
new file mode 100644
index 000000000..7a12eebb3
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/DashboardActivity.java
@@ -0,0 +1,176 @@
+
+package org.wordpress.android.ui;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.Window;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.widget.ProgressBar;
+import android.widget.Toast;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.WPWebChromeClient;
+import org.wordpress.android.util.WPWebViewClient;
+import org.wordpress.passcodelock.AppLockManager;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+
+/**
+ * Basic activity for displaying a WebView.
+ */
+public class DashboardActivity extends Activity {
+ /** Primary webview used to display content. */
+ protected WebView mWebView;
+
+ /**
+ * Blog for which this activity is loading content.
+ */
+ protected Blog mBlog;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ requestWindowFeature(Window.FEATURE_PROGRESS);
+
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.webview);
+
+ ActionBar actionBar = getActionBar();
+ setTitle(R.string.view_admin);
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
+ actionBar.setDisplayShowTitleEnabled(true);
+ }
+
+ mWebView = (WebView) findViewById(R.id.webView);
+ mWebView.getSettings().setUserAgentString(WordPress.getUserAgent());
+ mWebView.getSettings().setBuiltInZoomControls(true);
+ mWebView.getSettings().setJavaScriptEnabled(true);
+ mWebView.setScrollBarStyle(View.SCROLLBARS_INSIDE_OVERLAY);
+
+ Bundle extras = getIntent().getExtras();
+ if (extras != null && extras.containsKey("blogID")) {
+ mBlog = WordPress.wpDB.instantiateBlogByLocalId(extras.getInt("blogID", -1));
+ if (mBlog == null) {
+ mBlog = WordPress.getCurrentBlog();
+ }
+ }
+
+ if (mBlog == null) {
+ Toast.makeText(this, getResources().getText(R.string.blog_not_found),
+ Toast.LENGTH_SHORT).show();
+ finish();
+ }
+
+ mWebView.setWebViewClient(new WPWebViewClient(mBlog));
+ mWebView.setWebChromeClient(new WPWebChromeClient(this, (ProgressBar) findViewById(R.id.progress_bar)));
+
+ mWebView.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE);
+ mWebView.getSettings().setSavePassword(false);
+
+ loadDashboard();
+ }
+
+
+ private void loadDashboard() {
+ String dashboardUrl = mBlog.getAdminUrl();
+ loadAuthenticatedUrl(dashboardUrl);
+ }
+
+ /**
+ * Load the specified URL in the webview.
+ *
+ * @param url URL to load in the webview.
+ */
+ protected void loadUrl(String url) {
+ mWebView.loadUrl(url);
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (mWebView != null && mWebView.canGoBack())
+ mWebView.goBack();
+ else
+ super.onBackPressed();
+ }
+
+ /**
+ * Get the URL of the WordPress login page.
+ *
+ * @return URL of the login page.
+ */
+ protected String getLoginUrl() {
+ if (mBlog.getUrl().lastIndexOf("/") != -1) {
+ return mBlog.getUrl().substring(0, mBlog.getUrl().lastIndexOf("/"))
+ + "/wp-login.php";
+ } else {
+ return mBlog.getUrl().replace("xmlrpc.php", "wp-login.php");
+ }
+ }
+
+ /**
+ * Login to the WordPress blog and load the specified URL.
+ *
+ * @param url URL to be loaded in the webview.
+ */
+ protected void loadAuthenticatedUrl(String url) {
+ try {
+ String postData = String.format("log=%s&pwd=%s&redirect_to=%s",
+ URLEncoder.encode(mBlog.getUsername(), "UTF-8"), URLEncoder.encode(mBlog.getPassword(), "UTF-8"),
+ URLEncoder.encode(url, "UTF-8"));
+ mWebView.postUrl(getLoginUrl(), postData.getBytes());
+ } catch (UnsupportedEncodingException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.dashboard, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ if (mWebView == null)
+ return false;
+
+ int itemID = item.getItemId();
+ if (itemID == R.id.menu_refresh) {
+ mWebView.reload();
+ return true;
+ } else if (itemID == R.id.menu_browser) {
+ String url = mWebView.getUrl();
+ if (url != null) {
+ Uri uri = Uri.parse(url);
+ if (uri != null) {
+ Intent i = new Intent(Intent.ACTION_VIEW);
+ i.setData(uri);
+ startActivity(i);
+ AppLockManager.getInstance().setExtendedTimeout();
+ }
+ }
+ return true;
+ } else if (itemID == android.R.id.home) {
+ finish();
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/DeepLinkingIntentReceiverActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/DeepLinkingIntentReceiverActivity.java
new file mode 100644
index 000000000..115da4ba4
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/DeepLinkingIntentReceiverActivity.java
@@ -0,0 +1,82 @@
+package org.wordpress.android.ui;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.TextUtils;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.ui.accounts.WelcomeActivity;
+import org.wordpress.android.ui.reader.ReaderActivityLauncher;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.ToastUtils;
+
+/**
+ * An activity to handle deep linking.
+ *
+ * wordpress://viewpost?blogId={blogId}&postId={postId}
+ *
+ * Redirects users to the reader activity along with IDs passed in the intent
+ */
+public class DeepLinkingIntentReceiverActivity extends Activity {
+ private static final int INTENT_WELCOME = 0;
+
+ private String mBlogId;
+ private String mPostId;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ String action = getIntent().getAction();
+ Uri uri = getIntent().getData();
+
+ // check if this intent is started via custom scheme link
+ if (Intent.ACTION_VIEW.equals(action) && uri != null) {
+ mBlogId = uri.getQueryParameter("blogId");
+ mPostId = uri.getQueryParameter("postId");
+
+ // if user is logged in, show the post right away - otherwise show welcome activity
+ // and then show the post once the user has logged in
+ if (WordPress.hasValidWPComCredentials(this)) {
+ showPost();
+ } else {
+ Intent intent = new Intent(this, WelcomeActivity.class);
+ startActivityForResult(intent, INTENT_WELCOME);
+ }
+ } else {
+ finish();
+ }
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ // show the post if user is returning from successful login
+ if (requestCode == INTENT_WELCOME && resultCode == RESULT_OK)
+ showPost();
+ }
+
+ private void showPost() {
+ if (!TextUtils.isEmpty(mBlogId) && !TextUtils.isEmpty(mPostId)) {
+ try {
+ ReaderActivityLauncher.showReaderPostDetail(this, Long.parseLong(mBlogId), Long.parseLong(mPostId));
+ } catch (NumberFormatException e) {
+ AppLog.e(T.READER, e);
+ }
+ } else {
+ ToastUtils.showToast(this, R.string.error_generic);
+ }
+
+ finish();
+ }
+
+ @Override
+ public void onBackPressed() {
+ super.onBackPressed();
+ finish();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/ExpandableHeightGridView.java b/WordPress/src/main/java/org/wordpress/android/ui/ExpandableHeightGridView.java
new file mode 100644
index 000000000..ee758b752
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/ExpandableHeightGridView.java
@@ -0,0 +1,59 @@
+package org.wordpress.android.ui;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.ViewGroup;
+import android.widget.GridView;
+
+// based on http://stackoverflow.com/questions/8481844/gridview-height-gets-cut
+
+public class ExpandableHeightGridView extends GridView
+{
+ boolean expanded = false;
+
+ public ExpandableHeightGridView(Context context)
+ {
+ super(context);
+ }
+
+ public ExpandableHeightGridView(Context context, AttributeSet attrs)
+ {
+ super(context, attrs);
+ }
+
+ public ExpandableHeightGridView(Context context, AttributeSet attrs,
+ int defStyle)
+ {
+ super(context, attrs, defStyle);
+ }
+
+ public boolean isExpanded()
+ {
+ return expanded;
+ }
+
+ @Override
+ public void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
+ {
+ if (isExpanded())
+ {
+ // Calculate entire height by providing a very large height hint.
+ // View.MEASURED_SIZE_MASK represents the largest height possible.
+ int expandSpec = MeasureSpec.makeMeasureSpec(MEASURED_SIZE_MASK,
+ MeasureSpec.AT_MOST);
+ super.onMeasure(widthMeasureSpec, expandSpec);
+
+ ViewGroup.LayoutParams params = getLayoutParams();
+ params.height = getMeasuredHeight();
+ }
+ else
+ {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ }
+ }
+
+ public void setExpanded(boolean expanded)
+ {
+ this.expanded = expanded;
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/FadeInNetworkImageView.java b/WordPress/src/main/java/org/wordpress/android/ui/FadeInNetworkImageView.java
new file mode 100644
index 000000000..9cb489078
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/FadeInNetworkImageView.java
@@ -0,0 +1,43 @@
+package org.wordpress.android.ui;
+
+import android.animation.ObjectAnimator;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.util.AttributeSet;
+import android.view.View;
+
+import com.android.volley.toolbox.NetworkImageView;
+
+/**
+ * A custom NetworkImageView that does a fade in animation when the bitmap is set
+ * from: https://gist.github.com/benvd/5683818
+ * nbradbury 10-Mar-2015 - replaced previous TransitionDrawable with faster alpha animation
+ */
+
+public class FadeInNetworkImageView extends NetworkImageView {
+ public FadeInNetworkImageView(Context context) {
+ super(context);
+ }
+
+ public FadeInNetworkImageView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public FadeInNetworkImageView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ public void setImageBitmap(Bitmap bm) {
+ super.setImageBitmap(bm);
+
+ if (getContext() == null)
+ return;
+ int duration = getContext().getResources().getInteger(android.R.integer.config_shortAnimTime);
+
+ // use faster property animation if device supports it
+ ObjectAnimator alpha = ObjectAnimator.ofFloat(this, View.ALPHA, 0.25f, 1f);
+ alpha.setDuration(duration);
+ alpha.start();
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/HorizontalTabView.java b/WordPress/src/main/java/org/wordpress/android/ui/HorizontalTabView.java
new file mode 100644
index 000000000..3750396ef
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/HorizontalTabView.java
@@ -0,0 +1,241 @@
+package org.wordpress.android.ui;
+
+import java.util.ArrayList;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.graphics.Paint;
+import android.graphics.Typeface;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.HorizontalScrollView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.util.Utils;
+
+/**
+ * A view that mimics the action bar tabs. It can be placed anywhere and appears
+ * under the sliding menu, unlike the action bar tabs
+ */
+
+public class HorizontalTabView extends HorizontalScrollView implements OnClickListener {
+ private static final String TAG_PREFIX = "tab:";
+ private ArrayList<Tab> mTabs;
+ private ArrayList<TextView> mTextViews;
+ private LinearLayout mTabContainer;
+ private float mMaxTabWidth = 0f;
+ private TabListener mTabListener;
+ private boolean mEnableScroll = true;
+ private LinearLayout mSelectedLayout;
+
+ public HorizontalTabView(Context context) {
+ super(context);
+ init();
+ }
+ public HorizontalTabView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+ public HorizontalTabView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ init();
+ }
+
+ private void init() {
+ mTabs = new ArrayList<Tab>();
+ mTextViews = new ArrayList<TextView>();
+
+ setupBackground();
+ setupTabContainer();
+ }
+
+ private void setupBackground() {
+ setBackgroundColor(getResources().getColor(R.color.tab_background));
+ }
+
+ private void setupTabContainer() {
+ mTabContainer = new LinearLayout(getContext());
+ HorizontalScrollView.LayoutParams linearLayoutParams =
+ new HorizontalScrollView.LayoutParams(HorizontalScrollView.LayoutParams.WRAP_CONTENT, HorizontalScrollView.LayoutParams.WRAP_CONTENT);
+ mTabContainer.setLayoutParams(linearLayoutParams);
+ mTabContainer.setOrientation(LinearLayout.HORIZONTAL);
+
+ addView(mTabContainer);
+
+ }
+ public Tab newTab() {
+ return new Tab();
+ }
+
+ public void addTab(Tab tab) {
+ tab.setPosition(mTabs.size());
+ mTabs.add(tab);
+
+ int divWidth = (int) Utils.dpToPx(1);
+ int divTopMargin = (int) Utils.dpToPx(12);
+ int divHeight = (int) Utils.dpToPx(24);
+
+ int tabPad = (int) Utils.dpToPx(16);
+
+ int fontSizeSp = 12;
+
+ // add dividers in the middle - not using divider property as it is API 11
+ if (mTextViews.size() > 0) {
+ View divider = new View(getContext());
+ LinearLayout.LayoutParams separatorParams = new LinearLayout.LayoutParams(divWidth, divHeight);
+ separatorParams.topMargin = divTopMargin;
+ divider.setLayoutParams(separatorParams);
+ divider.setBackgroundColor(getResources().getColor(R.color.tab_divider));
+ mTabContainer.addView(divider);
+ }
+
+ TextView textView = new TextView(getContext());
+ LinearLayout.LayoutParams textViewParams =
+ new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
+ textView.setLayoutParams(textViewParams);
+ textView.setGravity(Gravity.CENTER);
+ textView.setText(tab.getText());
+ textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSizeSp);
+ textView.setTextColor(getResources().getColor(R.color.tab_text));
+ textView.setTypeface(null, Typeface.BOLD);
+
+ mTextViews.add(textView);
+
+ LinearLayout linearLayout = new LinearLayout(getContext());
+ LinearLayout.LayoutParams linearLayoutParams =
+ new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1);
+ linearLayout.setLayoutParams(linearLayoutParams);
+ linearLayout.addView(textView);
+ linearLayout.setTag(TAG_PREFIX + (mTabs.size()-1));
+ linearLayout.setOnClickListener(this);
+ linearLayout.setBackgroundResource(R.drawable.tab_indicator_ab_wordpress);
+ linearLayout.setPadding(tabPad, tabPad, tabPad, tabPad);
+
+ mTabContainer.addView(linearLayout);
+
+ recomputeTabWidths();
+ }
+
+ /** Make the tabs have the same widths, where this width is based on the longest tab title **/
+ private void recomputeTabWidths() {
+ // Determine the max width
+ for(TextView textView : mTextViews) {
+ Paint paint = textView.getPaint();
+ float width = paint.measureText(textView.getText().toString());
+ if (mMaxTabWidth < width)
+ mMaxTabWidth = width;
+ }
+
+ // Set the tabs to use the max width
+ for(TextView textView : mTextViews) {
+ LinearLayout.LayoutParams textViewParams =
+ new LinearLayout.LayoutParams((int) mMaxTabWidth, LinearLayout.LayoutParams.WRAP_CONTENT, Gravity.CENTER);
+ textView.setLayoutParams(textViewParams);
+ }
+
+ }
+
+ public class Tab {
+ private String mText;
+ private int mPosition;
+
+ @SuppressLint("DefaultLocale")
+ public CharSequence getText() {
+ return mText.toUpperCase();
+ }
+
+ public Tab setText(CharSequence pageTitle) {
+ mText = pageTitle.toString();
+ return this;
+ }
+
+ public int getPosition() {
+ return mPosition;
+ }
+
+ public void setPosition(int position) {
+ this.mPosition = position;
+ }
+ }
+
+ public interface TabListener {
+ public void onTabSelected(Tab tab);
+ }
+
+ public TabListener getTabListener() {
+ return mTabListener;
+ }
+
+ public void setTabListener(TabListener tabListener) {
+ this.mTabListener = tabListener;
+ }
+
+ @Override
+ public void onClick(View v) {
+ if (v instanceof LinearLayout) {
+ LinearLayout layout = (LinearLayout) v;
+
+ String tag = (String) layout.getTag();
+ int position = Integer.valueOf(tag.substring(TAG_PREFIX.length()));
+
+ // It is necessary to disable scrolling upon click before informing the listener
+ // because I've found that if setSelectedTab() was called in the listener implementation,
+ // and that setSelectedTab() is called again after onTabSelected(), it does not smooth scroll.
+
+ // The call to setSelectedTab() in this method is necessary because if there was no listener or if
+ // it did not call setSelectedTab(), then it would appear as if nothing happened.
+
+ mEnableScroll = false;
+
+ if (mTabListener != null)
+ mTabListener.onTabSelected(mTabs.get(position));
+
+ mEnableScroll = true;
+
+ setSelectedTab(position);
+ }
+ }
+
+ public void setSelectedTab(int position) {
+ if (position >= mTextViews.size())
+ return;
+
+ if (mEnableScroll) {
+ scrollToTab(position);
+ setSelectedLayout(getTabParent(position));
+ }
+ }
+
+ public void setTabText(int position, String text) {
+ mTabs.get(position).mText = text;
+ mTextViews.get(position).setText(text);
+ }
+
+ private void scrollToTab(int position) {
+ int tabWidth = getTabParent(position).getWidth();
+ int parentWidth = ((View) this.getParent()).getWidth();
+
+ int offset = parentWidth / 2 - tabWidth / 2;
+
+ smoothScrollTo(tabWidth * position - offset, 0);
+ }
+
+ private LinearLayout getTabParent(int position) {
+ View tab = mTextViews.get(position);
+ return (LinearLayout) tab.getParent();
+ }
+
+ private void setSelectedLayout(LinearLayout layout) {
+ if (mSelectedLayout != null) {
+ mSelectedLayout.setSelected(false);
+ }
+
+ mSelectedLayout = (LinearLayout)layout;
+ mSelectedLayout.setSelected(true);
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/MenuDrawerItem.java b/WordPress/src/main/java/org/wordpress/android/ui/MenuDrawerItem.java
new file mode 100644
index 000000000..e371cd03d
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/MenuDrawerItem.java
@@ -0,0 +1,99 @@
+/**
+ * Represents a single item in the WPActionBarActivity's menu drawer. A MenuDrawerItem determines
+ * the label and icon to use in the menu, its presence in the menu, its selection state, and the
+ * action that happens when the item is selected.
+ */
+package org.wordpress.android.ui;
+
+import android.view.View;
+
+public abstract class MenuDrawerItem {
+ /**
+ * Signifies that the item has no unique ID so should not be tracked in the last selected
+ * item preference.
+ */
+ public static int NO_ITEM_ID = -1;
+ /**
+ * Called when the menu item is selected.
+ */
+ abstract public void onSelectItem();
+ /**
+ * Determines if the menu item should be displayed in the menu. Default is always true.
+ */
+ public Boolean isVisible(){
+ return true;
+ };
+ /**
+ * Determines if the item is selected. Default is always false.
+ */
+ public Boolean isSelected(){
+ return false;
+ }
+ /**
+ * Method to allow the menu item to provide additional configuration to the view, default
+ * implementation does nothing.
+ */
+ public void onConfigureView(View view){};
+
+ // Resource id for the title string
+ protected int mTitle;
+ // Resource id for the icon drawable
+ protected int mIconRes;
+ // ID for the item for remembering which item was selected
+ private int mItemId;
+ /**
+ * Creates a MenuDrawerItem with the specific id, string resource id and drawable resource id
+ */
+ MenuDrawerItem(int itemId, int stringRes, int iconRes) {
+ mTitle = stringRes;
+ mIconRes = iconRes;
+ mItemId = itemId;
+ }
+ /**
+ * Creates a MenuDrawerItem with NO_ITEM_ID for it's id for items that shouldn't be remembered
+ * between application launches.
+ */
+ MenuDrawerItem(int stringRes, int iconRes){
+ this(NO_ITEM_ID, stringRes, iconRes);
+ }
+ /**
+ * Determines if the item has an id for remembering the last selected item
+ */
+ public boolean hasItemId(){
+ return getItemId() != NO_ITEM_ID;
+ }
+ /**
+ * Get's the item's unique ID
+ */
+ public int getItemId(){
+ return mItemId;
+ }
+ /**
+ * Returns the item's string representation (used by ArrayAdapter.getView)
+ */
+ public String toString(){
+ return "";
+ }
+ /**
+ * The resource id to use for the menu item's title
+ */
+ public int getTitleRes(){
+ return mTitle;
+ }
+ /**
+ * The resource id to use for the menu item's icon
+ */
+ public int getIconRes(){
+ return mIconRes;
+ }
+
+ public void selectItem(){
+ onSelectItem();
+ }
+ /**
+ * Allows the menu item to do additional manipulation to the view
+ */
+ public void configureView(View v){
+ onConfigureView(v);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/MultiSelectGridView.java b/WordPress/src/main/java/org/wordpress/android/ui/MultiSelectGridView.java
new file mode 100644
index 000000000..388f55703
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/MultiSelectGridView.java
@@ -0,0 +1,177 @@
+package org.wordpress.android.ui;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.CursorAdapter;
+import android.widget.GridView;
+import android.widget.ListAdapter;
+
+import org.wordpress.android.R;
+import org.wordpress.android.ui.media.MediaGridAdapter;
+
+import java.util.ArrayList;
+
+/**
+ * A GridView implementation that aims to do multiselect on GridViews since
+ * multi-select isn't supported pre-API 11.
+ *
+ */
+public class MultiSelectGridView extends GridView implements AdapterView.OnItemLongClickListener, AdapterView.OnItemClickListener {
+ private OnItemClickListener mOnItemClickListener;
+ private MultiSelectListener mMultiSelectListener;
+ private MediaGridAdapter mAdapter;
+ private boolean mIsInMultiSelectMode;
+ private boolean mIsMultiSelectModeEnabled;
+ private boolean mIsHighlightSelectModeEnabled;
+
+ public interface MultiSelectListener {
+ public void onMultiSelectChange(int count);
+ }
+
+ public MultiSelectGridView(Context context) {
+ super(context);
+ init();
+ }
+
+ public MultiSelectGridView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public MultiSelectGridView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ init();
+ }
+
+ private void init() {
+ super.setOnItemClickListener(this);
+ super.setOnItemLongClickListener(this);
+ mIsMultiSelectModeEnabled = true;
+ mIsHighlightSelectModeEnabled = false;
+ }
+
+ public void setMultiSelectModeActive(boolean active) {
+ mIsInMultiSelectMode = active;
+ }
+
+ public boolean isInMultiSelectMode(){
+ return mIsInMultiSelectMode ;
+ }
+
+ public void setMultiSelectModeEnabled(boolean enabled) {
+ mIsMultiSelectModeEnabled = enabled;
+ }
+
+ public boolean isMultiSelectModeEnabled() {
+ return mIsMultiSelectModeEnabled;
+ }
+
+ public void setHighlightSelectModeEnabled(boolean enabled) {
+ mIsHighlightSelectModeEnabled = enabled;
+ }
+
+ public boolean isHighlightSelectModeEnabled() {
+ return mIsHighlightSelectModeEnabled;
+ }
+
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ CheckableFrameLayout frameLayout = ((CheckableFrameLayout) view.findViewById(R.id.media_grid_frame_layout));
+
+ Cursor cursor = ((CursorAdapter) parent.getAdapter()).getCursor();
+
+ int mediaIdCol = cursor.getColumnIndex("mediaId");
+ if (mediaIdCol == -1)
+ return;
+
+ String mediaId = cursor.getString(mediaIdCol);
+
+ // run the default behavior if not in multiselect mode
+ if (!isInMultiSelectMode()) {
+ getSelectedItems().clear();
+ getSelectedItems().add(mediaId);
+ frameLayout.setChecked(true);
+ if (mOnItemClickListener != null)
+ mOnItemClickListener.onItemClick(parent, view, position, id);
+ mAdapter.notifyDataSetChanged();
+
+ if (isHighlightSelectModeEnabled())
+ notifyMultiSelectCountChanged();
+
+ return;
+ }
+
+ if (getSelectedItems().contains(mediaId)) {
+ // unselect item
+ frameLayout.setChecked(false);
+ } else {
+ // select item
+ frameLayout.setChecked(true);
+ }
+ notifyMultiSelectCountChanged();
+
+ }
+
+ @Override
+ public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
+ // do not allow item long clicks if multi-select is disabled
+ if (!mIsMultiSelectModeEnabled)
+ return true;
+
+ mIsInMultiSelectMode = true;
+
+ Cursor cursor = ((CursorAdapter) parent.getAdapter()).getCursor();
+ String mediaId = cursor.getString(cursor.getColumnIndex("mediaId"));
+
+ if (!getSelectedItems().contains(mediaId))
+ getSelectedItems().add(mediaId);
+ notifyMultiSelectCountChanged();
+
+ ((CheckableFrameLayout) view.findViewById(R.id.media_grid_frame_layout)).setChecked(true);
+
+ return true;
+ }
+
+ private void notifyMultiSelectCountChanged() {
+ if (mMultiSelectListener != null) {
+ int size = getSelectedItems().size();
+ if (size == 0) {
+ mIsInMultiSelectMode = false;
+ }
+ mMultiSelectListener.onMultiSelectChange(size);
+ }
+ }
+
+ @Override
+ public void setOnItemClickListener(OnItemClickListener listener) {
+ mOnItemClickListener = listener;
+ }
+
+ @Override
+ public void setOnItemLongClickListener(OnItemLongClickListener listener) {
+ // not implemented
+ }
+
+ public void setMultiSelectListener(MultiSelectListener listener) {
+ mMultiSelectListener = listener;
+ }
+
+ public void cancelSelection() {
+ getSelectedItems().clear();
+ mAdapter.notifyDataSetChanged();
+ notifyMultiSelectCountChanged();
+ }
+
+ @Override
+ public void setAdapter(ListAdapter adapter) {
+ super.setAdapter(adapter);
+ mAdapter = (MediaGridAdapter) adapter;
+ }
+
+ private ArrayList<String> getSelectedItems() {
+ return mAdapter.getCheckedItems();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/OnRearrangeListener.java b/WordPress/src/main/java/org/wordpress/android/ui/OnRearrangeListener.java
new file mode 100644
index 000000000..96c971078
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/OnRearrangeListener.java
@@ -0,0 +1,18 @@
+/**
+ * Copyright (c) 2011, Animoto Inc.
+ *
+ * All rights reserved. Licensed under the Apache License, Version 2.0 (the 'License'); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
+ * AS IS BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and limitations under the License.
+ *
+ * Github: https://github.com/thquinn/DraggableGridView
+ *
+ */
+
+package org.wordpress.android.ui;
+
+public interface OnRearrangeListener {
+ public abstract void onRearrange(int oldIndex, int newIndex);
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/PullToRefreshHeaderTransformer.java b/WordPress/src/main/java/org/wordpress/android/ui/PullToRefreshHeaderTransformer.java
new file mode 100644
index 000000000..0f23a30bf
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/PullToRefreshHeaderTransformer.java
@@ -0,0 +1,98 @@
+package org.wordpress.android.ui;
+
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.app.Activity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.Animation;
+
+import uk.co.senab.actionbarpulltorefresh.library.DefaultHeaderTransformer;
+import uk.co.senab.actionbarpulltorefresh.library.R;
+import uk.co.senab.actionbarpulltorefresh.library.sdk.Compat;
+
+public class PullToRefreshHeaderTransformer extends DefaultHeaderTransformer {
+ private View mHeaderView;
+ private ViewGroup mContentLayout;
+ private long mAnimationDuration;
+ private boolean mShowProgressBarOnly;
+ private Animation mHeaderOutAnimation;
+ private OnTopScrollChangedListener mOnTopScrollChangedListener;
+
+ public interface OnTopScrollChangedListener {
+ public void onTopScrollChanged(boolean scrolledOnTop);
+ }
+
+ public void setShowProgressBarOnly(boolean progressBarOnly) {
+ mShowProgressBarOnly = progressBarOnly;
+ }
+
+ @Override
+ public void onViewCreated(Activity activity, View headerView) {
+ super.onViewCreated(activity, headerView);
+ mHeaderView = headerView;
+ mContentLayout = (ViewGroup) headerView.findViewById(R.id.ptr_content);
+ mAnimationDuration = activity.getResources().getInteger(android.R.integer.config_shortAnimTime);
+ }
+
+ @Override
+ public boolean hideHeaderView() {
+ mShowProgressBarOnly = false;
+ return super.hideHeaderView();
+ }
+
+ @Override
+ public boolean showHeaderView() {
+ // Workaround to avoid this bug https://github.com/chrisbanes/ActionBar-PullToRefresh/issues/265
+ // Note, that also remove the alpha animation
+ resetContentLayoutAlpha();
+
+ boolean changeVis = mHeaderView.getVisibility() != View.VISIBLE;
+ mContentLayout.setVisibility(View.VISIBLE);
+ if (changeVis) {
+ mHeaderView.setVisibility(View.VISIBLE);
+ AnimatorSet animSet = new AnimatorSet();
+ ObjectAnimator alphaAnim = ObjectAnimator.ofFloat(mHeaderView, "alpha", 0f, 1f);
+ ObjectAnimator transAnim = ObjectAnimator.ofFloat(mContentLayout, "translationY",
+ -mContentLayout.getHeight(), 10f);
+ animSet.playTogether(transAnim, alphaAnim);
+ animSet.play(alphaAnim);
+ animSet.setDuration(mAnimationDuration);
+ animSet.start();
+ if (mShowProgressBarOnly) {
+ mContentLayout.setVisibility(View.INVISIBLE);
+ }
+ }
+ return changeVis;
+ }
+
+ @Override
+ public void onPulled(float percentagePulled) {
+ super.onPulled(percentagePulled);
+ }
+
+ private void resetContentLayoutAlpha() {
+ Compat.setAlpha(mContentLayout, 1f);
+ }
+
+ @Override
+ public void onReset() {
+ super.onReset();
+ // Reset the Content Layout
+ if (mContentLayout != null) {
+ Compat.setAlpha(mContentLayout, 1f);
+ mContentLayout.setVisibility(View.VISIBLE);
+ }
+ }
+
+ @Override
+ public void onTopScrollChanged(boolean scrolledOnTop) {
+ if (mOnTopScrollChangedListener != null) {
+ mOnTopScrollChangedListener.onTopScrollChanged(scrolledOnTop);
+ }
+ }
+
+ public void setOnTopScrollChangedListener(OnTopScrollChangedListener listener) {
+ mOnTopScrollChangedListener = listener;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/PullToRefreshHelper.java b/WordPress/src/main/java/org/wordpress/android/ui/PullToRefreshHelper.java
new file mode 100644
index 000000000..9b3e1dc5a
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/PullToRefreshHelper.java
@@ -0,0 +1,142 @@
+package org.wordpress.android.ui;
+
+import android.app.Activity;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+import android.preference.PreferenceManager;
+import android.support.v4.content.LocalBroadcastManager;
+import android.view.View;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.ToastUtils.Duration;
+
+import java.lang.ref.WeakReference;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import uk.co.senab.actionbarpulltorefresh.library.ActionBarPullToRefresh;
+import uk.co.senab.actionbarpulltorefresh.library.ActionBarPullToRefresh.SetupWizard;
+import uk.co.senab.actionbarpulltorefresh.library.Options;
+import uk.co.senab.actionbarpulltorefresh.library.PullToRefreshLayout;
+import uk.co.senab.actionbarpulltorefresh.library.listeners.OnRefreshListener;
+import uk.co.senab.actionbarpulltorefresh.library.viewdelegates.ViewDelegate;
+
+public class PullToRefreshHelper implements OnRefreshListener {
+ private static final String REFRESH_BUTTON_HIT_COUNT = "REFRESH_BUTTON_HIT_COUNT";
+ private static final Set<Integer> TOAST_FREQUENCY = new HashSet<Integer>(Arrays.asList(1, 5, 10, 20, 40, 80, 160,
+ 320, 640));
+ private PullToRefreshHeaderTransformer mHeaderTransformer;
+ private PullToRefreshLayout mPullToRefreshLayout;
+ private RefreshListener mRefreshListener;
+ private WeakReference<Activity> mActivityRef;
+
+ public PullToRefreshHelper(Activity activity, PullToRefreshLayout pullToRefreshLayout, RefreshListener listener) {
+ init(activity, pullToRefreshLayout, listener, null);
+ }
+
+ public PullToRefreshHelper(Activity activity, PullToRefreshLayout pullToRefreshLayout, RefreshListener listener,
+ java.lang.Class<?> viewClass) {
+ init(activity, pullToRefreshLayout, listener, viewClass);
+ }
+
+ public void init(Activity activity, PullToRefreshLayout pullToRefreshLayout, RefreshListener listener,
+ java.lang.Class<?> viewClass) {
+ mActivityRef = new WeakReference<Activity>(activity);
+ mRefreshListener = listener;
+ mPullToRefreshLayout = pullToRefreshLayout;
+ mHeaderTransformer = new PullToRefreshHeaderTransformer();
+ SetupWizard setupWizard = ActionBarPullToRefresh.from(activity).options(Options.create().headerTransformer(
+ mHeaderTransformer).build()).allChildrenArePullable().listener(this);
+ if (viewClass != null) {
+ setupWizard.useViewDelegate(viewClass, new ViewDelegate() {
+ @Override
+ public boolean isReadyForPull(View view, float v, float v2) {
+ return true;
+ }
+ }
+ );
+ }
+ setupWizard.setup(mPullToRefreshLayout);
+ }
+
+ public void setRefreshing(boolean refreshing) {
+ mHeaderTransformer.setShowProgressBarOnly(refreshing);
+ mPullToRefreshLayout.setRefreshing(refreshing);
+ }
+
+ public boolean isRefreshing() {
+ return mPullToRefreshLayout.isRefreshing();
+ }
+
+ @Override
+ public void onRefreshStarted(View view) {
+ mRefreshListener.onRefreshStarted(view);
+ }
+
+ public interface RefreshListener {
+ public void onRefreshStarted(View view);
+ }
+
+ public void setEnabled(boolean enabled) {
+ mPullToRefreshLayout.setEnabled(enabled);
+ }
+
+ public void refreshAction() {
+ Activity activity = mActivityRef.get();
+ if (activity == null) {
+ return;
+ }
+ setRefreshing(true);
+ mRefreshListener.onRefreshStarted(mPullToRefreshLayout);
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity);
+ int refreshHits = preferences.getInt(REFRESH_BUTTON_HIT_COUNT, 0);
+ refreshHits += 1;
+ if (TOAST_FREQUENCY.contains(refreshHits)) {
+ ToastUtils.showToast(activity, R.string.ptr_tip_message, Duration.LONG);
+ }
+ Editor editor = preferences.edit();
+ editor.putInt(REFRESH_BUTTON_HIT_COUNT, refreshHits);
+ editor.commit();
+ }
+
+ public void registerReceiver(Context context) {
+ if (context == null) {
+ return;
+ }
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(WordPress.BROADCAST_ACTION_REFRESH_MENU_PRESSED);
+ LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context);
+ lbm.registerReceiver(mReceiver, filter);
+ }
+
+ public void unregisterReceiver(Context context) {
+ if (context == null) {
+ return;
+ }
+ try {
+ LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context);
+ lbm.unregisterReceiver(mReceiver);
+ } catch (IllegalArgumentException e) {
+ // exception occurs if receiver already unregistered (safe to ignore)
+ }
+ }
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null || intent.getAction() == null) {
+ return;
+ }
+ if (intent.getAction().equals(WordPress.BROADCAST_ACTION_REFRESH_MENU_PRESSED)) {
+ refreshAction();
+ }
+ }
+ };
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/ShareIntentReceiverActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/ShareIntentReceiverActivity.java
new file mode 100644
index 000000000..49161092e
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/ShareIntentReceiverActivity.java
@@ -0,0 +1,316 @@
+package org.wordpress.android.ui;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Bundle;
+import android.preference.PreferenceManager;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemSelectedListener;
+import android.widget.ArrayAdapter;
+import android.widget.CheckedTextView;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.ui.accounts.WelcomeActivity;
+import org.wordpress.android.ui.media.MediaBrowserActivity;
+import org.wordpress.android.ui.posts.EditPostActivity;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.ToastUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * An activity to handle share intents, since there are multiple actions possible.
+ * If there are multiple blogs, it lets the user choose which blog to share to.
+ * It lists what actions that the user can perform and redirects them to the activity,
+ * along with the content passed in the intent
+ */
+public class ShareIntentReceiverActivity extends Activity implements OnItemSelectedListener {
+ public static final String SHARE_TEXT_BLOG_ID_KEY = "wp-settings-share-text-blogid";
+ public static final String SHARE_IMAGE_BLOG_ID_KEY = "wp-settings-share-image-blogid";
+ public static final String SHARE_IMAGE_ADDTO_KEY = "wp-settings-share-image-addto";
+ public static final String SHARE_LAST_USED_BLOG_ID_KEY = "wp-settings-share-last-used-text-blogid";
+ public static final String SHARE_LAST_USED_ADDTO_KEY = "wp-settings-share-last-used-image-addto";
+ public static final int ADD_TO_NEW_POST = 0;
+ public static final int ADD_TO_MEDIA_LIBRARY = 1;
+ private Spinner mBlogSpinner;
+ private Spinner mActionSpinner;
+ private CheckedTextView mAlwaysUseCheckBox;
+ private int mAccountIDs[];
+ private TextView mBlogSpinnerTitle;
+ private int mActionIndex;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.share_intent_receiver_dialog);
+
+ mBlogSpinnerTitle = (TextView) findViewById(R.id.blog_spinner_title);
+ mBlogSpinner = (Spinner) findViewById(R.id.blog_spinner);
+ mAlwaysUseCheckBox = (CheckedTextView) findViewById(R.id.always_use_checkbox);
+ mAlwaysUseCheckBox.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ mAlwaysUseCheckBox.setChecked(!mAlwaysUseCheckBox.isChecked());
+ }
+ });
+ String[] blogNames = getBlogNames();
+ if (blogNames == null) {
+ finishIfNoVisibleBlogs();
+ return;
+ }
+
+ if (autoShareIfEnabled()) {
+ return;
+ }
+
+ if (blogNames.length == 1) {
+ mBlogSpinner.setVisibility(View.GONE);
+ mBlogSpinnerTitle.setVisibility(View.GONE);
+ } else {
+ ArrayAdapter<String> adapter = new ArrayAdapter<String>(this,
+ R.layout.spinner_menu_dropdown_item, blogNames);
+ mBlogSpinner.setAdapter(adapter);
+ mBlogSpinner.setOnItemSelectedListener(this);
+ }
+
+ // If type is text/plain hide Media Gallery option
+ mActionSpinner = (Spinner) findViewById(R.id.action_spinner);
+ if (isSharingText()) {
+ mActionSpinner.setVisibility(View.GONE);
+ findViewById(R.id.action_spinner_title).setVisibility(View.GONE);
+ // if text/plain and only one blog, then don't show this fragment, share it directly to a new post
+ if (blogNames.length == 1) {
+ startActivityAndFinish(new Intent(this, EditPostActivity.class));
+ }
+ } else {
+ String[] actions = new String[]{getString(R.string.share_action_post), getString(
+ R.string.share_action_media)};
+ ArrayAdapter<String> actionAdapter = new ArrayAdapter<String>(this,
+ R.layout.spinner_menu_dropdown_item, actions);
+ mActionSpinner.setAdapter(actionAdapter);
+ mActionSpinner.setOnItemSelectedListener(this);
+ }
+ loadLastUsed();
+ }
+
+ @Override
+ public void onBackPressed() {
+ super.onBackPressed();
+ finish();
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> parent) {
+ // noop
+ }
+
+ private void finishIfNoVisibleBlogs() {
+ // If not signed in, then ask to sign in, else inform the user to set at least one blog
+ // visible
+ if (!WordPress.isSignedIn(getBaseContext())) {
+ ToastUtils.showToast(getBaseContext(), R.string.no_account, ToastUtils.Duration.LONG);
+ startActivity(new Intent(this, WelcomeActivity.class));
+ finish();
+ } else {
+ ToastUtils.showToast(getBaseContext(), R.string.cant_share_no_visible_blog, ToastUtils.Duration.LONG);
+ finish();
+ }
+ }
+
+ private int gepPositionByLocalBlogId(long localBlogId) {
+ for (int i = 0; i < mAccountIDs.length; i++) {
+ if (mAccountIDs[i] == localBlogId) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private void loadLastUsed() {
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this);
+ int localBlogId = settings.getInt(SHARE_LAST_USED_BLOG_ID_KEY, -1);
+ int actionPosition = settings.getInt(SHARE_LAST_USED_ADDTO_KEY, -1);
+ if (localBlogId != -1) {
+ int position = gepPositionByLocalBlogId(localBlogId);
+ if (position != -1) {
+ mBlogSpinner.setSelection(position);
+ }
+ }
+ if (actionPosition >= 0 && actionPosition < mActionSpinner.getCount()) {
+ mActionSpinner.setSelection(actionPosition);
+ }
+ }
+
+ private boolean isSharingText() {
+ return "text/plain".equals(getIntent().getType());
+ }
+
+ private String[] getBlogNames() {
+ List<Map<String, Object>> accounts = WordPress.wpDB.getVisibleAccounts();
+ if (accounts.size() > 0) {
+ final String blogNames[] = new String[accounts.size()];
+ mAccountIDs = new int[accounts.size()];
+ Blog blog;
+ for (int i = 0; i < accounts.size(); i++) {
+ Map<String, Object> curHash = accounts.get(i);
+ try {
+ blogNames[i] = StringUtils.unescapeHTML(curHash.get("blogName").toString());
+ } catch (Exception e) {
+ blogNames[i] = curHash.get("url").toString();
+ }
+ mAccountIDs[i] = (Integer) curHash.get("id");
+ blog = WordPress.wpDB.instantiateBlogByLocalId(mAccountIDs[i]);
+ if (blog == null) {
+ ToastUtils.showToast(this, R.string.blog_not_found, ToastUtils.Duration.SHORT);
+ return null;
+ }
+ }
+ return blogNames;
+ }
+ return null;
+ }
+
+ @Override
+ public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+ if (parent.getId() == R.id.blog_spinner) {
+ if (!selectBlog(mAccountIDs[position])) {
+ ToastUtils.showToast(this, R.string.blog_not_found, ToastUtils.Duration.SHORT);
+ finish();
+ }
+ } else if (parent.getId() == R.id.action_spinner) {
+ mActionIndex = position;
+ }
+ }
+
+ private boolean selectBlog(int blogId) {
+ WordPress.currentBlog = WordPress.wpDB.instantiateBlogByLocalId(blogId);
+ if (WordPress.currentBlog == null || WordPress.currentBlog.isHidden()) {
+ return false;
+ }
+ WordPress.wpDB.updateLastBlogId(WordPress.currentBlog.getLocalTableBlogId());
+ return true;
+ }
+
+ private void startActivityAndFinish(Intent intent) {
+ String action = getIntent().getAction();
+ if (intent != null) {
+ intent.setAction(action);
+ intent.setType(getIntent().getType());
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+ intent.putExtra(Intent.EXTRA_TEXT, getIntent().getStringExtra(Intent.EXTRA_TEXT));
+ intent.putExtra(Intent.EXTRA_SUBJECT, getIntent().getStringExtra(Intent.EXTRA_SUBJECT));
+
+ if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
+ ArrayList<Uri> extra = getIntent().getParcelableArrayListExtra((Intent.EXTRA_STREAM));
+ intent.putExtra(Intent.EXTRA_STREAM, extra);
+ } else {
+ Uri extra = getIntent().getParcelableExtra(Intent.EXTRA_STREAM);
+ intent.putExtra(Intent.EXTRA_STREAM, extra);
+ }
+ savePreferences();
+ startActivity(intent);
+ finish();
+ }
+ }
+
+ public void onShareClicked(View view) {
+ shareIt();
+ }
+
+ private void shareIt() {
+ Intent intent = null;
+ if (mActionIndex == ADD_TO_NEW_POST) {
+ // new post
+ intent = new Intent(this, EditPostActivity.class);
+ } else if (mActionIndex == ADD_TO_MEDIA_LIBRARY) {
+ // add to media gallery
+ intent = new Intent(this, MediaBrowserActivity.class);
+ }
+ startActivityAndFinish(intent);
+ }
+
+ private boolean autoShareIfEnabled() {
+ if (isSharingText()) {
+ return autoShareText();
+ } else {
+ return autoShareImage();
+ }
+ }
+
+ private boolean autoShareText() {
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this);
+ int blogId = settings.getInt(SHARE_TEXT_BLOG_ID_KEY, -1);
+ if (blogId != -1) {
+ mActionIndex = ADD_TO_NEW_POST;
+ if (selectBlog(blogId)) {
+ shareIt();
+ return true;
+ } else {
+ // blog is hidden or has been deleted, reset settings
+ SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(this).edit();
+ editor.remove(SHARE_TEXT_BLOG_ID_KEY);
+ editor.commit();
+ ToastUtils.showToast(this, R.string.auto_sharing_preference_reset_caused_by_error,
+ ToastUtils.Duration.LONG);
+ }
+ }
+ return false;
+ }
+
+ private boolean autoShareImage() {
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this);
+ int blogId = settings.getInt(SHARE_IMAGE_BLOG_ID_KEY, -1);
+ int addTo = settings.getInt(SHARE_IMAGE_ADDTO_KEY, -1);
+ if (blogId != -1 && addTo != -1) {
+ mActionIndex = addTo;
+ if (selectBlog(blogId)) {
+ shareIt();
+ return true;
+ } else {
+ // blog is hidden or has been deleted, reset settings
+ SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(this).edit();
+ editor.remove(SHARE_IMAGE_BLOG_ID_KEY);
+ editor.remove(SHARE_IMAGE_ADDTO_KEY);
+ editor.commit();
+ ToastUtils.showToast(this, R.string.auto_sharing_preference_reset_caused_by_error,
+ ToastUtils.Duration.LONG);
+ }
+ }
+ return false;
+ }
+
+ private void savePreferences() {
+ // If current blog is not set don't save preferences
+ if (WordPress.currentBlog == null) {
+ return ;
+ }
+ SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(this).edit();
+
+ // Save last used settings
+ editor.putInt(SHARE_LAST_USED_BLOG_ID_KEY, WordPress.currentBlog.getLocalTableBlogId());
+ editor.putInt(SHARE_LAST_USED_ADDTO_KEY, mActionIndex);
+
+ // Save "always use these settings"
+ if (mAlwaysUseCheckBox.isChecked()) {
+ if (isSharingText()) {
+ editor.putInt(SHARE_TEXT_BLOG_ID_KEY, WordPress.currentBlog.getLocalTableBlogId());
+ } else {
+ editor.putInt(SHARE_IMAGE_BLOG_ID_KEY, WordPress.currentBlog.getLocalTableBlogId());
+ // Add to new post or media
+ editor.putInt(SHARE_IMAGE_ADDTO_KEY, mActionIndex);
+ }
+ }
+ editor.commit();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/ViewSiteActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/ViewSiteActivity.java
new file mode 100644
index 000000000..71e7b8926
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/ViewSiteActivity.java
@@ -0,0 +1,71 @@
+
+package org.wordpress.android.ui;
+
+import android.os.Bundle;
+import android.view.MenuItem;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+
+import java.lang.reflect.Type;
+import java.util.Map;
+
+/**
+ * Activity to view the WordPress blog in a WebView
+ */
+public class ViewSiteActivity extends AuthenticatedWebViewActivity {
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ createMenuDrawer(this.findViewById(R.id.webview_wrapper));
+
+ this.setTitle(getResources().getText(R.string.view_site));
+
+ // configure webview
+ mWebView.getSettings().setJavaScriptEnabled(true);
+ mWebView.getSettings().setDomStorageEnabled(true);
+
+ loadSiteURL();
+ }
+
+ private void loadSiteURL() {
+ if (mBlog == null)
+ return;
+ String siteURL = null;
+ Gson gson = new Gson();
+ Type type = new TypeToken<Map<?, ?>>() {}.getType();
+ Map<?, ?> blogOptions = gson.fromJson(mBlog.getBlogOptions(), type);
+ if (blogOptions != null) {
+ Map<?, ?> homeURLMap = (Map<?, ?>) blogOptions.get("home_url");
+ if (homeURLMap != null)
+ siteURL = homeURLMap.get("value").toString();
+ }
+ // Try to guess the URL of the site if blogOptions is null (blog not added to the app)
+ if (siteURL == null) {
+ siteURL = mBlog.getUrl().replace("/xmlrpc.php", "");
+ }
+ loadAuthenticatedUrl(siteURL);
+ }
+
+ @Override
+ public void onBlogChanged() {
+ super.onBlogChanged();
+ mBlog = WordPress.currentBlog;
+ loadSiteURL();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ if (mMenuDrawer != null) {
+ mMenuDrawer.toggleMenu();
+ return true;
+ }
+ }
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/WPActionBarActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/WPActionBarActivity.java
new file mode 100644
index 000000000..669668a7e
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/WPActionBarActivity.java
@@ -0,0 +1,1150 @@
+
+package org.wordpress.android.ui;
+
+import android.annotation.TargetApi;
+import android.app.ActionBar;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.content.res.Configuration;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.preference.PreferenceManager;
+import android.support.v4.content.LocalBroadcastManager;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.AbsListView;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemSelectedListener;
+import android.widget.ArrayAdapter;
+import android.widget.BaseAdapter;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import android.widget.RelativeLayout;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import net.simonvt.menudrawer.MenuDrawer;
+import net.simonvt.menudrawer.Position;
+
+import org.wordpress.android.Constants;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.networking.SelfSignedSSLCertsManager;
+import org.wordpress.android.ui.accounts.WelcomeActivity;
+import org.wordpress.android.ui.comments.CommentsActivity;
+import org.wordpress.android.ui.media.MediaBrowserActivity;
+import org.wordpress.android.ui.notifications.NotificationsActivity;
+import org.wordpress.android.ui.posts.EditPostActivity;
+import org.wordpress.android.ui.posts.PagesActivity;
+import org.wordpress.android.ui.posts.PostsActivity;
+import org.wordpress.android.ui.prefs.PreferencesActivity;
+import org.wordpress.android.ui.reader.ReaderPostListActivity;
+import org.wordpress.android.ui.stats.StatsActivity;
+import org.wordpress.android.ui.themes.ThemeBrowserActivity;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.DeviceUtils;
+import org.wordpress.android.util.DisplayUtils;
+import org.wordpress.android.util.SimperiumUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.ToastUtils.Duration;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+import org.xmlrpc.android.ApiHelper;
+import org.xmlrpc.android.ApiHelper.ErrorType;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Base class for Activities that include a standard action bar and menu drawer.
+ */
+public abstract class WPActionBarActivity extends Activity {
+ public static final int NEW_BLOG_CANCELED = 10;
+
+ /**
+ * AuthenticatorRequest code used when no accounts exist, and user is prompted to add an
+ * account.
+ */
+ private static final int ADD_ACCOUNT_REQUEST = 100;
+ /**
+ * AuthenticatorRequest code for reloading menu after returning from the PreferencesActivity.
+ */
+ private static final int SETTINGS_REQUEST = 200;
+ /**
+ * AuthenticatorRequest code for re-authentication
+ */
+ private static final int AUTHENTICATE_REQUEST = 300;
+
+ /**
+ * Used to restore active activity on app creation
+ */
+ protected static final int READER_ACTIVITY = 0;
+ protected static final int POSTS_ACTIVITY = 1;
+ protected static final int MEDIA_ACTIVITY = 2;
+ protected static final int PAGES_ACTIVITY = 3;
+ protected static final int COMMENTS_ACTIVITY = 4;
+ protected static final int THEMES_ACTIVITY = 5;
+ protected static final int STATS_ACTIVITY = 6;
+ protected static final int QUICK_PHOTO_ACTIVITY = 7;
+ protected static final int QUICK_VIDEO_ACTIVITY = 8;
+ protected static final int VIEW_SITE_ACTIVITY = 9;
+ protected static final int DASHBOARD_ACTIVITY = 10;
+ protected static final int NOTIFICATIONS_ACTIVITY = 11;
+
+ protected static final String LAST_ACTIVITY_PREFERENCE = "wp_pref_last_activity";
+
+ protected MenuDrawer mMenuDrawer;
+ private static int[] blogIDs;
+ protected boolean isAnimatingRefreshButton;
+ protected boolean mShouldFinish;
+ private boolean mBlogSpinnerInitialized;
+ private boolean mReauthCanceled;
+ private boolean mNewBlogActivityRunning;
+
+ private MenuAdapter mAdapter;
+ protected List<MenuDrawerItem> mMenuItems = new ArrayList<MenuDrawerItem>();
+ private ListView mListView;
+ private Spinner mBlogSpinner;
+ protected boolean mFirstLaunch = false;
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // configure all the available menu items
+ mMenuItems.add(new ReaderMenuItem());
+ mMenuItems.add(new NotificationsMenuItem());
+ mMenuItems.add(new PostsMenuItem());
+ mMenuItems.add(new MediaMenuItem());
+ mMenuItems.add(new PagesMenuItem());
+ mMenuItems.add(new CommentsMenuItem());
+ mMenuItems.add(new ThemesMenuItem());
+ mMenuItems.add(new StatsMenuItem());
+ mMenuItems.add(new QuickPhotoMenuItem());
+ mMenuItems.add(new QuickVideoMenuItem());
+ mMenuItems.add(new ViewSiteMenuItem());
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ unregisterReceiver();
+
+ if (isAnimatingRefreshButton) {
+ isAnimatingRefreshButton = false;
+ }
+ if (mShouldFinish) {
+ overridePendingTransition(0, 0);
+ finish();
+ } else {
+ WordPress.shouldRestoreSelectedActivity = true;
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ registerReceiver();
+ refreshMenuDrawer();
+ }
+
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
+ protected boolean isActivityDestroyed() {
+ return (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 && isDestroyed());
+ }
+
+ protected void refreshMenuDrawer(){
+ // the current blog may have changed while we were away
+ setupCurrentBlog();
+ if (mMenuDrawer != null) {
+ updateMenuDrawer();
+ }
+
+ Blog currentBlog = WordPress.getCurrentBlog();
+
+ if (currentBlog != null && mListView != null && mListView.getHeaderViewsCount() > 0) {
+ for (int i = 0; i < blogIDs.length; i++) {
+ if (blogIDs[i] == currentBlog.getLocalTableBlogId()) {
+ if (mBlogSpinner != null) {
+ mBlogSpinner.setSelection(i);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Create a menu drawer and attach it to the activity.
+ *
+ * @param contentViewID {@link View} of the main content for the activity.
+ */
+ protected void createMenuDrawer(int contentViewID) {
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+
+ mMenuDrawer = attachMenuDrawer();
+ mMenuDrawer.setContentView(contentViewID);
+
+ initMenuDrawer();
+ }
+
+ /**
+ * Create a menu drawer and attach it to the activity.
+ *
+ * @param contentView {@link View} of the main content for the activity.
+ */
+ protected void createMenuDrawer(View contentView) {
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+
+ mMenuDrawer = attachMenuDrawer();
+ mMenuDrawer.setContentView(contentView);
+
+ initMenuDrawer();
+ }
+
+ /**
+ * returns true if this is an extra-large device in landscape mode
+ */
+ protected boolean isXLargeLandscape() {
+ return isXLarge() && (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE);
+ }
+
+ protected boolean isXLarge() {
+ return ((getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) ==
+ Configuration.SCREENLAYOUT_SIZE_XLARGE);
+ }
+
+ protected boolean isLargeOrXLarge() {
+ int mask = (getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK);
+ return (mask == Configuration.SCREENLAYOUT_SIZE_LARGE
+ || mask == Configuration.SCREENLAYOUT_SIZE_XLARGE);
+ }
+
+ /**
+ * Attach a menu drawer to the Activity
+ * Set to be a static drawer if on a landscape x-large device
+ */
+ private MenuDrawer attachMenuDrawer() {
+ final MenuDrawer menuDrawer;
+ ActionBar actionBar = getActionBar();
+
+ if (isStaticMenuDrawer()) {
+ menuDrawer = MenuDrawer.attach(this, MenuDrawer.Type.STATIC, Position.LEFT);
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(false);
+ }
+ } else {
+ menuDrawer = MenuDrawer.attach(this, MenuDrawer.Type.OVERLAY);
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+ menuDrawer.setDrawerIndicatorEnabled(true);
+ }
+
+ int shadowSizeInPixels = getResources().getDimensionPixelSize(R.dimen.menu_shadow_width);
+ menuDrawer.setDropShadowSize(shadowSizeInPixels);
+ menuDrawer.setDropShadowColor(getResources().getColor(R.color.md__shadowColor));
+ menuDrawer.setSlideDrawable(R.drawable.ic_drawer);
+ return menuDrawer;
+ }
+
+ public boolean isStaticMenuDrawer() {
+ return isXLargeLandscape();
+ }
+
+ private void initMenuDrawer() {
+ initMenuDrawer(-1);
+ }
+
+ /**
+ * Create menu drawer ListView and listeners
+ */
+ private void initMenuDrawer(int blogSelection) {
+ mListView = new ListView(this);
+ mListView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
+ mListView.setDivider(null);
+ mListView.setDividerHeight(0);
+ mListView.setCacheColorHint(android.R.color.transparent);
+
+ // if the ActionBar overlays window content, we must insert a view which is the same
+ // height as the ActionBar as the first header in the ListView - without this the
+ // ActionBar will cover the first item
+ if (DisplayUtils.hasActionBarOverlay(getWindow())) {
+ final int actionbarHeight = DisplayUtils.getActionBarHeight(this);
+ RelativeLayout header = new RelativeLayout(this);
+ header.setLayoutParams(new AbsListView.LayoutParams(AbsListView.LayoutParams.MATCH_PARENT, actionbarHeight));
+ mListView.addHeaderView(header, null, false);
+ }
+
+ mAdapter = new MenuAdapter(this);
+ String[] blogNames = getBlogNames();
+ if (blogNames.length > 1) {
+ addBlogSpinner(blogNames);
+ }
+
+ mListView.setOnItemClickListener(new AdapterView.OnItemClickListener(){
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ // account for header views
+ int menuPosition = position - mListView.getHeaderViewsCount();
+ // bail if the adjusted position is out of bounds for the adapter
+ if (menuPosition < 0 || menuPosition >= mAdapter.getCount())
+ return;
+ MenuDrawerItem item = mAdapter.getItem(menuPosition);
+ // if the item has an id, remember it for launch
+ if (item.hasItemId()){
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(WPActionBarActivity.this);
+ SharedPreferences.Editor editor = settings.edit();
+ editor.putInt(LAST_ACTIVITY_PREFERENCE, item.getItemId());
+ editor.commit();
+ }
+ // only perform selection if the item isn't already selected
+ if (!item.isSelected())
+ item.selectItem();
+ // save the last activity preference
+ // close the menu drawer
+ mMenuDrawer.closeMenu();
+ // if we have an intent, start the new activity
+ }
+ });
+ mListView.setOnScrollListener(new AbsListView.OnScrollListener() {
+ @Override
+ public void onScrollStateChanged(AbsListView view, int scrollState) {
+ }
+
+ @Override
+ public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
+ int totalItemCount) {
+ mMenuDrawer.invalidate();
+ }
+ });
+
+ mMenuDrawer.setMenuView(mListView);
+ mListView.setAdapter(mAdapter);
+ if (blogSelection != -1 && mBlogSpinner != null) {
+ mBlogSpinner.setSelection(blogSelection);
+ }
+ updateMenuDrawer();
+ }
+
+ private void addBlogSpinner(String[] blogNames) {
+ LayoutInflater layoutInflater = (LayoutInflater) this.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ LinearLayout spinnerWrapper = (LinearLayout) layoutInflater.inflate(R.layout.blog_spinner, null);
+ if (spinnerWrapper != null) {
+ spinnerWrapper.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (mBlogSpinner != null) {
+ mBlogSpinner.performClick();
+ }
+ }
+ });
+ }
+ mBlogSpinner = (Spinner) spinnerWrapper.findViewById(R.id.blog_spinner);
+ mBlogSpinner.setOnItemSelectedListener(mItemSelectedListener);
+ populateBlogSpinner(blogNames);
+ mListView.addHeaderView(spinnerWrapper);
+ }
+
+ /*
+ * sets the adapter for the blog spinner and populates it with the passed array of blog names
+ */
+ private void populateBlogSpinner(String[] blogNames) {
+ if (mBlogSpinner == null)
+ return;
+ mBlogSpinnerInitialized = false;
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ mBlogSpinner.setAdapter(new BlogSpinnerAdapter(actionBar.getThemedContext(), blogNames));
+ } else {
+ mBlogSpinner.setAdapter(new BlogSpinnerAdapter(this, blogNames));
+ }
+ }
+
+ /*
+ * update the blog names shown by the blog spinner
+ */
+ protected void refreshBlogSpinner(String[] blogNames) {
+ // spinner will be null if it's not supposed to be shown
+ if (mBlogSpinner == null || mBlogSpinner.getAdapter() == null) {
+ return;
+ }
+
+ ((BlogSpinnerAdapter) mBlogSpinner.getAdapter()).setBlogNames(blogNames);
+ }
+
+ /*
+ * adapter used by the blog spinner - shows the name of each blog
+ */
+ private class BlogSpinnerAdapter extends BaseAdapter {
+ private String[] mBlogNames;
+ private LayoutInflater mInflater;
+
+ BlogSpinnerAdapter(Context context, String[] blogNames) {
+ super();
+ mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ mBlogNames = blogNames;
+ }
+
+ protected void setBlogNames(String[] blogNames) {
+ mBlogNames = blogNames;
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public int getCount() {
+ return (mBlogNames != null ? mBlogNames.length : 0);
+ }
+
+ @Override
+ public Object getItem(int position) {
+ if (position < 0 || position >= getCount())
+ return "";
+ return mBlogNames[position];
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ final View view;
+ if (convertView == null) {
+ view = mInflater.inflate(R.layout.spinner_menu_dropdown_item, parent, false);
+ } else {
+ view = convertView;
+ }
+
+ final TextView text = (TextView) view.findViewById(R.id.menu_text_dropdown);
+ text.setText((String)getItem(position));
+
+ return view;
+ }
+ }
+
+ protected void startActivityWithDelay(final Intent i) {
+ if (isXLargeLandscape()) {
+ // Tablets in landscape don't need a delay because the menu drawer doesn't close
+ startActivity(i);
+ } else {
+ // When switching to LAST_ACTIVITY_PREFERENCE onCreate we don't need to delay
+ if (mFirstLaunch) {
+ startActivity(i);
+ return;
+ }
+ // Let the menu animation finish before starting a new activity
+ new Handler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ startActivity(i);
+ }
+ }, 400);
+ }
+ }
+
+ /**
+ * Update all of the items in the menu drawer based on the current active blog.
+ */
+ public void updateMenuDrawer() {
+ mAdapter.clear();
+ // iterate over the available menu items and only show the ones that should be visible
+ Iterator<MenuDrawerItem> availableItems = mMenuItems.iterator();
+ while (availableItems.hasNext()) {
+ MenuDrawerItem item = availableItems.next();
+ if (item.isVisible()) {
+ mAdapter.add(item);
+ }
+ }
+ mAdapter.notifyDataSetChanged();
+ }
+
+ public static class MenuAdapter extends ArrayAdapter<MenuDrawerItem> {
+ MenuAdapter(Context context) {
+ super(context, R.layout.menu_drawer_row, R.id.menu_row_title, new ArrayList<MenuDrawerItem>());
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ View view = super.getView(position, convertView, parent);
+ MenuDrawerItem item = getItem(position);
+
+ TextView titleTextView = (TextView) view.findViewById(R.id.menu_row_title);
+ titleTextView.setText(item.getTitleRes());
+
+ ImageView iconImageView = (ImageView) view.findViewById(R.id.menu_row_icon);
+ iconImageView.setImageResource(item.getIconRes());
+ // Hide the badge always
+ view.findViewById(R.id.menu_row_badge).setVisibility(View.GONE);
+
+ if (item.isSelected()) {
+ // http://stackoverflow.com/questions/5890379/setbackgroundresource-discards-my-xml-layout-attributes
+ int bottom = view.getPaddingBottom();
+ int top = view.getPaddingTop();
+ int right = view.getPaddingRight();
+ int left = view.getPaddingLeft();
+ view.setBackgroundResource(R.color.blue_dark);
+ view.setPadding(left, top, right, bottom);
+ } else {
+ view.setBackgroundResource(R.drawable.md_list_selector);
+ }
+ // allow the menudrawer item to configure the view
+ item.configureView(view);
+
+ return view;
+ }
+ }
+
+
+ /**
+ * Called when the activity has detected the user's press of the back key.
+ * If the activity has a menu drawer attached that is opened or in the
+ * process of opening, the back button press closes it. Otherwise, the
+ * normal back action is taken.
+ */
+ @Override
+ public void onBackPressed() {
+ if (mMenuDrawer != null) {
+ final int drawerState = mMenuDrawer.getDrawerState();
+ if (drawerState == MenuDrawer.STATE_OPEN || drawerState == MenuDrawer.STATE_OPENING) {
+ mMenuDrawer.closeMenu();
+ return;
+ }
+ }
+ super.onBackPressed();
+ }
+
+ /**
+ * Get the names of all the blogs configured within the application. If a
+ * blog does not have a specific name, the blog URL is returned.
+ *
+ * @return array of blog names
+ */
+ protected static String[] getBlogNames() {
+ List<Map<String, Object>> accounts = WordPress.wpDB.getVisibleAccounts();
+
+ int blogCount = accounts.size();
+ blogIDs = new int[blogCount];
+ String[] blogNames = new String[blogCount];
+
+ for (int i = 0; i < blogCount; i++) {
+ Map<String, Object> account = accounts.get(i);
+ String name;
+ if (account.get("blogName") != null) {
+ name = StringUtils.unescapeHTML(account.get("blogName").toString());
+ if (name.trim().length() == 0) {
+ name = StringUtils.getHost(account.get("url").toString());
+ }
+ } else {
+ name = StringUtils.getHost(account.get("url").toString());
+ }
+ blogNames[i] = name;
+ blogIDs[i] = Integer.valueOf(account.get("id").toString());
+ }
+
+ return blogNames;
+ }
+
+ private boolean askToSignInIfNot() {
+ if (!WordPress.isSignedIn(WPActionBarActivity.this)) {
+ AppLog.d(T.NUX, "No accounts configured. Sending user to set up an account");
+ mShouldFinish = false;
+ Intent intent = new Intent(this, WelcomeActivity.class);
+ intent.putExtra("request", WelcomeActivity.SIGN_IN_REQUEST);
+ startActivityForResult(intent, ADD_ACCOUNT_REQUEST);
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Setup the global state tracking which blog is currently active if the user is signed in.
+ */
+ public void setupCurrentBlog() {
+ if (askToSignInIfNot()) {
+ WordPress.getCurrentBlog();
+ }
+ }
+
+ private void showReader() {
+ Intent intent;
+ intent = new Intent(WPActionBarActivity.this, ReaderPostListActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivity(intent);
+ }
+
+ /*
+ * redirect to the Reader if there aren't any visible blogs
+ * returns true if redirected, false otherwise
+ */
+ protected boolean showReaderIfNoBlog() {
+ if (WordPress.wpDB.getNumVisibleAccounts() == 0) {
+ showReader();
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ switch (requestCode) {
+ case ADD_ACCOUNT_REQUEST:
+ mNewBlogActivityRunning = false;
+ if (resultCode == RESULT_OK) {
+ // new blog has been added, so rebuild cache of blogs and setup current blog
+ getBlogNames();
+ setupCurrentBlog();
+ initMenuDrawer();
+ mMenuDrawer.openMenu(false);
+ WordPress.registerForCloudMessaging(this);
+ // If logged in without blog, redirect to the Reader view
+ showReaderIfNoBlog();
+ } else {
+ finish();
+ }
+ break;
+ case SETTINGS_REQUEST:
+ // user returned from settings - skip if user signed out
+ if (mMenuDrawer != null && resultCode != PreferencesActivity.RESULT_SIGNED_OUT) {
+ // If we need to add or remove the blog spinner, init the drawer again
+ initMenuDrawer();
+
+ String[] blogNames = getBlogNames();
+ if (blogNames.length >= 1) {
+ setupCurrentBlog();
+ }
+ if (data != null && data.getBooleanExtra(PreferencesActivity.CURRENT_BLOG_CHANGED, true)) {
+ onBlogChanged();
+ }
+ WordPress.registerForCloudMessaging(this);
+ }
+
+ break;
+ case AUTHENTICATE_REQUEST:
+ if (resultCode == RESULT_CANCELED) {
+ mReauthCanceled = true;
+ Intent i = new Intent(this, WelcomeActivity.class);
+ startActivityForResult(i, ADD_ACCOUNT_REQUEST);
+ } else {
+ WordPress.registerForCloudMessaging(this);
+ }
+ break;
+ }
+ }
+
+ private OnItemSelectedListener mItemSelectedListener = new OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+ // http://stackoverflow.com/questions/5624825/spinner-onitemselected-executes-when-it-is-not-suppose-to/5918177#5918177
+ if (!mBlogSpinnerInitialized) {
+ mBlogSpinnerInitialized = true;
+ } else {
+ WordPress.setCurrentBlog(blogIDs[position]);
+ updateMenuDrawer();
+ onBlogChanged();
+ }
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> parent) {
+ }
+ };
+
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ if (mMenuDrawer != null) {
+ mMenuDrawer.toggleMenu();
+ return true;
+ } else {
+ onBackPressed();
+ }
+ } else if (item.getItemId() == R.id.menu_settings) {
+ Intent i = new Intent(this, PreferencesActivity.class);
+ startActivityForResult(i, SETTINGS_REQUEST);
+ } else if (item.getItemId() == R.id.menu_signout) {
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(this);
+ dialogBuilder.setTitle(getResources().getText(R.string.sign_out));
+ dialogBuilder.setMessage(getString(R.string.sign_out_confirm));
+ dialogBuilder.setPositiveButton(R.string.sign_out,
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog,
+ int whichButton) {
+ WordPress.signOut(WPActionBarActivity.this);
+ refreshMenuDrawer();
+ }
+ });
+ dialogBuilder.setNegativeButton(R.string.cancel,
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog,
+ int whichButton) {
+ // Just close the window.
+ }
+ });
+ dialogBuilder.setCancelable(true);
+ if (!isFinishing())
+ dialogBuilder.create().show();
+ } else if (item.getItemId() == R.id.menu_refresh) {
+ // Broadcast a refresh action, PullToRefreshHelper should trigger the default pull to refresh action
+ WordPress.sendLocalBroadcast(this, WordPress.BROADCAST_ACTION_REFRESH_MENU_PRESSED);
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void refreshCurrentBlogContent() {
+ if (WordPress.getCurrentBlog() != null) {
+ ApiHelper.GenericCallback callback = new ApiHelper.GenericCallback() {
+ @Override
+ public void onSuccess() {
+ if (isFinishing()) {
+ return;
+ }
+ // refresh spinner in case a blog's name has changed
+ refreshBlogSpinner(getBlogNames());
+ updateMenuDrawer();
+ }
+
+ @Override
+ public void onFailure(ErrorType errorType, String errorMessage, Throwable throwable) {
+ }
+ };
+ new ApiHelper.RefreshBlogContentTask(this, WordPress.getCurrentBlog(), callback).executeOnExecutor(
+ AsyncTask.THREAD_POOL_EXECUTOR, false);
+ }
+ }
+
+ /**
+ * This method is called when the user changes the active blog or hides all blogs
+ */
+ public void onBlogChanged() {
+ WordPress.wpDB.updateLastBlogId(WordPress.getCurrentLocalTableBlogId());
+ // the menu may have changed, we need to change the selection if the selected item
+ // is not available in the menu anymore
+ Iterator<MenuDrawerItem> itemIterator = mMenuItems.iterator();
+ while (itemIterator.hasNext()) {
+ MenuDrawerItem item = itemIterator.next();
+ // if the item is selected, but it's no longer visible we need to
+ // select the first available item from the adapter
+ if (item.isSelected() && !item.isVisible()) {
+ // then select the first item and activate it
+ if (mAdapter.getCount() > 0) {
+ mAdapter.getItem(0).selectItem();
+ }
+ // if it has an item id save it to the preferences
+ if (item.hasItemId()) {
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(
+ WPActionBarActivity.this);
+ SharedPreferences.Editor editor = settings.edit();
+ editor.putInt(LAST_ACTIVITY_PREFERENCE, item.getItemId());
+ editor.commit();
+ }
+ break;
+ }
+ }
+
+ refreshCurrentBlogContent();
+ if (shouldUpdateCurrentBlogStatsInBackground()) {
+ WordPress.sUpdateCurrentBlogStats.forceRun();
+ }
+ }
+
+ /**
+ * this method is called when the user switch blog - descendants should override
+ * if want to stop refreshing of Stats when switching blog.
+ */
+ protected boolean shouldUpdateCurrentBlogStatsInBackground() {
+ return true;
+ }
+
+ /**
+ * this method is called when the user signs out of the app - descendants should override
+ * this to perform activity-specific cleanup upon signout
+ */
+ public void onSignout() {
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ if (isXLarge()) {
+ if (mMenuDrawer != null) {
+ // Re-attach the drawer if an XLarge device is rotated, so it can be static if in landscape
+ View content = mMenuDrawer.getContentContainer().getChildAt(0);
+ if (content != null) {
+ mMenuDrawer.getContentContainer().removeView(content);
+ mMenuDrawer = attachMenuDrawer();
+ mMenuDrawer.setContentView(content);
+ if (mBlogSpinner != null) {
+ initMenuDrawer(mBlogSpinner.getSelectedItemPosition());
+ } else {
+ initMenuDrawer();
+ }
+ }
+ }
+ }
+ super.onConfigurationChanged(newConfig);
+ }
+
+ private class ReaderMenuItem extends MenuDrawerItem {
+ ReaderMenuItem(){
+ super(READER_ACTIVITY, R.string.reader, R.drawable.dashboard_icon_subs);
+ }
+
+ @Override
+ public Boolean isVisible(){
+ return WordPress.hasValidWPComCredentials(WPActionBarActivity.this);
+ }
+
+ @Override
+ public Boolean isSelected(){
+ return WPActionBarActivity.this instanceof ReaderPostListActivity;
+ }
+ @Override
+ public void onSelectItem(){
+ if (!isSelected())
+ mShouldFinish = true;
+ Intent intent;
+ intent = new Intent(WPActionBarActivity.this, ReaderPostListActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivityWithDelay(intent);
+ }
+ }
+
+ private class PostsMenuItem extends MenuDrawerItem {
+ PostsMenuItem() {
+ super(POSTS_ACTIVITY, R.string.posts, R.drawable.dashboard_icon_posts);
+ }
+
+ @Override
+ public Boolean isSelected() {
+ WPActionBarActivity activity = WPActionBarActivity.this;
+ return (activity instanceof PostsActivity) && !(activity instanceof PagesActivity);
+ }
+
+ @Override
+ public void onSelectItem() {
+ if (!(WPActionBarActivity.this instanceof PostsActivity)
+ || (WPActionBarActivity.this instanceof PagesActivity)) {
+ mShouldFinish = true;
+ AnalyticsTracker.track(AnalyticsTracker.Stat.OPENED_POSTS);
+ }
+ Intent intent = new Intent(WPActionBarActivity.this, PostsActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivityWithDelay(intent);
+ }
+ @Override
+ public Boolean isVisible() {
+ return WordPress.wpDB.getNumVisibleAccounts() != 0;
+ }
+ }
+
+ private class MediaMenuItem extends MenuDrawerItem {
+ MediaMenuItem(){
+ super(MEDIA_ACTIVITY, R.string.media, R.drawable.dashboard_icon_media);
+ }
+ @Override
+ public Boolean isSelected(){
+ return WPActionBarActivity.this instanceof MediaBrowserActivity;
+ }
+ @Override
+ public void onSelectItem(){
+ if (!(WPActionBarActivity.this instanceof MediaBrowserActivity)) {
+ mShouldFinish = true;
+ AnalyticsTracker.track(AnalyticsTracker.Stat.OPENED_MEDIA_LIBRARY);
+ }
+ Intent intent = new Intent(WPActionBarActivity.this, MediaBrowserActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivityWithDelay(intent);
+ }
+ @Override
+ public Boolean isVisible() {
+ return WordPress.wpDB.getNumVisibleAccounts() != 0;
+ }
+ }
+
+ private class PagesMenuItem extends MenuDrawerItem {
+ PagesMenuItem(){
+ super(PAGES_ACTIVITY, R.string.pages, R.drawable.dashboard_icon_pages);
+ }
+ @Override
+ public Boolean isSelected(){
+ return WPActionBarActivity.this instanceof PagesActivity;
+ }
+ @Override
+ public void onSelectItem(){
+ if (WordPress.getCurrentBlog() == null)
+ return;
+ if (!(WPActionBarActivity.this instanceof PagesActivity)) {
+ mShouldFinish = true;
+ AnalyticsTracker.track(AnalyticsTracker.Stat.OPENED_PAGES);
+ }
+ Intent intent = new Intent(WPActionBarActivity.this, PagesActivity.class);
+ intent.putExtra("id", WordPress.getCurrentBlog().getLocalTableBlogId());
+ intent.putExtra("isNew", true);
+ intent.putExtra(PostsActivity.EXTRA_VIEW_PAGES, true);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivityWithDelay(intent);
+ }
+ @Override
+ public Boolean isVisible() {
+ return WordPress.wpDB.getNumVisibleAccounts() != 0;
+ }
+ }
+
+ private class CommentsMenuItem extends MenuDrawerItem {
+ CommentsMenuItem(){
+ super(COMMENTS_ACTIVITY, R.string.tab_comments, R.drawable.dashboard_icon_comments);
+ }
+ @Override
+ public Boolean isSelected(){
+ return WPActionBarActivity.this instanceof CommentsActivity;
+ }
+ @Override
+ public void onSelectItem(){
+ if (WordPress.getCurrentBlog() == null)
+ return;
+ if (!(WPActionBarActivity.this instanceof CommentsActivity)) {
+ mShouldFinish = true;
+ AnalyticsTracker.track(AnalyticsTracker.Stat.OPENED_COMMENTS);
+ }
+ Intent intent = new Intent(WPActionBarActivity.this, CommentsActivity.class);
+ intent.putExtra("id", WordPress.getCurrentBlog().getLocalTableBlogId());
+ intent.putExtra("isNew", true);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivityWithDelay(intent);
+ }
+ @Override
+ public void configureView(View view){
+ if (WordPress.getCurrentBlog() != null) {
+ TextView bagdeTextView = (TextView) view.findViewById(R.id.menu_row_badge);
+ int commentCount = WordPress.getCurrentBlog().getUnmoderatedCommentCount();
+ if (commentCount > 0) {
+ bagdeTextView.setVisibility(View.VISIBLE);
+ } else
+ {
+ bagdeTextView.setVisibility(View.GONE);
+ }
+ bagdeTextView.setText(String.valueOf(commentCount));
+ }
+ }
+ @Override
+ public Boolean isVisible() {
+ return WordPress.wpDB.getNumVisibleAccounts() != 0;
+ }
+ }
+
+ private class ThemesMenuItem extends MenuDrawerItem {
+ ThemesMenuItem(){
+ super(THEMES_ACTIVITY, R.string.themes, R.drawable.dashboard_icon_themes);
+ }
+ @Override
+ public Boolean isSelected(){
+ return WPActionBarActivity.this instanceof ThemeBrowserActivity;
+ }
+ @Override
+ public void onSelectItem(){
+ if (!(WPActionBarActivity.this instanceof ThemeBrowserActivity))
+ mShouldFinish = true;
+ Intent intent = new Intent(WPActionBarActivity.this, ThemeBrowserActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivityWithDelay(intent);
+ }
+
+ @Override
+ public Boolean isVisible() {
+ if (WordPress.getCurrentBlog() != null && WordPress.getCurrentBlog().isAdmin() && WordPress.getCurrentBlog().isDotcomFlag())
+ return true;
+ return false;
+ }
+ }
+
+
+ private class StatsMenuItem extends MenuDrawerItem {
+ StatsMenuItem(){
+ super(STATS_ACTIVITY, R.string.tab_stats, R.drawable.dashboard_icon_stats);
+ }
+ @Override
+ public Boolean isSelected(){
+ return WPActionBarActivity.this instanceof StatsActivity;
+ }
+ @Override
+ public void onSelectItem(){
+ if (WordPress.getCurrentBlog() == null)
+ return;
+ if (!isSelected())
+ mShouldFinish = true;
+
+ Intent intent = new Intent(WPActionBarActivity.this, StatsActivity.class);
+ intent.putExtra("id", WordPress.getCurrentBlog().getLocalTableBlogId());
+ intent.putExtra("isNew", true);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivityWithDelay(intent);
+ }
+ @Override
+ public Boolean isVisible() {
+ return WordPress.wpDB.getNumVisibleAccounts() != 0;
+ }
+ }
+
+ private class QuickPhotoMenuItem extends MenuDrawerItem {
+ QuickPhotoMenuItem(){
+ super(R.string.quick_photo, R.drawable.dashboard_icon_photo);
+ }
+ @Override
+ public void onSelectItem(){
+ mShouldFinish = false;
+ Intent intent = new Intent(WPActionBarActivity.this, EditPostActivity.class);
+ intent.putExtra("quick-media", DeviceUtils.getInstance().hasCamera(getApplicationContext())
+ ? Constants.QUICK_POST_PHOTO_CAMERA
+ : Constants.QUICK_POST_PHOTO_LIBRARY);
+ intent.putExtra("isNew", true);
+ startActivityWithDelay(intent);
+ }
+ @Override
+ public Boolean isVisible() {
+ return WordPress.wpDB.getNumVisibleAccounts() != 0;
+ }
+ }
+
+ private class QuickVideoMenuItem extends MenuDrawerItem {
+ QuickVideoMenuItem(){
+ super(R.string.quick_video, R.drawable.dashboard_icon_video);
+ }
+ @Override
+ public void onSelectItem(){
+ mShouldFinish = false;
+ Intent intent = new Intent(WPActionBarActivity.this, EditPostActivity.class);
+ intent.putExtra("quick-media", DeviceUtils.getInstance().hasCamera(getApplicationContext())
+ ? Constants.QUICK_POST_VIDEO_CAMERA
+ : Constants.QUICK_POST_VIDEO_LIBRARY);
+ intent.putExtra("isNew", true);
+ startActivityWithDelay(intent);
+ }
+ @Override
+ public Boolean isVisible() {
+ return WordPress.wpDB.getNumVisibleAccounts() != 0;
+ }
+ }
+
+ private class ViewSiteMenuItem extends MenuDrawerItem {
+ ViewSiteMenuItem(){
+ super(VIEW_SITE_ACTIVITY, R.string.view_site, R.drawable.dashboard_icon_view);
+ }
+ @Override
+ public Boolean isSelected(){
+ return WPActionBarActivity.this instanceof ViewSiteActivity;
+ }
+ @Override
+ public void onSelectItem(){
+ if (!(WPActionBarActivity.this instanceof ViewSiteActivity)) {
+ mShouldFinish = true;
+ AnalyticsTracker.track(AnalyticsTracker.Stat.OPENED_VIEW_SITE);
+ }
+ Intent intent = new Intent(WPActionBarActivity.this, ViewSiteActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivityWithDelay(intent);
+ }
+ @Override
+ public Boolean isVisible() {
+ return WordPress.wpDB.getNumVisibleAccounts() != 0;
+ }
+ }
+
+ private class NotificationsMenuItem extends MenuDrawerItem {
+ NotificationsMenuItem(){
+ super(NOTIFICATIONS_ACTIVITY, R.string.notifications, R.drawable.dashboard_icon_notifications);
+ }
+ @Override
+ public Boolean isVisible(){
+ return WordPress.hasValidWPComCredentials(WPActionBarActivity.this);
+ }
+ @Override
+ public Boolean isSelected(){
+ return WPActionBarActivity.this instanceof NotificationsActivity;
+ }
+ @Override
+ public void onSelectItem(){
+ if (!(WPActionBarActivity.this instanceof NotificationsActivity))
+ mShouldFinish = true;
+ Intent intent = new Intent(WPActionBarActivity.this, NotificationsActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivityWithDelay(intent);
+ }
+ }
+
+ /**
+ * broadcast receiver which detects when user signs out of the app and calls onSignout()
+ * so descendants of this activity can do cleanup upon signout
+ */
+ private void registerReceiver() {
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(WordPress.BROADCAST_ACTION_SIGNOUT);
+ filter.addAction(WordPress.BROADCAST_ACTION_XMLRPC_TWO_FA_AUTH);
+ filter.addAction(WordPress.BROADCAST_ACTION_XMLRPC_INVALID_CREDENTIALS);
+ filter.addAction(WordPress.BROADCAST_ACTION_XMLRPC_INVALID_SSL_CERTIFICATE);
+ filter.addAction(WordPress.BROADCAST_ACTION_XMLRPC_LOGIN_LIMIT);
+ filter.addAction(WordPress.BROADCAST_ACTION_BLOG_LIST_CHANGED);
+ LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(this);
+ lbm.registerReceiver(mReceiver, filter);
+ }
+
+ private void unregisterReceiver() {
+ try {
+ LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(this);
+ lbm.unregisterReceiver(mReceiver);
+ } catch (IllegalArgumentException e) {
+ // exception occurs if receiver already unregistered (safe to ignore)
+ }
+ }
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null || intent.getAction() == null)
+ return;
+ if (intent.getAction().equals(WordPress.BROADCAST_ACTION_SIGNOUT)) {
+ onSignout();
+ }
+ if (intent.getAction().equals(WordPress.BROADCAST_ACTION_XMLRPC_INVALID_CREDENTIALS)) {
+ ToastUtils.showAuthErrorDialog(WPActionBarActivity.this);
+ }
+ if (intent.getAction().equals(SimperiumUtils.BROADCAST_ACTION_SIMPERIUM_NOT_AUTHORIZED) &&
+ WPActionBarActivity.this instanceof NotificationsActivity) {
+ ToastUtils.showAuthErrorDialog(WPActionBarActivity.this, R.string.sign_in_again, R.string.simperium_connection_error);
+ }
+ if (intent.getAction().equals(WordPress.BROADCAST_ACTION_XMLRPC_TWO_FA_AUTH)) {
+ // TODO: add a specific message like "you must use a specific app password"
+ ToastUtils.showAuthErrorDialog(WPActionBarActivity.this);
+ }
+ if (intent.getAction().equals(WordPress.BROADCAST_ACTION_XMLRPC_INVALID_SSL_CERTIFICATE)) {
+ SelfSignedSSLCertsManager.askForSslTrust(WPActionBarActivity.this);
+ }
+ if (intent.getAction().equals(WordPress.BROADCAST_ACTION_XMLRPC_LOGIN_LIMIT)) {
+ ToastUtils.showToast(context, R.string.limit_reached, Duration.LONG);
+ }
+ if (intent.getAction().equals(WordPress.BROADCAST_ACTION_BLOG_LIST_CHANGED)) {
+ initMenuDrawer();
+ }
+ }
+ };
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/WebViewActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/WebViewActivity.java
new file mode 100644
index 000000000..5d7d06f47
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/WebViewActivity.java
@@ -0,0 +1,103 @@
+
+package org.wordpress.android.ui;
+
+import android.app.ActionBar;
+import android.os.Bundle;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.Window;
+import android.webkit.WebView;
+
+import org.wordpress.android.R;
+
+/**
+ * Basic activity for displaying a WebView.
+ */
+public class WebViewActivity extends WPActionBarActivity {
+ /** Primary webview used to display content. */
+ protected WebView mWebView;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ requestWindowFeature(Window.FEATURE_PROGRESS);
+
+ super.onCreate(savedInstanceState);
+
+ // clear title text so there's no title until actual web page title can be shown
+ // this is done here rather than in the manifest to automatically handle descendants
+ // such as AuthenticatedWebViewActivity
+ setTitle("");
+
+ setContentView(R.layout.webview);
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
+ actionBar.setDisplayShowTitleEnabled(true);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+ // note: do NOT call mWebView.getSettings().setUserAgentString(WordPress.getUserAgent())
+ // here since it causes problems with the browser-sniffing that some sites rely on to
+ // format the page for mobile display
+ mWebView = (WebView) findViewById(R.id.webView);
+ mWebView.getSettings().setBuiltInZoomControls(true);
+ mWebView.setScrollBarStyle(View.SCROLLBARS_INSIDE_OVERLAY);
+
+ // load URL if one was provided in the intent
+ String url = getIntent().getStringExtra("url");
+ if (url != null) {
+ loadUrl(url);
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ // Flash video may keep playing if the webView isn't paused here
+ pauseWebView();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ resumeWebView();
+ }
+
+ private void pauseWebView() {
+ if (mWebView != null) {
+ mWebView.onPause();
+ }
+ }
+
+ private void resumeWebView() {
+ if (mWebView != null) {
+ mWebView.onResume();
+ }
+ }
+
+ /**
+ * Load the specified URL in the webview.
+ *
+ * @param url URL to load in the webview.
+ */
+ protected void loadUrl(String url) {
+ mWebView.loadUrl(url);
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (mWebView != null && mWebView.canGoBack())
+ mWebView.goBack();
+ else
+ super.onBackPressed();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/CreateUserAndBlog.java b/WordPress/src/main/java/org/wordpress/android/ui/accounts/CreateUserAndBlog.java
new file mode 100644
index 000000000..e084b32b3
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/CreateUserAndBlog.java
@@ -0,0 +1,260 @@
+package org.wordpress.android.ui.accounts;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.res.Resources;
+import android.content.res.XmlResourceParser;
+import android.preference.PreferenceManager;
+
+import com.wordpress.rest.RestRequest;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.BuildConfig;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.WordPressDB;
+import org.wordpress.android.networking.RestClientUtils;
+import org.wordpress.android.ui.reader.actions.ReaderUserActions;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.xmlpull.v1.XmlPullParser;
+
+import java.util.HashMap;
+import java.util.Hashtable;
+import java.util.Locale;
+import java.util.Map;
+
+public class CreateUserAndBlog {
+ public static final int WORDPRESS_COM_API_BLOG_VISIBILITY_PUBLIC = 1;
+ public static final int WORDPRESS_COM_API_BLOG_VISIBILITY_BLOCK_SEARCH_ENGINE = 0;
+ public static final int WORDPRESS_COM_API_BLOG_VISIBILITY_PRIVATE = -1;
+ private String mEmail;
+ private String mUsername;
+ private String mPassword;
+ private String mSiteUrl;
+ private String mSiteName;
+ private String mLanguage;
+ private Context mContext;
+ private Callback mCallback;
+ private NewAccountAbstractPageFragment.ErrorListener mErrorListener;
+ private RestClientUtils mRestClient;
+ private ResponseHandler mResponseHandler;
+
+ public CreateUserAndBlog(String email, String username, String password, String siteUrl, String siteName,
+ String language, RestClientUtils restClient, Context context,
+ NewAccountAbstractPageFragment.ErrorListener errorListener, Callback callback) {
+ mEmail = email;
+ mUsername = username;
+ mPassword = password;
+ mSiteUrl = siteUrl;
+ mSiteName = siteName;
+ mLanguage = language;
+ mCallback = callback;
+ mContext = context;
+ mErrorListener = errorListener;
+ mRestClient = restClient;
+ mResponseHandler = new ResponseHandler();
+ }
+
+ public static String getDeviceLanguage(Resources resources) {
+ XmlResourceParser parser = resources.getXml(R.xml.wpcom_languages);
+ Hashtable<String, String> entries = new Hashtable<String, String>();
+ String matchedDeviceLanguage = "en - English";
+ try {
+ int eventType = parser.getEventType();
+ String deviceLanguageCode = Locale.getDefault().getLanguage();
+
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ if (eventType == XmlPullParser.START_TAG) {
+ String name = parser.getName();
+ if (name.equals("language")) {
+ String currentID = null;
+ boolean currentLangIsDeviceLanguage = false;
+ int i = 0;
+ while (i < parser.getAttributeCount()) {
+ if (parser.getAttributeName(i).equals("id")) {
+ currentID = parser.getAttributeValue(i);
+ }
+ if (parser.getAttributeName(i).equals("code") &&
+ parser.getAttributeValue(i).equalsIgnoreCase(deviceLanguageCode)) {
+ currentLangIsDeviceLanguage = true;
+ }
+ i++;
+ }
+
+ while (eventType != XmlPullParser.END_TAG) {
+ if (eventType == XmlPullParser.TEXT) {
+ entries.put(parser.getText(), currentID);
+ if (currentLangIsDeviceLanguage) {
+ matchedDeviceLanguage = parser.getText();
+ }
+ }
+ eventType = parser.next();
+ }
+ }
+ }
+ eventType = parser.next();
+ }
+ } catch (Exception e) {
+ // do nothing
+ }
+ return matchedDeviceLanguage;
+ }
+
+ public void startCreateUserAndBlogProcess() {
+ validateUser();
+ }
+
+ public void startCreateBlogProcess() {
+ mResponseHandler.setMode(Mode.CREATE_BLOG_ONLY);
+ validateSite();
+ }
+
+ private void validateUser() {
+ String path = "users/new";
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("username", mUsername);
+ params.put("password", mPassword);
+ params.put("email", mEmail);
+ params.put("validate", "1");
+ params.put("client_id", BuildConfig.OAUTH_APP_ID);
+ params.put("client_secret", BuildConfig.OAUTH_APP_SECRET);
+ mResponseHandler.setStep(Step.VALIDATE_USER);
+ mRestClient.post(path, params, null, mResponseHandler, mErrorListener);
+ }
+
+ private void validateSite() {
+ String path = "sites/new";
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("blog_name", mSiteUrl);
+ params.put("blog_title", mSiteName);
+ params.put("lang_id", mLanguage);
+ params.put("public", String.valueOf(WORDPRESS_COM_API_BLOG_VISIBILITY_PUBLIC));
+ params.put("validate", "1");
+ params.put("client_id", BuildConfig.OAUTH_APP_ID);
+ params.put("client_secret", BuildConfig.OAUTH_APP_SECRET);
+ mResponseHandler.setStep(Step.VALIDATE_SITE);
+ mRestClient.post(path, params, null, mResponseHandler, mErrorListener);
+ }
+
+ private void createUser() {
+ String path = "users/new";
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("username", mUsername);
+ params.put("password", mPassword);
+ params.put("email", mEmail);
+ params.put("validate", "0");
+ params.put("client_id", BuildConfig.OAUTH_APP_ID);
+ params.put("client_secret", BuildConfig.OAUTH_APP_SECRET);
+ mResponseHandler.setStep(Step.CREATE_USER);
+ mRestClient.post(path, params, null, mResponseHandler, mErrorListener);
+ }
+
+ private void authenticateUser() {
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(mContext);
+ SharedPreferences.Editor editor = settings.edit();
+ editor.putString(WordPress.WPCOM_USERNAME_PREFERENCE, mUsername);
+ editor.putString(WordPress.WPCOM_PASSWORD_PREFERENCE, WordPressDB.encryptPassword(mPassword));
+ editor.commit();
+ mResponseHandler.setStep(Step.AUTHENTICATE_USER);
+ // fire off a request to get an access token
+ WordPress.getRestClientUtils().get("me", mResponseHandler, mErrorListener);
+ }
+
+ private void createBlog() {
+ String path = "sites/new";
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("blog_name", mSiteUrl);
+ params.put("blog_title", mSiteName);
+ params.put("lang_id", mLanguage);
+ params.put("public", String.valueOf(WORDPRESS_COM_API_BLOG_VISIBILITY_PUBLIC));
+ params.put("validate", "0");
+ params.put("client_id", BuildConfig.OAUTH_APP_ID);
+ params.put("client_secret", BuildConfig.OAUTH_APP_SECRET);
+ mResponseHandler.setStep(Step.CREATE_SITE);
+ WordPress.getRestClientUtils().post(path, params, null, mResponseHandler, mErrorListener);
+ }
+
+ public enum Step {
+ VALIDATE_USER, VALIDATE_SITE, CREATE_USER, AUTHENTICATE_USER, CREATE_SITE
+ }
+
+ private enum Mode {CREATE_USER_AND_BLOG, CREATE_BLOG_ONLY}
+
+ public interface Callback {
+ void onStepFinished(Step step);
+
+ void onSuccess(JSONObject createSiteResponse);
+
+ void onError(int messageId);
+ }
+
+ private class ResponseHandler implements RestRequest.Listener {
+ public ResponseHandler() {
+ super();
+ }
+
+ public void setMode(Mode mode) {
+ mMode = mode;
+ }
+
+ private Step mStep = Step.VALIDATE_USER;
+
+ public void setStep(Step step) {
+ mStep = step;
+ }
+
+ private void nextStep(JSONObject response) {
+ try {
+ if (mStep == Step.AUTHENTICATE_USER) {
+ mCallback.onStepFinished(Step.AUTHENTICATE_USER);
+ ReaderUserActions.setCurrentUser(response);
+ createBlog();
+ } else {
+ // steps VALIDATE_USER and VALIDATE_SITE could be run simultaneously in
+ // CREATE_USER_AND_BLOG mode
+ if (response.getBoolean("success")) {
+ switch (mStep) {
+ case VALIDATE_USER:
+ mCallback.onStepFinished(Step.VALIDATE_USER);
+ validateSite();
+ break;
+ case VALIDATE_SITE:
+ mCallback.onStepFinished(Step.VALIDATE_SITE);
+ if (mMode == Mode.CREATE_BLOG_ONLY) {
+ createBlog();
+ } else {
+ createUser();
+ }
+ break;
+ case CREATE_USER:
+ mCallback.onStepFinished(Step.CREATE_USER);
+ authenticateUser();
+ break;
+ case CREATE_SITE:
+ mCallback.onStepFinished(Step.CREATE_SITE);
+ mCallback.onSuccess(response);
+ break;
+ default:
+ break;
+ }
+ } else {
+ mCallback.onError(R.string.error_generic);
+ }
+ }
+ } catch (JSONException e) {
+ mCallback.onError(R.string.error_generic);
+ }
+ }
+
+ private Mode mMode = Mode.CREATE_USER_AND_BLOG;
+
+ @Override
+ public void onResponse(JSONObject response) {
+ AppLog.d(T.NUX, String.format("Create Account step %s", mStep.name()));
+ AppLog.d(T.NUX, String.format("OK %s", response.toString()));
+ nextStep(response);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/ManageBlogsActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/accounts/ManageBlogsActivity.java
new file mode 100644
index 000000000..c7020754e
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/ManageBlogsActivity.java
@@ -0,0 +1,190 @@
+package org.wordpress.android.ui.accounts;
+
+import android.app.ActionBar;
+import android.app.ListActivity;
+import android.content.Context;
+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.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.CheckedTextView;
+import android.widget.ListView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.ui.PullToRefreshHelper;
+import org.wordpress.android.ui.PullToRefreshHelper.RefreshListener;
+import org.wordpress.android.util.ListScrollPositionManager;
+import org.wordpress.android.util.MapUtils;
+import org.wordpress.android.util.NetworkUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.ToastUtils;
+
+import java.util.List;
+import java.util.Map;
+
+import uk.co.senab.actionbarpulltorefresh.library.PullToRefreshLayout;
+
+public class ManageBlogsActivity extends ListActivity {
+ private List<Map<String, Object>> mAccounts;
+ private static boolean mIsRefreshing;
+ private ListScrollPositionManager mListScrollPositionManager;
+ private PullToRefreshHelper mPullToRefreshHelper;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.empty_listview);
+ mListScrollPositionManager = new ListScrollPositionManager(getListView(), false);
+ setTitle(getString(R.string.blogs_visibility));
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setHomeButtonEnabled(true);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+
+ // pull to refresh setup
+ mPullToRefreshHelper = new PullToRefreshHelper(this, (PullToRefreshLayout) findViewById(R.id.ptr_layout),
+ new RefreshListener() {
+ @Override
+ public void onRefreshStarted(View view) {
+ if (!NetworkUtils.checkConnection(getBaseContext())) {
+ mPullToRefreshHelper.setRefreshing(false);
+ return;
+ }
+ new UpdateBlogTask(getApplicationContext()).execute();
+ }
+ });
+
+ // Load accounts and update from server
+ loadAccounts();
+ refreshBlogs();
+ }
+
+ @Override
+ protected void onListItemClick(ListView l, View v, int position, long id) {
+ super.onListItemClick(l, v, position, id);
+ CheckedTextView checkedView = (CheckedTextView) v;
+ checkedView.setChecked(!checkedView.isChecked());
+ setItemChecked(position, checkedView.isChecked());
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.manage_blogs, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ int itemId = item.getItemId();
+ switch (itemId) {
+ case R.id.menu_show_all:
+ selectAll();
+ return true;
+ case R.id.menu_hide_all:
+ deselectAll();
+ return true;
+ case android.R.id.home:
+ finish();
+ return true;
+ case R.id.menu_refresh:
+ WordPress.sendLocalBroadcast(this, WordPress.BROADCAST_ACTION_REFRESH_MENU_PRESSED);
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void selectAll() {
+ for (Map<String, Object> item : mAccounts) {
+ item.put("isHidden", false);
+ }
+ WordPress.wpDB.setAllDotComAccountsVisibility(true);
+ ((BlogsAdapter)getListView().getAdapter()).notifyDataSetChanged();
+ }
+
+ private void deselectAll() {
+ for (Map<String, Object> item : mAccounts) {
+ item.put("isHidden", true);
+ }
+ WordPress.wpDB.setAllDotComAccountsVisibility(false);
+ ((BlogsAdapter)getListView().getAdapter()).notifyDataSetChanged();
+ }
+
+ private void refreshBlogs() {
+ mPullToRefreshHelper.setRefreshing(true);
+ new UpdateBlogTask(getApplicationContext()).execute();
+ }
+
+ private void loadAccounts() {
+ ListView listView = getListView();
+ mAccounts = WordPress.wpDB.getAccountsBy("dotcomFlag=1", new String[] {"isHidden"});
+ listView.setAdapter(new BlogsAdapter(this, R.layout.manageblogs_listitem, mAccounts));
+ }
+
+ private void setItemChecked(int position, boolean checked) {
+ int blogId = MapUtils.getMapInt(mAccounts.get(position), "id");
+ WordPress.wpDB.setDotComAccountsVisibility(blogId, checked);
+ Map<String, Object> item = mAccounts.get(position);
+ item.put("isHidden", checked ? "0" : "1");
+ }
+
+ private class BlogsAdapter extends ArrayAdapter<Map<String, Object>> {
+ private int mResource;
+
+ public BlogsAdapter(Context context, int resource, List objects) {
+ super(context, resource, objects);
+ mResource = resource;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ LayoutInflater inflater = (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ View rowView = inflater.inflate(mResource, parent, false);
+ CheckedTextView nameView = (CheckedTextView) rowView.findViewById(R.id.blog_name);
+ String name = StringUtils.unescapeHTML(MapUtils.getMapStr(getItem(position), "blogName"));
+ if (name.trim().length() == 0) {
+ name = MapUtils.getMapStr(getItem(position), "url");
+ name = StringUtils.getHost(name);
+ }
+ nameView.setText(name);
+ nameView.setChecked(!MapUtils.getMapBool(getItem(position), "isHidden"));
+ return rowView;
+ }
+ }
+
+ private class UpdateBlogTask extends SetupBlogTask {
+ public UpdateBlogTask(Context context) {
+ super(context);
+ }
+
+ @Override
+ protected void onPostExecute(final List<Map<String, Object>> userBlogList) {
+ if (mErrorMsgId != 0) {
+ ToastUtils.showToast(getBaseContext(), mErrorMsgId, ToastUtils.Duration.SHORT);
+ }
+ mListScrollPositionManager.saveScrollOffset();
+ loadAccounts();
+ mListScrollPositionManager.restoreScrollOffset();
+ mPullToRefreshHelper.setRefreshing(false);
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ mPullToRefreshHelper.unregisterReceiver(this);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ mPullToRefreshHelper.registerReceiver(this);
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/NUXDialogFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/accounts/NUXDialogFragment.java
new file mode 100644
index 000000000..f7776b9f2
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/NUXDialogFragment.java
@@ -0,0 +1,125 @@
+package org.wordpress.android.ui.accounts;
+
+import android.app.DialogFragment;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.RelativeLayout;
+
+import org.wordpress.android.R;
+import org.wordpress.android.widgets.WPTextView;
+
+public class NUXDialogFragment extends DialogFragment {
+ private static String ARG_TITLE = "title";
+ private static String ARG_DESCRIPTION = "message";
+ private static String ARG_FOOTER = "footer";
+ private static String ARG_IMAGE = "image";
+ private static String ARG_TWO_BUTTONS = "two-buttons";
+ private static String ARG_SECOND_BUTTON_LABEL = "second-btn-label";
+ private static String ARG_SECOND_BUTTON_ACTION = "second-btn-action";
+ private static String ARG_SECOND_BUTTON_PARAM = "second-btn-param";
+
+ private ImageView mImageView;
+ private WPTextView mTitleTextView;
+ private WPTextView mDescriptionTextView;
+ private WPTextView mFooterOneButton;
+ private WPTextView mFooterLeftButton;
+ private WPTextView mFooterRightButton;
+ private RelativeLayout mFooterTwoButtons;
+
+ public static int ACTION_FINISH = 1;
+ public static int ACTION_OPEN_URL = 2;
+
+ public NUXDialogFragment() {
+ // Empty constructor required for DialogFragment
+ }
+
+ public static NUXDialogFragment newInstance(String title, String message, String footer,
+ int imageSource) {
+ return newInstance(title, message, footer, imageSource, false, "", 0, "");
+ }
+
+ public static NUXDialogFragment newInstance(String title, String message, String footer,
+ int imageSource, boolean twoButtons,
+ String secondButtonLabel, int secondButtonAction,
+ String secondButtonParam) {
+ NUXDialogFragment adf = new NUXDialogFragment();
+ Bundle bundle = new Bundle();
+ bundle.putString(ARG_TITLE, title);
+ bundle.putString(ARG_DESCRIPTION, message);
+ bundle.putString(ARG_FOOTER, footer);
+ bundle.putInt(ARG_IMAGE, imageSource);
+ bundle.putBoolean(ARG_TWO_BUTTONS, twoButtons);
+ bundle.putString(ARG_SECOND_BUTTON_LABEL, secondButtonLabel);
+ bundle.putInt(ARG_SECOND_BUTTON_ACTION, secondButtonAction);
+ bundle.putString(ARG_SECOND_BUTTON_PARAM, secondButtonParam);
+
+ adf.setArguments(bundle);
+ adf.setStyle(DialogFragment.STYLE_NO_TITLE, android.R.style.Theme);
+ return adf;
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ getDialog().getWindow().setBackgroundDrawable(getResources().getDrawable(R.color.nux_alert_bg));
+ View v = inflater.inflate(R.layout.nux_dialog_fragment, container, false);
+
+ mImageView = (ImageView)v.findViewById(R.id.nux_dialog_image);
+ mTitleTextView = (WPTextView)v.findViewById(R.id.nux_dialog_title);
+ mDescriptionTextView = (WPTextView)v.findViewById(R.id.nux_dialog_description);
+ mFooterOneButton = (WPTextView)v.findViewById(R.id.nux_dialog_footer_1_button);
+ mFooterTwoButtons = (RelativeLayout) v.findViewById(R.id.nux_dialog_footer_2_buttons);
+ mFooterRightButton = (WPTextView)v.findViewById(R.id.nux_dialog_get_started_button);
+ mFooterLeftButton = (WPTextView)v.findViewById(R.id.nux_dialog_learn_more_button);
+ Bundle args = this.getArguments();
+
+ mTitleTextView.setText(args.getString(ARG_TITLE));
+ mDescriptionTextView.setText(args.getString(ARG_DESCRIPTION));
+ mFooterOneButton.setText(args.getString(ARG_FOOTER));
+ mImageView.setImageResource(args.getInt(ARG_IMAGE));
+
+ if (args.getBoolean(ARG_TWO_BUTTONS)) {
+ mFooterOneButton.setVisibility(View.GONE);
+ mFooterTwoButtons.setVisibility(View.VISIBLE);
+ mFooterRightButton.setText(args.getString(ARG_SECOND_BUTTON_LABEL));
+ }
+
+ View.OnClickListener clickListenerDismiss = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ dismissAllowingStateLoss();
+ }
+ };
+
+ final int action = args.getInt(ARG_SECOND_BUTTON_ACTION, 0);
+ final String param = args.getString(ARG_SECOND_BUTTON_PARAM);
+
+ View.OnClickListener clickListenerFinish = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (action == ACTION_FINISH) {
+ getActivity().finish();
+ }
+ if (action == ACTION_OPEN_URL) {
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(param));
+ startActivity(intent);
+ dismissAllowingStateLoss();
+ }
+ }
+ };
+
+ v.setClickable(true);
+ v.setOnClickListener(clickListenerDismiss);
+ mFooterOneButton.setOnClickListener(clickListenerDismiss);
+ mFooterLeftButton.setOnClickListener(clickListenerDismiss);
+ mFooterRightButton.setOnClickListener(clickListenerFinish);
+
+ return v;
+ }
+}
+
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/NewAccountAbstractPageFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/accounts/NewAccountAbstractPageFragment.java
new file mode 100644
index 000000000..a83e6172e
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/NewAccountAbstractPageFragment.java
@@ -0,0 +1,308 @@
+package org.wordpress.android.ui.accounts;
+
+import android.app.Fragment;
+import android.app.FragmentTransaction;
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.os.Bundle;
+import android.text.method.PasswordTransformationMethod;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.EditText;
+import android.widget.ImageView;
+
+import com.android.volley.RequestQueue;
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.Volley;
+import com.wordpress.rest.RestRequest;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.R;
+import org.wordpress.android.networking.RestClientUtils;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+
+/**
+ * A fragment representing a single step in a wizard. The fragment shows a dummy title indicating
+ * the page number, along with some dummy text.
+ */
+public abstract class NewAccountAbstractPageFragment extends Fragment {
+ protected static RequestQueue requestQueue;
+ protected static RestClientUtils mRestClientUtils;
+ protected ConnectivityManager mSystemService;
+ protected boolean mPasswordVisible;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ AppLog.v(T.NUX, "NewAccountAbstractOage.onCreate()");
+ mSystemService = (ConnectivityManager) getActivity().getApplicationContext().
+ getSystemService(Context.CONNECTIVITY_SERVICE);
+ if (requestQueue == null) {
+ requestQueue = Volley.newRequestQueue(getActivity());
+ }
+ }
+
+ protected RestClientUtils getRestClientUtils() {
+ if (mRestClientUtils == null) {
+ mRestClientUtils = new RestClientUtils(requestQueue, null);
+ }
+ return mRestClientUtils;
+ }
+
+ protected void startProgress(String message) {
+ }
+
+ protected void updateProgress(String message) {
+ }
+
+ protected void endProgress() {
+ }
+
+ protected abstract void onDoneAction();
+
+ protected abstract boolean isUserDataValid();
+
+ protected boolean onDoneEvent(int actionId, KeyEvent event) {
+ if (actionId == EditorInfo.IME_ACTION_DONE || event != null && (event.getAction() == KeyEvent.ACTION_DOWN
+ && event.getKeyCode() == KeyEvent.KEYCODE_ENTER)) {
+ if (!isUserDataValid()) {
+ return true;
+ }
+
+ // hide keyboard before calling the done action
+ InputMethodManager inputManager = (InputMethodManager) getActivity().getSystemService(
+ Context.INPUT_METHOD_SERVICE);
+ View view = getActivity().getCurrentFocus();
+ if (view != null) {
+ inputManager.hideSoftInputFromWindow(view.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS);
+ }
+
+ // call child action
+ onDoneAction();
+ return true;
+ }
+ return false;
+ }
+
+ protected void initPasswordVisibilityButton(View rootView, final EditText passwordEditText) {
+ final ImageView passwordVisibility = (ImageView) rootView.findViewById(R.id.password_visibility);
+ if (passwordVisibility == null) {
+ return;
+ }
+ passwordVisibility.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ mPasswordVisible = !mPasswordVisible;
+ if (mPasswordVisible) {
+ passwordVisibility.setImageResource(R.drawable.dashicon_eye_open);
+ passwordEditText.setTransformationMethod(null);
+ } else {
+ passwordVisibility.setImageResource(R.drawable.dashicon_eye_closed);
+ passwordEditText.setTransformationMethod(PasswordTransformationMethod.getInstance());
+ }
+ passwordEditText.setSelection(passwordEditText.length());
+ }
+ });
+ }
+
+ protected boolean specificShowError(int messageId) {
+ return false;
+ }
+
+ protected boolean hasActivity() {
+ return (getActivity() != null && !isRemoving());
+ }
+
+ protected void showError(int messageId) {
+ if (!hasActivity()) {
+ return;
+ }
+ if (specificShowError(messageId)) {
+ return;
+ }
+ // Failback if it's not a specific error
+ showError(getString(messageId));
+ }
+
+ protected void showError(String message) {
+ FragmentTransaction ft = getFragmentManager().beginTransaction();
+ NUXDialogFragment nuxAlert = NUXDialogFragment.newInstance(getString(R.string.error), message, getString(
+ R.string.nux_tap_continue), R.drawable.nux_icon_alert);
+ ft.add(nuxAlert, "alert");
+ ft.commitAllowingStateLoss();
+ }
+
+ protected ErrorType getErrorType(int messageId) {
+ switch (messageId) {
+ case R.string.username_only_lowercase_letters_and_numbers:
+ case R.string.username_required:
+ case R.string.username_not_allowed:
+ case R.string.username_must_be_at_least_four_characters:
+ case R.string.username_contains_invalid_characters:
+ case R.string.username_must_include_letters:
+ case R.string.username_exists:
+ case R.string.username_reserved_but_may_be_available:
+ case R.string.username_invalid:
+ return ErrorType.USERNAME;
+ case R.string.password_invalid:
+ return ErrorType.PASSWORD;
+ case R.string.email_cant_be_used_to_signup:
+ case R.string.email_invalid:
+ case R.string.email_not_allowed:
+ case R.string.email_exists:
+ case R.string.email_reserved:
+ return ErrorType.EMAIL;
+ case R.string.blog_name_required:
+ case R.string.blog_name_not_allowed:
+ case R.string.blog_name_must_be_at_least_four_characters:
+ case R.string.blog_name_must_be_less_than_sixty_four_characters:
+ case R.string.blog_name_contains_invalid_characters:
+ case R.string.blog_name_cant_be_used:
+ case R.string.blog_name_only_lowercase_letters_and_numbers:
+ case R.string.blog_name_must_include_letters:
+ case R.string.blog_name_exists:
+ case R.string.blog_name_reserved:
+ case R.string.blog_name_reserved_but_may_be_available:
+ case R.string.blog_name_invalid:
+ return ErrorType.SITE_URL;
+ case R.string.blog_title_invalid:
+ return ErrorType.TITLE;
+ }
+ return ErrorType.UNDEFINED;
+ }
+
+ protected int getErrorMessageForErrorCode(String errorCode) {
+ if (errorCode.equals("username_only_lowercase_letters_and_numbers")) {
+ return R.string.username_only_lowercase_letters_and_numbers;
+ }
+ if (errorCode.equals("username_required")) {
+ return R.string.username_required;
+ }
+ if (errorCode.equals("username_not_allowed")) {
+ return R.string.username_not_allowed;
+ }
+ if (errorCode.equals("email_cant_be_used_to_signup")) {
+ return R.string.email_cant_be_used_to_signup;
+ }
+ if (errorCode.equals("username_must_be_at_least_four_characters")) {
+ return R.string.username_must_be_at_least_four_characters;
+ }
+ if (errorCode.equals("username_contains_invalid_characters")) {
+ return R.string.username_contains_invalid_characters;
+ }
+ if (errorCode.equals("username_must_include_letters")) {
+ return R.string.username_must_include_letters;
+ }
+ if (errorCode.equals("email_invalid")) {
+ return R.string.email_invalid;
+ }
+ if (errorCode.equals("email_not_allowed")) {
+ return R.string.email_not_allowed;
+ }
+ if (errorCode.equals("username_exists")) {
+ return R.string.username_exists;
+ }
+ if (errorCode.equals("email_exists")) {
+ return R.string.email_exists;
+ }
+ if (errorCode.equals("username_reserved_but_may_be_available")) {
+ return R.string.username_reserved_but_may_be_available;
+ }
+ if (errorCode.equals("email_reserved")) {
+ return R.string.email_reserved;
+ }
+ if (errorCode.equals("blog_name_required")) {
+ return R.string.blog_name_required;
+ }
+ if (errorCode.equals("blog_name_not_allowed")) {
+ return R.string.blog_name_not_allowed;
+ }
+ if (errorCode.equals("blog_name_must_be_at_least_four_characters")) {
+ return R.string.blog_name_must_be_at_least_four_characters;
+ }
+ if (errorCode.equals("blog_name_must_be_less_than_sixty_four_characters")) {
+ return R.string.blog_name_must_be_less_than_sixty_four_characters;
+ }
+ if (errorCode.equals("blog_name_contains_invalid_characters")) {
+ return R.string.blog_name_contains_invalid_characters;
+ }
+ if (errorCode.equals("blog_name_cant_be_used")) {
+ return R.string.blog_name_cant_be_used;
+ }
+ if (errorCode.equals("blog_name_only_lowercase_letters_and_numbers")) {
+ return R.string.blog_name_only_lowercase_letters_and_numbers;
+ }
+ if (errorCode.equals("blog_name_must_include_letters")) {
+ return R.string.blog_name_must_include_letters;
+ }
+ if (errorCode.equals("blog_name_exists")) {
+ return R.string.blog_name_exists;
+ }
+ if (errorCode.equals("blog_name_reserved")) {
+ return R.string.blog_name_reserved;
+ }
+ if (errorCode.equals("blog_name_reserved_but_may_be_available")) {
+ return R.string.blog_name_reserved_but_may_be_available;
+ }
+ if (errorCode.equals("password_invalid")) {
+ return R.string.password_invalid;
+ }
+ if (errorCode.equals("blog_name_invalid")) {
+ return R.string.blog_name_invalid;
+ }
+ if (errorCode.equals("blog_title_invalid")) {
+ return R.string.blog_title_invalid;
+ }
+ if (errorCode.equals("username_invalid")) {
+ return R.string.username_invalid;
+ }
+ return 0;
+ }
+
+ protected enum ErrorType {USERNAME, PASSWORD, SITE_URL, EMAIL, TITLE, UNDEFINED}
+
+ protected class ErrorListener implements RestRequest.ErrorListener {
+ @Override
+ public void onErrorResponse(VolleyError error) {
+ String message = null;
+ int messageId;
+ AppLog.e(T.NUX, error);
+ if (error.networkResponse != null && error.networkResponse.data != null) {
+ AppLog.e(T.NUX, String.format("Error message: %s", new String(error.networkResponse.data)));
+ String jsonString = new String(error.networkResponse.data);
+ try {
+ JSONObject errorObj = new JSONObject(jsonString);
+ messageId = getErrorMessageForErrorCode((String) errorObj.get("error"));
+ if (messageId == 0) {
+ // Not one of our common errors. Show the error message from the server.
+ message = (String) errorObj.get("message");
+ }
+ } catch (JSONException e) {
+ AppLog.e(T.NUX, e);
+ messageId = R.string.error_generic;
+ }
+ } else {
+ if (error.getMessage() != null) {
+ if (error.getMessage().contains("Limit reached")) {
+ messageId = R.string.limit_reached;
+ } else {
+ messageId = R.string.error_generic;
+ }
+ } else {
+ messageId = R.string.error_generic;
+ }
+ }
+ endProgress();
+ if (messageId == 0) {
+ showError(message);
+ } else {
+ showError(messageId);
+ }
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/NewAccountActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/accounts/NewAccountActivity.java
new file mode 100644
index 000000000..27ebe5874
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/NewAccountActivity.java
@@ -0,0 +1,17 @@
+package org.wordpress.android.ui.accounts;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.Window;
+
+import org.wordpress.android.R;
+
+// TODO: merge it with WelcomeFragmentSignIn
+public class NewAccountActivity extends Activity {
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ setContentView(R.layout.activity_new_account);
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/NewBlogActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/accounts/NewBlogActivity.java
new file mode 100644
index 000000000..40b0a3f0f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/NewBlogActivity.java
@@ -0,0 +1,47 @@
+package org.wordpress.android.ui.accounts;
+
+import android.app.Activity;
+import android.app.FragmentManager;
+import android.os.Bundle;
+import android.view.Window;
+
+import org.wordpress.android.R;
+
+public class NewBlogActivity extends Activity {
+ public static final String KEY_START_MODE = "start-mode";
+ public static final int CREATE_BLOG = 1;
+ public static final int CREATE_BLOG_LOGOUT_ON_CANCEL = 2;
+
+ private NewBlogFragment mNewBlogFragment;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ setContentView(R.layout.activity_new_blog);
+
+ FragmentManager fragmentManager = getFragmentManager();
+ mNewBlogFragment = (NewBlogFragment) fragmentManager.
+ findFragmentById(R.id.new_blog_fragment);
+ if (getActionMode() == CREATE_BLOG_LOGOUT_ON_CANCEL) {
+ mNewBlogFragment.setSignoutOnCancelMode(true);
+ }
+ }
+
+ private int getActionMode() {
+ Bundle extras = getIntent().getExtras();
+ if (extras != null) {
+ return extras.getInt(KEY_START_MODE, CREATE_BLOG);
+ }
+ return CREATE_BLOG;
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (mNewBlogFragment.isSignoutOnCancelMode()) {
+ mNewBlogFragment.onBackPressed();
+ } else {
+ super.onBackPressed();
+ }
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/NewBlogFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/accounts/NewBlogFragment.java
new file mode 100644
index 000000000..9cfd92010
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/NewBlogFragment.java
@@ -0,0 +1,297 @@
+package org.wordpress.android.ui.accounts;
+
+import android.app.Activity;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.preference.PreferenceManager;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.View.OnKeyListener;
+import android.view.ViewGroup;
+import android.widget.EditText;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.WordPressDB;
+import org.wordpress.android.ui.WPActionBarActivity;
+import org.wordpress.android.util.AlertUtil;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.EditTextUtils;
+import org.wordpress.android.widgets.WPTextView;
+
+public class NewBlogFragment extends NewAccountAbstractPageFragment implements TextWatcher {
+ private EditText mSiteUrlTextField;
+ private EditText mSiteTitleTextField;
+ private WPTextView mSignupButton;
+ private WPTextView mProgressTextSignIn;
+ private WPTextView mCancelButton;
+ private RelativeLayout mProgressBarSignIn;
+ private boolean mSignoutOnCancelMode;
+ private boolean mAutoCompleteUrl = true;
+
+ public NewBlogFragment() {
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ if (fieldsFilled()) {
+ mSignupButton.setEnabled(true);
+ } else {
+ mSignupButton.setEnabled(false);
+ }
+ }
+
+ public void setSignoutOnCancelMode(boolean mode) {
+ mSignoutOnCancelMode = mode;
+ mCancelButton.setVisibility(View.VISIBLE);
+ }
+
+ public boolean isSignoutOnCancelMode() {
+ return mSignoutOnCancelMode;
+ }
+
+ public void onBackPressed() {
+ signoutAndFinish();
+ }
+
+ protected void onDoneAction() {
+ validateAndCreateUserAndBlog();
+ }
+
+ private void signoutAndFinish() {
+ if (mSignoutOnCancelMode) {
+ WordPress.signOut(getActivity());
+ getActivity().setResult(WPActionBarActivity.NEW_BLOG_CANCELED);
+ getActivity().finish();
+ }
+ }
+
+ private boolean fieldsFilled() {
+ return EditTextUtils.getText(mSiteUrlTextField).trim().length() > 0
+ && EditTextUtils.getText(mSiteTitleTextField).trim().length() > 0;
+ }
+
+ protected void startProgress(String message) {
+ mProgressBarSignIn.setVisibility(View.VISIBLE);
+ mProgressTextSignIn.setVisibility(View.VISIBLE);
+ mSignupButton.setVisibility(View.GONE);
+ mProgressBarSignIn.setEnabled(false);
+ mProgressTextSignIn.setText(message);
+ mSiteTitleTextField.setEnabled(false);
+ mSiteUrlTextField.setEnabled(false);
+ }
+
+ protected void updateProgress(String message) {
+ mProgressTextSignIn.setText(message);
+ }
+
+ protected void endProgress() {
+ mProgressBarSignIn.setVisibility(View.GONE);
+ mProgressTextSignIn.setVisibility(View.GONE);
+ mSignupButton.setVisibility(View.VISIBLE);
+ mSiteTitleTextField.setEnabled(true);
+ mSiteUrlTextField.setEnabled(true);
+ }
+
+ private void showSiteUrlError(int messageId) {
+ mSiteUrlTextField.setError(getString(messageId));
+ mSiteUrlTextField.requestFocus();
+ }
+
+ private void showSiteTitleError(int messageId) {
+ mSiteTitleTextField.setError(getString(messageId));
+ mSiteTitleTextField.requestFocus();
+ }
+
+ protected boolean specificShowError(int messageId) {
+ switch (getErrorType(messageId)) {
+ case TITLE:
+ showSiteTitleError(messageId);
+ return true;
+ case SITE_URL:
+ showSiteUrlError(messageId);
+ return true;
+ }
+ return false;
+ }
+
+ protected boolean isUserDataValid() {
+ final String siteTitle = EditTextUtils.getText(mSiteTitleTextField).trim();
+ final String siteUrl = EditTextUtils.getText(mSiteUrlTextField).trim();
+ boolean retValue = true;
+
+ if (siteTitle.equals("")) {
+ mSiteTitleTextField.setError(getString(R.string.required_field));
+ mSiteTitleTextField.requestFocus();
+ retValue = false;
+ }
+
+ if (siteUrl.equals("")) {
+ mSiteUrlTextField.setError(getString(R.string.required_field));
+ mSiteUrlTextField.requestFocus();
+ retValue = false;
+ }
+ return retValue;
+ }
+
+ private String titleToUrl(String siteUrl) {
+ return siteUrl.replaceAll("[^a-zA-Z0-9]", "").toLowerCase();
+ }
+
+ private void validateAndCreateUserAndBlog() {
+ if (mSystemService.getActiveNetworkInfo() == null) {
+ AlertUtil.showAlert(getActivity(), R.string.no_network_title, R.string.no_network_message);
+ return;
+ }
+ if (!isUserDataValid()) {
+ return;
+ }
+
+ // prevent double tapping of the "done" btn in keyboard for those clients that don't dismiss the keyboard.
+ // Samsung S4 for example
+ if (View.VISIBLE == mProgressBarSignIn.getVisibility()) {
+ return;
+ }
+
+ startProgress(getString(R.string.validating_site_data));
+
+ final String siteUrl = EditTextUtils.getText(mSiteUrlTextField).trim();
+ final String siteName = EditTextUtils.getText(mSiteTitleTextField).trim();
+ final String language = CreateUserAndBlog.getDeviceLanguage(getActivity().getResources());
+
+ CreateUserAndBlog createUserAndBlog = new CreateUserAndBlog("", "", "", siteUrl, siteName, language,
+ getRestClientUtils(), getActivity(), new ErrorListener(), new CreateUserAndBlog.Callback() {
+ @Override
+ public void onStepFinished(CreateUserAndBlog.Step step) {
+ if (getActivity() != null) {
+ updateProgress(getString(R.string.create_new_blog_wpcom));
+ }
+ }
+
+ @Override
+ public void onSuccess(JSONObject createSiteResponse) {
+ if (getActivity() == null) {
+ return;
+ }
+ endProgress();
+ SetupBlog setupBlog = new SetupBlog();
+ try {
+ JSONObject details = createSiteResponse.getJSONObject("blog_details");
+ String blogName = details.getString("blogname");
+ String xmlRpcUrl = details.getString("xmlrpc");
+ String homeUrl = details.getString("url");
+ String blogId = details.getString("blogid");
+ final SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(getActivity());
+ String username = settings.getString(WordPress.WPCOM_USERNAME_PREFERENCE, "");
+ String password = WordPressDB.decryptPassword(settings.getString(
+ WordPress.WPCOM_PASSWORD_PREFERENCE, null));
+ setupBlog.addOrUpdateBlog(blogName, xmlRpcUrl, homeUrl, blogId, username, password, true);
+ } catch (JSONException e) {
+ AppLog.e(T.NUX, "Invalid JSON response from site/new", e);
+ }
+ getActivity().setResult(Activity.RESULT_OK);
+ getActivity().finish();
+ }
+
+ @Override
+ public void onError(int messageId) {
+ if (getActivity() == null) {
+ return;
+ }
+ endProgress();
+ showError(getString(messageId));
+ }
+ });
+ createUserAndBlog.startCreateBlogProcess();
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ // Inflate the layout containing a title and body text.
+ ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.create_blog_fragment, container, false);
+
+ mSignupButton = (WPTextView) rootView.findViewById(R.id.signup_button);
+ mSignupButton.setOnClickListener(mSignupClickListener);
+ mSignupButton.setEnabled(false);
+
+ mCancelButton = (WPTextView) rootView.findViewById(R.id.cancel_button);
+ mCancelButton.setOnClickListener(mCancelClickListener);
+
+ mProgressTextSignIn = (WPTextView) rootView.findViewById(R.id.nux_sign_in_progress_text);
+ mProgressBarSignIn = (RelativeLayout) rootView.findViewById(R.id.nux_sign_in_progress_bar);
+
+ mSiteUrlTextField = (EditText) rootView.findViewById(R.id.site_url);
+ mSiteUrlTextField.addTextChangedListener(this);
+ mSiteUrlTextField.setOnKeyListener(mSiteUrlKeyListener);
+ mSiteUrlTextField.setOnEditorActionListener(mEditorAction);
+
+ mSiteTitleTextField = (EditText) rootView.findViewById(R.id.site_title);
+ mSiteTitleTextField.addTextChangedListener(this);
+ mSiteTitleTextField.addTextChangedListener(mSiteTitleWatcher);
+ return rootView;
+ }
+
+ private final OnClickListener mSignupClickListener = new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ validateAndCreateUserAndBlog();
+ }
+ };
+
+ private final OnClickListener mCancelClickListener = new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ signoutAndFinish();
+ }
+ };
+
+ private final TextWatcher mSiteTitleWatcher = new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ // auto fill blog address from title if user hasn't modified url
+ if (mAutoCompleteUrl) {
+ mSiteUrlTextField.setText(titleToUrl(EditTextUtils.getText(mSiteTitleTextField)));
+ }
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ }
+ };
+
+ private final OnKeyListener mSiteUrlKeyListener = new OnKeyListener() {
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ mAutoCompleteUrl = EditTextUtils.isEmpty(mSiteUrlTextField);
+ return false;
+ }
+ };
+
+ private final TextView.OnEditorActionListener mEditorAction = new TextView.OnEditorActionListener() {
+ @Override
+ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+ return onDoneEvent(actionId, event);
+ }
+ };
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/NewUserPageFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/accounts/NewUserPageFragment.java
new file mode 100644
index 000000000..e2b512cc5
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/NewUserPageFragment.java
@@ -0,0 +1,394 @@
+package org.wordpress.android.ui.accounts;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.Html;
+import android.text.TextWatcher;
+import android.util.Patterns;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.View.OnKeyListener;
+import android.view.ViewGroup;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import org.json.JSONObject;
+import org.wordpress.android.Constants;
+import org.wordpress.android.R;
+import org.wordpress.android.util.AlertUtil;
+import org.wordpress.android.util.EditTextUtils;
+import org.wordpress.android.util.UserEmail;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+import org.wordpress.android.widgets.WPTextView;
+import org.wordpress.emailchecker.EmailChecker;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class NewUserPageFragment extends NewAccountAbstractPageFragment implements TextWatcher {
+ private EditText mSiteUrlTextField;
+ private EditText mEmailTextField;
+ private EditText mPasswordTextField;
+ private EditText mUsernameTextField;
+ private WPTextView mSignupButton;
+ private WPTextView mProgressTextSignIn;
+ private RelativeLayout mProgressBarSignIn;
+ private EmailChecker mEmailChecker;
+ private boolean mEmailAutoCorrected;
+ private boolean mAutoCompleteUrl = true;
+
+ public NewUserPageFragment() {
+ mEmailChecker = new EmailChecker();
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ if (fieldsFilled()) {
+ mSignupButton.setEnabled(true);
+ } else {
+ mSignupButton.setEnabled(false);
+ }
+ }
+
+ private boolean fieldsFilled() {
+ return EditTextUtils.getText(mEmailTextField).trim().length() > 0
+ && EditTextUtils.getText(mPasswordTextField).trim().length() > 0
+ && EditTextUtils.getText(mUsernameTextField).trim().length() > 0
+ && EditTextUtils.getText(mSiteUrlTextField).trim().length() > 0;
+ }
+
+ protected void startProgress(String message) {
+ mProgressBarSignIn.setVisibility(View.VISIBLE);
+ mProgressTextSignIn.setVisibility(View.VISIBLE);
+ mSignupButton.setVisibility(View.GONE);
+ mProgressBarSignIn.setEnabled(false);
+ mProgressTextSignIn.setText(message);
+ mEmailTextField.setEnabled(false);
+ mPasswordTextField.setEnabled(false);
+ mUsernameTextField.setEnabled(false);
+ mSiteUrlTextField.setEnabled(false);
+ }
+
+ protected void updateProgress(String message) {
+ mProgressTextSignIn.setText(message);
+ }
+
+ protected void endProgress() {
+ mProgressBarSignIn.setVisibility(View.GONE);
+ mProgressTextSignIn.setVisibility(View.GONE);
+ mSignupButton.setVisibility(View.VISIBLE);
+ mEmailTextField.setEnabled(true);
+ mPasswordTextField.setEnabled(true);
+ mUsernameTextField.setEnabled(true);
+ mSiteUrlTextField.setEnabled(true);
+ }
+
+ protected boolean isUserDataValid() {
+ // try to create the user
+ final String email = EditTextUtils.getText(mEmailTextField).trim();
+ final String password = EditTextUtils.getText(mPasswordTextField).trim();
+ final String username = EditTextUtils.getText(mUsernameTextField).trim();
+ final String siteUrl = EditTextUtils.getText(mSiteUrlTextField).trim();
+ boolean retValue = true;
+
+ if (email.equals("")) {
+ showEmailError(R.string.required_field);
+ retValue = false;
+ }
+
+ final Pattern emailRegExPattern = Patterns.EMAIL_ADDRESS;
+ Matcher matcher = emailRegExPattern.matcher(email);
+ if (!matcher.find() || email.length() > 100) {
+ showEmailError(R.string.invalid_email_message);
+ retValue = false;
+ }
+
+ if (username.equals("")) {
+ showUsernameError(R.string.required_field);
+ retValue = false;
+ }
+
+ if (username.length() < 4) {
+ showUsernameError(R.string.invalid_username_too_short);
+ retValue = false;
+ }
+
+ if (username.length() > 60) {
+ showUsernameError(R.string.invalid_username_too_long);
+ retValue = false;
+ }
+
+ if (siteUrl.length() < 4) {
+ showSiteUrlError(R.string.blog_name_must_be_at_least_four_characters);
+ retValue = false;
+ }
+
+ if (password.equals("")) {
+ showPasswordError(R.string.required_field);
+ retValue = false;
+ }
+
+ if (password.length() < 4) {
+ showPasswordError(R.string.invalid_password_message);
+ retValue = false;
+ }
+
+ return retValue;
+ }
+
+ protected void onDoneAction() {
+ validateAndCreateUserAndBlog();
+ }
+
+ private final OnClickListener mSignupClickListener = new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ validateAndCreateUserAndBlog();
+ }
+ };
+
+ private final TextView.OnEditorActionListener mEditorAction = new TextView.OnEditorActionListener() {
+ @Override
+ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+ return onDoneEvent(actionId, event);
+ }
+ };
+
+ private String siteUrlToSiteName(String siteUrl) {
+ return siteUrl;
+ }
+
+ private void finishThisStuff(String username) {
+ final Activity activity = getActivity();
+ if (activity != null) {
+ Intent intent = new Intent();
+ intent.putExtra("username", username);
+ activity.setResult(NewAccountActivity.RESULT_OK, intent);
+ activity.finish();
+ }
+ }
+
+ protected boolean specificShowError(int messageId) {
+ switch (getErrorType(messageId)) {
+ case USERNAME:
+ showUsernameError(messageId);
+ return true;
+ case PASSWORD:
+ showPasswordError(messageId);
+ return true;
+ case EMAIL:
+ showEmailError(messageId);
+ return true;
+ case SITE_URL:
+ showSiteUrlError(messageId);
+ return true;
+ }
+ return false;
+ }
+
+ private void showPasswordError(int messageId) {
+ mPasswordTextField.setError(getString(messageId));
+ mPasswordTextField.requestFocus();
+ }
+
+ private void showEmailError(int messageId) {
+ mEmailTextField.setError(getString(messageId));
+ mEmailTextField.requestFocus();
+ }
+
+ private void showUsernameError(int messageId) {
+ mUsernameTextField.setError(getString(messageId));
+ mUsernameTextField.requestFocus();
+ }
+
+ private void showSiteUrlError(int messageId) {
+ mSiteUrlTextField.setError(getString(messageId));
+ mSiteUrlTextField.requestFocus();
+ }
+
+ private void validateAndCreateUserAndBlog() {
+ if (mSystemService.getActiveNetworkInfo() == null) {
+ AlertUtil.showAlert(getActivity(), R.string.no_network_title, R.string.no_network_message);
+ return;
+ }
+ if (!isUserDataValid()) {
+ return;
+ }
+
+ // Prevent double tapping of the "done" btn in keyboard for those clients that don't dismiss the keyboard.
+ // Samsung S4 for example
+ if (View.VISIBLE == mProgressBarSignIn.getVisibility()) {
+ return;
+ }
+
+ startProgress(getString(R.string.validating_user_data));
+
+ final String siteUrl = EditTextUtils.getText(mSiteUrlTextField).trim();
+ final String email = EditTextUtils.getText(mEmailTextField).trim();
+ final String password = EditTextUtils.getText(mPasswordTextField).trim();
+ final String username = EditTextUtils.getText(mUsernameTextField).trim();
+ final String siteName = siteUrlToSiteName(siteUrl);
+ final String language = CreateUserAndBlog.getDeviceLanguage(getActivity().getResources());
+
+ CreateUserAndBlog createUserAndBlog = new CreateUserAndBlog(email, username, password,
+ siteUrl, siteName, language, getRestClientUtils(), getActivity(), new ErrorListener(),
+ new CreateUserAndBlog.Callback() {
+ @Override
+ public void onStepFinished(CreateUserAndBlog.Step step) {
+ if (!hasActivity()) {
+ return;
+ }
+ switch (step) {
+ case VALIDATE_USER:
+ updateProgress(getString(R.string.validating_site_data));
+ break;
+ case VALIDATE_SITE:
+ updateProgress(getString(R.string.creating_your_account));
+ break;
+ case CREATE_USER:
+ updateProgress(getString(R.string.creating_your_site));
+ break;
+ case CREATE_SITE:
+ // no messages
+ case AUTHENTICATE_USER:
+ default:
+ break;
+ }
+ }
+
+ @Override
+ public void onSuccess(JSONObject createSiteResponse) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.CREATED_ACCOUNT);
+ endProgress();
+ if (hasActivity()) {
+ finishThisStuff(username);
+ }
+ }
+
+ @Override
+ public void onError(int messageId) {
+ endProgress();
+ if (hasActivity()) {
+ showError(getString(messageId));
+ }
+ }
+ });
+ createUserAndBlog.startCreateUserAndBlogProcess();
+ }
+
+ private void autocorrectEmail() {
+ if (mEmailAutoCorrected) {
+ return;
+ }
+ final String email = EditTextUtils.getText(mEmailTextField).trim();
+ String suggest = mEmailChecker.suggestDomainCorrection(email);
+ if (suggest.compareTo(email) != 0) {
+ mEmailAutoCorrected = true;
+ mEmailTextField.setText(suggest);
+ mEmailTextField.setSelection(suggest.length());
+ }
+ }
+
+ private void initInfoButton(View rootView) {
+ ImageView infoBUtton = (ImageView) rootView.findViewById(R.id.info_button);
+ infoBUtton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent newAccountIntent = new Intent(getActivity(), NuxHelpActivity.class);
+ startActivity(newAccountIntent);
+ }
+ });
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ // Inflate the layout containing a title and body text.
+ ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.new_account_user_fragment_screen, container, false);
+
+ WPTextView termsOfServiceTextView = (WPTextView) rootView.findViewById(R.id.l_agree_terms_of_service);
+ termsOfServiceTextView.setText(Html.fromHtml(String.format(getString(R.string.agree_terms_of_service), "<u>",
+ "</u>")));
+ termsOfServiceTextView.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Uri uri = Uri.parse(Constants.URL_TOS);
+ startActivity(new Intent(Intent.ACTION_VIEW, uri));
+ }
+ }
+ );
+
+ mSignupButton = (WPTextView) rootView.findViewById(R.id.signup_button);
+ mSignupButton.setOnClickListener(mSignupClickListener);
+ mSignupButton.setEnabled(false);
+
+ mProgressTextSignIn = (WPTextView) rootView.findViewById(R.id.nux_sign_in_progress_text);
+ mProgressBarSignIn = (RelativeLayout) rootView.findViewById(R.id.nux_sign_in_progress_bar);
+
+ mEmailTextField = (EditText) rootView.findViewById(R.id.email_address);
+ mEmailTextField.setText(UserEmail.getPrimaryEmail(getActivity()));
+ mEmailTextField.setSelection(EditTextUtils.getText(mEmailTextField).length());
+ mPasswordTextField = (EditText) rootView.findViewById(R.id.password);
+ mUsernameTextField = (EditText) rootView.findViewById(R.id.username);
+ mSiteUrlTextField = (EditText) rootView.findViewById(R.id.site_url);
+
+ mEmailTextField.addTextChangedListener(this);
+ mPasswordTextField.addTextChangedListener(this);
+ mUsernameTextField.addTextChangedListener(this);
+ mSiteUrlTextField.addTextChangedListener(this);
+ mSiteUrlTextField.setOnKeyListener(mSiteUrlKeyListener);
+ mSiteUrlTextField.setOnEditorActionListener(mEditorAction);
+ mUsernameTextField.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ // auto fill blog address
+ mSiteUrlTextField.setError(null);
+ if (mAutoCompleteUrl) {
+ mSiteUrlTextField.setText(EditTextUtils.getText(mUsernameTextField));
+ }
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ }
+ });
+
+ mEmailTextField.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (!hasFocus) {
+ autocorrectEmail();
+ }
+ }
+ });
+ initPasswordVisibilityButton(rootView, mPasswordTextField);
+ initInfoButton(rootView);
+ return rootView;
+ }
+
+ private final OnKeyListener mSiteUrlKeyListener = new OnKeyListener() {
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ mAutoCompleteUrl = EditTextUtils.isEmpty(mSiteUrlTextField);
+ return false;
+ }
+ };
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/NuxHelpActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/accounts/NuxHelpActivity.java
new file mode 100644
index 000000000..778000258
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/NuxHelpActivity.java
@@ -0,0 +1,53 @@
+package org.wordpress.android.ui.accounts;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.Window;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.ui.AppLogViewerActivity;
+import org.wordpress.android.widgets.WPTextView;
+
+public class NuxHelpActivity extends Activity {
+ final private static String FAQ_URL = "http://android.wordpress.org/faq/";
+ final private static String FORUM_URL = "http://android.forums.wordpress.org/";
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ setContentView(R.layout.activity_nux_help);
+
+ WPTextView version = (WPTextView) findViewById(R.id.nux_help_version);
+ version.setText(getString(R.string.version) + " " + WordPress.versionName);
+
+ WPTextView helpCenterButton = (WPTextView) findViewById(R.id.help_button);
+ helpCenterButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(FAQ_URL)));
+ }
+ });
+
+ WPTextView forumButton = (WPTextView) findViewById(R.id.forum_button);
+ forumButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(FORUM_URL)));
+ }
+ });
+
+ WPTextView applogButton = (WPTextView) findViewById(R.id.applog_button);
+ applogButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ startActivity(new Intent(v.getContext(), AppLogViewerActivity.class));
+ }
+ });
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/SetupBlog.java b/WordPress/src/main/java/org/wordpress/android/ui/accounts/SetupBlog.java
new file mode 100644
index 000000000..973ab060c
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/SetupBlog.java
@@ -0,0 +1,429 @@
+package org.wordpress.android.ui.accounts;
+
+import android.content.Context;
+import android.webkit.URLUtil;
+
+import org.wordpress.android.Constants;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.MapUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.UrlUtils;
+import org.wordpress.android.util.Utils;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlrpc.android.ApiHelper;
+import org.xmlrpc.android.XMLRPCClientInterface;
+import org.xmlrpc.android.XMLRPCException;
+import org.xmlrpc.android.XMLRPCFactory;
+import org.xmlrpc.android.XMLRPCFault;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.net.ssl.SSLHandshakeException;
+import javax.net.ssl.SSLPeerUnverifiedException;
+
+public class SetupBlog {
+ private static final String DEFAULT_IMAGE_SIZE = "2000";
+ private String mUsername;
+ private String mPassword;
+ private String mHttpUsername = "";
+ private String mHttpPassword = "";
+ private String mXmlrpcUrl;
+
+ private int mErrorMsgId;
+ private boolean mIsCustomUrl;
+ private String mSelfHostedURL;
+
+ private boolean mHttpAuthRequired;
+ private boolean mErroneousSslCertificate;
+
+ public SetupBlog() {
+ }
+
+ public int getErrorMsgId() {
+ return mErrorMsgId;
+ }
+
+ public String getXmlrpcUrl() {
+ return mXmlrpcUrl;
+ }
+
+ public void setUsername(String username) {
+ mUsername = username;
+ }
+
+ public void setPassword(String password) {
+ mPassword = password;
+ }
+
+ public String getPassword() {
+ return mPassword;
+ }
+
+ public String getUsername() {
+ return mUsername;
+ }
+
+ public void setHttpUsername(String httpUsername) {
+ mHttpUsername = httpUsername;
+ }
+
+ public void setHttpPassword(String httpPassword) {
+ mHttpPassword = httpPassword;
+ }
+
+ public void setSelfHostedURL(String selfHostedURL) {
+ mSelfHostedURL = selfHostedURL;
+ }
+
+ public void setHttpAuthRequired(boolean httpAuthRequired) {
+ mHttpAuthRequired = httpAuthRequired;
+ }
+
+ public boolean isHttpAuthRequired() {
+ return mHttpAuthRequired;
+ }
+
+ public boolean isErroneousSslCertificates() {
+ return mErroneousSslCertificate;
+ }
+
+ private void handleXmlRpcFault(XMLRPCFault xmlRpcFault) {
+ AppLog.e(T.NUX, "XMLRPCFault received from XMLRPC call wp.getUsersBlogs", xmlRpcFault);
+ switch (xmlRpcFault.getFaultCode()) {
+ case 403:
+ mErrorMsgId = R.string.username_or_password_incorrect;
+ break;
+ case 404:
+ mErrorMsgId = R.string.xmlrpc_error;
+ break;
+ case 425:
+ mErrorMsgId = R.string.account_two_step_auth_enabled;
+ break;
+ default:
+ mErrorMsgId = R.string.no_site_error;
+ break;
+ }
+ }
+
+ private List<Map<String, Object>> getUsersBlogsRequest(URI uri) {
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(uri, mHttpUsername, mHttpPassword);
+ Object[] params = {mUsername, mPassword};
+ try {
+ Object[] userBlogs = (Object[]) client.call("wp.getUsersBlogs", params);
+ if (userBlogs == null) {
+ // Could happen if the returned server response is truncated
+ mErrorMsgId = R.string.xmlrpc_error;
+ return null;
+ }
+ Arrays.sort(userBlogs, Utils.BlogNameComparator);
+ List<Map<String, Object>> userBlogList = new ArrayList<Map<String, Object>>();
+ for (Object blog : userBlogs) {
+ try {
+ userBlogList.add((Map<String, Object>) blog);
+ } catch (ClassCastException e) {
+ AppLog.e(T.NUX, "invalid data received from XMLRPC call wp.getUsersBlogs");
+ }
+ }
+ return userBlogList;
+ } catch (XmlPullParserException parserException) {
+ mErrorMsgId = R.string.xmlrpc_error;
+ AppLog.e(T.NUX, "invalid data received from XMLRPC call wp.getUsersBlogs", parserException);
+ } catch (XMLRPCFault xmlRpcFault) {
+ handleXmlRpcFault(xmlRpcFault);
+ } catch (XMLRPCException xmlRpcException) {
+ AppLog.e(T.NUX, "XMLRPCException received from XMLRPC call wp.getUsersBlogs", xmlRpcException);
+ mErrorMsgId = R.string.no_site_error;
+ } catch (SSLHandshakeException e) {
+ if (!UrlUtils.getDomainFromUrl(mXmlrpcUrl).endsWith("wordpress.com")) {
+ mErroneousSslCertificate = true;
+ }
+ AppLog.w(T.NUX, "SSLHandshakeException failed. Erroneous SSL certificate detected.");
+ } catch (IOException e) {
+ AppLog.e(T.NUX, "Exception received from XMLRPC call wp.getUsersBlogs", e);
+ mErrorMsgId = R.string.no_site_error;
+ }
+ return null;
+ }
+
+ public List<Map<String, Object>> getBlogList() {
+ if (mSelfHostedURL != null && mSelfHostedURL.length() != 0) {
+ mXmlrpcUrl = getSelfHostedXmlrpcUrl(mSelfHostedURL);
+ } else {
+ mXmlrpcUrl = Constants.wpcomXMLRPCURL;
+ }
+
+ if (mXmlrpcUrl == null) {
+ if (!mHttpAuthRequired && mErrorMsgId == 0) {
+ mErrorMsgId = R.string.no_site_error;
+ }
+ return null;
+ }
+
+ // Validate the URL found before calling the client. Prevent a crash that can occur
+ // during the setup of self-hosted sites.
+ URI uri;
+ try {
+ uri = URI.create(mXmlrpcUrl);
+ return getUsersBlogsRequest(uri);
+ } catch (Exception e) {
+ mErrorMsgId = R.string.no_site_error;
+ return null;
+ }
+ }
+
+ private String getRsdUrl(String baseUrl) throws SSLHandshakeException {
+ String rsdUrl;
+ rsdUrl = ApiHelper.getRSDMetaTagHrefRegEx(baseUrl);
+ if (rsdUrl == null) {
+ rsdUrl = ApiHelper.getRSDMetaTagHref(baseUrl);
+ }
+ return rsdUrl;
+ }
+
+ private boolean isHTTPAuthErrorMessage(Exception e) {
+ if (e != null && e.getMessage() != null && e.getMessage().contains("401")) {
+ mHttpAuthRequired = true;
+ return mHttpAuthRequired;
+ }
+ return false;
+ }
+
+ private String getmXmlrpcByUserEnteredPath(String baseUrl) {
+ String xmlRpcUrl = null;
+ if (!UrlUtils.isValidUrlAndHostNotNull(baseUrl)) {
+ AppLog.e(T.NUX, "invalid URL: " + baseUrl);
+ mErrorMsgId = R.string.invalid_url_message;
+ return null;
+ }
+ URI uri = URI.create(baseUrl);
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(uri, mHttpUsername, mHttpPassword);
+ try {
+ client.call("system.listMethods");
+ xmlRpcUrl = baseUrl;
+ mIsCustomUrl = true;
+ return xmlRpcUrl;
+ } catch (XMLRPCException e) {
+ AppLog.i(T.NUX, "system.listMethods failed on: " + baseUrl);
+ if (isHTTPAuthErrorMessage(e)) {
+ return null;
+ }
+ } catch (SSLHandshakeException e) {
+ if (!UrlUtils.getDomainFromUrl(baseUrl).endsWith("wordpress.com")) {
+ mErroneousSslCertificate = true;
+ }
+ AppLog.w(T.NUX, "SSLHandshakeException failed. Erroneous SSL certificate detected.");
+ return null;
+ } catch (SSLPeerUnverifiedException e) {
+ if (!UrlUtils.getDomainFromUrl(baseUrl).endsWith("wordpress.com")) {
+ mErroneousSslCertificate = true;
+ }
+ AppLog.w(T.NUX, "SSLPeerUnverifiedException failed. Erroneous SSL certificate detected.");
+ return null;
+ } catch (IOException e) {
+ AppLog.i(T.NUX, "system.listMethods failed on: " + baseUrl);
+ if (isHTTPAuthErrorMessage(e)) {
+ return null;
+ }
+ } catch (XmlPullParserException e) {
+ AppLog.i(T.NUX, "system.listMethods failed on: " + baseUrl);
+ if (isHTTPAuthErrorMessage(e)) {
+ return null;
+ }
+ }
+
+ // Guess the xmlrpc path
+ String guessURL = baseUrl;
+ if (guessURL.substring(guessURL.length() - 1, guessURL.length()).equals("/")) {
+ guessURL = guessURL.substring(0, guessURL.length() - 1);
+ }
+ guessURL += "/xmlrpc.php";
+ uri = URI.create(guessURL);
+ client = XMLRPCFactory.instantiate(uri, mHttpUsername, mHttpPassword);
+ try {
+ client.call("system.listMethods");
+ xmlRpcUrl = guessURL;
+ return xmlRpcUrl;
+ } catch (XMLRPCException e) {
+ AppLog.e(T.NUX, "system.listMethods failed on: " + guessURL, e);
+ } catch (SSLHandshakeException e) {
+ if (!UrlUtils.getDomainFromUrl(baseUrl).endsWith("wordpress.com")) {
+ mErroneousSslCertificate = true;
+ }
+ AppLog.w(T.NUX, "SSLHandshakeException failed. Erroneous SSL certificate detected.");
+ return null;
+ } catch (SSLPeerUnverifiedException e) {
+ if (!UrlUtils.getDomainFromUrl(baseUrl).endsWith("wordpress.com")) {
+ mErroneousSslCertificate = true;
+ }
+ AppLog.w(T.NUX, "SSLPeerUnverifiedException failed. Erroneous SSL certificate detected.");
+ return null;
+ } catch (IOException e) {
+ AppLog.e(T.NUX, "system.listMethods failed on: " + guessURL, e);
+ } catch (XmlPullParserException e) {
+ AppLog.e(T.NUX, "system.listMethods failed on: " + guessURL, e);
+ }
+
+ return null;
+ }
+
+ // Attempts to retrieve the xmlrpc url for a self-hosted site, in this order:
+ // 1: Try to retrieve it by finding the ?rsd url in the site's header
+ // 2: Take whatever URL the user entered to see if that returns a correct response
+ // 3: Finally, just guess as to what the xmlrpc url should be
+ private String getSelfHostedXmlrpcUrl(String url) {
+ String xmlrpcUrl;
+
+ // Convert IDN names to punycode if necessary
+ url = UrlUtils.convertUrlToPunycodeIfNeeded(url);
+
+ // Add http to the beginning of the URL if needed
+ url = UrlUtils.addUrlSchemeIfNeeded(url, false);
+
+ if (!URLUtil.isValidUrl(url)) {
+ mErrorMsgId = R.string.invalid_url_message;
+ return null;
+ }
+
+ // Attempt to get the XMLRPC URL via RSD
+ String rsdUrl;
+ try {
+ rsdUrl = getRsdUrl(url);
+ } catch (SSLHandshakeException e) {
+ if (!UrlUtils.getDomainFromUrl(url).endsWith("wordpress.com")) {
+ mErroneousSslCertificate = true;
+ }
+ AppLog.w(T.NUX, "SSLHandshakeException failed. Erroneous SSL certificate detected.");
+ return null;
+ }
+
+ try {
+ if (rsdUrl != null) {
+ xmlrpcUrl = ApiHelper.getXMLRPCUrl(rsdUrl);
+ if (xmlrpcUrl == null) {
+ xmlrpcUrl = rsdUrl.replace("?rsd", "");
+ }
+ } else {
+ xmlrpcUrl = getmXmlrpcByUserEnteredPath(url);
+ }
+ } catch (SSLHandshakeException e) {
+ if (!UrlUtils.getDomainFromUrl(url).endsWith("wordpress.com")) {
+ mErroneousSslCertificate = true;
+ }
+ AppLog.w(T.NUX, "SSLHandshakeException failed. Erroneous SSL certificate detected.");
+ return null;
+ }
+
+ return xmlrpcUrl;
+ }
+
+ /**
+ * Add a new blog or update a blog name in local DB.
+ *
+ * @return true if a new blog has been added or an old blog has been updated.
+ * Return false if no change has been made.
+ */
+ public boolean addOrUpdateBlog(String blogName, String xmlRpcUrl, String homeUrl, String blogId, String username,
+ String password, boolean isAdmin) {
+ Blog blog;
+ if (!WordPress.wpDB.isBlogInDatabase(Integer.parseInt(blogId), xmlRpcUrl)) {
+ // The blog isn't in the app, so let's create it
+ blog = new Blog(xmlRpcUrl, username, password);
+ blog.setHomeURL(homeUrl);
+ blog.setHttpuser(mHttpUsername);
+ blog.setHttppassword(mHttpPassword);
+ blog.setBlogName(blogName);
+ // deprecated
+ blog.setImagePlacement("");
+ blog.setFullSizeImage(false);
+ blog.setMaxImageWidth(DEFAULT_IMAGE_SIZE);
+ // deprecated
+ blog.setMaxImageWidthId(0);
+ blog.setRemoteBlogId(Integer.parseInt(blogId));
+ blog.setDotcomFlag(xmlRpcUrl.contains("wordpress.com"));
+ // assigned later in getOptions call
+ blog.setWpVersion("");
+ blog.setAdmin(isAdmin);
+ WordPress.wpDB.saveBlog(blog);
+ return true;
+ } else {
+ // Update blog name
+ int localTableBlogId = WordPress.wpDB.getLocalTableBlogIdForRemoteBlogIdAndXmlRpcUrl(
+ Integer.parseInt(blogId), xmlRpcUrl);
+ try {
+ blog = WordPress.wpDB.instantiateBlogByLocalId(localTableBlogId);
+ if (!blogName.equals(blog.getBlogName())) {
+ blog.setBlogName(blogName);
+ WordPress.wpDB.saveBlog(blog);
+ return true;
+ }
+ } catch (Exception e) {
+ AppLog.e(T.NUX, "localTableBlogId: " + localTableBlogId + " not found");
+ }
+ return false;
+ }
+ }
+
+ /**
+ * Remove blogs that are not in the list and add others
+ * TODO: it's horribly slow due to datastructures used (List of Map), We should replace
+ * that by a HashSet of a specialized Blog class (that supports comparison)
+ *
+ * @return true if a change has been made (new blog added, old blog updated, blog deleted).
+ */
+ public boolean syncBlogs(Context context, List<Map<String, Object>> newBlogList) {
+ boolean retValue;
+
+ // Add all blogs from blogList
+ retValue = addBlogs(newBlogList);
+
+ // Delete blogs if not in blogList
+ List<Map<String, Object>> allBlogs = WordPress.wpDB.getAccountsBy("dotcomFlag=1", null);
+ Set<String> newBlogURLs = new HashSet<String>();
+ for (Map<String, Object> blog : newBlogList) {
+ newBlogURLs.add(blog.get("xmlrpc").toString() + blog.get("blogid").toString());
+ }
+ for (Map<String, Object> blog : allBlogs) {
+ if (!newBlogURLs.contains(blog.get("url").toString() + blog.get("blogId"))) {
+ WordPress.wpDB.deleteAccount(context, Integer.parseInt(blog.get("id").toString()));
+ retValue = true;
+ }
+ }
+ return retValue;
+ }
+
+ /**
+ * Add selected blog(s) to the database.
+ *
+ * @return true if a change has been made (new blog added or old blog updated).
+ */
+ public boolean addBlogs(List<Map<String, Object>> blogList) {
+ boolean retValue = false;
+ for (int i = 0; i < blogList.size(); i++) {
+ Map<String, Object> blogMap = blogList.get(i);
+ String blogName = StringUtils.unescapeHTML(blogMap.get("blogName").toString());
+ String xmlrpcUrl = (mIsCustomUrl) ? mXmlrpcUrl : blogMap.get("xmlrpc").toString();
+ if (!UrlUtils.isValidUrlAndHostNotNull(xmlrpcUrl)) {
+ // xmlrpcUrl is invalid, set the error message
+ mErrorMsgId = R.string.invalid_xmlrpc_url;
+ AppLog.e(T.NUX, "Invalid XMLRPC url: " + xmlrpcUrl);
+ return retValue;
+ }
+ String homeUrl = blogMap.get("url").toString();
+ String blogId = blogMap.get("blogid").toString();
+ boolean isAdmin = MapUtils.getMapBool(blogMap, "isAdmin");
+ retValue |= addOrUpdateBlog(blogName, xmlrpcUrl, homeUrl, blogId, mUsername, mPassword, isAdmin);
+ }
+ return retValue;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/SetupBlogTask.java b/WordPress/src/main/java/org/wordpress/android/ui/accounts/SetupBlogTask.java
new file mode 100644
index 000000000..a7c2cab44
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/SetupBlogTask.java
@@ -0,0 +1,56 @@
+package org.wordpress.android.ui.accounts;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.AsyncTask;
+import android.preference.PreferenceManager;
+
+import org.wordpress.android.WordPress;
+import org.wordpress.android.WordPressDB;
+
+import java.util.List;
+import java.util.Map;
+
+public class SetupBlogTask extends AsyncTask<Void, Void, List<Map<String, Object>>> {
+ protected SetupBlog mSetupBlog;
+ protected int mErrorMsgId;
+ protected Context mContext;
+ protected boolean mBlogListChanged;
+
+ public SetupBlogTask(Context context) {
+ mContext = context;
+ }
+
+ @Override
+ protected void onPreExecute() {
+ mSetupBlog = new SetupBlog();
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(mContext);
+ String username = settings.getString(WordPress.WPCOM_USERNAME_PREFERENCE, null);
+ String password = WordPressDB.decryptPassword(settings.getString(WordPress.WPCOM_PASSWORD_PREFERENCE, null));
+ mSetupBlog.setUsername(username);
+ mSetupBlog.setPassword(password);
+ }
+
+ @Override
+ protected List<Map<String, Object>> doInBackground(Void... args) {
+ List<Map<String, Object>> userBlogList = mSetupBlog.getBlogList();
+ mErrorMsgId = mSetupBlog.getErrorMsgId();
+ if (userBlogList != null) {
+ mBlogListChanged = mSetupBlog.syncBlogs(mContext, userBlogList);
+ }
+ return userBlogList;
+ }
+
+ public static class GenericSetupBlogTask extends SetupBlogTask {
+ public GenericSetupBlogTask(Context context) {
+ super(context);
+ }
+
+ @Override
+ protected void onPostExecute(final List<Map<String, Object>> userBlogList) {
+ if (mBlogListChanged) {
+ WordPress.sendLocalBroadcast(WordPress.getContext(), WordPress.BROADCAST_ACTION_BLOG_LIST_CHANGED);
+ }
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/WPComLoginActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/accounts/WPComLoginActivity.java
new file mode 100644
index 000000000..ddb22ad9c
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/WPComLoginActivity.java
@@ -0,0 +1,265 @@
+package org.wordpress.android.ui.accounts;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.content.SharedPreferences.Editor;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.preference.PreferenceManager;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.text.method.PasswordTransformationMethod;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.wordpress.rest.RestRequest;
+
+import org.json.JSONObject;
+import org.wordpress.android.Constants;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.WordPressDB;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.ui.reader.actions.ReaderUserActions;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.EditTextUtils;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlrpc.android.XMLRPCClientInterface;
+import org.xmlrpc.android.XMLRPCException;
+import org.xmlrpc.android.XMLRPCFactory;
+import org.xmlrpc.android.XMLRPCFault;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+
+
+/**
+ * An activity to let the user specify their WordPress.com credentials.
+ * Should be used to get WordPress.com credentials for JetPack integration in self-hosted sites.
+ */
+public class WPComLoginActivity extends Activity implements TextWatcher {
+ public static final int REQUEST_CODE = 5000;
+ public static final String JETPACK_AUTH_REQUEST = "jetpackAuthRequest";
+ private static final String NEED_HELP_URL = "http://android.wordpress.org/faq";
+ private String mUsername;
+ private String mPassword;
+ private Button mSignInButton;
+ private boolean mIsJetpackAuthRequest;
+ private boolean mIsWpcomAccountWith2FA;
+ private boolean mIsInvalidUsernameOrPassword;
+ private EditText mUsernameEditText;
+ private EditText mPasswordEditText;
+ private boolean mPasswordVisible;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.wp_dot_com_login_activity);
+ setTitle(getString(R.string.wpcom_signin_dialog_title));
+
+ if (getIntent().hasExtra(JETPACK_AUTH_REQUEST)) {
+ mIsJetpackAuthRequest = true;
+ }
+
+ mSignInButton = (Button) findViewById(R.id.saveDotcom);
+ mSignInButton.setOnClickListener(new Button.OnClickListener() {
+ public void onClick(View v) {
+ signIn();
+ }
+ });
+
+ TextView wpcomHelp = (TextView) findViewById(R.id.wpcomHelp);
+ wpcomHelp.setOnClickListener(new TextView.OnClickListener() {
+ public void onClick(View v) {
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(Uri.parse(NEED_HELP_URL));
+ startActivity(intent);
+ }
+ });
+
+ mUsernameEditText = (EditText) findViewById(R.id.dotcomUsername);
+ mUsernameEditText.addTextChangedListener(this);
+ mPasswordEditText = (EditText) findViewById(R.id.dotcomPassword);
+ mPasswordEditText.addTextChangedListener(this);
+ initPasswordVisibilityButton((ImageView) findViewById(R.id.password_visibility), mPasswordEditText);
+ }
+
+ private void signIn() {
+ mUsername = EditTextUtils.getText(mUsernameEditText);
+ mPassword = EditTextUtils.getText(mPasswordEditText);
+ boolean validUsernameAndPassword = true;
+
+ if (mUsername.equals("")) {
+ mUsernameEditText.setError(getString(R.string.required_field));
+ mUsernameEditText.requestFocus();
+ validUsernameAndPassword = false;
+ }
+ if (mPassword.equals("")) {
+ mPasswordEditText.setError(getString(R.string.required_field));
+ mPasswordEditText.requestFocus();
+ validUsernameAndPassword = false;
+ }
+ if (validUsernameAndPassword) {
+ new SignInTask().execute();
+ }
+ }
+
+ protected void initPasswordVisibilityButton(final ImageView passwordVisibilityToggleView,
+ final EditText passwordEditText) {
+ passwordVisibilityToggleView.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ mPasswordVisible = !mPasswordVisible;
+ if (mPasswordVisible) {
+ passwordVisibilityToggleView.setImageResource(R.drawable.dashicon_eye_open);
+ passwordEditText.setTransformationMethod(null);
+ } else {
+ passwordVisibilityToggleView.setImageResource(R.drawable.dashicon_eye_closed);
+ passwordEditText.setTransformationMethod(PasswordTransformationMethod.getInstance());
+ }
+ passwordEditText.setSelection(passwordEditText.length());
+ }
+ });
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ mPasswordEditText.setError(null);
+ mUsernameEditText.setError(null);
+ }
+
+ @Override
+ public void onBackPressed() {
+ super.onBackPressed();
+ setResult(Activity.RESULT_CANCELED);
+ }
+
+ private void setEditTextAndButtonEnabled(boolean enable) {
+ mUsernameEditText.setEnabled(enable);
+ mPasswordEditText.setEnabled(enable);
+ mSignInButton.setEnabled(enable);
+ }
+
+ private class SignInTask extends AsyncTask<Void, Void, Boolean> {
+ @Override
+ protected void onPreExecute() {
+ mSignInButton.setText(getString(R.string.attempting_configure));
+ setEditTextAndButtonEnabled(false);
+ mIsWpcomAccountWith2FA = false;
+ mIsInvalidUsernameOrPassword = false;
+ }
+
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ URI uri;
+ try {
+ uri = new URI(Constants.wpcomXMLRPCURL);
+ } catch (URISyntaxException e) {
+ AppLog.e(T.API, "Invalid URI syntax: " + Constants.wpcomXMLRPCURL);
+ return false;
+ }
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(uri, "", "");
+ Object[] signInParams = {mUsername, mPassword};
+ try {
+ client.call("wp.getUsersBlogs", signInParams);
+ Blog blog = WordPress.getCurrentBlog();
+ if (blog != null) {
+ blog.setDotcom_username(mUsername);
+ blog.setDotcom_password(mPassword);
+ }
+
+ // Don't change global WP.com settings if this is Jetpack auth request from stats
+ if (!mIsJetpackAuthRequest) {
+ // New wpcom credetials inserted here. Reset the app state: there is the possibility a different
+ // username/password is inserted here
+ WordPress.removeWpComUserRelatedData(WPComLoginActivity.this);
+ WordPress.sendLocalBroadcast(WPComLoginActivity.this, WordPress.BROADCAST_ACTION_SIGNOUT);
+
+ Editor settings = PreferenceManager.getDefaultSharedPreferences(WPComLoginActivity.this).edit();
+ settings.putString(WordPress.WPCOM_USERNAME_PREFERENCE, mUsername);
+ settings.putString(WordPress.WPCOM_PASSWORD_PREFERENCE, WordPressDB.encryptPassword(mPassword));
+ settings.commit();
+
+ // Make sure to update credentials for .wpcom blog even if currentBlog is null
+ WordPress.wpDB.updateWPComCredentials(mUsername, mPassword);
+
+ // Update regular blog credentials for WP.com auth requests
+ if (blog != null) {
+ blog.setUsername(mUsername);
+ blog.setPassword(mPassword);
+ }
+ }
+ if (blog != null) {
+ WordPress.wpDB.saveBlog(blog);
+ }
+ return true;
+ } catch (XMLRPCFault xmlRpcFault) {
+ AppLog.e(T.NUX, "XMLRPCFault received from XMLRPC call wp.getUsersBlogs", xmlRpcFault);
+ if (xmlRpcFault.getFaultCode() == 403) {
+ mIsInvalidUsernameOrPassword = true;
+ }
+ if (xmlRpcFault.getFaultCode() == 425) {
+ mIsWpcomAccountWith2FA = true;
+ return false;
+ }
+ } catch (XMLRPCException e) {
+ AppLog.e(T.NUX, "Exception received from XMLRPC call wp.getUsersBlogs", e);
+ } catch (IOException e) {
+ AppLog.e(T.NUX, "Exception received from XMLRPC call wp.getUsersBlogs", e);
+ } catch (XmlPullParserException e) {
+ AppLog.e(T.NUX, "Exception received from XMLRPC call wp.getUsersBlogs", e);
+ }
+
+ mIsWpcomAccountWith2FA = false;
+ return false;
+ }
+
+ @Override
+ protected void onPostExecute(Boolean isSignedIn) {
+ if (isSignedIn && !isFinishing()) {
+ if (!mIsJetpackAuthRequest) {
+ WordPress.getRestClientUtils().get("me", new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ WPComLoginActivity.this.setResult(RESULT_OK);
+ // Register the device again for Push Notifications
+ WordPress.registerForCloudMessaging(WPComLoginActivity.this);
+ ReaderUserActions.setCurrentUser(jsonObject);
+ finish();
+ }
+ }, null);
+ } else {
+ WPComLoginActivity.this.setResult(RESULT_OK);
+ finish();
+ }
+ } else {
+ if (mIsInvalidUsernameOrPassword) {
+ mUsernameEditText.setError(getString(R.string.username_or_password_incorrect));
+ mPasswordEditText.setError(getString(R.string.username_or_password_incorrect));
+ } else {
+ String errorMessage = mIsWpcomAccountWith2FA ? getString(R.string.account_two_step_auth_enabled)
+ : getString(R.string.nux_cannot_log_in);
+ Toast.makeText(getBaseContext(), errorMessage, Toast.LENGTH_LONG).show();
+ }
+ setEditTextAndButtonEnabled(true);
+ mSignInButton.setText(R.string.sign_in);
+ }
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/WelcomeActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/accounts/WelcomeActivity.java
new file mode 100644
index 000000000..8bc5e0707
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/WelcomeActivity.java
@@ -0,0 +1,59 @@
+package org.wordpress.android.ui.accounts;
+
+import android.app.Activity;
+import android.app.FragmentManager;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.Window;
+
+import org.wordpress.android.R;
+
+// TODO: this will probably be merged with New Account Activity (maybe add a tab bar)
+public class WelcomeActivity extends Activity {
+ public static final int SIGN_IN_REQUEST = 1;
+ public static final int ADD_SELF_HOSTED_BLOG = 2;
+ public static final int CREATE_ACCOUNT_REQUEST = 3;
+ public static final int SHOW_CERT_DETAILS = 4;
+ public static String START_FRAGMENT_KEY = "start-fragment";
+
+ private WelcomeFragmentSignIn mWelcomeFragmentSignIn;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ setContentView(R.layout.activity_welcome);
+ FragmentManager fragmentManager = getFragmentManager();
+ mWelcomeFragmentSignIn = (WelcomeFragmentSignIn) fragmentManager.
+ findFragmentById(R.id.sign_in_fragment);
+ actionMode(getIntent().getExtras());
+ }
+
+ private void actionMode(Bundle extras) {
+ int actionMode = SIGN_IN_REQUEST;
+ if (extras != null) {
+ actionMode = extras.getInt(START_FRAGMENT_KEY, -1);
+ }
+ switch (actionMode) {
+ case ADD_SELF_HOSTED_BLOG:
+ mWelcomeFragmentSignIn.forceSelfHostedMode();
+ break;
+ default:
+ break;
+ }
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (requestCode == SHOW_CERT_DETAILS) {
+ mWelcomeFragmentSignIn.askForSslTrust();
+ } else if (resultCode == RESULT_OK && data != null) {
+ String username = data.getStringExtra("username");
+ if (username != null) {
+ mWelcomeFragmentSignIn.signInDotComUser();
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/WelcomeFragmentSignIn.java b/WordPress/src/main/java/org/wordpress/android/ui/accounts/WelcomeFragmentSignIn.java
new file mode 100644
index 000000000..18c6f194a
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/WelcomeFragmentSignIn.java
@@ -0,0 +1,629 @@
+package org.wordpress.android.ui.accounts;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.FragmentTransaction;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.res.Configuration;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.preference.PreferenceManager;
+import android.text.Editable;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.util.Patterns;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import com.wordpress.rest.RestRequest;
+
+import org.json.JSONObject;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.WordPressDB;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.networking.SSLCertsViewActivity;
+import org.wordpress.android.networking.SelfSignedSSLCertsManager;
+import org.wordpress.android.ui.reader.actions.ReaderUserActions;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.EditTextUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+import org.wordpress.android.widgets.WPTextView;
+import org.wordpress.emailchecker.EmailChecker;
+import org.xmlrpc.android.ApiHelper;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class WelcomeFragmentSignIn extends NewAccountAbstractPageFragment implements TextWatcher {
+ private static final String DOT_COM_BASE_URL = "https://wordpress.com";
+ private static final String FORGOT_PASSWORD_RELATIVE_URL = "/wp-login.php?action=lostpassword";
+ private static final int WPCOM_ERRONEOUS_LOGIN_THRESHOLD = 3;
+ private EditText mUsernameEditText;
+ private EditText mPasswordEditText;
+ private EditText mUrlEditText;
+ private boolean mSelfHosted;
+ private WPTextView mSignInButton;
+ private WPTextView mCreateAccountButton;
+ private WPTextView mAddSelfHostedButton;
+ private WPTextView mProgressTextSignIn;
+ private WPTextView mForgotPassword;
+ private LinearLayout mBottomButtonsLayout;
+ private RelativeLayout mProgressBarSignIn;
+ private RelativeLayout mUrlButtonLayout;
+ private ImageView mInfoButton;
+ private ImageView mInfoButtonSecondary;
+ private EmailChecker mEmailChecker;
+ private boolean mEmailAutoCorrected;
+ private int mWPComErroneousLogInCount;
+
+ public WelcomeFragmentSignIn() {
+ mEmailChecker = new EmailChecker();
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.nux_fragment_welcome, container, false);
+ mUrlButtonLayout = (RelativeLayout) rootView.findViewById(R.id.url_button_layout);
+ mUsernameEditText = (EditText) rootView.findViewById(R.id.nux_username);
+ mUsernameEditText.addTextChangedListener(this);
+ mPasswordEditText = (EditText) rootView.findViewById(R.id.nux_password);
+ mPasswordEditText.addTextChangedListener(this);
+ mUrlEditText = (EditText) rootView.findViewById(R.id.nux_url);
+ mSignInButton = (WPTextView) rootView.findViewById(R.id.nux_sign_in_button);
+ mSignInButton.setOnClickListener(mSignInClickListener);
+ mProgressBarSignIn = (RelativeLayout) rootView.findViewById(R.id.nux_sign_in_progress_bar);
+ mProgressTextSignIn = (WPTextView) rootView.findViewById(R.id.nux_sign_in_progress_text);
+ mCreateAccountButton = (WPTextView) rootView.findViewById(R.id.nux_create_account_button);
+ mCreateAccountButton.setOnClickListener(mCreateAccountListener);
+ mAddSelfHostedButton = (WPTextView) rootView.findViewById(R.id.nux_add_selfhosted_button);
+ mAddSelfHostedButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (mUrlButtonLayout.getVisibility() == View.VISIBLE) {
+ mUrlButtonLayout.setVisibility(View.GONE);
+ mAddSelfHostedButton.setText(getString(R.string.nux_add_selfhosted_blog));
+ mSelfHosted = false;
+ } else {
+ mUrlButtonLayout.setVisibility(View.VISIBLE);
+ mAddSelfHostedButton.setText(getString(R.string.nux_oops_not_selfhosted_blog));
+ mSelfHosted = true;
+ }
+ }
+ });
+ mForgotPassword = (WPTextView) rootView.findViewById(R.id.forgot_password);
+ mForgotPassword.setOnClickListener(mForgotPasswordListener);
+ mUsernameEditText.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (!hasFocus) {
+ autocorrectUsername();
+ }
+ }
+ });
+ mPasswordEditText.setOnEditorActionListener(mEditorAction);
+ mUrlEditText.setOnEditorActionListener(mEditorAction);
+ mBottomButtonsLayout = (LinearLayout) rootView.findViewById(R.id.nux_bottom_buttons);
+ initPasswordVisibilityButton(rootView, mPasswordEditText);
+ initInfoButtons(rootView);
+ moveBottomButtons();
+ return rootView;
+ }
+
+ /**
+ * Hide toggle button "add self hosted / sign in with WordPress.com" and show self hosted URL
+ * edit box
+ */
+ public void forceSelfHostedMode() {
+ mUrlButtonLayout.setVisibility(View.VISIBLE);
+ mAddSelfHostedButton.setVisibility(View.GONE);
+ mCreateAccountButton.setVisibility(View.GONE);
+ mSelfHosted = true;
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ moveBottomButtons();
+ }
+
+ private void initInfoButtons(View rootView) {
+ OnClickListener infoButtonListener = new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent newAccountIntent = new Intent(getActivity(), NuxHelpActivity.class);
+ startActivity(newAccountIntent);
+ }
+ };
+ mInfoButton = (ImageView) rootView.findViewById(R.id.info_button);
+ mInfoButtonSecondary = (ImageView) rootView.findViewById(R.id.info_button_secondary);
+ mInfoButton.setOnClickListener(infoButtonListener);
+ mInfoButtonSecondary.setOnClickListener(infoButtonListener);
+ }
+
+ private void setSecondaryButtonVisible(boolean visible) {
+ mInfoButtonSecondary.setVisibility(visible ? View.VISIBLE : View.GONE);
+ mInfoButton.setVisibility(visible ? View.GONE : View.VISIBLE);
+ }
+
+ private void moveBottomButtons() {
+ if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ mBottomButtonsLayout.setOrientation(LinearLayout.HORIZONTAL);
+ if (getResources().getInteger(R.integer.isTablet) == 0) {
+ setSecondaryButtonVisible(true);
+ } else {
+ setSecondaryButtonVisible(false);
+ }
+ } else {
+ mBottomButtonsLayout.setOrientation(LinearLayout.VERTICAL);
+ setSecondaryButtonVisible(false);
+ }
+ }
+
+ private void autocorrectUsername() {
+ if (mEmailAutoCorrected) {
+ return;
+ }
+ final String email = EditTextUtils.getText(mUsernameEditText).trim();
+ // Check if the username looks like an email address
+ final Pattern emailRegExPattern = Patterns.EMAIL_ADDRESS;
+ Matcher matcher = emailRegExPattern.matcher(email);
+ if (!matcher.find()) {
+ return;
+ }
+ // It looks like an email address, then try to correct it
+ String suggest = mEmailChecker.suggestDomainCorrection(email);
+ if (suggest.compareTo(email) != 0) {
+ mEmailAutoCorrected = true;
+ mUsernameEditText.setText(suggest);
+ mUsernameEditText.setSelection(suggest.length());
+ }
+ }
+
+ private boolean isWPComLogin() {
+ return !mSelfHosted || TextUtils.isEmpty(EditTextUtils.getText(mUrlEditText).trim());
+ }
+
+ private View.OnClickListener mCreateAccountListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent newAccountIntent = new Intent(getActivity(), NewAccountActivity.class);
+ Activity activity = getActivity();
+ if (activity != null) {
+ activity.startActivityForResult(newAccountIntent, WelcomeActivity.CREATE_ACCOUNT_REQUEST);
+ }
+ }
+ };
+
+ private View.OnClickListener mForgotPasswordListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ String baseUrl = DOT_COM_BASE_URL;
+ if (!isWPComLogin()) {
+ baseUrl = EditTextUtils.getText(mUrlEditText).trim();
+ String lowerCaseBaseUrl = baseUrl.toLowerCase(Locale.getDefault());
+ if (!lowerCaseBaseUrl.startsWith("https://") && !lowerCaseBaseUrl.startsWith("http://")) {
+ baseUrl = "http://" + baseUrl;
+ }
+ }
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(baseUrl + FORGOT_PASSWORD_RELATIVE_URL));
+ startActivity(intent);
+ }
+ };
+
+ protected void onDoneAction() {
+ signin();
+ }
+
+ private TextView.OnEditorActionListener mEditorAction = new TextView.OnEditorActionListener() {
+ @Override
+ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+ if (mPasswordEditText == v) {
+ if (mSelfHosted) {
+ mUrlEditText.requestFocus();
+ return true;
+ } else {
+ return onDoneEvent(actionId, event);
+ }
+ }
+ return onDoneEvent(actionId, event);
+ }
+ };
+
+ private void signin() {
+ if (!isUserDataValid()) {
+ return;
+ }
+ new SetupBlogTask().execute();
+ }
+
+ private OnClickListener mSignInClickListener = new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ signin();
+ }
+ };
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ if (fieldsFilled()) {
+ mSignInButton.setEnabled(true);
+ } else {
+ mSignInButton.setEnabled(false);
+ }
+ mPasswordEditText.setError(null);
+ mUsernameEditText.setError(null);
+ }
+
+ private boolean fieldsFilled() {
+ return EditTextUtils.getText(mUsernameEditText).trim().length() > 0
+ && EditTextUtils.getText(mPasswordEditText).trim().length() > 0;
+ }
+
+ protected boolean isUserDataValid() {
+ final String username = EditTextUtils.getText(mUsernameEditText).trim();
+ final String password = EditTextUtils.getText(mPasswordEditText).trim();
+ boolean retValue = true;
+
+ if (username.equals("")) {
+ mUsernameEditText.setError(getString(R.string.required_field));
+ mUsernameEditText.requestFocus();
+ retValue = false;
+ }
+
+ if (password.equals("")) {
+ mPasswordEditText.setError(getString(R.string.required_field));
+ mPasswordEditText.requestFocus();
+ retValue = false;
+ }
+ return retValue;
+ }
+
+ private boolean selfHostedFieldsFilled() {
+ return fieldsFilled() && EditTextUtils.getText(mUrlEditText).trim().length() > 0;
+ }
+
+ private void showPasswordError(int messageId) {
+ mPasswordEditText.setError(getString(messageId));
+ mPasswordEditText.requestFocus();
+ }
+
+ private void showUsernameError(int messageId) {
+ mUsernameEditText.setError(getString(messageId));
+ mUsernameEditText.requestFocus();
+ }
+
+ private void showPasswordError(int messageId, String param) {
+ mPasswordEditText.setError(getString(messageId, param));
+ mPasswordEditText.requestFocus();
+ }
+
+ private void showUsernameError(int messageId, String param) {
+ mUsernameEditText.setError(getString(messageId, param));
+ mUsernameEditText.requestFocus();
+ }
+
+ private void showUrlError(int messageId) {
+ mUrlEditText.setError(getString(messageId));
+ mUrlEditText.requestFocus();
+ }
+
+ protected boolean specificShowError(int messageId) {
+ switch (getErrorType(messageId)) {
+ case USERNAME:
+ case PASSWORD:
+ showUsernameError(messageId);
+ showPasswordError(messageId);
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ public void signInDotComUser() {
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(
+ getActivity().getApplicationContext());
+ String username = settings.getString(WordPress.WPCOM_USERNAME_PREFERENCE, null);
+ String password = WordPressDB.decryptPassword(settings.getString(WordPress.WPCOM_PASSWORD_PREFERENCE, null));
+ if (username != null && password != null) {
+ mUsernameEditText.setText(username);
+ mPasswordEditText.setText(password);
+ new SetupBlogTask().execute();
+ }
+ }
+
+ protected void startProgress(String message) {
+ mProgressBarSignIn.setVisibility(View.VISIBLE);
+ mProgressTextSignIn.setVisibility(View.VISIBLE);
+ mSignInButton.setVisibility(View.GONE);
+ mProgressBarSignIn.setEnabled(false);
+ mProgressTextSignIn.setText(message);
+ mUsernameEditText.setEnabled(false);
+ mPasswordEditText.setEnabled(false);
+ mUrlEditText.setEnabled(false);
+ mAddSelfHostedButton.setEnabled(false);
+ mCreateAccountButton.setEnabled(false);
+ mForgotPassword.setEnabled(false);
+ }
+
+ protected void endProgress() {
+ mProgressBarSignIn.setVisibility(View.GONE);
+ mProgressTextSignIn.setVisibility(View.GONE);
+ mSignInButton.setVisibility(View.VISIBLE);
+ mUsernameEditText.setEnabled(true);
+ mPasswordEditText.setEnabled(true);
+ mUrlEditText.setEnabled(true);
+ mAddSelfHostedButton.setEnabled(true);
+ mCreateAccountButton.setEnabled(true);
+ mForgotPassword.setEnabled(true);
+ }
+
+ protected void askForSslTrust() {
+ AlertDialog.Builder alert = new AlertDialog.Builder(getActivity());
+ alert.setTitle(getString(R.string.ssl_certificate_error));
+ alert.setMessage(getString(R.string.ssl_certificate_ask_trust));
+ alert.setPositiveButton(R.string.ssl_certificate_trust, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int which) {
+ SetupBlogTask setupBlogTask = new SetupBlogTask();
+ try {
+ SelfSignedSSLCertsManager selfSignedSSLCertsManager = SelfSignedSSLCertsManager.getInstance(
+ getActivity());
+ selfSignedSSLCertsManager.addCertificates(selfSignedSSLCertsManager.getLastFailureChain());
+ } catch (IOException e) {
+ AppLog.e(T.NUX, e);
+ } catch (GeneralSecurityException e) {
+ AppLog.e(T.NUX, e);
+ }
+ setupBlogTask.execute();
+ }
+ });
+ alert.setNeutralButton(R.string.ssl_certificate_details, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int which) {
+ Intent intent = new Intent(getActivity(), SSLCertsViewActivity.class);
+ try {
+ SelfSignedSSLCertsManager selfSignedSSLCertsManager = SelfSignedSSLCertsManager.getInstance(
+ getActivity());
+ String lastFailureChainDesc = "URL: " + EditTextUtils.getText(mUrlEditText).trim() + "<br/><br/>"
+ + selfSignedSSLCertsManager.getLastFailureChainDescription().replaceAll("\n", "<br/>");
+ intent.putExtra(SSLCertsViewActivity.CERT_DETAILS_KEYS, lastFailureChainDesc);
+ getActivity().startActivityForResult(intent, WelcomeActivity.SHOW_CERT_DETAILS);
+ } catch (GeneralSecurityException e) {
+ AppLog.e(T.NUX, e);
+ } catch (IOException e) {
+ AppLog.e(T.NUX, e);
+ }
+ }
+ });
+ alert.setNegativeButton(R.string.ssl_certificate_do_not_trust, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int which) {
+ }
+ });
+ alert.show();
+ endProgress();
+ }
+
+ private class SetupBlogTask extends AsyncTask<Void, Void, List<Map<String, Object>>> {
+ private SetupBlog mSetupBlog;
+ private int mErrorMsgId;
+
+ private void setHttpCredentials(String username, String password) {
+ if (mSetupBlog == null) {
+ mSetupBlog = new SetupBlog();
+ }
+ mSetupBlog.setHttpUsername(username);
+ mSetupBlog.setHttpPassword(password);
+ }
+
+ @Override
+ protected void onPreExecute() {
+ if (mSetupBlog == null) {
+ mSetupBlog = new SetupBlog();
+ }
+ mSetupBlog.setUsername(EditTextUtils.getText(mUsernameEditText).trim());
+ mSetupBlog.setPassword(EditTextUtils.getText(mPasswordEditText).trim());
+ if (mSelfHosted) {
+ mSetupBlog.setSelfHostedURL(EditTextUtils.getText(mUrlEditText).trim());
+ } else {
+ mSetupBlog.setSelfHostedURL(null);
+ }
+ startProgress(selfHostedFieldsFilled() ? getString(R.string.attempting_configure) : getString(
+ R.string.connecting_wpcom));
+ }
+
+ private void refreshBlogContent(Map<String, Object> blogMap) {
+ String blogId = blogMap.get("blogid").toString();
+ String xmlRpcUrl = blogMap.get("xmlrpc").toString();
+ int intBlogId = StringUtils.stringToInt(blogId, -1);
+ if (intBlogId == -1) {
+ AppLog.e(T.NUX, "Can't refresh blog content - invalid blogId: " + blogId);
+ return;
+ }
+ int blogLocalId = WordPress.wpDB.getLocalTableBlogIdForRemoteBlogIdAndXmlRpcUrl(intBlogId, xmlRpcUrl);
+ Blog firstBlog = WordPress.wpDB.instantiateBlogByLocalId(blogLocalId);
+ new ApiHelper.RefreshBlogContentTask(getActivity(), firstBlog, null).executeOnExecutor(
+ AsyncTask.THREAD_POOL_EXECUTOR, false);
+ }
+
+ /**
+ * Get first blog and call RefreshBlogContentTask. First blog will be autoselected when user login.
+ * Also when a user add a new self hosted blog, userBlogList contains only one element.
+ * TODO: when user's default blog autoselection is implemented, we should refresh the default one and
+ * not the first one.
+ * We don't want to refresh the whole list because it can be huge and each blog is refreshed when
+ * user selects it.
+ */
+ private void refreshFirstBlogContent(List<Map<String, Object>> userBlogList) {
+ if (userBlogList != null && !userBlogList.isEmpty()) {
+ Map<String, Object> firstBlogMap = userBlogList.get(0);
+ refreshBlogContent(firstBlogMap);
+ }
+ }
+
+ @Override
+ protected List<Map<String, Object>> doInBackground(Void... args) {
+ List<Map<String, Object>> userBlogList = mSetupBlog.getBlogList();
+ mErrorMsgId = mSetupBlog.getErrorMsgId();
+ if (mErrorMsgId != 0) {
+ return null;
+ }
+ if (userBlogList != null) {
+ mSetupBlog.addBlogs(userBlogList);
+ }
+ mErrorMsgId = mSetupBlog.getErrorMsgId();
+ if (mErrorMsgId != 0) {
+ return null;
+ }
+ return userBlogList;
+ }
+
+ private void httpAuthRequired() {
+ // Prompt for http credentials
+ mSetupBlog.setHttpAuthRequired(false);
+ AlertDialog.Builder alert = new AlertDialog.Builder(getActivity());
+ alert.setTitle(R.string.http_authorization_required);
+
+ View httpAuth = getActivity().getLayoutInflater().inflate(R.layout.alert_http_auth, null);
+ final EditText usernameEditText = (EditText) httpAuth.findViewById(R.id.http_username);
+ final EditText passwordEditText = (EditText) httpAuth.findViewById(R.id.http_password);
+ alert.setView(httpAuth);
+ alert.setPositiveButton(R.string.sign_in, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ SetupBlogTask setupBlogTask = new SetupBlogTask();
+ setupBlogTask.setHttpCredentials(EditTextUtils.getText(usernameEditText), EditTextUtils.getText(
+ passwordEditText));
+ setupBlogTask.execute();
+ }
+ });
+
+ alert.setNegativeButton(R.string.cancel, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ // Canceled.
+ }
+ });
+
+ alert.show();
+ endProgress();
+ }
+
+ private void handleInvalidUsernameOrPassword() {
+ if (isWPComLogin()) {
+ mWPComErroneousLogInCount += 1;
+ if (mWPComErroneousLogInCount >= WPCOM_ERRONEOUS_LOGIN_THRESHOLD) {
+ mErrorMsgId = R.string.username_or_password_incorrect_selfhosted_hint;
+ }
+ }
+ if (mErrorMsgId == R.string.username_or_password_incorrect_selfhosted_hint) {
+ showUsernameError(mErrorMsgId, getString(R.string.nux_add_selfhosted_blog));
+ showPasswordError(mErrorMsgId, getString(R.string.nux_add_selfhosted_blog));
+ } else {
+ showUsernameError(mErrorMsgId);
+ showPasswordError(mErrorMsgId);
+ }
+ mErrorMsgId = 0;
+ endProgress();
+ }
+
+ private void signInError() {
+ FragmentTransaction ft = getFragmentManager().beginTransaction();
+ NUXDialogFragment nuxAlert;
+ if (mErrorMsgId == R.string.account_two_step_auth_enabled) {
+ nuxAlert = NUXDialogFragment.newInstance(getString(R.string.nux_cannot_log_in), getString(
+ mErrorMsgId), getString(R.string.nux_tap_continue), R.drawable.nux_icon_alert, true,
+ getString(R.string.visit_security_settings), NUXDialogFragment.ACTION_OPEN_URL,
+ "https://wordpress.com/settings/security/?ssl=forced");
+ } else {
+ if (mErrorMsgId == R.string.username_or_password_incorrect) {
+ handleInvalidUsernameOrPassword();
+ return;
+ } else if (mErrorMsgId == R.string.invalid_url_message) {
+ showUrlError(mErrorMsgId);
+ mErrorMsgId = 0;
+ endProgress();
+ return;
+ } else {
+ nuxAlert = NUXDialogFragment.newInstance(getString(R.string.nux_cannot_log_in), getString(
+ mErrorMsgId), getString(R.string.nux_tap_continue), R.drawable.nux_icon_alert);
+ }
+ }
+ ft.add(nuxAlert, "alert");
+ ft.commitAllowingStateLoss();
+ mErrorMsgId = 0;
+ endProgress();
+ }
+
+ @Override
+ protected void onPostExecute(final List<Map<String, Object>> userBlogList) {
+ if (mSetupBlog.isErroneousSslCertificates() && hasActivity()) {
+ askForSslTrust();
+ return;
+ }
+
+ if (mSetupBlog.isHttpAuthRequired() && hasActivity()) {
+ httpAuthRequired();
+ return;
+ }
+
+ if (userBlogList == null && mErrorMsgId != 0 && hasActivity()) {
+ signInError();
+ return;
+ }
+
+ refreshFirstBlogContent(userBlogList);
+
+ if (mSelfHosted) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.ADDED_SELF_HOSTED_SITE);
+ }
+
+ // Update wp.com credentials
+ if (mSetupBlog.getXmlrpcUrl() != null && mSetupBlog.getXmlrpcUrl().contains("wordpress.com")) {
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(WordPress.getContext());
+ SharedPreferences.Editor editor = settings.edit();
+ editor.putString(WordPress.WPCOM_USERNAME_PREFERENCE, mSetupBlog.getUsername());
+ editor.putString(WordPress.WPCOM_PASSWORD_PREFERENCE, WordPressDB.encryptPassword(
+ mSetupBlog.getPassword()));
+ editor.commit();
+ // Fire off a request to get an access token
+ WordPress.getRestClientUtils().get("me", new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ ReaderUserActions.setCurrentUser(jsonObject);
+ }
+ }, null);
+ }
+
+ if (userBlogList != null) {
+ if (getActivity() != null) {
+ getActivity().setResult(Activity.RESULT_OK);
+ getActivity().finish();
+ }
+ } else {
+ endProgress();
+ }
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentActions.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentActions.java
new file mode 100644
index 000000000..1ec7e2716
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentActions.java
@@ -0,0 +1,505 @@
+package org.wordpress.android.ui.comments;
+
+import android.os.Handler;
+import android.text.TextUtils;
+
+import com.android.volley.VolleyError;
+import com.wordpress.rest.RestRequest;
+
+import org.json.JSONObject;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.CommentTable;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.models.Comment;
+import org.wordpress.android.models.CommentList;
+import org.wordpress.android.models.CommentStatus;
+import org.wordpress.android.models.Note;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlrpc.android.XMLRPCClientInterface;
+import org.xmlrpc.android.XMLRPCException;
+import org.xmlrpc.android.XMLRPCFactory;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * actions related to comments - replies, moderating, etc.
+ * methods below do network calls in the background & update local DB upon success
+ * all methods below MUST be called from UI thread
+ */
+
+public class CommentActions {
+ private CommentActions() {
+ throw new AssertionError();
+ }
+
+ /*
+ * listener when a comment action is performed
+ */
+ public interface CommentActionListener {
+ public void onActionResult(boolean succeeded);
+ }
+
+ /*
+ * listener when comments are moderated or deleted
+ */
+ public interface OnCommentsModeratedListener {
+ public void onCommentsModerated(final CommentList moderatedComments);
+ }
+
+ /*
+ * used by comment fragments to alert container activity of a change to one or more
+ * comments (moderated, deleted, added, etc.)
+ */
+ public static enum ChangedFrom {COMMENT_LIST, COMMENT_DETAIL}
+ public static enum ChangeType {EDITED, STATUS, REPLIED, TRASHED}
+ public static interface OnCommentChangeListener {
+ public void onCommentChanged(ChangedFrom changedFrom, ChangeType changeType);
+ }
+
+
+ /*
+ * add a comment for the passed post
+ */
+ public static void addComment(final int accountId,
+ final String postID,
+ final String commentText,
+ final CommentActionListener actionListener) {
+ final Blog blog = WordPress.getBlog(accountId);
+ if (blog==null || TextUtils.isEmpty(commentText)) {
+ if (actionListener != null)
+ actionListener.onActionResult(false);
+ return;
+ }
+
+ final Handler handler = new Handler();
+
+ new Thread() {
+ @Override
+ public void run() {
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+
+ Map<String, Object> commentHash = new HashMap<String, Object>();
+ commentHash.put("content", commentText);
+ commentHash.put("author", "");
+ commentHash.put("author_url", "");
+ commentHash.put("author_email", "");
+
+ Object[] params = {
+ blog.getRemoteBlogId(),
+ blog.getUsername(),
+ blog.getPassword(),
+ postID,
+ commentHash};
+
+ int newCommentID;
+ try {
+ newCommentID = (Integer) client.call("wp.newComment", params);
+ } catch (XMLRPCException e) {
+ AppLog.e(T.COMMENTS, "Error while sending new comment", e);
+ newCommentID = -1;
+ } catch (IOException e) {
+ AppLog.e(T.COMMENTS, "Error while sending new comment", e);
+ newCommentID = -1;
+ } catch (XmlPullParserException e) {
+ AppLog.e(T.COMMENTS, "Error while sending new comment", e);
+ newCommentID = -1;
+ }
+
+ final boolean succeeded = (newCommentID >= 0);
+
+ if (actionListener != null) {
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ actionListener.onActionResult(succeeded);
+ }
+ });
+ }
+ }
+ }.start();
+ }
+
+ /**
+ * reply to an individual comment
+ */
+ static void submitReplyToComment(final int accountId,
+ final Comment comment,
+ final String replyText,
+ final CommentActionListener actionListener) {
+ final Blog blog = WordPress.getBlog(accountId);
+ if (blog==null || comment==null || TextUtils.isEmpty(replyText)) {
+ if (actionListener != null)
+ actionListener.onActionResult(false);
+ return;
+ }
+
+ final Handler handler = new Handler();
+
+ new Thread() {
+ @Override
+ public void run() {
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+
+ Map<String, Object> replyHash = new HashMap<String, Object>();
+ replyHash.put("comment_parent", Long.toString(comment.commentID));
+ replyHash.put("content", replyText);
+ replyHash.put("author", "");
+ replyHash.put("author_url", "");
+ replyHash.put("author_email", "");
+
+ Object[] params = {
+ blog.getRemoteBlogId(),
+ blog.getUsername(),
+ blog.getPassword(),
+ Long.toString(comment.postID),
+ replyHash };
+
+
+ long newCommentID;
+ try {
+ Object newCommentIDObject = client.call("wp.newComment", params);
+ if (newCommentIDObject instanceof Integer) {
+ newCommentID = ((Integer) newCommentIDObject).longValue();
+ } else if (newCommentIDObject instanceof Long) {
+ newCommentID = (Long) newCommentIDObject;
+ } else {
+ AppLog.e(T.COMMENTS, "wp.newComment returned the wrong data type");
+ newCommentID = -1;
+ }
+ } catch (XMLRPCException e) {
+ AppLog.e(T.COMMENTS, "Error while sending the new comment", e);
+ newCommentID = -1;
+ } catch (IOException e) {
+ AppLog.e(T.COMMENTS, "Error while sending the new comment", e);
+ newCommentID = -1;
+ } catch (XmlPullParserException e) {
+ AppLog.e(T.COMMENTS, "Error while sending the new comment", e);
+ newCommentID = -1;
+ }
+
+ final boolean succeeded = (newCommentID >= 0);
+
+ if (actionListener != null) {
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ actionListener.onActionResult(succeeded);
+ }
+ });
+ }
+ }
+ }.start();
+ }
+
+ /**
+ * reply to an individual comment that came from a notification - this differs from
+ * submitReplyToComment() in that it enables responding to a reply to a comment this
+ * user made on someone else's blog
+ */
+ static void submitReplyToCommentNote(final Note note,
+ final String replyText,
+ final CommentActionListener actionListener) {
+ if (note == null || TextUtils.isEmpty(replyText)) {
+ if (actionListener != null)
+ actionListener.onActionResult(false);
+ return;
+ }
+
+ RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ if (actionListener != null)
+ actionListener.onActionResult(true);
+ }
+ };
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ if (volleyError != null)
+ AppLog.e(T.COMMENTS, volleyError.getMessage(), volleyError);
+ if (actionListener != null)
+ actionListener.onActionResult(false);
+ }
+ };
+
+ Note.Reply reply = note.buildReply(replyText);
+ WordPress.getRestClientUtils().replyToComment(reply, listener, errorListener);
+ }
+
+ /**
+ * change the status of a single comment
+ */
+ static void moderateComment(final int accountId,
+ final Comment comment,
+ final CommentStatus newStatus,
+ final CommentActionListener actionListener) {
+ // deletion is handled separately
+ if (newStatus != null && newStatus.equals(CommentStatus.TRASH)) {
+ deleteComment(accountId, comment, actionListener);
+ return;
+ }
+
+ final Blog blog = WordPress.getBlog(accountId);
+
+ if (blog==null || comment==null || newStatus==null || newStatus==CommentStatus.UNKNOWN) {
+ if (actionListener != null)
+ actionListener.onActionResult(false);
+ return;
+ }
+
+ final Handler handler = new Handler();
+
+ new Thread() {
+ @Override
+ public void run() {
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+
+ Map<String, String> postHash = new HashMap<String, String>();
+ postHash.put("status", CommentStatus.toString(newStatus));
+ postHash.put("content", comment.getCommentText());
+ postHash.put("author", comment.getAuthorName());
+ postHash.put("author_url", comment.getAuthorUrl());
+ postHash.put("author_email", comment.getAuthorEmail());
+
+ Object[] params = { blog.getRemoteBlogId(),
+ blog.getUsername(),
+ blog.getPassword(),
+ Long.toString(comment.commentID),
+ postHash};
+
+ Object result;
+ try {
+ result = client.call("wp.editComment", params);
+ } catch (XMLRPCException e) {
+ AppLog.e(T.COMMENTS, "Error while editing comment", e);
+ result = null;
+ } catch (IOException e) {
+ AppLog.e(T.COMMENTS, "Error while editing comment", e);
+ result = null;
+ } catch (XmlPullParserException e) {
+ AppLog.e(T.COMMENTS, "Error while editing comment", e);
+ result = null;
+ }
+
+ final boolean success = (result != null && Boolean.parseBoolean(result.toString()));
+ if (success)
+ CommentTable.updateCommentStatus(blog.getLocalTableBlogId(), comment.commentID, CommentStatus.toString(newStatus));
+
+ if (actionListener != null) {
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ actionListener.onActionResult(success);
+ }
+ });
+ }
+ }
+ }.start();
+ }
+
+ /**
+ * change the status of multiple comments
+ * TODO: investigate using system.multiCall to perform a single call to moderate the list
+ */
+ static void moderateComments(final int accountId,
+ final CommentList comments,
+ final CommentStatus newStatus,
+ final OnCommentsModeratedListener actionListener) {
+ // deletion is handled separately
+ if (newStatus != null && newStatus.equals(CommentStatus.TRASH)) {
+ deleteComments(accountId, comments, actionListener);
+ return;
+ }
+
+ final Blog blog = WordPress.getBlog(accountId);
+
+ if (blog==null || comments==null || comments.size() == 0 || newStatus==null || newStatus==CommentStatus.UNKNOWN) {
+ if (actionListener != null)
+ actionListener.onCommentsModerated(new CommentList());
+ return;
+ }
+
+ final CommentList moderatedComments = new CommentList();
+ final String newStatusStr = CommentStatus.toString(newStatus);
+ final int localBlogId = blog.getLocalTableBlogId();
+ final int remoteBlogId = blog.getRemoteBlogId();
+
+ final Handler handler = new Handler();
+ new Thread() {
+ @Override
+ public void run() {
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+ for (Comment comment: comments) {
+ Map<String, String> postHash = new HashMap<String, String>();
+ postHash.put("status", newStatusStr);
+ postHash.put("content", comment.getCommentText());
+ postHash.put("author", comment.getAuthorName());
+ postHash.put("author_url", comment.getAuthorUrl());
+ postHash.put("author_email", comment.getAuthorEmail());
+
+ Object[] params = {
+ remoteBlogId,
+ blog.getUsername(),
+ blog.getPassword(),
+ Long.toString(comment.commentID),
+ postHash};
+
+ Object result;
+ try {
+ result = client.call("wp.editComment", params);
+ boolean success = (result != null && Boolean.parseBoolean(result.toString()));
+ if (success) {
+ comment.setStatus(newStatusStr);
+ moderatedComments.add(comment);
+ }
+ } catch (XMLRPCException e) {
+ AppLog.e(T.COMMENTS, "Error while editing comment", e);
+ } catch (IOException e) {
+ AppLog.e(T.COMMENTS, "Error while editing comment", e);
+ } catch (XmlPullParserException e) {
+ AppLog.e(T.COMMENTS, "Error while editing comment", e);
+ }
+ }
+
+ // update status in SQLite of successfully moderated comments
+ CommentTable.updateCommentsStatus(localBlogId, moderatedComments, newStatusStr);
+
+ if (actionListener != null) {
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ actionListener.onCommentsModerated(moderatedComments);
+ }
+ });
+ }
+ }
+ }.start();
+ }
+
+ /**
+ * delete (trash) a single comment
+ */
+ private static void deleteComment(final int accountId,
+ final Comment comment,
+ final CommentActionListener actionListener) {
+ final Blog blog = WordPress.getBlog(accountId);
+ if (blog==null || comment==null) {
+ if (actionListener != null)
+ actionListener.onActionResult(false);
+ return;
+ }
+
+ final Handler handler = new Handler();
+
+ new Thread() {
+ @Override
+ public void run() {
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+
+ Object[] params = {
+ blog.getRemoteBlogId(),
+ blog.getUsername(),
+ blog.getPassword(),
+ comment.commentID };
+
+ Object result;
+ try {
+ result = client.call("wp.deleteComment", params);
+ } catch (final XMLRPCException e) {
+ AppLog.e(T.COMMENTS, "Error while deleting comment", e);
+ result = null;
+ } catch (IOException e) {
+ AppLog.e(T.COMMENTS, "Error while deleting comment", e);
+ result = null;
+ } catch (XmlPullParserException e) {
+ AppLog.e(T.COMMENTS,"Error while deleting comment", e);
+ result = null;
+ }
+
+ final boolean success = (result != null && Boolean.parseBoolean(result.toString()));
+ if (success)
+ CommentTable.deleteComment(accountId, comment.commentID);
+
+ if (actionListener != null) {
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ actionListener.onActionResult(success);
+ }
+ });
+ }
+ }
+ }.start();
+ }
+
+ /**
+ * delete multiple comments
+ */
+ private static void deleteComments(final int accountId,
+ final CommentList comments,
+ final OnCommentsModeratedListener actionListener) {
+ final Blog blog = WordPress.getBlog(accountId);
+
+ if (blog==null || comments==null || comments.size() == 0) {
+ if (actionListener != null)
+ actionListener.onCommentsModerated(new CommentList());
+ return;
+ }
+
+ final CommentList deletedComments = new CommentList();
+ final int localBlogId = blog.getLocalTableBlogId();
+ final int remoteBlogId = blog.getRemoteBlogId();
+
+ final Handler handler = new Handler();
+ new Thread() {
+ @Override
+ public void run() {
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+
+ for (Comment comment: comments) {
+ Object[] params = {
+ remoteBlogId,
+ blog.getUsername(),
+ blog.getPassword(),
+ comment.commentID};
+
+ Object result;
+ try {
+ result = client.call("wp.deleteComment", params);
+ boolean success = (result != null && Boolean.parseBoolean(result.toString()));
+ if (success)
+ deletedComments.add(comment);
+ } catch (XMLRPCException e) {
+ AppLog.e(T.COMMENTS, "Error while deleting comment", e);
+ } catch (IOException e) {
+ AppLog.e(T.COMMENTS, "Error while deleting comment", e);
+ } catch (XmlPullParserException e) {
+ AppLog.e(T.COMMENTS, "Error while deleting comment", e);
+ }
+ }
+
+ // remove successfully deleted comments from SQLite
+ CommentTable.deleteComments(localBlogId, deletedComments);
+
+ if (actionListener != null) {
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ actionListener.onCommentsModerated(deletedComments);
+ }
+ });
+ }
+ }
+ }.start();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentAdapter.java
new file mode 100644
index 000000000..54a91c6a0
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentAdapter.java
@@ -0,0 +1,347 @@
+package org.wordpress.android.ui.comments;
+
+import android.content.Context;
+import android.os.AsyncTask;
+import android.text.Html;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ImageView;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.CommentTable;
+import org.wordpress.android.models.Comment;
+import org.wordpress.android.models.CommentList;
+import org.wordpress.android.util.AniUtils;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.DateTimeUtils;
+import org.wordpress.android.widgets.WPNetworkImageView;
+
+import java.util.HashSet;
+
+class CommentAdapter extends BaseAdapter {
+ static interface DataLoadedListener {
+ public void onDataLoaded(boolean isEmpty);
+ }
+
+ static interface OnLoadMoreListener {
+ public void onLoadMore();
+ }
+
+ static interface OnSelectedItemsChangeListener {
+ public void onSelectedItemsChanged();
+ }
+
+ private final LayoutInflater mInflater;
+ private final DataLoadedListener mDataLoadedListener;
+ private final OnLoadMoreListener mOnLoadMoreListener;
+ private final OnSelectedItemsChangeListener mOnSelectedChangeListener;
+
+ private CommentList mComments = new CommentList();
+ private final HashSet<Integer> mSelectedPositions = new HashSet<Integer>();
+
+ private final int mStatusColorSpam;
+ private final int mStatusColorUnapproved;
+ private final int mSelectionColor;
+
+ private final int mAvatarSz;
+ private long mHighlightedCommentId = -1;
+
+ private final String mStatusTextSpam;
+ private final String mStatusTextUnapproved;
+
+ private boolean mEnableSelection;
+
+ CommentAdapter(Context context,
+ DataLoadedListener onDataLoadedListener,
+ OnLoadMoreListener onLoadMoreListener,
+ OnSelectedItemsChangeListener onChangeListener) {
+ mInflater = LayoutInflater.from(context);
+
+ mDataLoadedListener = onDataLoadedListener;
+ mOnLoadMoreListener = onLoadMoreListener;
+ mOnSelectedChangeListener = onChangeListener;
+
+ mStatusColorSpam = context.getResources().getColor(R.color.comment_status_spam);
+ mStatusColorUnapproved = context.getResources().getColor(R.color.comment_status_unapproved);
+ mSelectionColor = context.getResources().getColor(R.color.blue_extra_light);
+
+ mStatusTextSpam = context.getResources().getString(R.string.comment_status_spam);
+ mStatusTextUnapproved = context.getResources().getString(R.string.comment_status_unapproved);
+
+ mAvatarSz = context.getResources().getDimensionPixelSize(R.dimen.avatar_sz_medium);
+ }
+
+ @Override
+ public int getCount() {
+ return (mComments != null ? mComments.size() : 0);
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return mComments.get(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ void clear() {
+ if (mComments.size() > 0) {
+ mComments.clear();
+ notifyDataSetChanged();
+ }
+ }
+
+ void setEnableSelection(boolean enable) {
+ if (enable == mEnableSelection)
+ return;
+
+ mEnableSelection = enable;
+ if (mEnableSelection) {
+ notifyDataSetChanged();
+ } else {
+ clearSelectedComments();
+ }
+ }
+
+ void clearSelectedComments() {
+ if (mSelectedPositions.size() > 0) {
+ mSelectedPositions.clear();
+ notifyDataSetChanged();
+ if (mOnSelectedChangeListener != null)
+ mOnSelectedChangeListener.onSelectedItemsChanged();
+ }
+ }
+
+ int getSelectedCommentCount() {
+ return mSelectedPositions.size();
+ }
+
+ CommentList getSelectedComments() {
+ CommentList comments = new CommentList();
+ if (!mEnableSelection)
+ return comments;
+
+ for (Integer position: mSelectedPositions) {
+ if (isPositionValid(position))
+ comments.add(mComments.get(position));
+ }
+
+ return comments;
+ }
+
+ private boolean isItemSelected(int position) {
+ return mSelectedPositions.contains(position);
+ }
+
+ void setItemSelected(int position, boolean isSelected, View view) {
+ if (isItemSelected(position) == isSelected)
+ return;
+
+ if (isSelected) {
+ mSelectedPositions.add(position);
+ } else {
+ mSelectedPositions.remove(position);
+ }
+
+ notifyDataSetChanged();
+
+ if (view != null && view.getTag() instanceof CommentHolder) {
+ CommentHolder holder = (CommentHolder) view.getTag();
+ // animate the selection change on ICS or later (looks wonky on Gingerbread)
+ holder.imgCheckmark.clearAnimation();
+ AniUtils.startAnimation(holder.imgCheckmark, isSelected ? R.anim.cab_select : R.anim.cab_deselect);
+ holder.imgCheckmark.setVisibility(isSelected ? View.VISIBLE : View.GONE);
+ }
+
+ if (mOnSelectedChangeListener != null)
+ mOnSelectedChangeListener.onSelectedItemsChanged();
+ }
+
+ void toggleItemSelected(int position, View view) {
+ setItemSelected(position, !isItemSelected(position), view);
+ }
+
+ /*
+ * this is used for tablet UI to highlight the comment displayed in the detail view
+ */
+ long getHighlightedCommentId() {
+ return mHighlightedCommentId;
+ }
+ void setHighlightedCommentId(long commentId) {
+ if (mHighlightedCommentId == commentId)
+ return;
+ mHighlightedCommentId = commentId;
+ notifyDataSetChanged();
+ }
+
+ public int indexOfCommentId(long commentId) {
+ return mComments.indexOfCommentId(commentId);
+ }
+
+ private boolean isPositionValid(int position) {
+ return (position >= 0 && position < mComments.size());
+ }
+
+ void replaceComments(final CommentList comments) {
+ mComments.replaceComments(comments);
+ notifyDataSetChanged();
+ }
+
+ void deleteComments(final CommentList comments) {
+ mComments.deleteComments(comments);
+ notifyDataSetChanged();
+ }
+
+ public View getView(final int position, View convertView, ViewGroup parent) {
+ final Comment comment = mComments.get(position);
+ final CommentHolder holder;
+
+ if (convertView == null || convertView.getTag() == null) {
+ convertView = mInflater.inflate(R.layout.comment_listitem, null);
+ holder = new CommentHolder(convertView);
+ convertView.setTag(holder);
+ } else {
+ holder = (CommentHolder) convertView.getTag();
+ }
+
+ holder.txtTitle.setText(Html.fromHtml(comment.getFormattedTitle()));
+ holder.txtComment.setText(comment.getUnescapedCommentText());
+ holder.txtDate.setText(DateTimeUtils.javaDateToTimeSpan(comment.getDatePublished()));
+
+ // status is only shown for comments that haven't been approved
+ final boolean showStatus;
+ switch (comment.getStatusEnum()) {
+ case SPAM :
+ showStatus = true;
+ holder.txtStatus.setText(mStatusTextSpam);
+ holder.txtStatus.setTextColor(mStatusColorSpam);
+ break;
+ case UNAPPROVED:
+ showStatus = true;
+ holder.txtStatus.setText(mStatusTextUnapproved);
+ holder.txtStatus.setTextColor(mStatusColorUnapproved);
+ break;
+ default :
+ showStatus = false;
+ break;
+ }
+ holder.txtStatus.setVisibility(showStatus ? View.VISIBLE : View.GONE);
+
+ final boolean useSelectionBackground;
+ if (mEnableSelection && isItemSelected(position)) {
+ useSelectionBackground = true;
+ if (holder.imgCheckmark.getVisibility() != View.VISIBLE)
+ holder.imgCheckmark.setVisibility(View.VISIBLE);
+ } else {
+ useSelectionBackground = (mHighlightedCommentId == comment.commentID);
+ if (holder.imgCheckmark.getVisibility() == View.VISIBLE)
+ holder.imgCheckmark.setVisibility(View.GONE);
+ holder.imgAvatar.setImageUrl(comment.getAvatarForDisplay(mAvatarSz), WPNetworkImageView.ImageType.AVATAR);
+ }
+
+ if (useSelectionBackground) {
+ convertView.setBackgroundColor(mSelectionColor);
+ } else {
+ convertView.setBackgroundDrawable(null);
+ }
+
+ // comment text needs to be to the left of date/status when the title is a single line and
+ // the status is displayed or else the status may overlap the comment text - note that
+ // getLineCount() will return 0 if the view hasn't been rendered yet, which is why we
+ // check getLineCount() <= 1
+ boolean adjustComment = (showStatus && holder.txtTitle.getLineCount() <= 1);
+ RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) holder.txtComment.getLayoutParams();
+ if (adjustComment) {
+ params.addRule(RelativeLayout.LEFT_OF, R.id.layout_date_status);
+ } else {
+ params.addRule(RelativeLayout.LEFT_OF, 0);
+ }
+
+ // request to load more comments when we near the end
+ if (mOnLoadMoreListener != null && position >= getCount()-1)
+ mOnLoadMoreListener.onLoadMore();
+
+ return convertView;
+ }
+
+ private class CommentHolder {
+ private final TextView txtTitle;
+ private final TextView txtComment;
+ private final TextView txtStatus;
+ private final TextView txtDate;
+ private final WPNetworkImageView imgAvatar;
+ private final ImageView imgCheckmark;
+
+ private CommentHolder(View row) {
+ txtTitle = (TextView) row.findViewById(R.id.title);
+ txtComment = (TextView) row.findViewById(R.id.comment);
+ txtStatus = (TextView) row.findViewById(R.id.status);
+ txtDate = (TextView) row.findViewById(R.id.text_date);
+ imgCheckmark = (ImageView) row.findViewById(R.id.image_checkmark);
+ imgAvatar = (WPNetworkImageView) row.findViewById(R.id.avatar);
+ }
+ }
+
+ /*
+ * load comments using an AsyncTask
+ */
+ void loadComments() {
+ if (mIsLoadTaskRunning) {
+ AppLog.w(AppLog.T.COMMENTS, "load comments task already active");
+ }
+ new LoadCommentsTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ /*
+ * AsyncTask to load comments from SQLite
+ */
+ private boolean mIsLoadTaskRunning = false;
+ private class LoadCommentsTask extends AsyncTask<Void, Void, Boolean> {
+ CommentList tmpComments;
+ @Override
+ protected void onPreExecute() {
+ mIsLoadTaskRunning = true;
+ }
+ @Override
+ protected void onCancelled() {
+ mIsLoadTaskRunning = false;
+ }
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ int localBlogId = WordPress.getCurrentLocalTableBlogId();
+ tmpComments = CommentTable.getCommentsForBlog(localBlogId);
+ if (mComments.isSameList(tmpComments))
+ return false;
+
+ // pre-calc transient values so they're cached when used by getView()
+ for (Comment comment: tmpComments) {
+ comment.getDatePublished();
+ comment.getUnescapedCommentText();
+ comment.getUnescapedPostTitle();
+ comment.getAvatarForDisplay(mAvatarSz);
+ comment.getFormattedTitle();
+ }
+
+ return true;
+ }
+ @Override
+ protected void onPostExecute(Boolean result) {
+ if (result) {
+ mComments = (CommentList)(tmpComments.clone());
+ notifyDataSetChanged();
+ }
+
+ if (mDataLoadedListener != null)
+ mDataLoadedListener.onDataLoaded(isEmpty());
+
+ mIsLoadTaskRunning = false;
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailFragment.java
new file mode 100644
index 000000000..3536c3a8a
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailFragment.java
@@ -0,0 +1,867 @@
+package org.wordpress.android.ui.comments;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Fragment;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.Html;
+import android.text.TextUtils;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.EditorInfo;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.ScrollView;
+import android.widget.TextView;
+
+import com.android.volley.VolleyError;
+import com.wordpress.rest.RestRequest;
+
+import org.json.JSONObject;
+import org.wordpress.android.Constants;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.CommentTable;
+import org.wordpress.android.datasets.ReaderPostTable;
+import org.wordpress.android.models.Comment;
+import org.wordpress.android.models.CommentStatus;
+import org.wordpress.android.models.Note;
+import org.wordpress.android.models.Note.EnabledActions;
+import org.wordpress.android.ui.comments.CommentActions.ChangeType;
+import org.wordpress.android.ui.comments.CommentActions.ChangedFrom;
+import org.wordpress.android.ui.comments.CommentActions.OnCommentChangeListener;
+import org.wordpress.android.ui.notifications.NotificationFragment;
+import org.wordpress.android.ui.reader.ReaderActivityLauncher;
+import org.wordpress.android.ui.reader.actions.ReaderActions;
+import org.wordpress.android.ui.reader.actions.ReaderPostActions;
+import org.wordpress.android.util.AniUtils;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.DateTimeUtils;
+import org.wordpress.android.util.EditTextUtils;
+import org.wordpress.android.util.GravatarUtils;
+import org.wordpress.android.util.HtmlUtils;
+import org.wordpress.android.util.NetworkUtils;
+import org.wordpress.android.util.PhotonUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.VolleyUtils;
+import org.wordpress.android.util.WPLinkMovementMethod;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+import org.wordpress.android.widgets.WPNetworkImageView;
+
+import java.util.EnumSet;
+
+/**
+ * comment detail displayed from both the notification list and the comment list
+ * prior to this there were separate comment detail screens for each list
+ */
+public class CommentDetailFragment extends Fragment implements NotificationFragment {
+ private int mLocalBlogId;
+ private int mRemoteBlogId;
+
+ private Comment mComment;
+ private Note mNote;
+
+ private TextView mTxtStatus;
+ private TextView mTxtContent;
+ private ImageView mImgSubmitReply;
+ private EditText mEditReply;
+ private ViewGroup mLayoutReply;
+ private ViewGroup mLayoutButtons;
+
+ private TextView mBtnModerateComment;
+ private TextView mBtnSpamComment;
+ private TextView mBtnEditComment;
+ private TextView mBtnTrashComment;
+
+ private boolean mIsSubmittingReply = false;
+ private boolean mIsModeratingComment = false;
+ private boolean mIsRequestingComment = false;
+ private boolean mIsUsersBlog = false;
+
+ private OnCommentChangeListener mOnCommentChangeListener;
+ private OnPostClickListener mOnPostClickListener;
+
+ private static final String KEY_LOCAL_BLOG_ID = "local_blog_id";
+ private static final String KEY_COMMENT_ID = "comment_id";
+
+ /*
+ * these determine which actions (moderation, replying, marking as spam) to enable
+ * for this comment - all actions are enabled when opened from the comment list, only
+ * changed when opened from a notification
+ */
+ private EnumSet<EnabledActions> mEnabledActions = EnumSet.allOf(EnabledActions.class);
+
+ /*
+ * used when called from comment list
+ */
+ static CommentDetailFragment newInstance(int localBlogId, long commentId) {
+ CommentDetailFragment fragment = new CommentDetailFragment();
+ fragment.setComment(localBlogId, commentId);
+ return fragment;
+ }
+
+ /*
+ * used when called from notification list for a comment notification
+ */
+ public static CommentDetailFragment newInstance(final Note note) {
+ CommentDetailFragment fragment = new CommentDetailFragment();
+ fragment.setNote(note);
+ return fragment;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (savedInstanceState != null) {
+ int localBlogId = savedInstanceState.getInt(KEY_LOCAL_BLOG_ID);
+ long commentId = savedInstanceState.getLong(KEY_COMMENT_ID);
+ setComment(localBlogId, commentId);
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ if (hasComment()) {
+ outState.putInt(KEY_LOCAL_BLOG_ID, getLocalBlogId());
+ outState.putLong(KEY_COMMENT_ID, getCommentId());
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ final View view = inflater.inflate(R.layout.comment_detail_fragment, container, false);
+
+ mTxtStatus = (TextView) view.findViewById(R.id.text_status);
+ mTxtContent = (TextView) view.findViewById(R.id.text_content);
+
+ mLayoutButtons = (ViewGroup) view.findViewById(R.id.layout_buttons);
+ mBtnModerateComment = (TextView) mLayoutButtons.findViewById(R.id.text_btn_moderate);
+ mBtnSpamComment = (TextView) mLayoutButtons.findViewById(R.id.text_btn_spam);
+ mBtnEditComment = (TextView) mLayoutButtons.findViewById(R.id.image_edit_comment);
+ mBtnTrashComment = (TextView) mLayoutButtons.findViewById(R.id.image_trash_comment);
+
+ setTextDrawable(mBtnSpamComment, R.drawable.ic_cab_spam);
+ setTextDrawable(mBtnEditComment, R.drawable.ab_icon_edit);
+ setTextDrawable(mBtnTrashComment, R.drawable.ic_cab_trash);
+
+ mLayoutReply = (ViewGroup) view.findViewById(R.id.layout_comment_box);
+ mEditReply = (EditText) mLayoutReply.findViewById(R.id.edit_comment);
+ mImgSubmitReply = (ImageView) mLayoutReply.findViewById(R.id.image_post_comment);
+
+ // hide moderation buttons until updateModerationButtons() is called
+ mLayoutButtons.setVisibility(View.GONE);
+ mBtnEditComment.setVisibility(View.GONE);
+
+ // this is necessary in order for anchor tags in the comment text to be clickable
+ mTxtContent.setLinksClickable(true);
+ mTxtContent.setMovementMethod(WPLinkMovementMethod.getInstance());
+
+ mEditReply.setHint(R.string.reader_hint_comment_on_comment);
+ mEditReply.setOnEditorActionListener(new TextView.OnEditorActionListener() {
+ @Override
+ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+ if (actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_ACTION_SEND)
+ submitReply();
+ return false;
+ }
+ });
+
+ mImgSubmitReply.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ submitReply();
+ }
+ });
+
+ mBtnSpamComment.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (mComment.getStatusEnum() == CommentStatus.SPAM) {
+ moderateComment(CommentStatus.APPROVED);
+ } else {
+ moderateComment(CommentStatus.SPAM);
+ }
+ }
+ });
+
+ mBtnEditComment.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ editComment();
+ }
+ });
+
+ mBtnTrashComment.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ confirmDeleteComment();
+ }
+ });
+
+ return view;
+ }
+
+ void setComment(int localBlogId, long commentId) {
+ setComment(localBlogId, CommentTable.getComment(localBlogId, commentId));
+ }
+
+ private void setComment(int localBlogId, final Comment comment) {
+ mComment = comment;
+ mLocalBlogId = localBlogId;
+
+ // is this comment on one of the user's blogs? it won't be if this was displayed from a
+ // notification about a reply to a comment this user posted on someone else's blog
+ mIsUsersBlog = (comment != null && WordPress.wpDB.isLocalBlogIdInDatabase(mLocalBlogId));
+
+ if (mIsUsersBlog)
+ mRemoteBlogId = WordPress.wpDB.getRemoteBlogIdForLocalTableBlogId(mLocalBlogId);
+
+ if (hasActivity())
+ showComment();
+ }
+
+ @Override
+ public Note getNote() {
+ return mNote;
+ }
+
+ @Override
+ public void setNote(Note note) {
+ mNote = note;
+ if (hasActivity() && mNote != null)
+ showComment();
+ }
+
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+ if (activity instanceof OnCommentChangeListener)
+ mOnCommentChangeListener = (OnCommentChangeListener) activity;
+ if (activity instanceof OnPostClickListener)
+ mOnPostClickListener = (OnPostClickListener) activity;
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ showComment();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ EditTextUtils.hideSoftInput(mEditReply);
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (requestCode == Constants.INTENT_COMMENT_EDITOR && resultCode == Activity.RESULT_OK) {
+ reloadComment();
+ // tell the host to reload the comment list
+ if (mOnCommentChangeListener != null)
+ mOnCommentChangeListener.onCommentChanged(ChangedFrom.COMMENT_DETAIL, ChangeType.EDITED);
+ }
+ }
+
+ private boolean hasActivity() {
+ return (getActivity() != null && !isRemoving());
+ }
+
+ private boolean hasComment() {
+ return (mComment != null);
+ }
+
+ long getCommentId() {
+ return (mComment != null ? mComment.commentID : 0);
+ }
+
+ private int getLocalBlogId() {
+ return mLocalBlogId;
+ }
+
+ private int getRemoteBlogId() {
+ return mRemoteBlogId;
+ }
+
+ /*
+ * reload the current comment from the local database
+ */
+ void reloadComment() {
+ if (!hasComment())
+ return;
+ Comment updatedComment = CommentTable.getComment(mLocalBlogId, getCommentId());
+ setComment(mLocalBlogId, updatedComment);
+ }
+
+ /*
+ * resets to no comment
+ */
+ void clear() {
+ setNote(null);
+ setComment(0, null);
+ }
+
+ /*
+ * open the comment for editing
+ */
+ private void editComment() {
+ if (!hasActivity() || !hasComment())
+ return;
+ // IMPORTANT: don't use getActivity().startActivityForResult() or else onActivityResult()
+ // won't be called in this fragment
+ // https://code.google.com/p/android/issues/detail?id=15394#c45
+ Intent intent = new Intent(getActivity(), EditCommentActivity.class);
+ intent.putExtra(EditCommentActivity.ARG_LOCAL_BLOG_ID, getLocalBlogId());
+ intent.putExtra(EditCommentActivity.ARG_COMMENT_ID, getCommentId());
+ startActivityForResult(intent, Constants.INTENT_COMMENT_EDITOR);
+ }
+
+ /*
+ * display the current comment
+ */
+ private void showComment() {
+ if (!hasActivity() || getView() == null)
+ return;
+
+ // these two views contain all the other views except the progress bar
+ final ScrollView scrollView = (ScrollView) getView().findViewById(R.id.scroll_view);
+ final View layoutBottom = getView().findViewById(R.id.layout_bottom);
+
+ // hide container views when comment is null (will happen when opened from a notification)
+ if (mComment == null) {
+ scrollView.setVisibility(View.GONE);
+ layoutBottom.setVisibility(View.GONE);
+
+ // if a notification was passed, request its associated comment
+ if (mNote != null && !mIsRequestingComment)
+ showCommentForNote(mNote);
+
+ return;
+ }
+
+ scrollView.setVisibility(View.VISIBLE);
+ layoutBottom.setVisibility(View.VISIBLE);
+
+ final WPNetworkImageView imgAvatar = (WPNetworkImageView) getView().findViewById(R.id.image_avatar);
+ final TextView txtName = (TextView) getView().findViewById(R.id.text_name);
+ final TextView txtDate = (TextView) getView().findViewById(R.id.text_date);
+
+ txtName.setText(mComment.hasAuthorName() ? mComment.getAuthorName() : getString(R.string.anonymous));
+ txtDate.setText(DateTimeUtils.javaDateToTimeSpan(mComment.getDatePublished()));
+
+ int maxImageSz = getResources().getDimensionPixelSize(R.dimen.reader_comment_max_image_size);
+ CommentUtils.displayHtmlComment(mTxtContent, mComment.getCommentText(), maxImageSz);
+
+ int avatarSz = getResources().getDimensionPixelSize(R.dimen.avatar_sz_large);
+ if (mComment.hasProfileImageUrl()) {
+ imgAvatar.setImageUrl(PhotonUtils.fixAvatar(mComment.getProfileImageUrl(), avatarSz), WPNetworkImageView.ImageType.AVATAR);
+ } else if (mComment.hasAuthorEmail()) {
+ String avatarUrl = GravatarUtils.gravatarUrlFromEmail(mComment.getAuthorEmail(), avatarSz);
+ imgAvatar.setImageUrl(avatarUrl, WPNetworkImageView.ImageType.AVATAR);
+ } else {
+ imgAvatar.setImageUrl(null, WPNetworkImageView.ImageType.AVATAR);
+ }
+
+ updateStatusViews();
+
+ // navigate to author's blog when avatar or name clicked
+ if (mComment.hasAuthorUrl()) {
+ View.OnClickListener authorListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ ReaderActivityLauncher.openUrl(getActivity(), mComment.getAuthorUrl());
+ }
+ };
+ imgAvatar.setOnClickListener(authorListener);
+ txtName.setOnClickListener(authorListener);
+ txtName.setTextColor(getResources().getColor(R.color.reader_hyperlink));
+ } else {
+ txtName.setTextColor(getResources().getColor(R.color.grey_medium_dark));
+ }
+
+ showPostTitle(getRemoteBlogId(), mComment.postID);
+
+ // make sure reply box is showing
+ if (mLayoutReply.getVisibility() != View.VISIBLE && canReply())
+ AniUtils.flyIn(mLayoutReply);
+ }
+
+ /*
+ * displays the passed post title for the current comment, updates stored title if one doesn't exist
+ */
+ private void setPostTitle(TextView txtTitle, String postTitle, boolean isHyperlink) {
+ if (txtTitle == null || !hasActivity())
+ return;
+ if (TextUtils.isEmpty(postTitle)) {
+ txtTitle.setText(R.string.untitled);
+ return;
+ }
+
+ // if comment doesn't have a post title, set it to the passed one and save to comment table
+ if (hasComment() && !mComment.hasPostTitle()) {
+ mComment.setPostTitle(postTitle);
+ CommentTable.updateCommentPostTitle(getLocalBlogId(), getCommentId(), postTitle);
+ }
+
+ // display "on [Post Title]..."
+ if (isHyperlink) {
+ String html = getString(R.string.on)
+ + " <font color=" + HtmlUtils.colorResToHtmlColor(getActivity(), R.color.reader_hyperlink) + ">"
+ + postTitle.trim()
+ + "</font>";
+ txtTitle.setText(Html.fromHtml(html));
+ } else {
+ txtTitle.setText(getString(R.string.on) + " " + postTitle.trim());
+ }
+ }
+
+ /*
+ * ensure the post associated with this comment is available to the reader and show its
+ * title above the comment
+ */
+ private void showPostTitle(final int blogId, final long postId) {
+ if (!hasActivity())
+ return;
+
+ final TextView txtPostTitle = (TextView) getView().findViewById(R.id.text_post_title);
+ boolean postExists = ReaderPostTable.postExists(blogId, postId);
+
+ // the post this comment is on can only be requested if this is a .com blog or a
+ // jetpack-enabled self-hosted blog, and we have valid .com credentials
+ boolean isDotComOrJetpack = WordPress.wpDB.isRemoteBlogIdDotComOrJetpack(mRemoteBlogId);
+ boolean canRequestPost = isDotComOrJetpack && WordPress.hasValidWPComCredentials(getActivity());
+
+ final String title;
+ final boolean hasTitle;
+ if (mComment.hasPostTitle()) {
+ // use comment's stored post title if available
+ title = mComment.getPostTitle();
+ hasTitle = true;
+ } else if (postExists) {
+ // use title from post if available
+ title = ReaderPostTable.getPostTitle(blogId, postId);
+ hasTitle = !TextUtils.isEmpty(title);
+ } else {
+ title = null;
+ hasTitle = false;
+ }
+ if (hasTitle) {
+ setPostTitle(txtPostTitle, title, canRequestPost);
+ } else {
+ txtPostTitle.setText(postExists ? R.string.untitled : R.string.loading);
+ }
+
+ // if this is a .com or jetpack blog, tapping the title shows the associated post
+ // in the reader
+ if (canRequestPost) {
+ // first make sure this post is available to the reader, and once it's retrieved set
+ // the title if it wasn't set above
+ if (!postExists) {
+ AppLog.d(T.COMMENTS, "comment detail > retrieving post");
+ ReaderPostActions.requestPost(blogId, postId, new ReaderActions.ActionListener() {
+ @Override
+ public void onActionResult(boolean succeeded) {
+ if (!hasActivity())
+ return;
+ // update title if it wasn't set above
+ if (!hasTitle) {
+ String postTitle = ReaderPostTable.getPostTitle(blogId, postId);
+ if (!TextUtils.isEmpty(postTitle)) {
+ setPostTitle(txtPostTitle, postTitle, true);
+ } else {
+ txtPostTitle.setText(R.string.untitled);
+ }
+ }
+ }
+ });
+ }
+
+ txtPostTitle.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (mOnPostClickListener != null) {
+ mOnPostClickListener.onPostClicked(getNote(), mRemoteBlogId, (int) mComment.postID);
+ } else {
+ // right now this will happen from notifications
+ AppLog.i(T.COMMENTS, "comment detail > no post click listener");
+ ReaderActivityLauncher.showReaderPostDetail(getActivity(), mRemoteBlogId, mComment.postID);
+ }
+ }
+ });
+ }
+ }
+
+ private void confirmDeleteComment() {
+ AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+ builder.setMessage(R.string.dlg_confirm_trash_comments);
+ builder.setTitle(R.string.trash);
+ builder.setCancelable(true);
+ builder.setPositiveButton(R.string.trash_yes, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int id) {
+ moderateComment(CommentStatus.TRASH);
+ }
+ });
+ builder.setNegativeButton(R.string.trash_no, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int id) {
+ dialog.cancel();
+ }
+ });
+ AlertDialog alert = builder.create();
+ alert.show();
+ }
+
+ private void dismissDialog(int id) {
+ if (!hasActivity())
+ return;
+ try {
+ getActivity().dismissDialog(id);
+ } catch (IllegalArgumentException e) {
+ // raised when dialog wasn't created
+ }
+ }
+
+ /*
+ * approve, unapprove, spam, or trash the current comment
+ */
+ private void moderateComment(final CommentStatus newStatus) {
+ if (!hasActivity() || !hasComment() || mIsModeratingComment)
+ return;
+ if (!NetworkUtils.checkConnection(getActivity()))
+ return;
+
+ // show dialog while moderating
+ final int dlgId;
+ switch (newStatus) {
+ case APPROVED:
+ dlgId = CommentDialogs.ID_COMMENT_DLG_APPROVING;
+ AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATION_APPROVED);
+ break;
+ case UNAPPROVED:
+ dlgId = CommentDialogs.ID_COMMENT_DLG_UNAPPROVING;
+ break;
+ case SPAM:
+ dlgId = CommentDialogs.ID_COMMENT_DLG_SPAMMING;
+ AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATION_FLAGGED_AS_SPAM);
+ break;
+ case TRASH:
+ dlgId = CommentDialogs.ID_COMMENT_DLG_TRASHING;
+ AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATION_TRASHED);
+ break;
+ default :
+ return;
+ }
+ AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATION_PERFORMED_ACTION);
+ getActivity().showDialog(dlgId);
+
+ // disable buttons during request
+ mLayoutButtons.setEnabled(false);
+
+ // animate the buttons out (updateStatusViews will re-display them when request completes)
+ mLayoutButtons.clearAnimation();
+ AniUtils.flyOut(mLayoutButtons);
+
+ // hide status (updateStatusViews will un-hide it)
+ if (mTxtStatus.getVisibility() == View.VISIBLE) {
+ mTxtStatus.clearAnimation();
+ AniUtils.startAnimation(mTxtStatus, R.anim.fade_out);
+ mTxtStatus.setVisibility(View.INVISIBLE);
+ }
+
+ CommentActions.CommentActionListener actionListener = new CommentActions.CommentActionListener() {
+ @Override
+ public void onActionResult(boolean succeeded) {
+ mIsModeratingComment = false;
+ if (hasActivity()) {
+ dismissDialog(dlgId);
+ mLayoutButtons.setEnabled(true);
+ if (succeeded) {
+ mComment.setStatus(CommentStatus.toString(newStatus));
+ } else {
+ ToastUtils.showToast(getActivity(), R.string.error_moderate_comment, ToastUtils.Duration.LONG);
+ }
+ if (newStatus == CommentStatus.TRASH) {
+ // clear the comment if it was trashed
+ clear();
+ } else {
+ // reflect the new status - note this MUST come after mComment.setStatus
+ updateStatusViews();
+ }
+ }
+
+ if (succeeded && mOnCommentChangeListener != null) {
+ ChangeType changeType = (newStatus == CommentStatus.TRASH ? ChangeType.TRASHED : ChangeType.STATUS);
+ mOnCommentChangeListener.onCommentChanged(ChangedFrom.COMMENT_DETAIL, changeType);
+ }
+ }
+ };
+ mIsModeratingComment = true;
+ CommentActions.moderateComment(mLocalBlogId, mComment, newStatus, actionListener);
+ }
+
+ /*
+ * post comment box text as a reply to the current comment
+ */
+ private void submitReply() {
+ if (!hasActivity() || mIsSubmittingReply)
+ return;
+
+ if (!NetworkUtils.checkConnection(getActivity()))
+ return;
+
+ final String replyText = EditTextUtils.getText(mEditReply);
+ if (TextUtils.isEmpty(replyText))
+ return;
+
+ // disable editor, hide soft keyboard, hide submit icon, and show progress spinner while submitting
+ mEditReply.setEnabled(false);
+ EditTextUtils.hideSoftInput(mEditReply);
+ mImgSubmitReply.setVisibility(View.GONE);
+ final ProgressBar progress = (ProgressBar) getView().findViewById(R.id.progress_submit_comment);
+ progress.setVisibility(View.VISIBLE);
+
+ // animate the buttons out (updateStatusViews will re-display them when request completes)
+ mLayoutButtons.clearAnimation();
+ AniUtils.flyOut(mLayoutButtons);
+
+ CommentActions.CommentActionListener actionListener = new CommentActions.CommentActionListener() {
+ @Override
+ public void onActionResult(boolean succeeded) {
+ mIsSubmittingReply = false;
+ if (succeeded && mOnCommentChangeListener != null)
+ mOnCommentChangeListener.onCommentChanged(ChangedFrom.COMMENT_DETAIL, ChangeType.REPLIED);
+ if (hasActivity()) {
+ mEditReply.setEnabled(true);
+ mImgSubmitReply.setVisibility(View.VISIBLE);
+ progress.setVisibility(View.GONE);
+ updateStatusViews();
+ if (succeeded) {
+ ToastUtils.showToast(getActivity(), getString(R.string.note_reply_successful));
+ mEditReply.setText(null);
+ } else {
+ ToastUtils.showToast(getActivity(), R.string.reply_failed, ToastUtils.Duration.LONG);
+ // refocus editor on failure and show soft keyboard
+ EditTextUtils.showSoftInput(mEditReply);
+ }
+ }
+ }
+ };
+
+ mIsSubmittingReply = true;
+
+ AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATION_REPLIED_TO);
+ if (mNote != null) {
+ CommentActions.submitReplyToCommentNote(mNote, replyText, actionListener);
+ } else {
+ CommentActions.submitReplyToComment(mLocalBlogId, mComment, replyText, actionListener);
+ }
+ }
+
+ /*
+ * sets the drawable for moderation buttons
+ */
+ private void setTextDrawable(final TextView view, int resId) {
+ view.setCompoundDrawablesWithIntrinsicBounds(null, getResources().getDrawable(resId), null, null);
+ }
+
+ /*
+ * update the text, drawable & click listener for mBtnModerate based on
+ * the current status of the comment, show mBtnSpam if the comment isn't
+ * already marked as spam, and show the current status of the comment
+ */
+ private void updateStatusViews() {
+ if (!hasActivity() || !hasComment())
+ return;
+
+ final int moderationDrawResId; // drawable resource id for moderation button
+ final int moderationTextResId; // string resource id for moderation button
+ final CommentStatus newStatus; // status to apply when moderation button is tapped
+ final int statusTextResId; // string resource id for status text
+ final int statusColor; // color for status text
+
+ switch (mComment.getStatusEnum()) {
+ case APPROVED:
+ moderationDrawResId = R.drawable.ic_cab_unapprove;
+ moderationTextResId = R.string.mnu_comment_unapprove;
+ newStatus = CommentStatus.UNAPPROVED;
+ statusTextResId = R.string.comment_status_approved;
+ statusColor = getActivity().getResources().getColor(R.color.comment_status_approved);
+ break;
+ case UNAPPROVED:
+ moderationDrawResId = R.drawable.ic_cab_approve;
+ moderationTextResId = R.string.mnu_comment_approve;
+ newStatus = CommentStatus.APPROVED;
+ statusTextResId = R.string.comment_status_unapproved;
+ statusColor = getActivity().getResources().getColor(R.color.comment_status_unapproved);
+ break;
+ case SPAM:
+ moderationDrawResId = R.drawable.ic_cab_approve;
+ moderationTextResId = R.string.mnu_comment_approve;
+ newStatus = CommentStatus.APPROVED;
+ statusTextResId = R.string.comment_status_spam;
+ statusColor = getActivity().getResources().getColor(R.color.comment_status_spam);
+ break;
+ case TRASH:
+ // should never get here
+ moderationDrawResId = R.drawable.ic_cab_approve;
+ moderationTextResId = R.string.mnu_comment_approve;
+ newStatus = CommentStatus.APPROVED;
+ statusTextResId = R.string.comment_status_trash;
+ statusColor = getActivity().getResources().getColor(R.color.comment_status_spam);
+ break;
+ default:
+ return;
+ }
+
+ // comment status is only shown if this comment is from one of this user's blogs and the
+ // comment hasn't been approved
+ if (mIsUsersBlog && mComment.getStatusEnum() != CommentStatus.APPROVED) {
+ mTxtStatus.setText(getString(statusTextResId).toUpperCase());
+ mTxtStatus.setTextColor(statusColor);
+ if (mTxtStatus.getVisibility() != View.VISIBLE) {
+ mTxtStatus.clearAnimation();
+ AniUtils.fadeIn(mTxtStatus);
+ }
+ } else {
+ mTxtStatus.setVisibility(View.GONE);
+ }
+
+ if (canModerate()) {
+ setTextDrawable(mBtnModerateComment, moderationDrawResId);
+ mBtnModerateComment.setText(moderationTextResId);
+ mBtnModerateComment.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ moderateComment(newStatus);
+ }
+ });
+ mBtnModerateComment.setVisibility(View.VISIBLE);
+ } else {
+ mBtnModerateComment.setVisibility(View.GONE);
+ }
+
+ if (canMarkAsSpam()) {
+ mBtnSpamComment.setVisibility(View.VISIBLE);
+ if (mComment.getStatusEnum() == CommentStatus.SPAM) {
+ mBtnSpamComment.setText(R.string.mnu_comment_unspam);
+ } else {
+ mBtnSpamComment.setText(R.string.mnu_comment_spam);
+ }
+ } else {
+ mBtnSpamComment.setVisibility(View.GONE);
+ }
+
+ mBtnTrashComment.setVisibility(canTrash() ? View.VISIBLE : View.GONE);
+ mBtnEditComment.setVisibility(canEdit() ? View.VISIBLE : View.GONE);
+
+ // animate the buttons in if they're not visible
+ if (mLayoutButtons.getVisibility() != View.VISIBLE && (canMarkAsSpam() || canModerate())) {
+ mLayoutButtons.clearAnimation();
+ AniUtils.flyIn(mLayoutButtons);
+ }
+ }
+
+ /*
+ * does user have permission to moderate/reply/spam this comment?
+ */
+ private boolean canModerate() {
+ if (mEnabledActions == null)
+ return false;
+ return (mEnabledActions.contains(EnabledActions.ACTION_APPROVE)
+ || mEnabledActions.contains(EnabledActions.ACTION_UNAPPROVE));
+ }
+ private boolean canMarkAsSpam() {
+ return (mEnabledActions != null && mEnabledActions.contains(EnabledActions.ACTION_SPAM));
+ }
+ private boolean canReply() {
+ return (mEnabledActions != null && mEnabledActions.contains(EnabledActions.ACTION_REPLY));
+ }
+ private boolean canTrash() {
+ return canModerate();
+ }
+ private boolean canEdit() {
+ return canModerate();
+ }
+
+ /*
+ * display the comment associated with the passed notification
+ */
+ private void showCommentForNote(Note note) {
+ /*
+ * determine which actions to enable for this comment - if the comment is from this user's
+ * blog then all actions will be enabled, but they won't be if it's a reply to a comment
+ * this user made on someone else's blog
+ */
+ mEnabledActions = note.getEnabledActions();
+
+ mRemoteBlogId = note.getBlogId();
+ long commentId = note.getCommentId();
+
+ // note that the local blog id won't be found if the comment is from someone else's blog
+ int localBlogId = WordPress.wpDB.getLocalTableBlogIdForRemoteBlogId(mRemoteBlogId);
+
+ // first try to get from local db, if that fails request it from the server
+ final Comment comment = (localBlogId > 0 ? CommentTable.getComment(localBlogId, commentId) : null);
+ if (comment != null) {
+ setComment(localBlogId, comment);
+ } else {
+ requestComment(localBlogId, mRemoteBlogId, commentId);
+ }
+ }
+
+ /*
+ * request a comment - note that this uses the REST API rather than XMLRPC, which means the user must
+ * either be wp.com or have Jetpack, but it's safe to do this since this method is only called when
+ * displayed from a notification (and notifications require wp.com/Jetpack)
+ */
+ private void requestComment(final int localBlogId,
+ final int remoteBlogId,
+ final long commentId) {
+ final ProgressBar progress = (hasActivity() ? (ProgressBar) getView().findViewById(R.id.progress_loading) : null);
+ if (progress != null)
+ progress.setVisibility(View.VISIBLE);
+
+ RestRequest.Listener restListener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ mIsRequestingComment = false;
+ if (hasActivity()) {
+ if (progress != null)
+ progress.setVisibility(View.GONE);
+ Comment comment = Comment.fromJSON(jsonObject);
+ if (comment != null) {
+ // save comment to local db if localBlogId is valid
+ if (localBlogId > 0)
+ CommentTable.addComment(localBlogId, comment);
+ // now, at long last, show the comment
+ setComment(localBlogId, comment);
+ }
+ }
+ }
+ };
+ RestRequest.ErrorListener restErrListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ mIsRequestingComment = false;
+ AppLog.e(T.COMMENTS, VolleyUtils.errStringFromVolleyError(volleyError), volleyError);
+ if (hasActivity()) {
+ if (progress != null)
+ progress.setVisibility(View.GONE);
+ ToastUtils.showToast(getActivity(), R.string.reader_toast_err_get_comment, ToastUtils.Duration.LONG);
+ }
+ }
+ };
+
+ final String path = String.format("/sites/%s/comments/%s", remoteBlogId, commentId);
+ mIsRequestingComment = true;
+ WordPress.getRestClientUtils().get(path, restListener, restErrListener);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDialogs.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDialogs.java
new file mode 100644
index 000000000..8ee4ffa10
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDialogs.java
@@ -0,0 +1,48 @@
+package org.wordpress.android.ui.comments;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.app.ProgressDialog;
+
+import org.wordpress.android.R;
+
+/**
+ * Dialogs related to comment moderation displayed from CommentsActivity and NotificationsActivity
+ */
+public class CommentDialogs {
+ public static final int ID_COMMENT_DLG_APPROVING = 100;
+ public static final int ID_COMMENT_DLG_UNAPPROVING = 101;
+ public static final int ID_COMMENT_DLG_SPAMMING = 102;
+ public static final int ID_COMMENT_DLG_TRASHING = 103;
+
+ private CommentDialogs() {
+ throw new AssertionError();
+ }
+
+ public static Dialog createCommentDialog(Activity activity, int dialogId) {
+ final int resId;
+ switch (dialogId) {
+ case ID_COMMENT_DLG_APPROVING :
+ resId = R.string.dlg_approving_comments;
+ break;
+ case ID_COMMENT_DLG_UNAPPROVING:
+ resId = R.string.dlg_unapproving_comments;
+ break;
+ case ID_COMMENT_DLG_TRASHING:
+ resId = R.string.dlg_trashing_comments;
+ break;
+ case ID_COMMENT_DLG_SPAMMING:
+ resId = R.string.dlg_spamming_comments;
+ break;
+ default :
+ return null;
+ }
+
+ ProgressDialog dialog = new ProgressDialog(activity);
+ dialog.setMessage(activity.getString(resId));
+ dialog.setIndeterminate(true);
+ dialog.setCancelable(false);
+
+ return dialog;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentUtils.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentUtils.java
new file mode 100644
index 000000000..b30711994
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentUtils.java
@@ -0,0 +1,57 @@
+package org.wordpress.android.ui.comments;
+
+import android.text.Html;
+import android.text.Spanned;
+import android.text.util.Linkify;
+import android.widget.TextView;
+
+import org.wordpress.android.util.Emoticons;
+import org.wordpress.android.util.WPImageGetter;
+
+public class CommentUtils {
+ /*
+ * displays comment text as html, including retrieving images
+ */
+ public static void displayHtmlComment(TextView textView, String content, int maxImageSize) {
+ if (textView == null)
+ return;
+
+ if (content == null) {
+ textView.setText(null);
+ return;
+ }
+
+ // skip performance hit of html conversion if content doesn't contain html
+ if (!content.contains("<") && !content.contains("&")) {
+ textView.setText(content.trim());
+ // make sure unnamed links are clickable
+ if (content.contains("://"))
+ Linkify.addLinks(textView, Linkify.WEB_URLS);
+ return;
+ }
+
+ // convert emoticons first (otherwise they'll be downloaded)
+ content = Emoticons.replaceEmoticonsWithEmoji(content);
+
+ // now convert to HTML with an image getter that enforces a max image size
+ final Spanned html;
+ if (maxImageSize > 0 && content.contains("<img")) {
+ html = Html.fromHtml(content, new WPImageGetter(textView, maxImageSize), null);
+ } else {
+ html = Html.fromHtml(content);
+ }
+
+ // remove extra \n\n added by Html.convert()
+ CharSequence source = html;
+ int start = 0;
+ int end = source.length();
+ while (start < end && Character.isWhitespace(source.charAt(start))) {
+ start++;
+ }
+ while (end > start && Character.isWhitespace(source.charAt(end - 1))) {
+ end--;
+ }
+
+ textView.setText(source.subSequence(start, end));
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentsActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentsActivity.java
new file mode 100644
index 000000000..b4ad3acdd
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentsActivity.java
@@ -0,0 +1,383 @@
+package org.wordpress.android.ui.comments;
+
+import android.app.ActionBar;
+import android.app.Dialog;
+import android.app.Fragment;
+import android.app.FragmentManager;
+import android.app.FragmentTransaction;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.BlogPairId;
+import org.wordpress.android.models.Note;
+import org.wordpress.android.ui.WPActionBarActivity;
+import org.wordpress.android.ui.comments.CommentsListFragment.OnCommentSelectedListener;
+import org.wordpress.android.ui.notifications.NotificationFragment;
+import org.wordpress.android.ui.reader.ReaderPostDetailFragment;
+import org.wordpress.android.util.AppLog;
+
+public class CommentsActivity extends WPActionBarActivity
+ implements OnCommentSelectedListener,
+ NotificationFragment.OnPostClickListener,
+ CommentActions.OnCommentChangeListener {
+ private static final String KEY_HIGHLIGHTED_COMMENT_ID = "highlighted_comment_id";
+ private static final String KEY_SELECTED_COMMENT_ID = "selected_comment_id";
+ private static final String KEY_SELECTED_POST_ID = "selected_post_id";
+ private boolean mDualPane;
+ private long mSelectedCommentId;
+ private boolean mCommentSelected;
+ private BlogPairId mSelectedReaderPost;
+ private BlogPairId mTmpSelectedReaderPost;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(null);
+ createMenuDrawer(R.layout.comment_activity);
+ View detailView = findViewById(R.id.fragment_comment_detail);
+ mDualPane = detailView != null && detailView.getVisibility() == View.VISIBLE;
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayShowTitleEnabled(true);
+ }
+ setTitle(getString(R.string.tab_comments));
+ FragmentManager fm = getFragmentManager();
+ fm.addOnBackStackChangedListener(mOnBackStackChangedListener);
+ restoreSavedInstance(savedInstanceState);
+ }
+
+ private void restoreSavedInstance(Bundle savedInstanceState) {
+ if (savedInstanceState != null) {
+ // restore the highlighted comment
+ long commentId = savedInstanceState.getLong(KEY_HIGHLIGHTED_COMMENT_ID);
+ if (commentId != 0) {
+ if (hasListFragment()) {
+ // on dual pane mode, the highlighted comment is also selected
+ getListFragment().setHighlightedCommentId(commentId);
+ }
+ if (mDualPane) {
+ onCommentSelected(commentId);
+ }
+ }
+ // restore the selected comment
+ if (!mDualPane) {
+ commentId = savedInstanceState.getLong(KEY_SELECTED_COMMENT_ID);
+ if (commentId != 0) {
+ onCommentSelected(commentId);
+ }
+ }
+ // restore the post detail fragment if one was selected
+ BlogPairId selectedPostId = (BlogPairId) savedInstanceState.get(KEY_SELECTED_POST_ID);
+ if (selectedPostId != null) {
+ showReaderFragment(selectedPostId.getRemoteBlogId(), selectedPostId.getId());
+ }
+ }
+ }
+
+ /**
+ * Called by CommentAdapter after comment adapter first load
+ */
+ public void commentAdapterFirstLoad() {
+ // if dual pane mode and no selected comments, select the first comment in the list
+ if (mDualPane && !mCommentSelected && hasListFragment()) {
+ long firstCommentId = getListFragment().getFirstCommentId();
+ onCommentSelected(firstCommentId);
+ }
+ // used to scroll the list view after it has been created
+ if (mSelectedCommentId != 0 && hasListFragment()) {
+ getListFragment().setHighlightedCommentId(mSelectedCommentId);
+ }
+ }
+
+ @Override
+ public void onBlogChanged() {
+ super.onBlogChanged();
+
+ // clear the backstack
+ FragmentManager fm = getFragmentManager();
+ fm.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE);
+
+ // clear and update the comment list
+ if (hasListFragment()) {
+ getListFragment().onBlogChanged();
+ getListFragment().clear();
+ reloadCommentList();
+ updateCommentList();
+ }
+
+ // clear comment detail
+ if (hasDetailFragment()) {
+ getDetailFragment().clear();
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.comments, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ if (mDualPane) {
+ // let WPActionBarActivity handle it (toggles menu drawer)
+ return super.onOptionsItemSelected(item);
+ } else {
+ FragmentManager fm = getFragmentManager();
+ if (fm.getBackStackEntryCount() > 0) {
+ fm.popBackStack();
+ return true;
+ }
+ }
+ break;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private final FragmentManager.OnBackStackChangedListener mOnBackStackChangedListener =
+ new FragmentManager.OnBackStackChangedListener() {
+ public void onBackStackChanged() {
+ int backStackEntryCount = getFragmentManager().getBackStackEntryCount();
+ // This is ugly, but onBackStackChanged is not called just after a fragment commit.
+ // In a 2 commits in a row case, onBackStackChanged is called twice but after the
+ // 2 commits. That's why mSelectedReaderPost can't be affected correctly after the first commit.
+ switch (backStackEntryCount) {
+ case 2:
+ // 2 entries means we're showing the associated post in the reader detail fragment
+ // (can't happen in dual pane mode)
+ mSelectedReaderPost = mTmpSelectedReaderPost;
+ break;
+ case 1:
+ // In dual pane mode, 1 entry means:
+ // we're showing the associated post in the reader detail fragment
+ // In single pane mode, 1 entry means:
+ // we're showing the comment fragment on top of comment list
+ if (mDualPane) {
+ mSelectedReaderPost = mTmpSelectedReaderPost;
+ } else {
+ mSelectedReaderPost = null;
+ }
+ break;
+ case 0:
+ if (!mDualPane) {
+ getListFragment().setHighlightedCommentId(-1);
+ }
+ mMenuDrawer.setDrawerIndicatorEnabled(true);
+ mSelectedCommentId = 0;
+ mSelectedReaderPost = null;
+ break;
+ }
+ }
+ };
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ AppLog.d(AppLog.T.COMMENTS, "comment activity new intent");
+ }
+
+ /*
+ * called from comment list & comment detail when comments are moderated/replied/trashed
+ */
+ @Override
+ public void onCommentChanged(CommentActions.ChangedFrom changedFrom, CommentActions.ChangeType changeType) {
+ // update the comment counter on the menu drawer
+ updateMenuDrawer();
+
+ switch (changedFrom) {
+ case COMMENT_LIST:
+ reloadCommentDetail();
+ break;
+ case COMMENT_DETAIL:
+ switch (changeType) {
+ case TRASHED:
+ updateCommentList();
+ // remove the detail view since comment was deleted
+ FragmentManager fm = getFragmentManager();
+ if (fm.getBackStackEntryCount() > 0) {
+ fm.popBackStack();
+ }
+ break;
+ case REPLIED:
+ updateCommentList();
+ break;
+ default:
+ reloadCommentList();
+ break;
+ }
+ break;
+ }
+ }
+
+ private CommentDetailFragment getDetailFragment() {
+ Fragment fragment = getFragmentManager().findFragmentByTag(getString(
+ R.string.fragment_tag_comment_detail));
+ if (fragment == null) {
+ return null;
+ }
+ return (CommentDetailFragment) fragment;
+ }
+
+ private boolean hasDetailFragment() {
+ return (getDetailFragment() != null);
+ }
+
+ private CommentsListFragment getListFragment() {
+ Fragment fragment = getFragmentManager().findFragmentByTag(getString(
+ R.string.fragment_tag_comment_list));
+ if (fragment == null) {
+ return null;
+ }
+ return (CommentsListFragment) fragment;
+ }
+
+ private boolean hasListFragment() {
+ return (getListFragment() != null);
+ }
+
+ private ReaderPostDetailFragment getReaderFragment() {
+ Fragment fragment = getFragmentManager().findFragmentByTag(getString(R.string.fragment_tag_reader_post_detail));
+ if (fragment == null)
+ return null;
+ return (ReaderPostDetailFragment)fragment;
+ }
+
+ private boolean hasReaderFragment() {
+ return (getReaderFragment() != null);
+ }
+
+ void showReaderFragment(long remoteBlogId, long postId) {
+ mTmpSelectedReaderPost = new BlogPairId(remoteBlogId, postId);
+ FragmentManager fm = getFragmentManager();
+ fm.executePendingTransactions();
+
+ Fragment fragment = ReaderPostDetailFragment.newInstance(remoteBlogId, postId);
+ FragmentTransaction ft = fm.beginTransaction();
+ String tagForFragment = getString(R.string.fragment_tag_reader_post_detail);
+ ft.add(R.id.layout_fragment_container, fragment, tagForFragment)
+ .addToBackStack(tagForFragment)
+ .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE);
+ if (hasDetailFragment())
+ ft.hide(getDetailFragment());
+ ft.commit();
+ }
+
+ /*
+ * called from comment list when user taps a comment
+ */
+ @Override
+ public void onCommentSelected(long commentId) {
+ mSelectedCommentId = commentId;
+ FragmentManager fm = getFragmentManager();
+ if (fm == null) {
+ return;
+ }
+ fm.executePendingTransactions();
+ mCommentSelected = true;
+ CommentDetailFragment detailFragment = getDetailFragment();
+ CommentsListFragment listFragment = getListFragment();
+
+ if (mDualPane) {
+ // dual pane mode with list/detail side-by-side - remove the reader fragment if it exists,
+ // then show this comment in the detail view and highlight it in the list view
+ if (hasReaderFragment()) {
+ fm.popBackStackImmediate();
+ }
+ detailFragment.setComment(WordPress.getCurrentLocalTableBlogId(), commentId);
+ if (listFragment != null) {
+ listFragment.setHighlightedCommentId(commentId);
+ }
+ } else {
+ FragmentTransaction ft = fm.beginTransaction();
+ String tagForFragment = getString(R.string.fragment_tag_comment_detail);
+ detailFragment = CommentDetailFragment.newInstance(WordPress.getCurrentLocalTableBlogId(),
+ commentId);
+ ft.add(R.id.layout_fragment_container, detailFragment, tagForFragment).addToBackStack(tagForFragment)
+ .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE);
+ if (listFragment != null) {
+ listFragment.setHighlightedCommentId(commentId);
+ ft.hide(listFragment);
+ }
+ ft.commitAllowingStateLoss();
+ mMenuDrawer.setDrawerIndicatorEnabled(false);
+ }
+ }
+
+ /*
+ * called from comment detail when user taps a link to a post - show the post in a
+ * reader detail fragment
+ */
+ @Override
+ public void onPostClicked(Note note, int remoteBlogId, int postId) {
+ showReaderFragment(remoteBlogId, postId);
+ }
+
+ /*
+ * reload the comment in the detail view if it's showing
+ */
+ private void reloadCommentDetail() {
+ CommentDetailFragment detailFragment = getDetailFragment();
+ if (detailFragment != null)
+ detailFragment.reloadComment();
+ }
+
+ /*
+ * reload the comment list from existing data
+ */
+ private void reloadCommentList() {
+ CommentsListFragment listFragment = getListFragment();
+ if (listFragment != null)
+ listFragment.loadComments();
+ }
+
+ /*
+ * tell the comment list to get recent comments from server
+ */
+ void updateCommentList() {
+ CommentsListFragment listFragment = getListFragment();
+ if (listFragment != null) {
+ listFragment.updateComments(false);
+ listFragment.setRefreshing(true);
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ // https://code.google.com/p/android/issues/detail?id=19917
+ if (outState.isEmpty()) {
+ outState.putBoolean("bug_19917_fix", true);
+ }
+
+ // retain the id of the highlighted and selected comments
+ if (mSelectedCommentId != 0) {
+ outState.putLong(KEY_SELECTED_COMMENT_ID, mSelectedCommentId);
+ }
+ if (mSelectedReaderPost != null) {
+ outState.putSerializable(KEY_SELECTED_POST_ID, mSelectedReaderPost);
+ }
+ if (hasListFragment()) {
+ long commentId = getListFragment().getHighlightedCommentId();
+ if (commentId != 0) {
+ outState.putLong(KEY_HIGHLIGHTED_COMMENT_ID, commentId);
+ }
+ }
+ super.onSaveInstanceState(outState);
+ }
+
+ @Override
+ protected Dialog onCreateDialog(int id) {
+ Dialog dialog = CommentDialogs.createCommentDialog(this, id);
+ if (dialog != null)
+ return dialog;
+ return super.onCreateDialog(id);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentsListFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentsListFragment.java
new file mode 100644
index 000000000..51f3a0d19
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentsListFragment.java
@@ -0,0 +1,660 @@
+package org.wordpress.android.ui.comments;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Fragment;
+import android.content.DialogInterface;
+import android.graphics.drawable.Drawable;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.view.ActionMode;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ListView;
+import android.widget.ProgressBar;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.models.Comment;
+import org.wordpress.android.models.CommentList;
+import org.wordpress.android.models.CommentStatus;
+import org.wordpress.android.ui.PullToRefreshHelper;
+import org.wordpress.android.ui.PullToRefreshHelper.RefreshListener;
+import org.wordpress.android.ui.WPActionBarActivity;
+import org.wordpress.android.ui.comments.CommentActions.ChangeType;
+import org.wordpress.android.ui.comments.CommentActions.ChangedFrom;
+import org.wordpress.android.ui.comments.CommentActions.OnCommentChangeListener;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.NetworkUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.xmlrpc.android.ApiHelper;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import uk.co.senab.actionbarpulltorefresh.library.PullToRefreshLayout;
+
+public class CommentsListFragment extends Fragment {
+ private boolean mIsUpdatingComments = false;
+ private boolean mCanLoadMoreComments = true;
+ private boolean mHasAutoRefreshedComments = false;
+ private boolean mHasCheckedDeletedComments = false;
+
+ private ProgressBar mProgressLoadMore;
+ private PullToRefreshHelper mPullToRefreshHelper;
+ private ListView mListView;
+ private View mEmptyView;
+ private CommentAdapter mCommentAdapter;
+ private ActionMode mActionMode;
+
+ private UpdateCommentsTask mUpdateCommentsTask;
+
+ private OnCommentSelectedListener mOnCommentSelectedListener;
+ private OnCommentChangeListener mOnCommentChangeListener;
+
+ private static final int COMMENTS_PER_PAGE = 30;
+ private static final String KEY_AUTO_REFRESHED = "has_auto_refreshed";
+ private static final String KEY_HAS_CHECKED_DELETED_COMMENTS = "has_checked_deleted_comments";
+ private boolean mFirstLoad = true;
+
+ private ListView getListView() {
+ return mListView;
+ }
+
+ private CommentAdapter getCommentAdapter() {
+ if (mCommentAdapter == null) {
+ /*
+ * called after comments have been loaded
+ */
+ CommentAdapter.DataLoadedListener dataLoadedListener = new CommentAdapter.DataLoadedListener() {
+ @Override
+ public void onDataLoaded(boolean isEmpty) {
+ if (!hasActivity())
+ return;
+ if (isEmpty) {
+ showEmptyView();
+ } else {
+ hideEmptyView();
+ }
+ if (mFirstLoad) {
+ mFirstLoad = false;
+ if (getActivity() != null && getActivity() instanceof CommentsActivity) {
+ ((CommentsActivity) getActivity()).commentAdapterFirstLoad();
+ }
+ }
+ }
+ };
+
+ // adapter calls this to request more comments from server when it reaches the end
+ CommentAdapter.OnLoadMoreListener loadMoreListener = new CommentAdapter.OnLoadMoreListener() {
+ @Override
+ public void onLoadMore() {
+ if (mCanLoadMoreComments && !mIsUpdatingComments) {
+ updateComments(true);
+ }
+ }
+ };
+
+ // adapter calls this when selected comments have changed (CAB)
+ CommentAdapter.OnSelectedItemsChangeListener changeListener = new CommentAdapter.OnSelectedItemsChangeListener() {
+ @Override
+ public void onSelectedItemsChanged() {
+ if (mActionMode != null) {
+ if (getSelectedCommentCount() == 0) {
+ mActionMode.finish();
+ } else {
+ updateActionModeTitle();
+ // must invalidate to ensure onPrepareActionMode is called
+ mActionMode.invalidate();
+ }
+ }
+ }
+ };
+
+ mCommentAdapter = new CommentAdapter(getActivity(),
+ dataLoadedListener,
+ loadMoreListener,
+ changeListener);
+ }
+ return mCommentAdapter;
+ }
+
+ private boolean hasCommentAdapter() {
+ return (mCommentAdapter != null);
+ }
+
+ private int getSelectedCommentCount() {
+ return getCommentAdapter().getSelectedCommentCount();
+ }
+
+ void clear() {
+ if (hasCommentAdapter()) {
+ getCommentAdapter().clear();
+ }
+ }
+
+ public long getFirstCommentId() {
+ if (getCommentAdapter() != null && getCommentAdapter().getCount() > 0) {
+ return ((Comment) getCommentAdapter().getItem(0)).commentID;
+ }
+ return 0;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (savedInstanceState != null) {
+ mHasAutoRefreshedComments = savedInstanceState.getBoolean(KEY_AUTO_REFRESHED);
+ }
+ }
+
+ @Override
+ public void onActivityCreated(Bundle bundle) {
+ super.onActivityCreated(bundle);
+ setUpListView();
+ getCommentAdapter().loadComments();
+ if (!NetworkUtils.isNetworkAvailable(getActivity())) {
+ return;
+ }
+ if (!mHasAutoRefreshedComments) {
+ updateComments(false);
+ mPullToRefreshHelper.setRefreshing(true);
+ mHasAutoRefreshedComments = true;
+ }
+ }
+
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+ try {
+ // check that the containing activity implements our callback
+ mOnCommentSelectedListener = (OnCommentSelectedListener) activity;
+ mOnCommentChangeListener = (OnCommentChangeListener) activity;
+ } catch (ClassCastException e) {
+ activity.finish();
+ throw new ClassCastException(activity.toString() + " must implement Callback");
+ }
+ }
+
+ public void onBlogChanged() {
+ mHasCheckedDeletedComments = false;
+ if (mUpdateCommentsTask != null) {
+ mUpdateCommentsTask.setRetryOnCancelled(true);
+ mUpdateCommentsTask.cancel(true);
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.comment_list_fragment, container, false);
+
+ mListView = (ListView) view.findViewById(android.R.id.list);
+ mEmptyView = view.findViewById(android.R.id.empty);
+
+ // progress bar that appears when loading more comments
+ mProgressLoadMore = (ProgressBar) view.findViewById(R.id.progress_loading);
+ mProgressLoadMore.setVisibility(View.GONE);
+
+ // pull to refresh setup
+ mPullToRefreshHelper = new PullToRefreshHelper(getActivity(),
+ (PullToRefreshLayout) view.findViewById(R.id.ptr_layout),
+ new RefreshListener() {
+ @Override
+ public void onRefreshStarted(View view) {
+ if (getActivity() == null || !NetworkUtils.checkConnection(getActivity())) {
+ mPullToRefreshHelper.setRefreshing(false);
+ return;
+ }
+ updateComments(false);
+ }
+ });
+ return view;
+ }
+
+ public void setRefreshing(boolean refreshing) {
+ mPullToRefreshHelper.setRefreshing(refreshing);
+ }
+
+ private void dismissDialog(int id) {
+ if (!hasActivity())
+ return;
+ try {
+ getActivity().dismissDialog(id);
+ } catch (IllegalArgumentException e) {
+ // raised when dialog wasn't created
+ }
+ }
+
+ private void moderateSelectedComments(final CommentStatus newStatus) {
+ final CommentList selectedComments = getCommentAdapter().getSelectedComments();
+ final CommentList updateComments = new CommentList();
+
+ // build list of comments whose status is different than passed
+ for (Comment comment: selectedComments) {
+ if (comment.getStatusEnum() != newStatus)
+ updateComments.add(comment);
+ }
+ if (updateComments.size() == 0)
+ return;
+
+ if (!NetworkUtils.checkConnection(getActivity()))
+ return;
+
+ final int dlgId;
+ switch (newStatus) {
+ case APPROVED:
+ dlgId = CommentDialogs.ID_COMMENT_DLG_APPROVING;
+ break;
+ case UNAPPROVED:
+ dlgId = CommentDialogs.ID_COMMENT_DLG_UNAPPROVING;
+ break;
+ case SPAM:
+ dlgId = CommentDialogs.ID_COMMENT_DLG_SPAMMING;
+ break;
+ case TRASH:
+ dlgId = CommentDialogs.ID_COMMENT_DLG_TRASHING;
+ break;
+ default :
+ return;
+ }
+ getActivity().showDialog(dlgId);
+
+ CommentActions.OnCommentsModeratedListener listener = new CommentActions.OnCommentsModeratedListener() {
+ @Override
+ public void onCommentsModerated(final CommentList moderatedComments) {
+ if (!hasActivity())
+ return;
+ finishActionMode();
+ dismissDialog(dlgId);
+ if (moderatedComments.size() > 0) {
+ getCommentAdapter().clearSelectedComments();
+ getCommentAdapter().replaceComments(moderatedComments);
+ if (mOnCommentChangeListener != null) {
+ ChangeType changeType = (newStatus == CommentStatus.TRASH ? ChangeType.TRASHED : ChangeType.STATUS);
+ mOnCommentChangeListener.onCommentChanged(ChangedFrom.COMMENT_LIST, changeType);
+ }
+ } else {
+ ToastUtils.showToast(getActivity(), R.string.error_moderate_comment);
+ }
+ }
+ };
+
+ CommentActions.moderateComments(WordPress.getCurrentLocalTableBlogId(), updateComments, newStatus, listener);
+ }
+
+ private void confirmDeleteComments() {
+ AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+ builder.setMessage(R.string.dlg_confirm_trash_comments);
+ builder.setTitle(R.string.trash);
+ builder.setCancelable(true);
+ builder.setPositiveButton(R.string.trash_yes, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int id) {
+ deleteSelectedComments();
+ }
+ });
+ builder.setNegativeButton(R.string.trash_no, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int id) {
+ dialog.cancel();
+ }
+ });
+ AlertDialog alert = builder.create();
+ alert.show();
+ }
+
+ private void deleteSelectedComments() {
+ if (!NetworkUtils.checkConnection(getActivity()))
+ return;
+
+ final CommentList selectedComments = getCommentAdapter().getSelectedComments();
+ getActivity().showDialog(CommentDialogs.ID_COMMENT_DLG_TRASHING);
+ CommentActions.OnCommentsModeratedListener listener = new CommentActions.OnCommentsModeratedListener() {
+ @Override
+ public void onCommentsModerated(final CommentList deletedComments) {
+ if (!hasActivity())
+ return;
+ finishActionMode();
+ dismissDialog(CommentDialogs.ID_COMMENT_DLG_TRASHING);
+ if (deletedComments.size() > 0) {
+ getCommentAdapter().clearSelectedComments();
+ getCommentAdapter().deleteComments(deletedComments);
+ if (mOnCommentChangeListener != null)
+ mOnCommentChangeListener.onCommentChanged(ChangedFrom.COMMENT_LIST, ChangeType.TRASHED);
+ } else {
+ ToastUtils.showToast(getActivity(), R.string.error_moderate_comment);
+ }
+ }
+ };
+
+ CommentActions.moderateComments(WordPress.getCurrentLocalTableBlogId(), selectedComments, CommentStatus.TRASH,
+ listener);
+ }
+
+ long getHighlightedCommentId() {
+ return (hasCommentAdapter() ? getCommentAdapter().getHighlightedCommentId() : 0);
+ }
+ void setHighlightedCommentId(long commentId) {
+ getCommentAdapter().setHighlightedCommentId(commentId);
+ int position = getCommentAdapter().indexOfCommentId(commentId);
+ if (position != -1) {
+ getListView().setSelection(position);
+ }
+ }
+
+ private void setUpListView() {
+ ListView listView = this.getListView();
+ listView.setAdapter(getCommentAdapter());
+
+ listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ if (mActionMode == null) {
+ Comment comment = (Comment) getCommentAdapter().getItem(position);
+ mOnCommentSelectedListener.onCommentSelected(comment.commentID);
+ getListView().invalidateViews();
+ } else {
+ getCommentAdapter().toggleItemSelected(position, view);
+ }
+ }
+ });
+
+ listView.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
+ @Override
+ public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
+ // enable CAB if it's not already enabled
+ if (mActionMode == null) {
+ if (getActivity() instanceof WPActionBarActivity) {
+ ((WPActionBarActivity) getActivity()).startActionMode(new ActionModeCallback());
+ getCommentAdapter().setEnableSelection(true);
+ getCommentAdapter().setItemSelected(position, true, view);
+ }
+ } else {
+ getCommentAdapter().toggleItemSelected(position, view);
+ }
+ return true;
+ }
+ });
+ }
+
+ void loadComments() {
+ // this is called from CommentsActivity when a comment was changed in the detail view,
+ // and the change will already be in SQLite so simply reload the comment adapter
+ // to show the change
+ getCommentAdapter().loadComments();
+ }
+
+ /*
+ * get latest comments from server, or pass loadMore=true to get comments beyond the
+ * existing ones
+ */
+ void updateComments(boolean loadMore) {
+ if (mIsUpdatingComments) {
+ AppLog.w(AppLog.T.COMMENTS, "update comments task already running");
+ return;
+ }
+
+ mUpdateCommentsTask = new UpdateCommentsTask(loadMore);
+ mUpdateCommentsTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ /*
+ * task to retrieve latest comments from server
+ */
+ private class UpdateCommentsTask extends AsyncTask<Void, Void, CommentList> {
+ boolean isError;
+ final boolean isLoadingMore;
+ boolean mRetryOnCancelled;
+
+ private UpdateCommentsTask(boolean loadMore) {
+ isLoadingMore = loadMore;
+ }
+
+ public void setRetryOnCancelled(boolean retryOnCancelled) {
+ mRetryOnCancelled = retryOnCancelled;
+ }
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ mIsUpdatingComments = true;
+ if (isLoadingMore) {
+ showLoadingProgress();
+ }
+ }
+
+ @Override
+ protected void onCancelled() {
+ super.onCancelled();
+ mIsUpdatingComments = false;
+ mUpdateCommentsTask = null;
+ if (mRetryOnCancelled) {
+ mRetryOnCancelled = false;
+ updateComments(false);
+ } else {
+ mPullToRefreshHelper.setRefreshing(false);
+ }
+ }
+
+ @Override
+ protected CommentList doInBackground(Void... args) {
+ if (!hasActivity())
+ return null;
+
+ Blog blog = WordPress.getCurrentBlog();
+ if (blog == null) {
+ isError = true;
+ return null;
+ }
+
+ // the first time this is called, make sure comments deleted on server are removed
+ // from the local database
+ if (!mHasCheckedDeletedComments && !isLoadingMore) {
+ mHasCheckedDeletedComments = true;
+ ApiHelper.removeDeletedComments(blog);
+ }
+
+ Map<String, Object> hPost = new HashMap<String, Object>();
+ if (isLoadingMore) {
+ int numExisting = getCommentAdapter().getCount();
+ hPost.put("offset", numExisting);
+ hPost.put("number", COMMENTS_PER_PAGE);
+ } else {
+ hPost.put("number", COMMENTS_PER_PAGE);
+ }
+
+ Object[] params = { blog.getRemoteBlogId(),
+ blog.getUsername(),
+ blog.getPassword(),
+ hPost };
+ try {
+ return ApiHelper.refreshComments(getActivity(), blog, params);
+ } catch (Exception e) {
+ isError = true;
+ return null;
+ }
+ }
+
+ protected void onPostExecute(CommentList comments) {
+ mIsUpdatingComments = false;
+ mUpdateCommentsTask = null;
+ if (!hasActivity()) {
+ return;
+ }
+ if (isLoadingMore) {
+ hideLoadingProgress();
+ }
+ mPullToRefreshHelper.setRefreshing(false);
+
+ if (isCancelled())
+ return;
+
+ mCanLoadMoreComments = (comments != null && comments.size() > 0);
+
+ // result will be null on error OR if no more comments exists
+ if (comments == null) {
+ if (isError && !getActivity().isFinishing()) {
+ ToastUtils.showToast(getActivity(), getString(R.string.error_refresh_comments));
+ }
+ return;
+ }
+
+ if (comments.size() > 0) {
+ getCommentAdapter().loadComments();
+ }
+ }
+ }
+
+ public interface OnCommentSelectedListener {
+ public void onCommentSelected(long commentId);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ if (outState.isEmpty()) {
+ outState.putBoolean("bug_19917_fix", true);
+ }
+ outState.putBoolean(KEY_AUTO_REFRESHED, mHasAutoRefreshedComments);
+ outState.putBoolean(KEY_HAS_CHECKED_DELETED_COMMENTS, mHasCheckedDeletedComments);
+ super.onSaveInstanceState(outState);
+ }
+
+ private boolean hasActivity() {
+ return (getActivity() != null && !isRemoving());
+ }
+
+ private void showEmptyView() {
+ if (mEmptyView != null)
+ mEmptyView.setVisibility(View.VISIBLE);
+ }
+
+ private void hideEmptyView() {
+ if (mEmptyView != null)
+ mEmptyView.setVisibility(View.GONE);
+ }
+
+ /**
+ * show/hide progress bar which appears at the bottom when loading more comments
+ */
+ private void showLoadingProgress() {
+ if (hasActivity() && mProgressLoadMore != null) {
+ mProgressLoadMore.setVisibility(View.VISIBLE);
+ }
+ }
+
+ private void hideLoadingProgress() {
+ if (hasActivity() && mProgressLoadMore != null) {
+ mProgressLoadMore.setVisibility(View.GONE);
+ }
+ }
+
+ /****
+ * Contextual ActionBar (CAB) routines
+ ***/
+ private void updateActionModeTitle() {
+ if (mActionMode == null)
+ return;
+ int numSelected = getSelectedCommentCount();
+ if (numSelected > 0) {
+ mActionMode.setTitle(Integer.toString(numSelected));
+ } else {
+ mActionMode.setTitle("");
+ }
+ }
+
+ private void finishActionMode() {
+ if (mActionMode != null) {
+ mActionMode.finish();
+ }
+ }
+
+ private final class ActionModeCallback implements ActionMode.Callback {
+ @Override
+ public boolean onCreateActionMode(ActionMode actionMode, Menu menu) {
+ mActionMode = actionMode;
+ MenuInflater inflater = actionMode.getMenuInflater();
+ inflater.inflate(R.menu.menu_comments_cab, menu);
+ mPullToRefreshHelper.setEnabled(false);
+ return true;
+ }
+
+ private void setItemEnabled(Menu menu, int menuId, boolean isEnabled) {
+ final MenuItem item = menu.findItem(menuId);
+ if (item == null || item.isEnabled() == isEnabled)
+ return;
+ item.setEnabled(isEnabled);
+ if (item.getIcon() != null) {
+ // must mutate the drawable to avoid affecting other instances of it
+ Drawable icon = item.getIcon().mutate();
+ icon.setAlpha(isEnabled ? 255 : 128);
+ item.setIcon(icon);
+ }
+ }
+
+ @Override
+ public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) {
+ final CommentList selectedComments = getCommentAdapter().getSelectedComments();
+
+ boolean hasSelection = (selectedComments.size() > 0);
+ boolean hasApproved = hasSelection && selectedComments.hasAnyWithStatus(CommentStatus.APPROVED);
+ boolean hasUnapproved = hasSelection && selectedComments.hasAnyWithStatus(CommentStatus.UNAPPROVED);
+ boolean hasSpam = hasSelection && selectedComments.hasAnyWithStatus(CommentStatus.SPAM);
+ boolean hasAnyNonSpam = hasSelection && selectedComments.hasAnyWithoutStatus(CommentStatus.SPAM);
+
+ setItemEnabled(menu, R.id.menu_approve, hasUnapproved || hasSpam);
+ setItemEnabled(menu, R.id.menu_unapprove, hasApproved);
+ setItemEnabled(menu, R.id.menu_spam, hasAnyNonSpam);
+ setItemEnabled(menu, R.id.menu_trash, hasSelection);
+
+ return true;
+ }
+
+ @Override
+ public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) {
+ int numSelected = getSelectedCommentCount();
+ if (numSelected == 0)
+ return false;
+
+ switch (menuItem.getItemId()) {
+ case R.id.menu_approve :
+ moderateSelectedComments(CommentStatus.APPROVED);
+ return true;
+ case R.id.menu_unapprove :
+ moderateSelectedComments(CommentStatus.UNAPPROVED);
+ return true;
+ case R.id.menu_spam :
+ moderateSelectedComments(CommentStatus.SPAM);
+ return true;
+ case R.id.menu_trash :
+ // unlike the other status changes, we ask the user to confirm trashing
+ confirmDeleteComments();
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ @Override
+ public void onDestroyActionMode(ActionMode mode) {
+ getCommentAdapter().setEnableSelection(false);
+ mPullToRefreshHelper.setEnabled(true);
+ mActionMode = null;
+ }
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ mPullToRefreshHelper.registerReceiver(getActivity());
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ mPullToRefreshHelper.unregisterReceiver(getActivity());
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/EditCommentActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/EditCommentActivity.java
new file mode 100644
index 000000000..59361cfd3
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/EditCommentActivity.java
@@ -0,0 +1,320 @@
+package org.wordpress.android.ui.comments;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.ProgressDialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.widget.EditText;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.CommentTable;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.models.Comment;
+import org.wordpress.android.models.CommentStatus;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.EditTextUtils;
+import org.wordpress.android.util.NetworkUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlrpc.android.XMLRPCClientInterface;
+import org.xmlrpc.android.XMLRPCException;
+import org.xmlrpc.android.XMLRPCFactory;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+public class EditCommentActivity extends Activity {
+ static final String ARG_LOCAL_BLOG_ID = "blog_id";
+ static final String ARG_COMMENT_ID = "comment_id";
+
+ private static final int ID_DIALOG_SAVING = 0;
+
+ private int mLocalBlogId;
+ private long mCommentId;
+ private Comment mComment;
+
+ @Override
+ public void onCreate(Bundle icicle) {
+ super.onCreate(icicle);
+
+ setContentView(R.layout.comment_edit_activity);
+ setTitle(getString(R.string.edit_comment));
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayShowTitleEnabled(true);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+
+ if (!loadComment(getIntent())) {
+ ToastUtils.showToast(this, R.string.error_load_comment);
+ finish();
+ }
+ }
+
+ private boolean loadComment(Intent intent) {
+ if (intent == null)
+ return false;
+
+ mLocalBlogId = intent.getIntExtra(ARG_LOCAL_BLOG_ID, 0);
+ mCommentId = intent.getLongExtra(ARG_COMMENT_ID, 0);
+ mComment = CommentTable.getComment(mLocalBlogId, mCommentId);
+ if (mComment == null)
+ return false;
+
+ final EditText editAuthorName = (EditText) this.findViewById(R.id.author_name);
+ editAuthorName.setText(mComment.getAuthorName());
+
+ final EditText editAuthorEmail = (EditText) this.findViewById(R.id.author_email);
+ editAuthorEmail.setText(mComment.getAuthorEmail());
+
+ final EditText editAuthorUrl = (EditText) this.findViewById(R.id.author_url);
+ editAuthorUrl.setText(mComment.getAuthorUrl());
+
+ final EditText editContent = (EditText) this.findViewById(R.id.comment_content);
+ editContent.setText(mComment.getCommentText());
+
+ // show error when comment content is empty
+ editContent.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+ @Override
+ public void afterTextChanged(Editable s) {
+ boolean hasError = (editContent.getError() != null);
+ boolean hasText = (s != null && s.length() > 0);
+ if (!hasText && !hasError) {
+ editContent.setError(getString(R.string.content_required));
+ } else if (hasText && hasError) {
+ editContent.setError(null);
+ }
+ }
+ });
+
+ return true;
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.edit_comment, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ onBackPressed();
+ return true;
+ case R.id.menu_save_comment:
+ saveComment();
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ private String getEditTextStr(int resId) {
+ final EditText edit = (EditText) findViewById(resId);
+ return EditTextUtils.getText(edit);
+ }
+
+ private void saveComment() {
+ // make sure comment content was entered
+ final EditText editContent = (EditText) findViewById(R.id.comment_content);
+ if (EditTextUtils.isEmpty(editContent)) {
+ editContent.setError(getString(R.string.content_required));
+ return;
+ }
+
+ // return immediately if comment hasn't changed
+ if (!isCommentEdited()) {
+ ToastUtils.showToast(this, R.string.toast_comment_unedited);
+ return;
+ }
+
+ // make sure we have an active connection
+ if (!NetworkUtils.checkConnection(this))
+ return;
+
+ if (mIsUpdateTaskRunning)
+ AppLog.w(AppLog.T.COMMENTS, "update task already running");
+ new UpdateCommentTask().execute();
+ }
+
+ /*
+ * returns true if user made any changes to the comment
+ */
+ private boolean isCommentEdited() {
+ if (mComment == null)
+ return false;
+
+ final String authorName = getEditTextStr(R.id.author_name);
+ final String authorEmail = getEditTextStr(R.id.author_email);
+ final String authorUrl = getEditTextStr(R.id.author_url);
+ final String content = getEditTextStr(R.id.comment_content);
+
+ return !(authorName.equals(mComment.getAuthorName())
+ && authorEmail.equals(mComment.getAuthorEmail())
+ && authorUrl.equals(mComment.getAuthorUrl())
+ && content.equals(mComment.getCommentText()));
+ }
+
+ @Override
+ protected Dialog onCreateDialog(int id) {
+ if (id == ID_DIALOG_SAVING) {
+ ProgressDialog savingDialog = new ProgressDialog(this);
+ savingDialog.setMessage(getResources().getText(R.string.saving_changes));
+ savingDialog.setIndeterminate(true);
+ savingDialog.setCancelable(true);
+ return savingDialog;
+ }
+ return super.onCreateDialog(id);
+ }
+
+ private void showSaveDialog() {
+ showDialog(ID_DIALOG_SAVING);
+ }
+
+ private void dismissSaveDialog() {
+ try {
+ dismissDialog(ID_DIALOG_SAVING);
+ } catch (IllegalArgumentException e) {
+ // dialog doesn't exist
+ }
+ }
+
+ /*
+ * AsyncTask to save comment to server
+ */
+ private boolean mIsUpdateTaskRunning = false;
+ private class UpdateCommentTask extends AsyncTask<Void, Void, Boolean> {
+ @Override
+ protected void onPreExecute() {
+ mIsUpdateTaskRunning = true;
+ showSaveDialog();
+ }
+ @Override
+ protected void onCancelled() {
+ mIsUpdateTaskRunning = false;
+ dismissSaveDialog();
+ }
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ final Blog blog;
+ blog = WordPress.wpDB.instantiateBlogByLocalId(mLocalBlogId);
+ if (blog == null) {
+ AppLog.e(AppLog.T.COMMENTS, "Invalid local blog id:" + mLocalBlogId);
+ return false;
+ }
+ final String authorName = getEditTextStr(R.id.author_name);
+ final String authorEmail = getEditTextStr(R.id.author_email);
+ final String authorUrl = getEditTextStr(R.id.author_url);
+ final String content = getEditTextStr(R.id.comment_content);
+
+ final Map<String, String> postHash = new HashMap<String, String>();
+
+ // using CommentStatus.toString() rather than getStatus() ensures that the XML-RPC
+ // status value is used - important since comment may have been loaded via the
+ // REST API, which uses different status values
+ postHash.put("status", CommentStatus.toString(mComment.getStatusEnum()));
+ postHash.put("content", content);
+ postHash.put("author", authorName);
+ postHash.put("author_url", authorUrl);
+ postHash.put("author_email", authorEmail);
+
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+ Object[] xmlParams = {blog.getRemoteBlogId(), blog.getUsername(), blog.getPassword(), Long.toString(
+ mCommentId), postHash};
+
+ try {
+ Object result = client.call("wp.editComment", xmlParams);
+ boolean isSaved = (result != null && Boolean.parseBoolean(result.toString()));
+ if (isSaved) {
+ mComment.setAuthorEmail(authorEmail);
+ mComment.setAuthorUrl(authorUrl);
+ mComment.setAuthorName(authorName);
+ mComment.setCommentText(content);
+ CommentTable.updateComment(mLocalBlogId, mComment);
+ }
+ return isSaved;
+ } catch (XMLRPCException e) {
+ AppLog.e(AppLog.T.COMMENTS, e);
+ return false;
+ } catch (IOException e) {
+ AppLog.e(AppLog.T.COMMENTS, e);
+ return false;
+ } catch (XmlPullParserException e) {
+ AppLog.e(AppLog.T.COMMENTS, e);
+ return false;
+ }
+ }
+ @Override
+ protected void onPostExecute(Boolean result) {
+ mIsUpdateTaskRunning = false;
+ dismissSaveDialog();
+
+ if (result) {
+ setResult(RESULT_OK);
+ finish();
+ } else {
+ // alert user to error
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(EditCommentActivity.this);
+ dialogBuilder.setTitle(getResources().getText(R.string.error));
+ dialogBuilder.setMessage(R.string.error_edit_comment);
+ dialogBuilder.setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ // just close the dialog
+ }
+ });
+ dialogBuilder.setCancelable(true);
+ dialogBuilder.create().show();
+ }
+ }
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (isCommentEdited()) {
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(
+ EditCommentActivity.this);
+ dialogBuilder.setTitle(getResources().getText(R.string.cancel_edit));
+ dialogBuilder.setMessage(getResources().getText(R.string.sure_to_cancel_edit_comment));
+ dialogBuilder.setPositiveButton(getResources().getText(R.string.yes),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ finish();
+ }
+ });
+ dialogBuilder.setNegativeButton(
+ getResources().getText(R.string.no),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ // just close the dialog
+ }
+ });
+ dialogBuilder.setCancelable(true);
+ dialogBuilder.create().show();
+ } else {
+ super.onBackPressed();
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaAddFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaAddFragment.java
new file mode 100644
index 000000000..6195a27ac
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaAddFragment.java
@@ -0,0 +1,288 @@
+package org.wordpress.android.ui.media;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.database.Cursor;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.provider.MediaStore;
+import android.support.v4.content.CursorLoader;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Toast;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.models.MediaFile;
+import org.wordpress.android.util.MediaUploadService;
+import org.wordpress.android.util.MediaUtils;
+import org.wordpress.android.util.MediaUtils.LaunchCameraCallback;
+import org.wordpress.android.util.MediaUtils.RequestCode;
+import org.wordpress.android.util.ToastUtils;
+
+import java.io.File;
+import java.util.List;
+
+/**
+ * An invisible fragment in charge of launching the right intents to camera, video, and image library.
+ * Also queues up media for upload and listens to notifications from the upload service.
+ */
+public class MediaAddFragment extends Fragment implements LaunchCameraCallback {
+ private static final String BUNDLE_MEDIA_CAPTURE_PATH = "mediaCapturePath";
+ private String mMediaCapturePath = "";
+ private MediaAddFragmentCallback mCallback;
+
+ public interface MediaAddFragmentCallback {
+ public void onMediaAdded(String mediaId);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ // This view doesn't really matter as this fragment is invisible
+
+ if (savedInstanceState != null && savedInstanceState.getString(BUNDLE_MEDIA_CAPTURE_PATH) != null)
+ mMediaCapturePath = savedInstanceState.getString(BUNDLE_MEDIA_CAPTURE_PATH);
+
+ return inflater.inflate(R.layout.actionbar_add_media_cell, container, false);
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ try {
+ mCallback = (MediaAddFragmentCallback) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString() + " must implement " + MediaAddFragmentCallback.class.getSimpleName());
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ if (mMediaCapturePath != null && !mMediaCapturePath.equals(""))
+ outState.putString(BUNDLE_MEDIA_CAPTURE_PATH, mMediaCapturePath);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(getActivity());
+ lbm.registerReceiver(mReceiver, new IntentFilter(MediaUploadService.MEDIA_UPLOAD_INTENT_NOTIFICATION));
+
+ startMediaUploadService();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+
+ LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(getActivity());
+ lbm.unregisterReceiver(mReceiver);
+ }
+
+ private BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (MediaUploadService.MEDIA_UPLOAD_INTENT_NOTIFICATION.equals(action)) {
+ String mediaId = intent.getStringExtra(MediaUploadService.MEDIA_UPLOAD_INTENT_NOTIFICATION_EXTRA);
+ String errorMessage = intent.getStringExtra(MediaUploadService.MEDIA_UPLOAD_INTENT_NOTIFICATION_ERROR);
+ if (errorMessage != null) {
+ ToastUtils.showToast(context, errorMessage, ToastUtils.Duration.SHORT);
+ }
+ mCallback.onMediaAdded(mediaId);
+ }
+ }
+ };
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (data != null || requestCode == RequestCode.ACTIVITY_REQUEST_CODE_TAKE_PHOTO || requestCode == RequestCode.ACTIVITY_REQUEST_CODE_TAKE_VIDEO) {
+ String path;
+
+ switch (requestCode) {
+ case RequestCode.ACTIVITY_REQUEST_CODE_PICTURE_LIBRARY:
+ case RequestCode.ACTIVITY_REQUEST_CODE_VIDEO_LIBRARY:
+ Uri imageUri = data.getData();
+ fetchMedia(imageUri);
+ break;
+ case RequestCode.ACTIVITY_REQUEST_CODE_TAKE_PHOTO:
+ if (resultCode == Activity.RESULT_OK) {
+ path = mMediaCapturePath;
+ mMediaCapturePath = null;
+ queueFileForUpload(path);
+ }
+ break;
+ case RequestCode.ACTIVITY_REQUEST_CODE_TAKE_VIDEO:
+ if (resultCode == Activity.RESULT_OK) {
+ path = getRealPathFromURI(MediaUtils.getLastRecordedVideoUri(getActivity()));
+ queueFileForUpload(path);
+ }
+ break;
+ }
+
+ }
+ }
+
+ private void fetchMedia(Uri mediaUri) {
+ if (!MediaUtils.isInMediaStore(mediaUri)) {
+ // Create an AsyncTask to download the file
+ new DownloadMediaTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, mediaUri);
+ } else {
+ // It is a regular local media file
+ String path = getRealPathFromURI(mediaUri);
+ queueFileForUpload(path);
+ }
+ }
+
+ private String getRealPathFromURI(Uri uri) {
+ String path;
+ if ("content".equals(uri.getScheme())) {
+ path = getRealPathFromContentURI(uri);
+ } else if ("file".equals(uri.getScheme())) {
+ path = uri.getPath();
+ } else {
+ path = uri.toString();
+ }
+ return path;
+ }
+
+ private String getRealPathFromContentURI(Uri contentUri) {
+ if (contentUri == null)
+ return null;
+
+ String[] proj = { MediaStore.Images.Media.DATA };
+ CursorLoader loader = new CursorLoader(getActivity(), contentUri, proj, null, null, null);
+ Cursor cursor = loader.loadInBackground();
+
+ if (cursor == null)
+ return null;
+
+ int column_index = cursor.getColumnIndex(MediaStore.Images.Media.DATA);
+ if (column_index == -1) {
+ cursor.close();
+ return null;
+ }
+
+ cursor.moveToFirst();
+ String path = cursor.getString(column_index);
+ cursor.close();
+ return path;
+ }
+
+ private void queueFileForUpload(String path) {
+ if (path == null || path.equals("")) {
+ Toast.makeText(getActivity(), "Error opening file", Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ Blog blog = WordPress.getCurrentBlog();
+
+ File file = new File(path);
+ if (!file.exists())
+ return;
+
+ String mimeType = MediaUtils.getMediaFileMimeType(file);
+ String fileName = MediaUtils.getMediaFileName(file, mimeType);
+
+ MediaFile mediaFile = new MediaFile();
+ mediaFile.setBlogId(String.valueOf(blog.getLocalTableBlogId()));
+ mediaFile.setFileName(fileName);
+ mediaFile.setFilePath(path);
+ mediaFile.setUploadState("queued");
+ mediaFile.setDateCreatedGMT(System.currentTimeMillis());
+ mediaFile.setMediaId(String.valueOf(System.currentTimeMillis()));
+ if (mimeType != null && mimeType.startsWith("image")) {
+ // get width and height
+ BitmapFactory.Options bfo = new BitmapFactory.Options();
+ bfo.inJustDecodeBounds = true;
+ BitmapFactory.decodeFile(path, bfo);
+ mediaFile.setWidth(bfo.outWidth);
+ mediaFile.setHeight(bfo.outHeight);
+ }
+
+ if (!TextUtils.isEmpty(mimeType))
+ mediaFile.setMimeType(mimeType);
+ mediaFile.save();
+
+ mCallback.onMediaAdded(mediaFile.getMediaId());
+
+ startMediaUploadService();
+ }
+
+ private void startMediaUploadService() {
+ getActivity().startService(new Intent(getActivity(), MediaUploadService.class));
+ }
+
+ @Override
+ public void onMediaCapturePathReady(String mediaCapturePath) {
+ mMediaCapturePath = mediaCapturePath;
+ }
+
+ public void launchCamera(){
+ MediaUtils.launchCamera(this, this);
+ }
+
+ public void launchVideoCamera() {
+ MediaUtils.launchVideoCamera(this);
+ }
+
+ public void launchVideoLibrary() {
+ MediaUtils.launchVideoLibrary(this);
+ }
+
+ public void launchPictureLibrary() {
+ MediaUtils.launchPictureLibrary(this);
+ }
+
+ public void addToQueue(String mediaId) {
+ String blogId = String.valueOf(WordPress.getCurrentBlog().getLocalTableBlogId());
+ WordPress.wpDB.updateMediaUploadState(blogId, mediaId, "queued");
+ startMediaUploadService();
+ }
+
+ public void uploadList(List<Uri> uriList) {
+ for (Uri uri : uriList) {
+ fetchMedia(uri);
+ }
+ }
+
+ private class DownloadMediaTask extends AsyncTask<Uri, Integer, Uri> {
+ @Override
+ protected Uri doInBackground(Uri... uris) {
+ Uri imageUri = uris[0];
+ return MediaUtils.downloadExternalMedia(getActivity(), imageUri);
+ }
+
+ @Override
+ protected void onPreExecute() {
+ Toast.makeText(getActivity(), R.string.download, Toast.LENGTH_SHORT).show();
+ }
+
+ protected void onPostExecute(Uri newUri) {
+ if (getActivity() == null)
+ return;
+
+ if (newUri != null) {
+ String path = getRealPathFromURI(newUri);
+ queueFileForUpload(path);
+ }
+ else
+ Toast.makeText(getActivity(), getString(R.string.error_downloading_image), Toast.LENGTH_SHORT).show();
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaBrowserActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaBrowserActivity.java
new file mode 100644
index 000000000..008fcf864
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaBrowserActivity.java
@@ -0,0 +1,745 @@
+package org.wordpress.android.ui.media;
+
+import android.app.ActionBar;
+import android.app.AlertDialog;
+import android.app.AlertDialog.Builder;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnClickListener;
+import android.content.Intent;
+import android.database.Cursor;
+import android.graphics.drawable.ColorDrawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.app.Fragment;
+import android.app.FragmentManager;
+import android.app.FragmentTransaction;
+import android.text.TextUtils;
+import android.view.ActionMode;
+import android.view.Gravity;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.MenuItem.OnActionExpandListener;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemClickListener;
+import android.widget.ArrayAdapter;
+import android.widget.ListView;
+import android.widget.PopupWindow;
+import android.widget.SearchView;
+import android.widget.SearchView.OnQueryTextListener;
+import android.widget.Toast;
+
+import org.wordpress.android.Constants;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.FeatureSet;
+import org.wordpress.android.ui.WPActionBarActivity;
+import org.wordpress.android.ui.media.MediaAddFragment.MediaAddFragmentCallback;
+import org.wordpress.android.ui.media.MediaEditFragment.MediaEditFragmentCallback;
+import org.wordpress.android.ui.media.MediaGridFragment.Filter;
+import org.wordpress.android.ui.media.MediaGridFragment.MediaGridListener;
+import org.wordpress.android.ui.media.MediaItemFragment.MediaItemFragmentCallback;
+import org.wordpress.android.ui.posts.EditPostActivity;
+import org.wordpress.android.ui.posts.EditPostContentFragment;
+import org.wordpress.android.util.MediaDeleteService;
+import org.wordpress.android.util.MediaUtils;
+import org.wordpress.android.util.Utils;
+import org.wordpress.android.util.WPAlertDialogFragment;
+import org.xmlrpc.android.ApiHelper;
+import org.xmlrpc.android.ApiHelper.GetFeatures.Callback;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The main activity in which the user can browse their media.
+ * Accessible via the menu drawer as "Media"
+ */
+
+public class MediaBrowserActivity extends WPActionBarActivity implements MediaGridListener,
+ MediaItemFragmentCallback, OnQueryTextListener, OnActionExpandListener, MediaEditFragmentCallback,
+ MediaAddFragmentCallback,
+ ActionMode.Callback {
+ private static final String SAVED_QUERY = "SAVED_QUERY";
+
+ private MediaGridFragment mMediaGridFragment;
+ private MediaItemFragment mMediaItemFragment;
+ private MediaEditFragment mMediaEditFragment;
+ private MediaAddFragment mMediaAddFragment;
+ private PopupWindow mAddMediaPopup;
+
+ private SearchView mSearchView;
+ private MenuItem mSearchMenuItem;
+ private Menu mMenu;
+ private FeatureSet mFeatureSet;
+ private ActionMode mActionMode;
+
+ private int mMultiSelectCount;
+ private String mQuery;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (WordPress.wpDB == null) {
+ Toast.makeText(this, R.string.fatal_db_error, Toast.LENGTH_LONG).show();
+ finish();
+ return;
+ }
+
+ setTitle(R.string.media);
+
+ createMenuDrawer(R.layout.media_browser_activity);
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayShowTitleEnabled(true);
+ }
+
+ FragmentManager fm = getFragmentManager();
+ fm.addOnBackStackChangedListener(mOnBackStackChangedListener);
+ FragmentTransaction ft = fm.beginTransaction();
+ setupBaseLayout();
+
+ mMediaAddFragment = (MediaAddFragment) fm.findFragmentById(R.id.mediaAddFragment);
+ mMediaGridFragment = (MediaGridFragment) fm.findFragmentById(R.id.mediaGridFragment);
+
+ mMediaItemFragment = (MediaItemFragment) fm.findFragmentByTag(MediaItemFragment.TAG);
+ if (mMediaItemFragment != null)
+ ft.hide(mMediaGridFragment);
+
+ mMediaEditFragment = (MediaEditFragment) fm.findFragmentByTag(MediaEditFragment.TAG);
+ if (mMediaEditFragment != null && !mMediaEditFragment.isInLayout())
+ ft.hide(mMediaItemFragment);
+
+ ft.commit();
+
+ setupAddMenuPopup();
+
+ String action = getIntent().getAction();
+ if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) {
+ // We arrived here from a share action
+ uploadSharedFiles();
+ }
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putString(SAVED_QUERY, mQuery);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ mQuery = savedInstanceState.getString(SAVED_QUERY);
+ }
+
+ private void uploadSharedFiles() {
+ Intent intent = getIntent();
+ String action = intent.getAction();
+ final List<Uri> multi_stream;
+ if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
+ multi_stream = intent.getParcelableArrayListExtra((Intent.EXTRA_STREAM));
+ } else {
+ multi_stream = new ArrayList<Uri>();
+ multi_stream.add((Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM));
+ }
+ mMediaAddFragment.uploadList(multi_stream);
+
+ // clear the intent's action, so that in case the user rotates, we don't re-upload the same
+ // files
+ getIntent().setAction(null);
+ }
+
+ private final FragmentManager.OnBackStackChangedListener mOnBackStackChangedListener = new FragmentManager.OnBackStackChangedListener() {
+ public void onBackStackChanged() {
+ setupBaseLayout();
+ }
+ };
+
+ private void setupBaseLayout() {
+ // hide access to the drawer when there are fragments in the back stack
+ if (getFragmentManager().getBackStackEntryCount() == 0) {
+ mMenuDrawer.setDrawerIndicatorEnabled(true);
+ } else {
+ mMenuDrawer.setDrawerIndicatorEnabled(false);
+ }
+ }
+
+ /** Setup the popup that allows you to add new media from camera, video camera or local files **/
+ private void setupAddMenuPopup() {
+ String capturePhoto = getResources().getString(R.string.media_add_popup_capture_photo);
+ String captureVideo = getResources().getString(R.string.media_add_popup_capture_video);
+ String pickPhotoFromGallery = getResources().getString(
+ R.string.select_photo);
+ String pickVideoFromGallery = getResources().getString(
+ R.string.select_video);
+ final ArrayAdapter<String> adapter = new ArrayAdapter<String>(MediaBrowserActivity.this,
+ R.layout.actionbar_add_media_cell,
+ new String[] {
+ capturePhoto, captureVideo, pickPhotoFromGallery, pickVideoFromGallery
+ });
+
+ View layoutView = getLayoutInflater().inflate(R.layout.actionbar_add_media, null, false);
+ ListView listView = (ListView) layoutView.findViewById(R.id.actionbar_add_media_listview);
+ listView.setAdapter(adapter);
+ listView.setOnItemClickListener(new OnItemClickListener() {
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ adapter.notifyDataSetChanged();
+
+ // Support video only if you are self-hosted or are a dot-com blog with the video
+ // press upgrade
+ boolean selfHosted = !WordPress.getCurrentBlog().isDotcomFlag();
+ boolean isVideoEnabled = selfHosted
+ || (mFeatureSet != null && mFeatureSet.isVideopressEnabled());
+
+ if (position == 0) {
+ mMediaAddFragment.launchCamera();
+ } else if (position == 1) {
+ if (isVideoEnabled) {
+ mMediaAddFragment.launchVideoCamera();
+ } else {
+ showVideoPressUpgradeDialog();
+ }
+ } else if (position == 2) {
+ mMediaAddFragment.launchPictureLibrary();
+ } else if (position == 3) {
+ if (isVideoEnabled) {
+ mMediaAddFragment.launchVideoLibrary();
+ } else {
+ showVideoPressUpgradeDialog();
+ }
+ }
+
+ mAddMediaPopup.dismiss();
+
+ }
+
+ });
+
+ int width = getResources().getDimensionPixelSize(R.dimen.action_bar_spinner_width);
+
+ mAddMediaPopup = new PopupWindow(layoutView, width, ViewGroup.LayoutParams.WRAP_CONTENT, true);
+ mAddMediaPopup.setBackgroundDrawable(new ColorDrawable());
+ }
+
+ private void showVideoPressUpgradeDialog() {
+ FragmentTransaction ft = getFragmentManager().beginTransaction();
+ String title = getString(R.string.media_no_video_title);
+ String message = getString(R.string.media_no_video_message);
+ String infoTitle = getString(R.string.learn_more);
+ String infoURL = Constants.videoPressURL;
+ WPAlertDialogFragment alert = WPAlertDialogFragment.newUrlInfoDialog(title, message, infoTitle, infoURL);
+ ft.add(alert, "alert");
+ ft.commitAllowingStateLoss();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ startMediaDeleteService();
+ getFeatureSet();
+ }
+
+ /** Get the feature set for a wordpress.com hosted blog **/
+ private void getFeatureSet() {
+ if (WordPress.getCurrentBlog() == null || !WordPress.getCurrentBlog().isDotcomFlag())
+ return;
+
+ ApiHelper.GetFeatures task = new ApiHelper.GetFeatures(new Callback() {
+ @Override
+ public void onResult(FeatureSet featureSet) {
+ mFeatureSet = featureSet;
+ }
+
+ });
+
+ List<Object> apiArgs = new ArrayList<Object>();
+ apiArgs.add(WordPress.getCurrentBlog());
+ task.execute(apiArgs);
+
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+
+ if (mSearchMenuItem != null)
+ mSearchMenuItem.collapseActionView();
+ }
+
+ @Override
+ public void onBlogChanged() {
+ super.onBlogChanged();
+
+ // clear edit fragment
+ if (mMediaEditFragment != null) {
+ mMediaEditFragment.loadMedia(null);
+
+ // hide if in phone
+ if (!mMediaEditFragment.isInLayout() && mMediaEditFragment.isVisible()) {
+ getFragmentManager().popBackStack();
+ }
+ }
+
+ getFragmentManager().executePendingTransactions();
+
+ // clear item fragment (only visible on phone)
+ if (mMediaItemFragment != null && mMediaItemFragment.isVisible()) {
+ getFragmentManager().popBackStack();
+ }
+
+ // reset the media fragment
+ if (mMediaGridFragment != null) {
+ mMediaGridFragment.reset();
+ mMediaGridFragment.refreshSpinnerAdapter();
+
+ if (!mMediaGridFragment.hasRetrievedAllMediaFromServer()) {
+ mMediaGridFragment.refreshMediaFromServer(0, false);
+ mMediaGridFragment.setRefreshing(true);
+ }
+ }
+
+ // check what features (e.g. video) the user has
+ getFeatureSet();
+ }
+
+ @Override
+ public void onMediaItemSelected(String mediaId) {
+ if (mSearchView != null)
+ mSearchView.clearFocus();
+
+ // collapse the search menu on phone
+ if (mSearchMenuItem != null && !Utils.isTablet())
+ mSearchMenuItem.collapseActionView();
+
+ FragmentManager fm = getFragmentManager();
+
+ if (mMediaEditFragment == null || !mMediaEditFragment.isInLayout()) {
+ // phone: hide the grid and show the item details
+ if (fm.getBackStackEntryCount() == 0) {
+ FragmentTransaction ft = fm.beginTransaction();
+ ft.hide(mMediaGridFragment);
+ mMediaGridFragment.clearCheckedItems();
+ setupBaseLayout();
+
+ mMediaItemFragment = MediaItemFragment.newInstance(mediaId);
+ ft.add(R.id.media_browser_container, mMediaItemFragment, MediaItemFragment.TAG);
+ ft.addToBackStack(null);
+ ft.commit();
+ mMenuDrawer.setDrawerIndicatorEnabled(false);
+ }
+ } else {
+ // tablet: update the edit fragment with the new item
+ mMediaEditFragment.loadMedia(mediaId);
+ }
+
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ mMenu = menu;
+ getMenuInflater().inflate(R.menu.media, menu);
+ mSearchView = (SearchView) menu.findItem(R.id.menu_search).getActionView();
+ mSearchView.setOnQueryTextListener(this);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ int itemId = item.getItemId();
+
+ if (itemId == android.R.id.home) {
+ FragmentManager fm = getFragmentManager();
+ if (fm.getBackStackEntryCount() > 0) {
+ fm.popBackStack();
+ setupBaseLayout();
+ return true;
+ }
+ } else if (itemId == R.id.menu_new_media) {
+ View view = findViewById(R.id.menu_new_media);
+ if (view != null) {
+ int y_offset = getResources().getDimensionPixelSize(R.dimen.action_bar_spinner_y_offset);
+ int[] loc = new int[2];
+ view.getLocationOnScreen(loc);
+ mAddMediaPopup.showAtLocation(view, Gravity.TOP | Gravity.LEFT, loc[0],
+ loc[1] + view.getHeight() + y_offset);
+ } else {
+ // In case menu button is not on screen (declared showAsAction="ifRoom"), center the popup in the view.
+ View gridView = findViewById(R.id.media_gridview);
+ mAddMediaPopup.showAtLocation(gridView, Gravity.CENTER, 0, 0);
+ }
+ return true;
+ } else if (itemId == R.id.menu_search) {
+ mSearchMenuItem = item;
+ mSearchMenuItem.setOnActionExpandListener(this);
+ mSearchMenuItem.expandActionView();
+
+ mSearchView = (SearchView) item.getActionView();
+ mSearchView.setOnQueryTextListener(this);
+
+ // load last saved query
+ if (!TextUtils.isEmpty(mQuery)) {
+ onQueryTextSubmit(mQuery);
+ mSearchView.setQuery(mQuery, true);
+ }
+ return true;
+ } else if (itemId == R.id.menu_edit_media) {
+ String mediaId = mMediaItemFragment.getMediaId();
+ FragmentManager fm = getFragmentManager();
+
+ if (mMediaEditFragment == null || !mMediaEditFragment.isInLayout()) {
+ // phone layout: hide item details, show and update edit fragment
+ FragmentTransaction ft = fm.beginTransaction();
+
+ if (mMediaItemFragment.isVisible())
+ ft.hide(mMediaItemFragment);
+
+ mMediaEditFragment = MediaEditFragment.newInstance(mediaId);
+ ft.add(R.id.media_browser_container, mMediaEditFragment, MediaEditFragment.TAG);
+ ft.addToBackStack(null);
+ ft.commit();
+ mMenuDrawer.setDrawerIndicatorEnabled(false);
+ } else {
+ // tablet layout: update edit fragment
+ mMediaEditFragment.loadMedia(mediaId);
+ }
+
+ if (mSearchView != null)
+ mSearchView.clearFocus();
+
+ } else if (itemId == R.id.menu_delete) {
+ if (mMediaEditFragment != null && mMediaEditFragment.isInLayout()) {
+ String mediaId = mMediaEditFragment.getMediaId();
+ launchConfirmDeleteDialog(mediaId);
+ }
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void launchConfirmDeleteDialog(final String mediaId) {
+ if (mediaId == null)
+ return;
+
+ Builder builder = new AlertDialog.Builder(this)
+ .setMessage(R.string.confirm_delete_media)
+ .setCancelable(true)
+ .setPositiveButton(R.string.delete, new OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ ArrayList<String> ids = new ArrayList<String>(1);
+ ids.add(mediaId);
+ onDeleteMedia(ids);
+ }
+ })
+ .setNegativeButton(R.string.cancel, null);
+ AlertDialog dialog = builder.create();
+ dialog.show();
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ menu.findItem(R.id.menu_delete).setVisible(Utils.isTablet());
+ return super.onPrepareOptionsMenu(menu);
+ }
+
+ @Override
+ public void onMediaItemListDownloaded() {
+ if (mMediaItemFragment != null) {
+ mMediaGridFragment.setRefreshing(false);
+ if (mMediaItemFragment.isInLayout()) {
+ mMediaItemFragment.loadDefaultMedia();
+ }
+ }
+ }
+
+ @Override
+ public void onMediaItemListDownloadStart() {
+ mMediaGridFragment.setRefreshing(true);
+ }
+
+ @Override
+ public boolean onQueryTextSubmit(String query) {
+ if (mMediaGridFragment != null) {
+ mMediaGridFragment.search(query);
+ }
+ mQuery = query;
+ mSearchView.clearFocus();
+ return true;
+ }
+
+ @Override
+ public boolean onQueryTextChange(String newText) {
+ if (mMediaGridFragment != null) {
+ mMediaGridFragment.search(newText);
+ }
+ mQuery = newText;
+ return true;
+ }
+
+ @Override
+ public void onResume(Fragment fragment) {
+ invalidateOptionsMenu();
+ }
+
+ @Override
+ public void onPause(Fragment fragment) {
+ invalidateOptionsMenu();
+ }
+
+ @Override
+ public boolean onMenuItemActionExpand(MenuItem item) {
+ // currently we don't support searching from within a filter, so hide it
+ if (mMediaGridFragment != null) {
+ mMediaGridFragment.setFilterVisibility(View.GONE);
+ mMediaGridFragment.setFilter(Filter.ALL);
+ }
+
+ // load last search query
+ if (!TextUtils.isEmpty(mQuery))
+ onQueryTextChange(mQuery);
+ mMenu.findItem(R.id.menu_new_media).setVisible(false);
+ return true;
+ }
+
+ @Override
+ public boolean onMenuItemActionCollapse(MenuItem item) {
+ // preserve the previous query
+ String tmpQuery = mQuery;
+ onQueryTextChange("");
+ mQuery = tmpQuery;
+
+ if (mMediaGridFragment != null) {
+ mMediaGridFragment.setFilterVisibility(View.VISIBLE);
+ mMediaGridFragment.setFilter(Filter.ALL);
+ }
+ mMenu.findItem(R.id.menu_new_media).setVisible(true);
+ return true;
+ }
+
+ @Override
+ public void onDeleteMedia(final List<String> ids) {
+ final String blogId = String.valueOf(WordPress.getCurrentBlog().getLocalTableBlogId());
+ List<String> sanitizedIds = new ArrayList<String>(ids.size());
+
+ if (mMediaItemFragment != null && mMediaItemFragment.isVisible()) {
+ // phone layout: pop the item fragment if it's visible
+ getFragmentManager().popBackStack();
+ }
+
+ //Make sure there are no media in "uploading"
+ for (String currentID : ids) {
+ if (MediaUtils.canDeleteMedia(blogId, currentID))
+ sanitizedIds.add(currentID);
+ }
+
+ if( sanitizedIds.size() != ids.size()) {
+ if ( ids.size() == 1 )
+ Toast.makeText(this, R.string.wait_until_upload_completes, Toast.LENGTH_LONG).show();
+ else
+ Toast.makeText(this, R.string.cannot_delete_multi_media_items, Toast.LENGTH_LONG).show();
+ }
+
+ // mark items for delete without actually deleting items yet,
+ // and then refresh the grid
+ WordPress.wpDB.setMediaFilesMarkedForDelete(blogId, sanitizedIds);
+
+ if (mMediaEditFragment != null) {
+ String mediaId = mMediaEditFragment.getMediaId();
+ for (String id : sanitizedIds) {
+ if (id.equals(mediaId)) {
+ mMediaEditFragment.loadMedia(null);
+ break;
+ }
+ }
+ }
+ mMediaGridFragment.clearCheckedItems();
+ mMediaGridFragment.refreshMediaFromDB();
+
+ startMediaDeleteService();
+ }
+
+ public void onSavedEdit(String mediaId, boolean result) {
+ if (mMediaEditFragment != null && mMediaEditFragment.isVisible() && result) {
+ FragmentManager fm = getFragmentManager();
+ fm.popBackStack();
+
+ // refresh media item details (phone-only)
+ if (mMediaItemFragment != null)
+ mMediaItemFragment.loadMedia(mediaId);
+
+ // refresh grid
+ mMediaGridFragment.refreshMediaFromDB();
+ }
+ }
+
+ private void startMediaDeleteService() {
+ startService(new Intent(this, MediaDeleteService.class));
+ }
+
+ @Override
+ public void onMultiSelectChange(int count) {
+ mMultiSelectCount = count;
+
+ if (count > 0 && mActionMode == null) {
+ mActionMode = startActionMode(this);
+ } else if (count == 0 && mActionMode != null) {
+ mActionMode.finish();
+ }
+
+ // update contextual action bar title
+ if (count > 0 && mActionMode != null)
+ mActionMode.setTitle(count + " selected");
+
+ // update contextual action bar menu items
+ if (mActionMode != null)
+ mActionMode.invalidate();
+
+ invalidateOptionsMenu();
+ }
+
+ @Override
+ public void onBackPressed() {
+ FragmentManager fm = getFragmentManager();
+ if (mMenuDrawer.isMenuVisible()) {
+ super.onBackPressed();
+ } else if (fm.getBackStackEntryCount() > 0) {
+ fm.popBackStack();
+ setupBaseLayout();
+ } else {
+ super.onBackPressed();
+ }
+ }
+
+ @Override
+ public void onMediaAdded(String mediaId) {
+ if (WordPress.getCurrentBlog() == null || mediaId == null) {
+ return;
+ }
+ String blogId = String.valueOf(WordPress.getCurrentBlog().getLocalTableBlogId());
+ Cursor cursor = WordPress.wpDB.getMediaFile(blogId, mediaId);
+
+ if (cursor == null || !cursor.moveToFirst()) {
+ mMediaGridFragment.removeFromMultiSelect(mediaId);
+ if (mMediaEditFragment != null && mMediaEditFragment.isVisible()
+ && mediaId.equals(mMediaEditFragment.getMediaId())) {
+ if (mMediaEditFragment.isInLayout()) {
+ mMediaEditFragment.loadMedia(null);
+ } else {
+ getFragmentManager().popBackStack();
+ }
+ }
+ } else {
+ mMediaGridFragment.refreshMediaFromDB();
+ }
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ @Override
+ public void onRetryUpload(String mediaId) {
+ mMediaAddFragment.addToQueue(mediaId);
+ }
+
+ @Override
+ public boolean onCreateActionMode(ActionMode mode, Menu menu) {
+ MenuInflater inflater = mode.getMenuInflater();
+ inflater.inflate(R.menu.media_multiselect, menu);
+ if (mMediaGridFragment != null) {
+ mMediaGridFragment.setPullToRefreshEnabled(false);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
+ if (mActionMode != null) {
+ if (mMultiSelectCount == 1) {
+ menu.findItem(R.id.media_multiselect_actionbar_post).setVisible(true);
+ menu.findItem(R.id.media_multiselect_actionbar_gallery).setVisible(false);
+ } else if (mMultiSelectCount > 1) {
+ menu.findItem(R.id.media_multiselect_actionbar_post).setVisible(false);
+ menu.findItem(R.id.media_multiselect_actionbar_gallery).setVisible(true);
+ } else {
+ menu.findItem(R.id.media_multiselect_actionbar_post).setVisible(false);
+ menu.findItem(R.id.media_multiselect_actionbar_gallery).setVisible(false);
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
+ int id = item.getItemId();
+ switch (id) {
+ case R.id.media_multiselect_actionbar_post:
+ handleNewPost();
+ return true;
+ case R.id.media_multiselect_actionbar_gallery:
+ handleMultiSelectPost();
+ return true;
+ case R.id.media_multiselect_actionbar_trash:
+ handleMultiSelectDelete();
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void onDestroyActionMode(ActionMode mode) {
+ mActionMode = null;
+ mMediaGridFragment.setPullToRefreshEnabled(true);
+ cancelMultiSelect();
+ }
+
+ private void cancelMultiSelect() {
+ mMediaGridFragment.clearCheckedItems();
+ }
+
+ private void handleNewPost() {
+ if (mMediaGridFragment == null)
+ return;
+
+ ArrayList<String> ids = mMediaGridFragment.getCheckedItems();
+
+ Intent i = new Intent(this, EditPostActivity.class);
+ i.setAction(EditPostContentFragment.NEW_MEDIA_POST);
+ i.putExtra(EditPostContentFragment.NEW_MEDIA_POST_EXTRA, ids.get(0));
+ startActivity(i);
+ }
+
+ private void handleMultiSelectDelete() {
+ Builder builder = new AlertDialog.Builder(this)
+ .setMessage(R.string.confirm_delete_multi_media)
+ .setCancelable(true)
+ .setPositiveButton(R.string.delete, new OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ ArrayList<String> ids = mMediaGridFragment.getCheckedItems();
+ onDeleteMedia(ids);
+ mMediaGridFragment.refreshSpinnerAdapter();
+ }
+ })
+ .setNegativeButton(R.string.cancel, null);
+ AlertDialog dialog = builder.create();
+ dialog.show();
+ }
+
+ private void handleMultiSelectPost() {
+ if (mMediaGridFragment == null)
+ return;
+
+ ArrayList<String> ids = mMediaGridFragment.getCheckedItems();
+
+ Intent i = new Intent(this, EditPostActivity.class);
+ i.setAction(EditPostContentFragment.NEW_MEDIA_GALLERY);
+ i.putExtra(EditPostContentFragment.NEW_MEDIA_GALLERY_EXTRA_IDS, ids);
+ startActivity(i);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaEditFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaEditFragment.java
new file mode 100644
index 000000000..251c7dea4
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaEditFragment.java
@@ -0,0 +1,395 @@
+package org.wordpress.android.ui.media;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+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.OnClickListener;
+import android.view.ViewGroup;
+import android.view.ViewGroup.LayoutParams;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.ScrollView;
+import android.widget.Toast;
+
+import com.android.volley.toolbox.ImageLoader;
+import com.android.volley.toolbox.NetworkImageView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.util.ImageHelper.BitmapWorkerCallback;
+import org.wordpress.android.util.ImageHelper.BitmapWorkerTask;
+import org.wordpress.android.util.MediaUtils;
+import org.xmlrpc.android.ApiHelper;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A fragment for editing media on the Media tab
+ */
+public class MediaEditFragment extends Fragment {
+ private static final String ARGS_MEDIA_ID = "media_id";
+ // also appears in the layouts, from the strings.xml
+ public static final String TAG = "MediaEditFragment";
+
+ private NetworkImageView mNetworkImageView;
+ private ImageView mLocalImageView;
+ private EditText mTitleView;
+ private EditText mCaptionView;
+ private EditText mDescriptionView;
+ private Button mSaveButton;
+
+ private MediaEditFragmentCallback mCallback;
+
+ private boolean mIsMediaUpdating = false;
+
+ private String mMediaId;
+ private ScrollView mScrollView;
+ private View mLinearLayout;
+ private ImageLoader mImageLoader;
+
+ public interface MediaEditFragmentCallback {
+ void onResume(Fragment fragment);
+ void onPause(Fragment fragment);
+ void onSavedEdit(String mediaId, boolean result);
+ }
+
+ public static MediaEditFragment newInstance(String mediaId) {
+ MediaEditFragment fragment = new MediaEditFragment();
+
+ Bundle args = new Bundle();
+ args.putString(ARGS_MEDIA_ID, mediaId);
+ fragment.setArguments(args);
+
+ return fragment;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+ mImageLoader = MediaImageLoader.getInstance();
+
+ // retain this fragment across configuration changes
+ setRetainInstance(true);
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ try {
+ mCallback = (MediaEditFragmentCallback) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString() + " must implement "
+ + MediaEditFragmentCallback.class.getSimpleName());
+ }
+ }
+
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ // set callback to null so we don't accidentally leak the activity instance
+ mCallback = null;
+ }
+
+ private boolean hasCallback() {
+ return (mCallback != null);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ if (hasCallback()) {
+ mCallback.onResume(this);
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ if (hasCallback()) {
+ mCallback.onPause(this);
+ }
+ }
+
+ public String getMediaId() {
+ if (mMediaId != null) {
+ return mMediaId;
+ } else if (getArguments() != null) {
+ mMediaId = getArguments().getString(ARGS_MEDIA_ID);
+ return mMediaId;
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ mScrollView = (ScrollView) inflater.inflate(R.layout.media_edit_fragment, container, false);
+
+ mLinearLayout = mScrollView.findViewById(R.id.media_edit_linear_layout);
+ mTitleView = (EditText) mScrollView.findViewById(R.id.media_edit_fragment_title);
+ mCaptionView = (EditText) mScrollView.findViewById(R.id.media_edit_fragment_caption);
+ mDescriptionView = (EditText) mScrollView.findViewById(R.id.media_edit_fragment_description);
+ mLocalImageView = (ImageView) mScrollView.findViewById(R.id.media_edit_fragment_image_local);
+ mNetworkImageView = (NetworkImageView) mScrollView.findViewById(R.id.media_edit_fragment_image_network);
+ mSaveButton = (Button) mScrollView.findViewById(R.id.media_edit_save_button);
+ mSaveButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ editMedia();
+ }
+ });
+
+ disableEditingOnOldVersion();
+
+ loadMedia(getMediaId());
+
+ return mScrollView;
+ }
+
+ private void disableEditingOnOldVersion() {
+ if (MediaUtils.isWordPressVersionWithMediaEditingCapabilities()) {
+ return;
+ }
+
+ mSaveButton.setEnabled(false);
+ mTitleView.setEnabled(false);
+ mCaptionView.setEnabled(false);
+ mDescriptionView.setEnabled(false);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ }
+
+ public void loadMedia(String mediaId) {
+ mMediaId = mediaId;
+ Blog blog = WordPress.getCurrentBlog();
+
+ if (blog != null && getActivity() != null) {
+ String blogId = String.valueOf(blog.getLocalTableBlogId());
+
+ if (mMediaId != null) {
+ Cursor cursor = WordPress.wpDB.getMediaFile(blogId, mMediaId);
+ refreshViews(cursor);
+ cursor.close();
+ } else {
+ refreshViews(null);
+ }
+ }
+ }
+
+ void hideKeyboard() {
+ if (getActivity() != null && getActivity().getCurrentFocus() != null) {
+ InputMethodManager inputManager = (InputMethodManager) getActivity().getSystemService(
+ Context.INPUT_METHOD_SERVICE);
+ inputManager.hideSoftInputFromWindow(getActivity().getCurrentFocus().getWindowToken(),
+ InputMethodManager.HIDE_NOT_ALWAYS);
+ }
+ }
+
+ void editMedia() {
+ hideKeyboard();
+
+ final String mediaId = this.getMediaId();
+ final String title = mTitleView.getText().toString();
+ final String description = mDescriptionView.getText().toString();
+ final Blog currentBlog = WordPress.getCurrentBlog();
+ final String caption = mCaptionView.getText().toString();
+
+ ApiHelper.EditMediaItemTask task = new ApiHelper.EditMediaItemTask(mediaId, title, description, caption,
+ new ApiHelper.GenericCallback() {
+ @Override
+ public void onSuccess() {
+ String blogId = String.valueOf(currentBlog.getLocalTableBlogId());
+ WordPress.wpDB.updateMediaFile(blogId, mediaId, title, description, caption);
+ if (getActivity() != null) {
+ Toast.makeText(getActivity(), R.string.media_edit_success, Toast.LENGTH_LONG).show();
+ }
+ setMediaUpdating(false);
+ if (hasCallback()) {
+ mCallback.onSavedEdit(mediaId, true);
+ }
+ }
+
+ @Override
+ public void onFailure(ApiHelper.ErrorType errorType, String errorMessage, Throwable throwable) {
+ if (getActivity() != null) {
+ Toast.makeText(getActivity(), R.string.media_edit_failure, Toast.LENGTH_LONG).show();
+ getActivity().invalidateOptionsMenu();
+ }
+ setMediaUpdating(false);
+ if (hasCallback()) {
+ mCallback.onSavedEdit(mediaId, false);
+ }
+ }
+ }
+ );
+
+ List<Object> apiArgs = new ArrayList<Object>();
+ apiArgs.add(currentBlog);
+
+ if (!isMediaUpdating()) {
+ setMediaUpdating(true);
+ task.execute(apiArgs);
+ }
+ }
+
+ private void setMediaUpdating(boolean isUpdating) {
+ mIsMediaUpdating = isUpdating;
+ mSaveButton.setEnabled(!isUpdating);
+
+ if (isUpdating) {
+ mSaveButton.setText(R.string.saving);
+ } else {
+ mSaveButton.setText(R.string.save);
+ }
+ }
+
+ private boolean isMediaUpdating() {
+ return mIsMediaUpdating;
+ }
+
+ private void refreshImageView(Cursor cursor, boolean isLocal) {
+ final String imageUri;
+ if (isLocal) {
+ imageUri = cursor.getString(cursor.getColumnIndex("filePath"));
+ } else {
+ imageUri = cursor.getString(cursor.getColumnIndex("fileURL"));
+ }
+ if (MediaUtils.isValidImage(imageUri)) {
+ int width = cursor.getInt(cursor.getColumnIndex("width"));
+ int height = cursor.getInt(cursor.getColumnIndex("height"));
+
+ // differentiating between tablet and phone
+ float screenWidth;
+ if (this.isInLayout()) {
+ screenWidth = mLinearLayout.getMeasuredWidth();
+ } else {
+ screenWidth = getActivity().getResources().getDisplayMetrics().widthPixels;
+ }
+ float screenHeight = getActivity().getResources().getDisplayMetrics().heightPixels;
+
+ if (width > screenWidth) {
+ height = (int) (height / (width / screenWidth));
+ } else if (height > screenHeight) {
+ width = (int) (width / (height / screenHeight));
+ }
+
+ if (isLocal) {
+ loadLocalImage(mLocalImageView, imageUri, width, height);
+ mLocalImageView.setLayoutParams(new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, height));
+ } else {
+ mNetworkImageView.setImageUrl(imageUri + "?w=" + screenWidth, mImageLoader);
+ mNetworkImageView.setLayoutParams(new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, height));
+ }
+ } else {
+ mNetworkImageView.setVisibility(View.GONE);
+ mLocalImageView.setVisibility(View.GONE);
+ }
+ }
+
+ private void refreshViews(Cursor cursor) {
+ if (cursor == null || !cursor.moveToFirst() || cursor.getCount() == 0) {
+ mLinearLayout.setVisibility(View.GONE);
+ return;
+ }
+
+ mLinearLayout.setVisibility(View.VISIBLE);
+
+ mScrollView.scrollTo(0, 0);
+
+ String state = cursor.getString(cursor.getColumnIndex("uploadState"));
+ boolean isLocal = MediaUtils.isLocalFile(state);
+ if (isLocal) {
+ mNetworkImageView.setVisibility(View.GONE);
+ mLocalImageView.setVisibility(View.VISIBLE);
+ } else {
+ mNetworkImageView.setVisibility(View.VISIBLE);
+ mLocalImageView.setVisibility(View.GONE);
+ }
+
+ // user can't edit local files
+ mSaveButton.setEnabled(!isLocal);
+ mTitleView.setEnabled(!isLocal);
+ mCaptionView.setEnabled(!isLocal);
+ mDescriptionView.setEnabled(!isLocal);
+
+ mMediaId = cursor.getString(cursor.getColumnIndex("mediaId"));
+ mTitleView.setText(cursor.getString(cursor.getColumnIndex("title")));
+ mTitleView.requestFocus();
+ mTitleView.setSelection(mTitleView.getText().length());
+ mCaptionView.setText(cursor.getString(cursor.getColumnIndex("caption")));
+ mDescriptionView.setText(cursor.getString(cursor.getColumnIndex("description")));
+
+ refreshImageView(cursor, isLocal);
+ disableEditingOnOldVersion();
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ if (!isInLayout()) {
+ inflater.inflate(R.menu.media_edit, menu);
+ }
+ }
+
+ @Override
+ public void onPrepareOptionsMenu(Menu menu) {
+ if (!isInLayout()) {
+ menu.findItem(R.id.menu_new_media).setVisible(false);
+ menu.findItem(R.id.menu_search).setVisible(false);
+
+ if (!MediaUtils.isWordPressVersionWithMediaEditingCapabilities()) {
+ menu.findItem(R.id.menu_save_media).setVisible(false);
+ }
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ int itemId = item.getItemId();
+ if (itemId == R.id.menu_save_media) {
+ item.setActionView(R.layout.progressbar);
+ editMedia();
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private synchronized void loadLocalImage(ImageView imageView, String filePath, int width, int height) {
+ if (MediaUtils.isValidImage(filePath)) {
+ imageView.setTag(filePath);
+
+ Bitmap bitmap = WordPress.getBitmapCache().get(filePath);
+ if (bitmap != null) {
+ imageView.setImageBitmap(bitmap);
+ } else {
+ BitmapWorkerTask task = new BitmapWorkerTask(imageView, width, height, new BitmapWorkerCallback() {
+ @Override
+ public void onBitmapReady(String path, ImageView imageView, Bitmap bitmap) {
+ if (imageView != null) {
+ imageView.setImageBitmap(bitmap);
+ }
+ WordPress.getBitmapCache().put(path, bitmap);
+ }
+ });
+ task.execute(filePath);
+ }
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGalleryActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGalleryActivity.java
new file mode 100644
index 000000000..e3ddfa54f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGalleryActivity.java
@@ -0,0 +1,192 @@
+package org.wordpress.android.ui.media;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.app.FragmentManager;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.Toast;
+
+import com.sothree.slidinguppanel.SlidingUpPanelLayout;
+import com.sothree.slidinguppanel.SlidingUpPanelLayout.PanelSlideListener;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.MediaGallery;
+import org.wordpress.android.ui.media.MediaGallerySettingsFragment.MediaGallerySettingsCallback;
+import org.wordpress.android.util.Utils;
+
+import java.util.ArrayList;
+
+/**
+ * An activity where the user can manage a media gallery
+ */
+public class MediaGalleryActivity extends Activity implements MediaGallerySettingsCallback {
+ public static final int REQUEST_CODE = 3000;
+
+ // params for the gallery
+ public static final String PARAMS_MEDIA_GALLERY = "PARAMS_MEDIA_GALLERY";
+
+ // launches media picker in onCreate() if set
+ public static final String PARAMS_LAUNCH_PICKER = "PARAMS_LAUNCH_PICKER";
+
+ // result of the gallery
+ public static final String RESULT_MEDIA_GALLERY = "RESULT_MEDIA_GALLERY";
+
+
+ private MediaGalleryEditFragment mMediaGalleryEditFragment;
+ private MediaGallerySettingsFragment mMediaGallerySettingsFragment;
+
+ private SlidingUpPanelLayout mSlidingPanelLayout;
+ private boolean mIsPanelCollapsed = true;
+
+ private MediaGallery mMediaGallery;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (WordPress.wpDB == null) {
+ Toast.makeText(this, R.string.fatal_db_error, Toast.LENGTH_LONG).show();
+ finish();
+ return;
+ }
+
+ setTitle(R.string.media_gallery_edit);
+
+ setContentView(R.layout.media_gallery_activity);
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayShowTitleEnabled(true);
+ }
+
+ FragmentManager fm = getFragmentManager();
+
+ mMediaGallery = (MediaGallery) getIntent().getSerializableExtra(PARAMS_MEDIA_GALLERY);
+ if (mMediaGallery == null) {
+ mMediaGallery = new MediaGallery();
+ }
+
+ mMediaGalleryEditFragment = (MediaGalleryEditFragment) fm.findFragmentById(R.id.mediaGalleryEditFragment);
+ mMediaGallerySettingsFragment = (MediaGallerySettingsFragment) fm.findFragmentById(R.id.mediaGallerySettingsFragment);
+ if (savedInstanceState == null) {
+ // if not null, the fragments will remember its state
+ mMediaGallerySettingsFragment.setRandom(mMediaGallery.isRandom());
+ mMediaGallerySettingsFragment.setNumColumns(mMediaGallery.getNumColumns());
+ mMediaGallerySettingsFragment.setType(mMediaGallery.getType());
+
+ mMediaGalleryEditFragment.setMediaIds(mMediaGallery.getIds());
+ }
+
+ mSlidingPanelLayout = (SlidingUpPanelLayout) findViewById(R.id.media_gallery_root);
+ if (mSlidingPanelLayout != null) {
+ // sliding panel layout is on phone only
+
+ mSlidingPanelLayout.setDragView(mMediaGallerySettingsFragment.getDragView());
+ mSlidingPanelLayout.setPanelHeight((int) Utils.dpToPx(48));
+ mSlidingPanelLayout.setPanelSlideListener(new PanelSlideListener() {
+ @Override
+ public void onPanelSlide(View panel, float slideOffset) {
+ }
+
+ @Override
+ public void onPanelExpanded(View panel) {
+ mMediaGallerySettingsFragment.onPanelExpanded();
+ mIsPanelCollapsed = false;
+ }
+
+ @Override
+ public void onPanelCollapsed(View panel) {
+ mMediaGallerySettingsFragment.onPanelCollapsed();
+ mIsPanelCollapsed = true;
+ }
+ });
+ }
+
+ if (getIntent().hasExtra(PARAMS_LAUNCH_PICKER)) {
+ handleAddMedia();
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ getMenuInflater().inflate(R.menu.media_gallery, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == R.id.menu_add_media) {
+ handleAddMedia();
+ return true;
+ } else if (item.getItemId() == R.id.menu_save) {
+ handleSaveMedia();
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ if (requestCode == MediaGalleryPickerActivity.REQUEST_CODE) {
+ if (resultCode == RESULT_OK) {
+ ArrayList<String> ids = data.getStringArrayListExtra(MediaGalleryPickerActivity.RESULT_IDS);
+ mMediaGalleryEditFragment.setMediaIds(ids);
+ }
+ }
+ super.onActivityResult(requestCode, resultCode, data);
+ }
+
+ private void handleAddMedia() {
+ // need to make MediaGalleryAdd into an activity rather than a fragment because I can't add this fragment
+ // on top of the slidingpanel layout (since it needs to be the root layout)
+
+ ArrayList<String> mediaIds = mMediaGalleryEditFragment.getMediaIds();
+
+ Intent intent = new Intent(this, MediaGalleryPickerActivity.class);
+ intent.putExtra(MediaGalleryPickerActivity.PARAM_SELECTED_IDS, mediaIds);
+ startActivityForResult(intent, MediaGalleryPickerActivity.REQUEST_CODE);
+
+ }
+
+ private void handleSaveMedia() {
+ Intent intent = new Intent();
+ ArrayList<String> ids = mMediaGalleryEditFragment.getMediaIds();
+ boolean isRandom = mMediaGallerySettingsFragment.isRandom();
+ int numColumns = mMediaGallerySettingsFragment.getNumColumns();
+ String type = mMediaGallerySettingsFragment.getType();
+
+ mMediaGallery.setIds(ids);
+ mMediaGallery.setRandom(isRandom);
+ mMediaGallery.setNumColumns(numColumns);
+ mMediaGallery.setType(type);
+
+ intent.putExtra(RESULT_MEDIA_GALLERY, mMediaGallery);
+ setResult(RESULT_OK, intent);
+ finish();
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (Utils.isTablet()) {
+ super.onBackPressed();
+ } else {
+ if (mSlidingPanelLayout != null && !mIsPanelCollapsed)
+ mSlidingPanelLayout.collapsePane();
+ else
+ super.onBackPressed();
+ }
+
+ }
+
+ @Override
+ public void onReverseClicked() {
+ mMediaGalleryEditFragment.reverseIds();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGalleryAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGalleryAdapter.java
new file mode 100644
index 000000000..31046face
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGalleryAdapter.java
@@ -0,0 +1,141 @@
+package org.wordpress.android.ui.media;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.android.volley.toolbox.ImageLoader;
+import com.android.volley.toolbox.NetworkImageView;
+import com.mobeta.android.dslv.ResourceDragSortCursorAdapter;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.MediaUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.Utils;
+
+/**
+ * Adapter for a drag-sort listview where the user can drag media items to sort their order
+ * for a media gallery
+ */
+class MediaGalleryAdapter extends ResourceDragSortCursorAdapter {
+ private ImageLoader mImageLoader;
+
+ public MediaGalleryAdapter(Context context, int layout, Cursor c, boolean autoRequery, ImageLoader imageLoader) {
+ super(context, layout, c, autoRequery);
+ setImageLoader(imageLoader);
+ }
+
+ void setImageLoader(ImageLoader imageLoader) {
+ if (imageLoader != null) {
+ mImageLoader = imageLoader;
+ } else {
+ mImageLoader = WordPress.imageLoader;
+ }
+ }
+
+ private static class GridViewHolder {
+ private final TextView filenameView;
+ private final TextView titleView;
+ private final TextView uploadDateView;
+ private final ImageView imageView;
+ private final TextView fileTypeView;
+ private final TextView dimensionView;
+
+ GridViewHolder(View view) {
+ filenameView = (TextView) view.findViewById(R.id.media_grid_item_filename);
+ titleView = (TextView) view.findViewById(R.id.media_grid_item_name);
+ uploadDateView = (TextView) view.findViewById(R.id.media_grid_item_upload_date);
+ imageView = (ImageView) view.findViewById(R.id.media_grid_item_image);
+ fileTypeView = (TextView) view.findViewById(R.id.media_grid_item_filetype);
+ dimensionView = (TextView) view.findViewById(R.id.media_grid_item_dimension);
+ }
+ }
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ final GridViewHolder holder;
+ if (view.getTag() instanceof GridViewHolder) {
+ holder = (GridViewHolder) view.getTag();
+ } else {
+ holder = new GridViewHolder(view);
+ view.setTag(holder);
+ }
+
+ String state = cursor.getString(cursor.getColumnIndex("uploadState"));
+ boolean isLocalFile = MediaUtils.isLocalFile(state);
+
+ // file name
+ String fileName = cursor.getString(cursor.getColumnIndex("fileName"));
+ if (holder.filenameView != null) {
+ holder.filenameView.setText("File name: " + fileName);
+ }
+
+ // title of media
+ String title = cursor.getString(cursor.getColumnIndex("title"));
+ if (title == null || title.equals(""))
+ title = fileName;
+ holder.titleView.setText(title);
+
+ // upload date
+ if (holder.uploadDateView != null) {
+ String date = MediaUtils.getDate(cursor.getLong(cursor.getColumnIndex("date_created_gmt")));
+ holder.uploadDateView.setText("Uploaded on: " + date);
+ }
+
+ // load image
+ if (isLocalFile) {
+ // should not be local file
+ } else {
+ loadNetworkImage(cursor, (NetworkImageView) holder.imageView);
+ }
+
+ // get the file extension from the fileURL
+ String filePath = StringUtils.notNullStr(cursor.getString(cursor.getColumnIndex("filePath")));
+ if (filePath.isEmpty())
+ filePath = StringUtils.notNullStr(cursor.getString(cursor.getColumnIndex("fileURL")));
+
+ // file type
+ String fileType = filePath.replaceAll(".*\\.(\\w+)$", "$1").toUpperCase();
+ if (Utils.isXLarge(context)) {
+ holder.fileTypeView.setText("File type: " + fileType);
+ } else {
+ holder.fileTypeView.setText(fileType);
+ }
+
+ // dimensions
+ if (holder.dimensionView != null) {
+ if( MediaUtils.isValidImage(filePath)) {
+ int width = cursor.getInt(cursor.getColumnIndex("width"));
+ int height = cursor.getInt(cursor.getColumnIndex("height"));
+
+ if (width > 0 && height > 0) {
+ String dimensions = width + "x" + height;
+ holder.dimensionView.setText("Dimensions: " + dimensions);
+ holder.dimensionView.setVisibility(View.VISIBLE);
+ }
+ } else {
+ holder.dimensionView.setVisibility(View.GONE);
+ }
+ }
+
+ }
+
+ private void loadNetworkImage(Cursor cursor, NetworkImageView imageView) {
+ String thumbnailURL = cursor.getString(cursor.getColumnIndex("thumbnailURL"));
+ if (thumbnailURL == null) {
+ imageView.setImageUrl(null, null);
+ return;
+ }
+
+ Uri uri = Uri.parse(thumbnailURL);
+ if (uri != null && MediaUtils.isValidImage(uri.getLastPathSegment())) {
+ imageView.setTag(thumbnailURL);
+ imageView.setImageUrl(thumbnailURL, mImageLoader);
+ } else {
+ imageView.setImageUrl(null, null);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGalleryEditFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGalleryEditFragment.java
new file mode 100644
index 000000000..fa32a5781
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGalleryEditFragment.java
@@ -0,0 +1,193 @@
+package org.wordpress.android.ui.media;
+
+import android.app.Fragment;
+import android.database.Cursor;
+import android.database.CursorWrapper;
+import android.os.Bundle;
+import android.util.SparseIntArray;
+import android.view.ContextMenu;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+
+import com.mobeta.android.dslv.DragSortListView;
+import com.mobeta.android.dslv.DragSortListView.DropListener;
+import com.mobeta.android.dslv.DragSortListView.RemoveListener;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+
+import java.util.ArrayList;
+import java.util.Collections;
+
+/**
+ * Fragment where containing a drag-sort listview where the user can drag items
+ * to change their position in a media gallery
+ *
+ */
+public class MediaGalleryEditFragment extends Fragment implements DropListener, RemoveListener {
+ private static final String SAVED_MEDIA_IDS = "SAVED_MEDIA_IDS";
+ private MediaGalleryAdapter mGridAdapter;
+ private ArrayList<String> mIds;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+
+ mIds = new ArrayList<String>();
+ if (savedInstanceState != null)
+ mIds = savedInstanceState.getStringArrayList(SAVED_MEDIA_IDS);
+
+ mGridAdapter = new MediaGalleryAdapter(getActivity(), R.layout.media_gallery_item, null, true, MediaImageLoader.getInstance());
+
+ View view = inflater.inflate(R.layout.media_gallery_edit_fragment, container, false);
+
+ DragSortListView gridView = (DragSortListView) view.findViewById(R.id.edit_media_gallery_gridview);
+ gridView.setAdapter(mGridAdapter);
+ gridView.setOnCreateContextMenuListener(this);
+ gridView.setDropListener(this);
+ gridView.setRemoveListener(this);
+ refreshGridView();
+
+ return view;
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putStringArrayList(SAVED_MEDIA_IDS, mIds);
+ }
+
+
+ private void refreshGridView() {
+ if (WordPress.getCurrentBlog() == null)
+ return;
+
+ String blogId = String.valueOf(WordPress.getCurrentBlog().getLocalTableBlogId());
+
+ Cursor cursor = WordPress.wpDB.getMediaFiles(blogId, mIds);
+
+ if (cursor == null) {
+ mGridAdapter.changeCursor(null);
+ return;
+ }
+
+ SparseIntArray positions = mapIdsToCursorPositions(cursor);
+ mGridAdapter.swapCursor(new OrderedCursor(cursor, positions));
+ }
+
+ private SparseIntArray mapIdsToCursorPositions(Cursor cursor) {
+ SparseIntArray positions = new SparseIntArray();
+ int size = mIds.size();
+ for (int i = 0; i < size; i++) {
+ while (cursor.moveToNext()) {
+ String mediaId = cursor.getString(cursor.getColumnIndex("mediaId"));
+ if (mediaId.equals(mIds.get(i))) {
+ positions.put(i, cursor.getPosition());
+ cursor.moveToPosition(-1);
+ break;
+ }
+ }
+ }
+ return positions;
+ }
+
+ public void setMediaIds(ArrayList<String> ids) {
+ mIds = ids;
+ refreshGridView();
+ }
+
+ public ArrayList<String> getMediaIds() {
+ return mIds;
+ }
+
+ public void reverseIds() {
+ Collections.reverse(mIds);
+ refreshGridView();
+ }
+
+
+ private class OrderedCursor extends CursorWrapper {
+ final int mPos;
+ private final int mCount;
+
+ // a map of custom position to cursor position
+ private final SparseIntArray mPositions;
+
+ /** A wrapper to allow for a custom order of items in a cursor **/
+ public OrderedCursor(Cursor cursor, SparseIntArray positions) {
+ super(cursor);
+ cursor.moveToPosition(-1);
+ mPos = 0;
+ mCount = cursor.getCount();
+ mPositions = positions;
+ }
+
+ @Override
+ public boolean move(int offset) {
+ return this.moveToPosition(this.mPos+offset);
+ }
+
+ @Override
+ public boolean moveToNext() {
+ return this.moveToPosition(this.mPos+1);
+ }
+
+ @Override
+ public boolean moveToPrevious() {
+ return this.moveToPosition(this.mPos-1);
+ }
+
+ @Override
+ public boolean moveToFirst() {
+ return this.moveToPosition(0);
+ }
+
+ @Override
+ public boolean moveToLast() {
+ return this.moveToPosition(this.mCount-1);
+ }
+
+ @Override
+ public boolean moveToPosition(int position) {
+ return super.moveToPosition(mPositions.get(position));
+ }
+
+
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
+ AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo;
+ Cursor cursor = mGridAdapter.getCursor();
+ if (cursor == null)
+ return;
+ cursor.moveToPosition(info.position);
+ String mediaId = cursor.getString(cursor.getColumnIndex("mediaId"));
+
+ menu.add(ContextMenu.NONE, mIds.indexOf(mediaId), ContextMenu.NONE, R.string.delete);
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ int index = item.getItemId();
+ mIds.remove(index);
+ refreshGridView();
+ return true;
+ }
+
+ @Override
+ public void drop(int from, int to) {
+ String id = mIds.get(from);
+ mIds.remove(id);
+ mIds.add(to, id);
+ refreshGridView();
+ }
+
+ @Override
+ public void remove(int position) {
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGalleryPickerActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGalleryPickerActivity.java
new file mode 100644
index 000000000..2914861b9
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGalleryPickerActivity.java
@@ -0,0 +1,270 @@
+package org.wordpress.android.ui.media;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.content.Intent;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.os.Handler;
+import android.view.ActionMode;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.Toast;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.ui.MultiSelectGridView;
+import org.wordpress.android.ui.MultiSelectGridView.MultiSelectListener;
+import org.wordpress.android.util.ToastUtils;
+import org.xmlrpc.android.ApiHelper;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * An activity where the user can add new images to their media gallery or where the user
+ * can choose a single image to embed into their post.
+ */
+public class MediaGalleryPickerActivity extends Activity
+ implements MultiSelectListener, ActionMode.Callback, MediaGridAdapter.MediaGridAdapterCallback,
+ AdapterView.OnItemClickListener {
+ private MultiSelectGridView mGridView;
+ private MediaGridAdapter mGridAdapter;
+ private ActionMode mActionMode;
+
+ private ArrayList<String> mFilteredItems;
+ private boolean mIsSelectOneItem;
+ private boolean mIsRefreshing;
+ private boolean mHasRetrievedAllMedia;
+
+ private static final String STATE_FILTERED_ITEMS = "STATE_FILTERED_ITEMS";
+ private static final String STATE_SELECTED_ITEMS = "STATE_SELECTED_ITEMS";
+ private static final String STATE_IS_SELECT_ONE_ITEM = "STATE_IS_SELECT_ONE_ITEM";
+
+ public static final int REQUEST_CODE = 4000;
+ public static final String PARAM_SELECT_ONE_ITEM = "PARAM_SELECT_ONE_ITEM";
+ private static final String PARAM_FILTERED_IDS = "PARAM_FILTERED_IDS";
+ public static final String PARAM_SELECTED_IDS = "PARAM_SELECTED_IDS";
+ public static final String RESULT_IDS = "RESULT_IDS";
+ public static final String TAG = MediaGalleryPickerActivity.class.getSimpleName();
+
+ private int mOldMediaSyncOffset = 0;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ ArrayList<String> checkedItems = new ArrayList<String>();
+ mFilteredItems = getIntent().getStringArrayListExtra(PARAM_FILTERED_IDS);
+ mIsSelectOneItem = getIntent().getBooleanExtra(PARAM_SELECT_ONE_ITEM, false);
+
+ ArrayList<String> prevSelectedItems = getIntent().getStringArrayListExtra(PARAM_SELECTED_IDS);
+ if( prevSelectedItems != null )
+ checkedItems.addAll(prevSelectedItems);
+
+ if (savedInstanceState != null) {
+ checkedItems.addAll(savedInstanceState.getStringArrayList(STATE_SELECTED_ITEMS));
+ mFilteredItems = savedInstanceState.getStringArrayList(STATE_FILTERED_ITEMS);
+ mIsSelectOneItem = savedInstanceState.getBoolean(STATE_IS_SELECT_ONE_ITEM, mIsSelectOneItem);
+ }
+
+ setContentView(R.layout.media_gallery_picker_layout);
+ mGridView = (MultiSelectGridView) findViewById(R.id.media_gallery_picker_gridview);
+ mGridView.setMultiSelectListener(this);
+ if (mIsSelectOneItem) {
+ mGridView.setOnItemClickListener(this);
+ setTitle(R.string.select_from_media_library);
+ mGridView.setHighlightSelectModeEnabled(false);
+ mGridView.setMultiSelectModeEnabled(false);
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+ } else {
+ mActionMode = startActionMode(this);
+ mActionMode.setTitle(checkedItems.size() + " selected");
+ mGridView.setMultiSelectModeActive(true);
+ }
+ mGridAdapter = new MediaGridAdapter(this, null, 0, checkedItems, MediaImageLoader.getInstance());
+ mGridAdapter.setCallback(this);
+ mGridView.setAdapter(mGridAdapter);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ refreshViews();
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putStringArrayList(STATE_SELECTED_ITEMS, mGridAdapter.getCheckedItems());
+ outState.putStringArrayList(STATE_FILTERED_ITEMS, mFilteredItems);
+ outState.putBoolean(STATE_IS_SELECT_ONE_ITEM, mIsSelectOneItem);
+ }
+
+ private void refreshViews() {
+ if (WordPress.getCurrentBlog() == null)
+ return;
+ final String blogId = String.valueOf(WordPress.getCurrentBlog().getLocalTableBlogId());
+ Cursor cursor = WordPress.wpDB.getMediaImagesForBlog(blogId, mFilteredItems);
+ if (cursor.getCount() == 0) {
+ refreshMediaFromServer(0);
+ } else {
+ mGridAdapter.swapCursor(cursor);
+ }
+ }
+
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ setResult(RESULT_CANCELED, new Intent());
+ finish();
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ public void onMultiSelectChange(int count) {
+ mActionMode.setTitle(count + " selected");
+ // stay always in multi-select mode, even when count reaches 0
+ if (count == 0 && !mIsSelectOneItem)
+ mGridView.setMultiSelectModeActive(true);
+ }
+
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ // Single select, just finish the activity once an item is selected
+ Intent intent = new Intent();
+ intent.putExtra(RESULT_IDS, mGridAdapter.getCheckedItems());
+ setResult(RESULT_OK, intent);
+ finish();
+ }
+
+ @Override
+ public boolean onCreateActionMode(ActionMode mode, Menu menu) {
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
+ return false;
+ }
+
+ @Override
+ public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
+ return false;
+ }
+
+ @Override
+ public void onDestroyActionMode(ActionMode mode) {
+ Intent intent = new Intent();
+ intent.putExtra(RESULT_IDS, mGridAdapter.getCheckedItems());
+ setResult(RESULT_OK, intent);
+ finish();
+ }
+
+ @Override
+ public void fetchMoreData(int offset) {
+ if (!mHasRetrievedAllMedia)
+ refreshMediaFromServer(offset);
+ }
+
+ @Override
+ public void onRetryUpload(String mediaId) {
+ }
+
+ @Override
+ public boolean isInMultiSelect() {
+ return false;
+ }
+
+ private void noMediaFinish() {
+ ToastUtils.showToast(this, R.string.media_empty_list, ToastUtils.Duration.LONG);
+ // Delay activity finish
+ new Handler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ finish();
+ }
+ }, 1500);
+ }
+
+ void refreshMediaFromServer(int offset) {
+ if (offset == 0 || !mIsRefreshing) {
+ if (offset == mOldMediaSyncOffset) {
+ // we're pulling the same data again for some reason. Pull from the beginning.
+ offset = 0;
+ }
+ mOldMediaSyncOffset = offset;
+ mIsRefreshing = true;
+ mGridAdapter.setRefreshing(true);
+
+ List<Object> apiArgs = new ArrayList<Object>();
+ apiArgs.add(WordPress.getCurrentBlog());
+
+ ApiHelper.SyncMediaLibraryTask.Callback callback = new ApiHelper.SyncMediaLibraryTask.Callback() {
+ // refersh db from server. If returned count is 0, we've retrieved all the media.
+ // stop retrieving until the user manually refreshes
+
+ @Override
+ public void onSuccess(int count) {
+ MediaGridAdapter adapter = (MediaGridAdapter) mGridView.getAdapter();
+ mHasRetrievedAllMedia = (count == 0);
+ adapter.setHasRetrievedAll(mHasRetrievedAllMedia);
+ String blogId = String.valueOf(WordPress.getCurrentBlog().getLocalTableBlogId());
+ if (WordPress.wpDB.getMediaCountAll(blogId) == 0 && count == 0) {
+ // There is no media at all
+ noMediaFinish();
+ }
+ mIsRefreshing = false;
+
+ // the activity may be gone by the time this finishes, so check for it
+ if (!isFinishing()) {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ //mListener.onMediaItemListDownloaded();
+ mGridAdapter.setRefreshing(false);
+ String blogId = String.valueOf(WordPress.getCurrentBlog().getLocalTableBlogId());
+ Cursor cursor = WordPress.wpDB.getMediaImagesForBlog(blogId, mFilteredItems);
+ mGridAdapter.swapCursor(cursor);
+
+ }
+ });
+ }
+ }
+
+ @Override
+ public void onFailure(ApiHelper.ErrorType errorType, String errorMessage, Throwable throwable) {
+ if (errorType != ApiHelper.ErrorType.NO_ERROR) {
+ String message = errorType == ApiHelper.ErrorType.NO_UPLOAD_FILES_CAP
+ ? getString(R.string.media_error_no_permission)
+ : getString(R.string.error_refresh_media);
+ Toast.makeText(MediaGalleryPickerActivity.this, message, Toast.LENGTH_SHORT).show();
+ MediaGridAdapter adapter = (MediaGridAdapter) mGridView.getAdapter();
+ mHasRetrievedAllMedia = true;
+ adapter.setHasRetrievedAll(mHasRetrievedAllMedia);
+ }
+
+ // the activity may be cone by the time we get this, so check for it
+ if (!isFinishing()) {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mIsRefreshing = false;
+ mGridAdapter.setRefreshing(false);
+ }
+ });
+ }
+
+ }
+ };
+
+ ApiHelper.SyncMediaLibraryTask getMediaTask = new ApiHelper.SyncMediaLibraryTask(offset, MediaGridFragment.Filter.ALL, callback);
+ getMediaTask.execute(apiArgs);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGallerySettingsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGallerySettingsFragment.java
new file mode 100644
index 000000000..3f7a5304c
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGallerySettingsFragment.java
@@ -0,0 +1,380 @@
+package org.wordpress.android.ui.media;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.os.Bundle;
+import android.util.SparseBooleanArray;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.CompoundButton.OnCheckedChangeListener;
+import android.widget.ScrollView;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.ui.ExpandableHeightGridView;
+import org.wordpress.android.util.Utils;
+
+import java.util.ArrayList;
+
+/**
+ * The fragment containing the settings for the media gallery
+ */
+public class MediaGallerySettingsFragment extends Fragment implements OnCheckedChangeListener {
+ private static final int DEFAULT_THUMBNAIL_COLUMN_COUNT = 3;
+
+ private static final String STATE_NUM_COLUMNS = "STATE_NUM_COLUMNS";
+ private static final String STATE_GALLERY_TYPE_ORD = "GALLERY_TYPE_ORD";
+ private static final String STATE_RANDOM_ORDER = "STATE_RANDOM_ORDER";
+
+ private CheckBox mThumbnailCheckbox;
+ private CheckBox mSquaresCheckbox;
+ private CheckBox mTiledCheckbox;
+ private CheckBox mCirclesCheckbox;
+ private CheckBox mSlideshowCheckbox;
+
+ private GalleryType mType;
+ private int mNumColumns;
+ private boolean mIsRandomOrder;
+
+ private View mNumColumnsContainer;
+ private View mHeader;
+
+ private CheckBox mRandomOrderCheckbox;
+
+ private boolean mAllowCheckChanged;
+
+ private TextView mTitleView;
+
+ private ScrollView mScrollView;
+
+ private CustomGridAdapter mGridAdapter;
+
+ private MediaGallerySettingsCallback mCallback;
+
+
+ private enum GalleryType {
+ DEFAULT(""),
+ TILED("rectangular"),
+ SQUARES("square"),
+ CIRCLES("circle"),
+ SLIDESHOW("slideshow");
+
+ private final String mTag;
+
+ private GalleryType(String tag) {
+ mTag = tag;
+ }
+
+ public String getTag() {
+ return mTag;
+ }
+
+ public static GalleryType getTypeFromTag(String tag) {
+ if (tag.equals("rectangular"))
+ return TILED;
+ else if (tag.equals("square"))
+ return SQUARES;
+ else if (tag.equals("circle"))
+ return CIRCLES;
+ else if (tag.equals("slideshow"))
+ return SLIDESHOW;
+ else
+ return DEFAULT;
+ }
+
+ }
+
+ public interface MediaGallerySettingsCallback {
+ public void onReverseClicked();
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ try {
+ mCallback = (MediaGallerySettingsCallback) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString() + " must implement MediaGallerySettingsCallback");
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ mAllowCheckChanged = true;
+ mType = GalleryType.DEFAULT;
+ mNumColumns = DEFAULT_THUMBNAIL_COLUMN_COUNT;
+ mIsRandomOrder = false;
+
+ restoreState(savedInstanceState);
+
+ View view = inflater.inflate(R.layout.media_gallery_settings_fragment, container, false);
+
+ mHeader = view.findViewById(R.id.media_gallery_settings_header);
+ mScrollView = (ScrollView) view.findViewById(R.id.media_gallery_settings_content_container);
+ mTitleView = (TextView) view.findViewById(R.id.media_gallery_settings_title);
+
+ if (!Utils.isTablet()) // show the arrow initially as collapsed when on phone
+ onPanelCollapsed();
+
+ mNumColumnsContainer = view.findViewById(R.id.media_gallery_settings_num_columns_container);
+ int visible = (mType == GalleryType.DEFAULT) ? View.VISIBLE : View.GONE;
+ mNumColumnsContainer.setVisibility(visible);
+
+ ExpandableHeightGridView numColumnsGrid = (ExpandableHeightGridView) view.findViewById(R.id.media_gallery_num_columns_grid);
+ numColumnsGrid.setExpanded(true);
+ ArrayList<String> list = new ArrayList<String>(9);
+ for (int i = 1; i <= 9; i++) {
+ list.add(i + "");
+ }
+
+ mGridAdapter = new CustomGridAdapter(mNumColumns);
+ numColumnsGrid.setAdapter(mGridAdapter);
+
+ mThumbnailCheckbox = (CheckBox) view.findViewById(R.id.media_gallery_type_thumbnail_grid);
+ mTiledCheckbox = (CheckBox) view.findViewById(R.id.media_gallery_type_tiled);
+ mSquaresCheckbox = (CheckBox) view.findViewById(R.id.media_gallery_type_squares);
+ mCirclesCheckbox = (CheckBox) view.findViewById(R.id.media_gallery_type_circles);
+ mSlideshowCheckbox = (CheckBox) view.findViewById(R.id.media_gallery_type_slideshow);
+
+ setType(mType.getTag());
+
+ mThumbnailCheckbox.setOnCheckedChangeListener(this);
+ mTiledCheckbox.setOnCheckedChangeListener(this);
+ mSquaresCheckbox.setOnCheckedChangeListener(this);
+ mCirclesCheckbox.setOnCheckedChangeListener(this);
+
+ mSlideshowCheckbox.setOnCheckedChangeListener(this);
+
+ mRandomOrderCheckbox = (CheckBox) view.findViewById(R.id.media_gallery_random_checkbox);
+ mRandomOrderCheckbox.setChecked(mIsRandomOrder);
+ mRandomOrderCheckbox.setOnCheckedChangeListener(this);
+
+ Button reverseButton = (Button) view.findViewById(R.id.media_gallery_settings_reverse_button);
+ reverseButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ mCallback.onReverseClicked();
+ }
+ });
+
+ return view;
+ }
+
+ private void restoreState(Bundle savedInstanceState) {
+ if (savedInstanceState == null)
+ return;
+
+ mNumColumns = savedInstanceState.getInt(STATE_NUM_COLUMNS);
+ int galleryTypeOrdinal = savedInstanceState.getInt(STATE_GALLERY_TYPE_ORD);
+ mType = GalleryType.values()[galleryTypeOrdinal];
+ mIsRandomOrder = savedInstanceState.getBoolean(STATE_RANDOM_ORDER);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putInt(STATE_NUM_COLUMNS, mNumColumns);
+ outState.putBoolean(STATE_RANDOM_ORDER, mIsRandomOrder);
+ outState.putInt(STATE_GALLERY_TYPE_ORD, mType.ordinal());
+ }
+
+ @Override
+ public void onCheckedChanged(CompoundButton button, boolean checked) {
+ if (!mAllowCheckChanged)
+ return;
+
+ // the checkboxes for types are mutually exclusive, so when one is set,
+ // the others must be unset. Disable the checkChange listener during this time,
+ // and re-enable it once done.
+ mAllowCheckChanged = false;
+
+ int numColumnsContainerVisible = View.GONE;
+ switch (button.getId()) {
+ case R.id.media_gallery_type_thumbnail_grid:
+ numColumnsContainerVisible = View.VISIBLE;
+
+ mType = GalleryType.DEFAULT;
+ mThumbnailCheckbox.setChecked(true);
+ mSquaresCheckbox.setChecked(false);
+ mTiledCheckbox.setChecked(false);
+ mCirclesCheckbox.setChecked(false);
+ mSlideshowCheckbox.setChecked(false);
+ break;
+ case R.id.media_gallery_type_tiled:
+ mType = GalleryType.TILED;
+ mThumbnailCheckbox.setChecked(false);
+ mTiledCheckbox.setChecked(true);
+ mSquaresCheckbox.setChecked(false);
+ mCirclesCheckbox.setChecked(false);
+ mSlideshowCheckbox.setChecked(false);
+ break;
+ case R.id.media_gallery_type_squares:
+ mType = GalleryType.SQUARES;
+ mThumbnailCheckbox.setChecked(false);
+ mTiledCheckbox.setChecked(false);
+ mSquaresCheckbox.setChecked(true);
+ mCirclesCheckbox.setChecked(false);
+ mSlideshowCheckbox.setChecked(false);
+ break;
+ case R.id.media_gallery_type_circles:
+ mType = GalleryType.CIRCLES;
+ mThumbnailCheckbox.setChecked(false);
+ mSquaresCheckbox.setChecked(false);
+ mTiledCheckbox.setChecked(false);
+ mCirclesCheckbox.setChecked(true);
+ mSlideshowCheckbox.setChecked(false);
+ break;
+ case R.id.media_gallery_type_slideshow:
+ mType = GalleryType.SLIDESHOW;
+ mThumbnailCheckbox.setChecked(false);
+ mSquaresCheckbox.setChecked(false);
+ mTiledCheckbox.setChecked(false);
+ mCirclesCheckbox.setChecked(false);
+ mSlideshowCheckbox.setChecked(true);
+ break;
+ case R.id.media_gallery_random_checkbox:
+ numColumnsContainerVisible = mNumColumnsContainer.getVisibility();
+ mIsRandomOrder = checked;
+ break;
+ }
+
+ mNumColumnsContainer.setVisibility(numColumnsContainerVisible);
+
+ mAllowCheckChanged = true;
+ }
+
+ private class CustomGridAdapter extends BaseAdapter implements OnCheckedChangeListener {
+ private boolean mAllowCheckChanged;
+ private final SparseBooleanArray mCheckedPositions;
+
+ public CustomGridAdapter(int selection) {
+ mAllowCheckChanged = true;
+ mCheckedPositions = new SparseBooleanArray(9);
+ setSelection(selection);
+ }
+
+ // when a number of columns is checked, the numbers less than
+ // the one chose are also set to checked on the ui.
+ // e.g. when 3 is checked, 1 and 2 are as well.
+ private void setSelection(int selection) {
+ for (int i = 0; i < 9; i++){
+ if (i + 1 <= selection)
+ mCheckedPositions.put(i, true);
+ else
+ mCheckedPositions.put(i, false);
+ }
+ }
+
+ @Override
+ public int getCount() {
+ return 9;
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return position;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+ CheckBox checkbox = (CheckBox) inflater.inflate(R.layout.media_gallery_column_checkbox, parent, false);
+ checkbox.setChecked(mCheckedPositions.get(position));
+ checkbox.setTag(position);
+ checkbox.setText(String.valueOf(position + 1));
+ checkbox.setOnCheckedChangeListener(this);
+
+ return checkbox;
+ }
+
+ @Override
+ public void onCheckedChanged(CompoundButton button, boolean checked) {
+ if (mAllowCheckChanged) {
+ mAllowCheckChanged = false;
+
+ int position = (Integer) button.getTag();
+ mNumColumns = position + 1;
+
+ int count = mCheckedPositions.size();
+ for (int i = 0; i < count; i++) {
+ if (i <= position)
+ mCheckedPositions.put(i, true);
+ else
+ mCheckedPositions.put(i, false);
+ }
+ notifyDataSetChanged();
+ mAllowCheckChanged = true;
+ }
+ }
+
+ }
+
+ public View getDragView() {
+ return mHeader;
+ }
+
+ public void onPanelExpanded() {
+ mTitleView.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.gallery_arrow_dropdown_open, 0);
+ mScrollView.scrollTo(0, 0);
+ }
+
+ public void onPanelCollapsed() {
+ mTitleView.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.gallery_arrow_dropdown_closed, 0);
+ }
+
+ public void setRandom(boolean random) {
+ mIsRandomOrder = random;
+ mRandomOrderCheckbox.setChecked(mIsRandomOrder);
+ }
+
+ public boolean isRandom() {
+ return mIsRandomOrder;
+ }
+
+ public void setType(String type) {
+ mType = GalleryType.getTypeFromTag(type);
+ switch (mType) {
+ case CIRCLES:
+ mCirclesCheckbox.setChecked(true);
+ break;
+ case DEFAULT:
+ mThumbnailCheckbox.setChecked(true);
+ break;
+ case SLIDESHOW:
+ mSlideshowCheckbox.setChecked(true);
+ break;
+ case SQUARES:
+ mSquaresCheckbox.setChecked(true);
+ break;
+ case TILED:
+ mTiledCheckbox.setChecked(true);
+ break;
+ }
+ }
+
+ public String getType() {
+ return mType.getTag();
+ }
+
+ public void setNumColumns(int numColumns) {
+ mNumColumns = numColumns;
+ mGridAdapter.setSelection(numColumns);
+ mGridAdapter.notifyDataSetChanged();
+ }
+
+ public int getNumColumns() {
+ return mNumColumns;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGridAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGridAdapter.java
new file mode 100644
index 000000000..f35102aae
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGridAdapter.java
@@ -0,0 +1,531 @@
+package org.wordpress.android.ui.media;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.database.MergeCursor;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.Handler;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.view.ViewStub;
+import android.widget.CursorAdapter;
+import android.widget.GridView;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import com.android.volley.toolbox.ImageLoader;
+import com.android.volley.toolbox.NetworkImageView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.ui.CheckableFrameLayout;
+import org.wordpress.android.ui.CheckableFrameLayout.OnCheckedChangeListener;
+import org.wordpress.android.util.ImageHelper.BitmapWorkerCallback;
+import org.wordpress.android.util.ImageHelper.BitmapWorkerTask;
+import org.wordpress.android.util.MediaUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.Utils;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * An adapter for the media gallery listViews.
+ */
+public class MediaGridAdapter extends CursorAdapter {
+ private MediaGridAdapterCallback mCallback;
+ private final ArrayList<String> mCheckedItems;
+ private boolean mHasRetrievedAll;
+ private boolean mIsRefreshing;
+ private int mCursorDataCount;
+ private int mGridItemWidth;
+ private final Map<String, List<BitmapReadyCallback>> mFilePathToCallbackMap;
+ private final Handler mHandler;
+ private final int mLocalImageWidth;
+ private final LayoutInflater mInflater;
+ private boolean mIsCurrentBlogPhotonCapable;
+ private ImageLoader mImageLoader;
+ private Context mContext;
+
+ public interface MediaGridAdapterCallback {
+ public void fetchMoreData(int offset);
+ public void onRetryUpload(String mediaId);
+ public boolean isInMultiSelect();
+ }
+
+ interface BitmapReadyCallback {
+ void onBitmapReady(Bitmap bitmap);
+ }
+
+ private static enum ViewTypes {
+ LOCAL, NETWORK, PROGRESS, SPACER
+ }
+
+ public MediaGridAdapter(Context context, Cursor c, int flags, ArrayList<String> checkedItems,
+ ImageLoader imageLoader) {
+ super(context, c, flags);
+ mContext = context;
+ mCheckedItems = checkedItems;
+ mLocalImageWidth = context.getResources().getDimensionPixelSize(R.dimen.media_grid_local_image_width);
+ mInflater = LayoutInflater.from(context);
+ mFilePathToCallbackMap = new HashMap<String, List<BitmapReadyCallback>>();
+ mHandler = new Handler();
+ setImageLoader(imageLoader);
+
+ checkPhotonCapable();
+ }
+
+ void setImageLoader(ImageLoader imageLoader) {
+ if (imageLoader != null) {
+ mImageLoader = imageLoader;
+ } else {
+ mImageLoader = WordPress.imageLoader;
+ }
+ }
+
+ private void checkPhotonCapable() {
+ mIsCurrentBlogPhotonCapable =
+ (WordPress.getCurrentBlog() != null && WordPress.getCurrentBlog().isPhotonCapable());
+ }
+
+ public ArrayList<String> getCheckedItems() {
+ return mCheckedItems;
+ }
+
+ private static class GridViewHolder {
+ private final TextView filenameView;
+ private final TextView titleView;
+ private final TextView uploadDateView;
+ private final ImageView imageView;
+ private final TextView fileTypeView;
+ private final TextView dimensionView;
+ private final CheckableFrameLayout frameLayout;
+
+ private final TextView stateTextView;
+ private final ProgressBar progressUpload;
+ private final RelativeLayout uploadStateView;
+
+ GridViewHolder(View view) {
+ filenameView = (TextView) view.findViewById(R.id.media_grid_item_filename);
+ titleView = (TextView) view.findViewById(R.id.media_grid_item_name);
+ uploadDateView = (TextView) view.findViewById(R.id.media_grid_item_upload_date);
+ imageView = (ImageView) view.findViewById(R.id.media_grid_item_image);
+ fileTypeView = (TextView) view.findViewById(R.id.media_grid_item_filetype);
+ dimensionView = (TextView) view.findViewById(R.id.media_grid_item_dimension);
+ frameLayout = (CheckableFrameLayout) view.findViewById(R.id.media_grid_frame_layout);
+
+ stateTextView = (TextView) view.findViewById(R.id.media_grid_item_upload_state);
+ progressUpload = (ProgressBar) view.findViewById(R.id.media_grid_item_upload_progress);
+ uploadStateView = (RelativeLayout) view.findViewById(R.id.media_grid_item_upload_state_container);
+ }
+ }
+
+ @SuppressLint("DefaultLocale")
+ @Override
+ public void bindView(final View view, Context context, Cursor cursor) {
+ int itemViewType = getItemViewType(cursor.getPosition());
+
+ if (itemViewType == ViewTypes.PROGRESS.ordinal()) {
+ if (mIsRefreshing) {
+ int height = mContext.getResources().getDimensionPixelSize(R.dimen.media_grid_progress_height);
+ view.setLayoutParams(new GridView.LayoutParams(GridView.LayoutParams.MATCH_PARENT, height));
+ view.setVisibility(View.VISIBLE);
+ } else {
+ view.setLayoutParams(new GridView.LayoutParams(0, 0));
+ view.setVisibility(View.GONE);
+ }
+ return;
+ } else if (itemViewType == ViewTypes.SPACER.ordinal()) {
+ CheckableFrameLayout frameLayout = (CheckableFrameLayout) view.findViewById(R.id.media_grid_frame_layout);
+ updateGridWidth(context, frameLayout);
+ view.setVisibility(View.INVISIBLE);
+ return;
+ }
+
+ final GridViewHolder holder;
+ if (view.getTag() instanceof GridViewHolder) {
+ holder = (GridViewHolder) view.getTag();
+ } else {
+ holder = new GridViewHolder(view);
+ view.setTag(holder);
+ }
+
+ final String mediaId = cursor.getString(cursor.getColumnIndex("mediaId"));
+
+ String state = cursor.getString(cursor.getColumnIndex("uploadState"));
+ boolean isLocalFile = MediaUtils.isLocalFile(state);
+
+ // file name
+ String fileName = cursor.getString(cursor.getColumnIndex("fileName"));
+ if (holder.filenameView != null) {
+ holder.filenameView.setText(fileName);
+ }
+
+ // title of media
+ String title = cursor.getString(cursor.getColumnIndex("title"));
+ if (title == null || title.equals(""))
+ title = fileName;
+ holder.titleView.setText(title);
+
+ // upload date
+ if (holder.uploadDateView != null) {
+ String date = MediaUtils.getDate(cursor.getLong(cursor.getColumnIndex("date_created_gmt")));
+ holder.uploadDateView.setText(date);
+ }
+
+ // load image
+ if (isLocalFile) {
+ loadLocalImage(cursor, holder.imageView);
+ } else {
+ loadNetworkImage(cursor, (NetworkImageView) holder.imageView);
+ }
+
+ // get the file extension from the fileURL
+ String mimeType = cursor.getString(cursor.getColumnIndex("mimeType"));
+ String fileExtension = MediaUtils.getExtensionForMimeType(mimeType);
+ fileExtension = fileExtension.toUpperCase();
+ // file type
+ if (Utils.isXLarge(context) && !TextUtils.isEmpty(fileExtension)) {
+ holder.fileTypeView.setText("File type: " + fileExtension);
+ } else {
+ holder.fileTypeView.setText(fileExtension);
+ }
+
+ // dimensions
+ String filePath = cursor.getString(cursor.getColumnIndex("fileURL"));
+ TextView dimensionView = (TextView) view.findViewById(R.id.media_grid_item_dimension);
+ if (dimensionView != null) {
+ if( MediaUtils.isValidImage(filePath)) {
+ int width = cursor.getInt(cursor.getColumnIndex("width"));
+ int height = cursor.getInt(cursor.getColumnIndex("height"));
+
+ if (width > 0 && height > 0) {
+ String dimensions = width + "x" + height;
+ holder.dimensionView.setText(dimensions);
+ holder.dimensionView.setVisibility(View.VISIBLE);
+ }
+ } else {
+ holder.dimensionView.setVisibility(View.GONE);
+ }
+ }
+
+ // multi-select highlighting
+ holder.frameLayout.setTag(mediaId);
+ holder.frameLayout.setOnCheckedChangeListener(new OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CheckableFrameLayout view, boolean isChecked) {
+ String mediaId = (String) view.getTag();
+ if (isChecked) {
+ if (!mCheckedItems.contains(mediaId)) {
+ mCheckedItems.add(mediaId);
+ }
+ } else {
+ mCheckedItems.remove(mediaId);
+ }
+
+ }
+ });
+ holder.frameLayout.setChecked(mCheckedItems.contains(mediaId));
+
+ // resizing layout to fit nicely into grid view
+ updateGridWidth(context, holder.frameLayout);
+
+ // show upload state
+ if (holder.stateTextView != null) {
+ if (state != null && state.length() > 0) {
+ // show the progressbar only when the state is uploading
+ if (state.equals("uploading")) {
+ holder.progressUpload.setVisibility(View.VISIBLE);
+ } else {
+ holder.progressUpload.setVisibility(View.GONE);
+ }
+
+ // add onclick to retry failed uploads
+ if (state.equals("failed")) {
+ state = "retry";
+ holder.stateTextView.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (!inMultiSelect()) {
+ ((TextView) v).setText("queued");
+ v.setOnClickListener(null);
+ mCallback.onRetryUpload(mediaId);
+ }
+ }
+
+ });
+ }
+
+ holder.stateTextView.setText(state);
+ holder.uploadStateView.setVisibility(View.VISIBLE);
+ } else {
+ holder.uploadStateView.setVisibility(View.GONE);
+ }
+ }
+
+ // if we are near the end, make a call to fetch more
+ int position = cursor.getPosition();
+ if (position == mCursorDataCount - 1 && !mHasRetrievedAll) {
+ if (mCallback != null) {
+ mCallback.fetchMoreData(mCursorDataCount);
+ }
+ }
+ }
+
+ private boolean inMultiSelect() {
+ return mCallback.isInMultiSelect();
+ }
+
+ private void loadNetworkImage(Cursor cursor, NetworkImageView imageView) {
+ String thumbnailURL = cursor.getString(cursor.getColumnIndex("thumbnailURL"));
+
+ // Allow non-private wp.com and Jetpack blogs to use photon to get a higher res thumbnail
+ if (mIsCurrentBlogPhotonCapable){
+ String imageURL = cursor.getString(cursor.getColumnIndex("fileURL"));
+ if (imageURL != null) {
+ thumbnailURL = StringUtils.getPhotonUrl(imageURL, mGridItemWidth);
+ }
+ }
+
+ if (thumbnailURL != null) {
+ Uri uri = Uri.parse(thumbnailURL);
+ String filepath = uri.getLastPathSegment();
+
+ int placeholderResId = MediaUtils.getPlaceholder(filepath);
+ imageView.setImageResource(0);
+ imageView.setErrorImageResId(placeholderResId);
+
+ // no default image while downloading
+ imageView.setDefaultImageResId(0);
+
+ if (MediaUtils.isValidImage(filepath)) {
+ imageView.setTag(thumbnailURL);
+ imageView.setImageUrl(thumbnailURL, mImageLoader);
+ } else {
+ imageView.setImageResource(placeholderResId);
+ }
+ } else {
+ imageView.setImageResource(0);
+ }
+
+ }
+
+ private synchronized void loadLocalImage(Cursor cursor, final ImageView imageView) {
+ final String filePath = cursor.getString(cursor.getColumnIndex("filePath"));
+
+ if (MediaUtils.isValidImage(filePath)) {
+ imageView.setTag(filePath);
+
+ Bitmap bitmap = WordPress.getBitmapCache().get(filePath);
+ if (bitmap != null) {
+ imageView.setImageBitmap(bitmap);
+ } else {
+ imageView.setImageBitmap(null);
+
+ boolean shouldFetch = false;
+
+ List<BitmapReadyCallback> list;
+ if (mFilePathToCallbackMap.containsKey(filePath)) {
+ list = mFilePathToCallbackMap.get(filePath);
+ } else {
+ list = new ArrayList<MediaGridAdapter.BitmapReadyCallback>();
+ shouldFetch = true;
+ mFilePathToCallbackMap.put(filePath, list);
+ }
+ list.add(new BitmapReadyCallback() {
+ @Override
+ public void onBitmapReady(Bitmap bitmap) {
+ if (imageView.getTag() instanceof String && imageView.getTag().equals(filePath))
+ imageView.setImageBitmap(bitmap);
+ }
+ });
+
+
+ if (shouldFetch) {
+ fetchBitmap(filePath);
+ }
+ }
+ } else {
+ // if not image, for now show no image.
+ imageView.setImageBitmap(null);
+ }
+ }
+
+ private void fetchBitmap(final String filePath) {
+ BitmapWorkerTask task = new BitmapWorkerTask(null, mLocalImageWidth, mLocalImageWidth, new BitmapWorkerCallback() {
+ @Override
+ public void onBitmapReady(final String path, ImageView imageView, final Bitmap bitmap) {
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ List<BitmapReadyCallback> callbacks = mFilePathToCallbackMap.get(path);
+ for (BitmapReadyCallback callback : callbacks) {
+ callback.onBitmapReady(bitmap);
+ }
+
+ WordPress.getBitmapCache().put(path, bitmap);
+ callbacks.clear();
+ mFilePathToCallbackMap.remove(path);
+ }
+ });
+ }
+ });
+ task.execute(filePath);
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup root) {
+ int itemViewType = getItemViewType(cursor.getPosition());
+
+ // spacer and progress spinner views
+ if (itemViewType == ViewTypes.PROGRESS.ordinal()) {
+ return mInflater.inflate(R.layout.media_grid_progress, root, false);
+ } else if (itemViewType == ViewTypes.SPACER.ordinal()) {
+ return mInflater.inflate(R.layout.media_grid_item, root, false);
+ }
+
+ View view = mInflater.inflate(R.layout.media_grid_item, root, false);
+ ViewStub imageStub = (ViewStub) view.findViewById(R.id.media_grid_image_stub);
+
+ // We need to use ViewStubs to inflate the image to either:
+ // - a regular ImageView (for local images)
+ // - a FadeInNetworkImageView (for network images)
+ // This is because the NetworkImageView can't load local images.
+ // The other option would be to inflate multiple layouts, but that would lead
+ // to extra near-duplicate xml files that would need to be maintained.
+ if (itemViewType == ViewTypes.LOCAL.ordinal()) {
+ imageStub.setLayoutResource(R.layout.media_grid_image_local);
+ } else {
+ imageStub.setLayoutResource(R.layout.media_grid_image_network);
+ }
+
+ imageStub.inflate();
+
+ view.setTag(new GridViewHolder(view));
+
+ return view;
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return ViewTypes.values().length;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ Cursor cursor = getCursor();
+ cursor.moveToPosition(position);
+
+ // spacer / progress cells
+ int _id = cursor.getInt(cursor.getColumnIndex("_id"));
+ if (_id < 0) {
+ if (_id == Integer.MIN_VALUE)
+ return ViewTypes.PROGRESS.ordinal();
+ else
+ return ViewTypes.SPACER.ordinal();
+ }
+
+ // regular cells
+ String state = cursor.getString(cursor.getColumnIndex("uploadState"));
+ if (MediaUtils.isLocalFile(state))
+ return ViewTypes.LOCAL.ordinal();
+ else
+ return ViewTypes.NETWORK.ordinal();
+ }
+
+ /** Updates the width of a cell to max out the space available, for phones **/
+ private void updateGridWidth(Context context, View view) {
+ setGridItemWidth();
+ int columnCount = getColumnCount(context);
+
+ if (columnCount > 1) {
+ RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(mGridItemWidth, mGridItemWidth);
+ int margins = (int) Utils.dpToPx(8);
+ params.setMargins(0, margins, 0, margins);
+ params.addRule(RelativeLayout.CENTER_IN_PARENT, 1);
+ view.setLayoutParams(params);
+ }
+
+ }
+
+ @Override
+ public Cursor swapCursor(Cursor newCursor) {
+ checkPhotonCapable();
+
+ if (newCursor == null) {
+ mCursorDataCount = 0;
+ return super.swapCursor(newCursor);
+ }
+
+ mCursorDataCount = newCursor.getCount();
+
+ // to mimic the infinite the notification's infinite scroll ui
+ // (with a progress spinner on the bottom of the list), we'll need to add
+ // extra cells in the gridview:
+ // - spacer cells as fillers to place the progress spinner on the first cell (_id < 0)
+ // - progress spinner cell (_id = Integer.MIN_VALUE)
+
+ // use a matrix cursor to create the extra rows
+ MatrixCursor matrixCursor = new MatrixCursor(new String[] { "_id" });
+
+ // add spacer cells
+ int columnCount = getColumnCount(mContext);
+ int remainder = newCursor.getCount() % columnCount;
+ if (remainder > 0) {
+ int spaceCount = columnCount - remainder;
+ for (int i = 0; i < spaceCount; i++ ) {
+ int id = i - spaceCount;
+ matrixCursor.addRow(new Object[] {id + ""});
+ }
+ }
+
+ // add progress spinner cell
+ matrixCursor.addRow(new Object[] { Integer.MIN_VALUE });
+
+ // use a merge cursor to place merge the extra rows at the bottom of the newly swapped cursor
+ MergeCursor mergeCursor = new MergeCursor(new Cursor[] { newCursor, matrixCursor });
+ return super.swapCursor(mergeCursor);
+ }
+
+ /** Return the number of columns in the media grid **/
+ private int getColumnCount(Context context) {
+ return context.getResources().getInteger(R.integer.media_grid_num_columns);
+ }
+
+ public void setCallback(MediaGridAdapterCallback callback) {
+ mCallback = callback;
+ }
+
+ public void setHasRetrievedAll(boolean b) {
+ mHasRetrievedAll = b;
+ }
+
+ public void setRefreshing(boolean refreshing) {
+ mIsRefreshing = refreshing;
+ notifyDataSetChanged();
+ }
+
+ public int getDataCount() {
+ return mCursorDataCount;
+ }
+
+ private void setGridItemWidth() {
+ int maxWidth = mContext.getResources().getDisplayMetrics().widthPixels;
+ int columnCount = getColumnCount(mContext);
+ if (columnCount > 0) {
+ int dp8 = (int) Utils.dpToPx(8);
+ int padding = (columnCount + 1) * dp8;
+ mGridItemWidth = (maxWidth - padding) / columnCount;
+ }
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGridFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGridFragment.java
new file mode 100644
index 000000000..0cfcf27f6
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaGridFragment.java
@@ -0,0 +1,693 @@
+package org.wordpress.android.ui.media;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Fragment;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.AbsListView.RecyclerListener;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemClickListener;
+import android.widget.AdapterView.OnItemSelectedListener;
+import android.widget.ArrayAdapter;
+import android.widget.DatePicker;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.ImageLoader.ImageContainer;
+import com.android.volley.toolbox.ImageLoader.ImageListener;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.ui.CheckableFrameLayout;
+import org.wordpress.android.ui.CustomSpinner;
+import org.wordpress.android.ui.MultiSelectGridView;
+import org.wordpress.android.ui.MultiSelectGridView.MultiSelectListener;
+import org.wordpress.android.ui.PullToRefreshHelper;
+import org.wordpress.android.ui.PullToRefreshHelper.RefreshListener;
+import org.wordpress.android.ui.media.MediaGridAdapter.MediaGridAdapterCallback;
+import org.wordpress.android.util.NetworkUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.ToastUtils.Duration;
+import org.xmlrpc.android.ApiHelper;
+import org.xmlrpc.android.ApiHelper.SyncMediaLibraryTask.Callback;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.GregorianCalendar;
+import java.util.List;
+
+import uk.co.senab.actionbarpulltorefresh.library.PullToRefreshLayout;
+
+/**
+ * The grid displaying the media items.
+ * It appears as 2 columns on phone and 1 column on tablet (essentially a listview)
+ */
+public class MediaGridFragment extends Fragment implements OnItemClickListener,
+ MediaGridAdapterCallback, RecyclerListener, MultiSelectListener {
+ private static final String BUNDLE_CHECKED_STATES = "BUNDLE_CHECKED_STATES";
+ private static final String BUNDLE_IN_MULTI_SELECT_MODE = "BUNDLE_IN_MULTI_SELECT_MODE";
+ private static final String BUNDLE_SCROLL_POSITION = "BUNDLE_SCROLL_POSITION";
+ private static final String BUNDLE_HAS_RETREIEVED_ALL_MEDIA = "BUNDLE_HAS_RETREIEVED_ALL_MEDIA";
+ private static final String BUNDLE_FILTER = "BUNDLE_FILTER";
+
+ private static final String BUNDLE_DATE_FILTER_SET = "BUNDLE_DATE_FILTER_SET";
+ private static final String BUNDLE_DATE_FILTER_VISIBLE = "BUNDLE_DATE_FILTER_VISIBLE";
+ private static final String BUNDLE_DATE_FILTER_START_YEAR = "BUNDLE_DATE_FILTER_START_YEAR";
+ private static final String BUNDLE_DATE_FILTER_START_MONTH = "BUNDLE_DATE_FILTER_START_MONTH";
+ private static final String BUNDLE_DATE_FILTER_START_DAY = "BUNDLE_DATE_FILTER_START_DAY";
+ private static final String BUNDLE_DATE_FILTER_END_YEAR = "BUNDLE_DATE_FILTER_END_YEAR";
+ private static final String BUNDLE_DATE_FILTER_END_MONTH = "BUNDLE_DATE_FILTER_END_MONTH";
+ private static final String BUNDLE_DATE_FILTER_END_DAY = "BUNDLE_DATE_FILTER_END_DAY";
+
+ private Filter mFilter = Filter.ALL;
+ private String[] mFiltersText;
+ private MultiSelectGridView mGridView;
+ private MediaGridAdapter mGridAdapter;
+ private MediaGridListener mListener;
+
+ private ArrayList<String> mCheckedItems;
+
+ private boolean mIsRefreshing = false;
+ private boolean mHasRetrievedAllMedia = false;
+ private String mSearchTerm;
+
+ private View mSpinnerContainer;
+ private TextView mResultView;
+ private LinearLayout mEmptyView;
+ private TextView mEmptyViewTitle;
+ private CustomSpinner mSpinner;
+ private PullToRefreshHelper mPullToRefreshHelper;
+
+ private int mOldMediaSyncOffset = 0;
+
+ private boolean mIsDateFilterSet = false;
+ private boolean mSpinnerHasLaunched = false;
+
+ private int mStartYear, mStartMonth, mStartDay, mEndYear, mEndMonth, mEndDay;
+ private AlertDialog mDatePickerDialog;
+
+ public interface MediaGridListener {
+ public void onMediaItemListDownloadStart();
+ public void onMediaItemListDownloaded();
+ public void onMediaItemSelected(String mediaId);
+ public void onMultiSelectChange(int count);
+ public void onRetryUpload(String mediaId);
+ }
+
+ public enum Filter {
+ ALL, IMAGES, UNATTACHED, CUSTOM_DATE;
+
+ public static Filter getFilter(int filterPos) {
+ if (filterPos > Filter.values().length)
+ return ALL;
+ else
+ return Filter.values()[filterPos];
+ }
+ }
+
+ private final OnItemSelectedListener mFilterSelectedListener = new OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+ // need this to stop the bug where onItemSelected is called during initialization, before user input
+ if (!mSpinnerHasLaunched) {
+ return;
+ }
+ if (position == Filter.CUSTOM_DATE.ordinal()) {
+ mIsDateFilterSet = true;
+ }
+ setFilter(Filter.getFilter(position));
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> parent) {
+ }
+ };
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+
+ mCheckedItems = new ArrayList<String>();
+ mFiltersText = new String[Filter.values().length];
+
+ mGridAdapter = new MediaGridAdapter(getActivity(), null, 0, mCheckedItems, MediaImageLoader.getInstance());
+ mGridAdapter.setCallback(this);
+
+ View view = inflater.inflate(R.layout.media_grid_fragment, container);
+
+ mGridView = (MultiSelectGridView) view.findViewById(R.id.media_gridview);
+ mGridView.setOnItemClickListener(this);
+ mGridView.setRecyclerListener(this);
+ mGridView.setMultiSelectListener(this);
+ mGridView.setAdapter(mGridAdapter);
+
+ mEmptyView = (LinearLayout) view.findViewById(R.id.empty_view);
+ mEmptyViewTitle = (TextView) view.findViewById(R.id.empty_view_title);
+
+ mResultView = (TextView) view.findViewById(R.id.media_filter_result_text);
+
+ mSpinner = (CustomSpinner) view.findViewById(R.id.media_filter_spinner);
+ mSpinner.setOnItemSelectedListener(mFilterSelectedListener);
+ mSpinner.setOnItemSelectedEvenIfUnchangedListener(mFilterSelectedListener);
+
+ mSpinnerContainer = view.findViewById(R.id.media_filter_spinner_container);
+ mSpinnerContainer.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (!isInMultiSelect()) {
+ mSpinnerHasLaunched = true;
+ mSpinner.performClick();
+ }
+ }
+
+ });
+
+ // pull to refresh setup
+ mPullToRefreshHelper = new PullToRefreshHelper(getActivity(),
+ (PullToRefreshLayout) view.findViewById(R.id.ptr_layout),
+ new RefreshListener() {
+ @Override
+ public void onRefreshStarted(View view) {
+ if (getActivity() == null || !NetworkUtils.checkConnection(getActivity())) {
+ mPullToRefreshHelper.setRefreshing(false);
+ return;
+ }
+ refreshMediaFromServer(0, false);
+ }
+ }, LinearLayout.class);
+
+ restoreState(savedInstanceState);
+ setupSpinnerAdapter();
+
+ return view;
+ }
+
+ private void restoreState(Bundle savedInstanceState) {
+ if (savedInstanceState == null)
+ return;
+
+ boolean isInMultiSelectMode = savedInstanceState.getBoolean(BUNDLE_IN_MULTI_SELECT_MODE);
+
+ if (savedInstanceState.containsKey(BUNDLE_CHECKED_STATES)) {
+ mCheckedItems.addAll(savedInstanceState.getStringArrayList(BUNDLE_CHECKED_STATES));
+ if (isInMultiSelectMode) {
+ mListener.onMultiSelectChange(mCheckedItems.size());
+ onMultiSelectChange(mCheckedItems.size());
+ mPullToRefreshHelper.setEnabled(false);
+ }
+ mGridView.setMultiSelectModeActive(isInMultiSelectMode);
+ }
+
+ mGridView.setSelection(savedInstanceState.getInt(BUNDLE_SCROLL_POSITION, 0));
+ mHasRetrievedAllMedia = savedInstanceState.getBoolean(BUNDLE_HAS_RETREIEVED_ALL_MEDIA, false);
+ mFilter = Filter.getFilter(savedInstanceState.getInt(BUNDLE_FILTER));
+
+ mIsDateFilterSet = savedInstanceState.getBoolean(BUNDLE_DATE_FILTER_SET, false);
+ mStartDay = savedInstanceState.getInt(BUNDLE_DATE_FILTER_START_DAY);
+ mStartMonth = savedInstanceState.getInt(BUNDLE_DATE_FILTER_START_MONTH);
+ mStartYear = savedInstanceState.getInt(BUNDLE_DATE_FILTER_START_YEAR);
+ mEndDay = savedInstanceState.getInt(BUNDLE_DATE_FILTER_END_DAY);
+ mEndMonth = savedInstanceState.getInt(BUNDLE_DATE_FILTER_END_MONTH);
+ mEndYear = savedInstanceState.getInt(BUNDLE_DATE_FILTER_END_YEAR);
+
+ boolean datePickerShowing = savedInstanceState.getBoolean(BUNDLE_DATE_FILTER_VISIBLE);
+ if (datePickerShowing)
+ showDatePicker();
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ saveState(outState);
+ }
+
+ private void saveState(Bundle outState) {
+ outState.putStringArrayList(BUNDLE_CHECKED_STATES, mCheckedItems);
+ outState.putInt(BUNDLE_SCROLL_POSITION, mGridView.getFirstVisiblePosition());
+ outState.putBoolean(BUNDLE_HAS_RETREIEVED_ALL_MEDIA, mHasRetrievedAllMedia);
+ outState.putBoolean(BUNDLE_IN_MULTI_SELECT_MODE, isInMultiSelect());
+ outState.putInt(BUNDLE_FILTER, mFilter.ordinal());
+
+ outState.putBoolean(BUNDLE_DATE_FILTER_SET, mIsDateFilterSet);
+ outState.putBoolean(BUNDLE_DATE_FILTER_VISIBLE, (mDatePickerDialog != null && mDatePickerDialog.isShowing()));
+ outState.putInt(BUNDLE_DATE_FILTER_START_DAY, mStartDay);
+ outState.putInt(BUNDLE_DATE_FILTER_START_MONTH, mStartMonth);
+ outState.putInt(BUNDLE_DATE_FILTER_START_YEAR, mStartYear);
+ outState.putInt(BUNDLE_DATE_FILTER_END_DAY, mEndDay);
+ outState.putInt(BUNDLE_DATE_FILTER_END_MONTH, mEndMonth);
+ outState.putInt(BUNDLE_DATE_FILTER_END_YEAR, mEndYear);
+ }
+
+ private void setupSpinnerAdapter() {
+ if (getActivity() == null || WordPress.getCurrentBlog() == null) {
+ return;
+ }
+
+ updateFilterText();
+
+ Context context = getActivity();
+ ActionBar actionBar = getActivity().getActionBar();
+ if (actionBar != null) {
+ if (actionBar.getThemedContext() != null) {
+ context = getActivity().getActionBar().getThemedContext();
+ }
+ }
+ ArrayAdapter<String> adapter = new ArrayAdapter<String>(context, R.layout.spinner_menu_dropdown_item, mFiltersText);
+ mSpinner.setAdapter(adapter);
+ mSpinner.setSelection(mFilter.ordinal());
+ }
+
+ public void refreshSpinnerAdapter() {
+ updateFilterText();
+ updateSpinnerAdapter();
+ setFilter(mFilter);
+ }
+
+ void resetSpinnerAdapter() {
+ setFiltersText(0, 0, 0);
+ updateSpinnerAdapter();
+ }
+
+ private void updateFilterText() {
+ if (WordPress.currentBlog == null)
+ return;
+
+ String blogId = String.valueOf(WordPress.getCurrentBlog().getLocalTableBlogId());
+
+ int countAll = WordPress.wpDB.getMediaCountAll(blogId);
+ int countImages = WordPress.wpDB.getMediaCountImages(blogId);
+ int countUnattached = WordPress.wpDB.getMediaCountUnattached(blogId);
+
+ setFiltersText(countAll, countImages, countUnattached);
+ }
+
+ private void setFiltersText(int countAll, int countImages, int countUnattached) {
+ mFiltersText[0] = getResources().getString(R.string.all) + " (" + countAll + ")";
+ mFiltersText[1] = getResources().getString(R.string.images) + " (" + countImages + ")";
+ mFiltersText[2] = getResources().getString(R.string.unattached) + " (" + countUnattached + ")";
+ mFiltersText[3] = getResources().getString(R.string.custom_date) + "...";
+ }
+
+ private void updateSpinnerAdapter() {
+ ArrayAdapter<String> adapter = (ArrayAdapter<String>) mSpinner.getAdapter();
+ if (adapter != null) {
+ adapter.notifyDataSetChanged();
+ }
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ try {
+ mListener = (MediaGridListener) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString() + " must implement MediaGridListener");
+ }
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ mPullToRefreshHelper.registerReceiver(getActivity());
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ mPullToRefreshHelper.unregisterReceiver(getActivity());
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ if (!NetworkUtils.isNetworkAvailable(this.getActivity())) {
+ mHasRetrievedAllMedia = true;
+ }
+
+ refreshSpinnerAdapter();
+ refreshMediaFromDB();
+ }
+
+ public void refreshMediaFromDB() {
+ setFilter(mFilter);
+ if (mGridAdapter.getDataCount() == 0 && !mHasRetrievedAllMedia) {
+ refreshMediaFromServer(0, true);
+ }
+ }
+
+ public void refreshMediaFromServer(int offset, final boolean auto) {
+ // do not refresh if custom date filter is shown
+ if (WordPress.getCurrentBlog() == null || mFilter == Filter.CUSTOM_DATE) {
+ return;
+ }
+
+ // do not refresh if in search
+ if (mSearchTerm != null && mSearchTerm.length() > 0) {
+ return;
+ }
+
+ if (offset == 0 || !mIsRefreshing) {
+ if (offset == mOldMediaSyncOffset) {
+ // we're pulling the same data again for some reason. Pull from the beginning.
+ offset = 0;
+ }
+ mOldMediaSyncOffset = offset;
+
+ mIsRefreshing = true;
+ mListener.onMediaItemListDownloadStart();
+ mGridAdapter.setRefreshing(true);
+
+ List<Object> apiArgs = new ArrayList<Object>();
+ apiArgs.add(WordPress.getCurrentBlog());
+
+ Callback callback = new Callback() {
+ // refresh db from server. If returned count is 0, we've retrieved all the media.
+ // stop retrieving until the user manually refreshes
+
+ @Override
+ public void onSuccess(int count) {
+ MediaGridAdapter adapter = (MediaGridAdapter) mGridView.getAdapter();
+ mHasRetrievedAllMedia = (count == 0);
+ adapter.setHasRetrievedAll(mHasRetrievedAllMedia);
+
+ mIsRefreshing = false;
+
+ // the activity may be gone by the time this finishes, so check for it
+ if (getActivity() != null && MediaGridFragment.this.isVisible()) {
+ getActivity().runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ refreshSpinnerAdapter();
+ setFilter(mFilter);
+ if (!auto)
+ mGridView.setSelection(0);
+ mListener.onMediaItemListDownloaded();
+ mGridAdapter.setRefreshing(false);
+ mPullToRefreshHelper.setRefreshing(false);
+ }
+ });
+ }
+ }
+
+ @Override
+ public void onFailure(ApiHelper.ErrorType errorType, String errorMessage, Throwable throwable) {
+ if (errorType != ApiHelper.ErrorType.NO_ERROR) {
+ if (getActivity() != null) {
+ String message = errorType == ApiHelper.ErrorType.NO_UPLOAD_FILES_CAP ? getString(
+ R.string.media_error_no_permission) : getString(R.string.error_refresh_media);
+ ToastUtils.showToast(getActivity(), message, Duration.LONG);
+ }
+ MediaGridAdapter adapter = (MediaGridAdapter) mGridView.getAdapter();
+ mHasRetrievedAllMedia = true;
+ adapter.setHasRetrievedAll(mHasRetrievedAllMedia);
+ }
+
+ // the activity may be cone by the time we get this, so check for it
+ if (getActivity() != null && MediaGridFragment.this.isVisible()) {
+ getActivity().runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mIsRefreshing = false;
+ mListener.onMediaItemListDownloaded();
+ mGridAdapter.setRefreshing(false);
+ mPullToRefreshHelper.setRefreshing(false);
+ }
+ });
+ }
+ }
+ };
+
+ ApiHelper.SyncMediaLibraryTask getMediaTask = new ApiHelper.SyncMediaLibraryTask(offset, mFilter, callback);
+ getMediaTask.execute(apiArgs);
+ }
+ }
+
+ public void search(String searchTerm) {
+ mSearchTerm = searchTerm;
+ Blog blog = WordPress.getCurrentBlog();
+ if (blog != null) {
+ String blogId = String.valueOf(blog.getLocalTableBlogId());
+ Cursor cursor = WordPress.wpDB.getMediaFilesForBlog(blogId, searchTerm);
+ mGridAdapter.changeCursor(cursor);
+ }
+ }
+
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ Cursor cursor = ((MediaGridAdapter) parent.getAdapter()).getCursor();
+ String mediaId = cursor.getString(cursor.getColumnIndex("mediaId"));
+ mListener.onMediaItemSelected(mediaId);
+ }
+
+ public void setFilterVisibility(int visibility) {
+ if (mSpinner != null)
+ mSpinner.setVisibility(visibility);
+ }
+
+ private void setEmptyViewVisible(boolean visible) {
+ setEmptyViewVisible(visible, -1);
+ }
+
+ private void setEmptyViewVisible(boolean visible, int messageId) {
+ if (visible) {
+ mGridView.setVisibility(View.GONE);
+ mEmptyView.setVisibility(View.VISIBLE);
+ if (messageId != -1) {
+ mEmptyViewTitle.setText(getResources().getString(messageId));
+ }
+ } else {
+ mEmptyView.setVisibility(View.GONE);
+ mGridView.setVisibility(View.VISIBLE);
+ }
+ }
+
+ public void setFilter(Filter filter) {
+ mFilter = filter;
+ Cursor cursor = filterItems(mFilter);
+ if (filter != Filter.CUSTOM_DATE || cursor == null || cursor.getCount() == 0) {
+ mResultView.setVisibility(View.GONE);
+ }
+ if (cursor != null && cursor.getCount() != 0) {
+ mGridAdapter.swapCursor(cursor);
+ setEmptyViewVisible(false);
+ } else {
+ if (filter != Filter.CUSTOM_DATE) {
+ setEmptyViewVisible(true, R.string.media_empty_list);
+ }
+ }
+ }
+
+ Cursor setDateFilter() {
+ Blog blog = WordPress.getCurrentBlog();
+
+ if (blog == null)
+ return null;
+
+ String blogId = String.valueOf(blog.getLocalTableBlogId());
+
+ GregorianCalendar startDate = new GregorianCalendar(mStartYear, mStartMonth, mStartDay);
+ GregorianCalendar endDate = new GregorianCalendar(mEndYear, mEndMonth, mEndDay);
+
+ long one_day = 24 * 60 * 60 * 1000;
+ Cursor cursor = WordPress.wpDB.getMediaFilesForBlog(blogId, startDate.getTimeInMillis(), endDate.getTimeInMillis() + one_day);
+ mGridAdapter.swapCursor(cursor);
+
+ if (cursor != null && cursor.moveToFirst()) {
+ mResultView.setVisibility(View.VISIBLE);
+ setEmptyViewVisible(false);
+
+ SimpleDateFormat fmt = new SimpleDateFormat("dd-MMM-yyyy");
+ fmt.setCalendar(startDate);
+ String formattedStart = fmt.format(startDate.getTime());
+ String formattedEnd = fmt.format(endDate.getTime());
+
+ // TODO: replace hard-coded text with string resource
+ mResultView.setText("Displaying media from " + formattedStart + " to " + formattedEnd);
+ return cursor;
+ } else {
+ setEmptyViewVisible(true, R.string.media_empty_list_custom_date);
+ }
+ return null;
+ }
+
+ private Cursor filterItems(Filter filter) {
+ Blog blog = WordPress.getCurrentBlog();
+
+ if (blog == null)
+ return null;
+
+ String blogId = String.valueOf(blog.getLocalTableBlogId());
+
+ switch (filter) {
+ case ALL:
+ return WordPress.wpDB.getMediaFilesForBlog(blogId);
+ case IMAGES:
+ return WordPress.wpDB.getMediaImagesForBlog(blogId);
+ case UNATTACHED:
+ return WordPress.wpDB.getMediaUnattachedForBlog(blogId);
+ case CUSTOM_DATE:
+ // show date picker only when the user clicks on the spinner, not when we are doing syncing
+ if (mIsDateFilterSet) {
+ mIsDateFilterSet = false;
+ showDatePicker();
+ } else {
+ return setDateFilter();
+ }
+ break;
+ }
+ return null;
+ }
+
+ void showDatePicker() {
+ // Inflate your custom layout containing 2 DatePickers
+ LayoutInflater inflater = getActivity().getLayoutInflater();
+ View customView = inflater.inflate(R.layout.date_range_dialog, null);
+
+ // Define your date pickers
+ final DatePicker dpStartDate = (DatePicker) customView.findViewById(R.id.dpStartDate);
+ final DatePicker dpEndDate = (DatePicker) customView.findViewById(R.id.dpEndDate);
+
+ // Build the dialog
+ AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+ builder.setView(customView); // Set the view of the dialog to your custom layout
+ builder.setTitle("Select start and end date");
+ builder.setPositiveButton("OK", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ mStartYear = dpStartDate.getYear();
+ mStartMonth = dpStartDate.getMonth();
+ mStartDay = dpStartDate.getDayOfMonth();
+ mEndYear = dpEndDate.getYear();
+ mEndMonth = dpEndDate.getMonth();
+ mEndDay = dpEndDate.getDayOfMonth();
+ setDateFilter();
+
+ dialog.dismiss();
+ }
+ });
+
+ // Create and show the dialog
+ mDatePickerDialog = builder.create();
+ mDatePickerDialog.show();
+ }
+
+ @Override
+ public void fetchMoreData(int offset) {
+ if (!mHasRetrievedAllMedia)
+ refreshMediaFromServer(offset, true);
+ }
+
+ @Override
+ public void onMovedToScrapHeap(View view) {
+ // cancel image fetch requests if the view has been moved to recycler.
+
+ View imageView = view.findViewById(R.id.media_grid_item_image);
+ if (imageView != null) {
+ // this tag is set in the MediaGridAdapter class
+ String tag = (String) imageView.getTag();
+ if (tag != null && tag.startsWith("http")) {
+ // need a listener to cancel request, even if the listener does nothing
+ ImageContainer container = WordPress.imageLoader.get(tag, new ImageListener() {
+ @Override
+ public void onErrorResponse(VolleyError error) { }
+
+ @Override
+ public void onResponse(ImageContainer response, boolean isImmediate) { }
+
+ });
+ container.cancelRequest();
+ }
+ }
+
+ CheckableFrameLayout layout = (CheckableFrameLayout) view.findViewById(R.id.media_grid_frame_layout);
+ if (layout != null) {
+ layout.setOnCheckedChangeListener(null);
+ }
+
+ }
+
+ @Override
+ public void onMultiSelectChange(int count) {
+ if (count == 0) {
+ // enable filtering when not in multiselect
+ mSpinner.setEnabled(true);
+ mSpinnerContainer.setEnabled(true);
+ mSpinnerContainer.setVisibility(View.VISIBLE);
+ } else {
+ // disable filtering on multiselect
+ mSpinner.setEnabled(false);
+ mSpinnerContainer.setEnabled(false);
+ mSpinnerContainer.setVisibility(View.GONE);
+ }
+
+ mListener.onMultiSelectChange(count);
+ }
+
+ @Override
+ public boolean isInMultiSelect() {
+ return mGridView.isInMultiSelectMode();
+ }
+
+ public ArrayList<String> getCheckedItems() {
+ return mCheckedItems;
+ }
+
+ public void clearCheckedItems() {
+ mGridView.cancelSelection();
+ }
+
+ @Override
+ public void onRetryUpload(String mediaId) {
+ mListener.onRetryUpload(mediaId);
+ }
+
+ public boolean hasRetrievedAllMediaFromServer() {
+ return mHasRetrievedAllMedia;
+ }
+
+ /*
+ * called by activity when blog is changed
+ */
+ protected void reset() {
+ mCheckedItems.clear();
+
+ mGridView.setSelection(0);
+ mGridView.requestFocusFromTouch();
+ mGridView.setSelection(0);
+
+ mGridAdapter.setImageLoader(MediaImageLoader.getInstance());
+ mGridAdapter.changeCursor(null);
+
+ resetSpinnerAdapter();
+
+ mHasRetrievedAllMedia = false;
+ }
+
+ public void removeFromMultiSelect(String mediaId) {
+ if (isInMultiSelect()) {
+ mCheckedItems.remove(mediaId);
+ mListener.onMultiSelectChange(mCheckedItems.size());
+ onMultiSelectChange(mCheckedItems.size());
+ }
+ }
+
+ public void setRefreshing(boolean refreshing) {
+ mPullToRefreshHelper.setRefreshing(refreshing);
+ }
+
+ public void setPullToRefreshEnabled(boolean enabled) {
+ mPullToRefreshHelper.setEnabled(enabled);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaImageLoader.java b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaImageLoader.java
new file mode 100644
index 000000000..d484d2fa5
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaImageLoader.java
@@ -0,0 +1,42 @@
+package org.wordpress.android.ui.media;
+
+import android.content.Context;
+
+import com.android.volley.RequestQueue;
+import com.android.volley.toolbox.ImageLoader;
+import com.android.volley.toolbox.Volley;
+
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.VolleyUtils;
+
+/**
+ * provides the ImageLoader and backing RequestQueue for media image requests - necessary because
+ * images in protected blogs need to be authenticated, which requires a separate RequestQueue
+ */
+class MediaImageLoader {
+ private MediaImageLoader() {
+ throw new AssertionError();
+ }
+
+ static ImageLoader getInstance() {
+ return getInstance(WordPress.getCurrentBlog());
+ }
+
+ static ImageLoader getInstance(Blog blog) {
+ if (blog != null && VolleyUtils.isCustomHTTPClientStackNeeded(blog)) {
+ // use ImageLoader with authenticating request queue for protected blogs
+ AppLog.d(AppLog.T.MEDIA, "using custom imageLoader");
+ Context context = WordPress.getContext();
+ RequestQueue authRequestQueue = Volley.newRequestQueue(context, VolleyUtils.getHTTPClientStack(context, blog));
+ ImageLoader imageLoader = new ImageLoader(authRequestQueue, WordPress.getBitmapCache());
+ imageLoader.setBatchedResponseDelay(0);
+ return imageLoader;
+ } else {
+ // use default ImageLoader for all others
+ AppLog.d(AppLog.T.MEDIA, "using default imageLoader");
+ return WordPress.imageLoader;
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaItemFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaItemFragment.java
new file mode 100644
index 000000000..6522fcf24
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaItemFragment.java
@@ -0,0 +1,356 @@
+package org.wordpress.android.ui.media;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.AlertDialog.Builder;
+import android.app.Fragment;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnClickListener;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.drawable.ColorDrawable;
+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.ViewGroup;
+import android.view.ViewGroup.LayoutParams;
+import android.view.ViewStub;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.android.volley.toolbox.ImageLoader;
+import com.android.volley.toolbox.NetworkImageView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.util.ImageHelper.BitmapWorkerCallback;
+import org.wordpress.android.util.ImageHelper.BitmapWorkerTask;
+import org.wordpress.android.util.MediaUtils;
+import org.wordpress.android.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A fragment display a media item's details.
+ * Only appears on phone.
+ */
+public class MediaItemFragment extends Fragment {
+ private static final String ARGS_MEDIA_ID = "media_id";
+
+ public static final String TAG = MediaItemFragment.class.getName();
+
+ private View mView;
+
+ private ImageView mImageView;
+ private TextView mTitleView;
+ private TextView mCaptionView;
+ private TextView mDescriptionView;
+ private TextView mDateView;
+ private TextView mFileNameView;
+ private TextView mFileTypeView;
+ private TextView mDimensionsView;
+ private MediaItemFragmentCallback mCallback;
+ private ImageLoader mImageLoader;
+
+ private boolean mIsLocal;
+
+ public interface MediaItemFragmentCallback {
+ public void onResume(Fragment fragment);
+ public void onPause(Fragment fragment);
+ public void onDeleteMedia(final List<String> ids);
+ }
+
+ public static MediaItemFragment newInstance(String mediaId) {
+ MediaItemFragment fragment = new MediaItemFragment();
+
+ Bundle args = new Bundle();
+ args.putString(ARGS_MEDIA_ID, mediaId);
+ fragment.setArguments(args);
+
+ return fragment;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mImageLoader = MediaImageLoader.getInstance();
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ try {
+ mCallback = (MediaItemFragmentCallback) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString() + " must implement MediaItemFragmentCallback");
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ mCallback.onResume(this);
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ mCallback.onPause(this);
+ }
+
+ public String getMediaId() {
+ if (getArguments() != null)
+ return getArguments().getString(ARGS_MEDIA_ID);
+ else
+ return null;
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ mView = inflater.inflate(R.layout.media_listitem_details, container, false);
+
+ mTitleView = (TextView) mView.findViewById(R.id.media_listitem_details_title);
+ mCaptionView = (TextView) mView.findViewById(R.id.media_listitem_details_caption);
+ mDescriptionView = (TextView) mView.findViewById(R.id.media_listitem_details_description);
+ mDateView = (TextView) mView.findViewById(R.id.media_listitem_details_date);
+ mFileNameView = (TextView) mView.findViewById(R.id.media_listitem_details_file_name);
+ mFileTypeView = (TextView) mView.findViewById(R.id.media_listitem_details_file_type);
+ mDimensionsView = (TextView) mView.findViewById(R.id.media_listitem_details_dimensions);
+
+ loadMedia(getMediaId());
+
+ return mView;
+ }
+
+ /** Loads the first media item for the current blog from the database **/
+ public void loadDefaultMedia() {
+ loadMedia(null);
+ }
+
+ public void loadMedia(String mediaId) {
+ Blog blog = WordPress.getCurrentBlog();
+
+ if (blog != null) {
+ String blogId = String.valueOf(blog.getLocalTableBlogId());
+
+ Cursor cursor;
+
+ // if the id is null, get the first media item in the database
+ if (mediaId == null) {
+ cursor = WordPress.wpDB.getFirstMediaFileForBlog(blogId);
+ } else {
+ cursor = WordPress.wpDB.getMediaFile(blogId, mediaId);
+ }
+
+ refreshViews(cursor);
+ cursor.close();
+ }
+ }
+
+ private void refreshViews(Cursor cursor) {
+ if (!cursor.moveToFirst())
+ return;
+
+ // check whether or not to show the edit button
+ String state = cursor.getString(cursor.getColumnIndex("uploadState"));
+ mIsLocal = MediaUtils.isLocalFile(state);
+ if (mIsLocal && getActivity() != null) {
+ getActivity().invalidateOptionsMenu();
+ }
+
+ // title
+ mTitleView.setText(cursor.getString(cursor.getColumnIndex("title")));
+
+ // caption
+ String caption = cursor.getString(cursor.getColumnIndex("caption"));
+ if (caption == null || caption.length() == 0) {
+ mCaptionView.setVisibility(View.GONE);
+ } else {
+ mCaptionView.setText(caption);
+ mCaptionView.setVisibility(View.VISIBLE);
+ }
+
+ // description
+ String desc = cursor.getString(cursor.getColumnIndex("description"));
+ if (desc == null || desc.length() == 0) {
+ mDescriptionView.setVisibility(View.GONE);
+ } else {
+ mDescriptionView.setText(desc);
+ mDescriptionView.setVisibility(View.VISIBLE);
+ }
+
+ // added / upload date
+ String date = MediaUtils.getDate(cursor.getLong(cursor.getColumnIndex("date_created_gmt")));
+ if (mIsLocal) {
+ mDateView.setText("Added on: " + date);
+ } else {
+ mDateView.setText("Uploaded on: " + date);
+ }
+
+ // file name
+ String fileName = cursor.getString(cursor.getColumnIndex("fileName"));
+ mFileNameView.setText("File name: " + fileName);
+
+ // get the file extension from the fileURL
+ String fileURL = cursor.getString(cursor.getColumnIndex("fileURL"));
+ if (fileURL != null) {
+ String fileType = fileURL.replaceAll(".*\\.(\\w+)$", "$1").toUpperCase();
+ mFileTypeView.setText("File type: " + fileType);
+ mFileTypeView.setVisibility(View.VISIBLE);
+ } else {
+ mFileTypeView.setVisibility(View.GONE);
+ }
+
+ String imageUri = cursor.getString(cursor.getColumnIndex("fileURL"));
+ if (imageUri == null)
+ imageUri = cursor.getString(cursor.getColumnIndex("filePath"));
+
+ inflateImageView();
+
+ // image and dimensions
+ if (MediaUtils.isValidImage(imageUri)) {
+ int width = cursor.getInt(cursor.getColumnIndex("width"));
+ int height = cursor.getInt(cursor.getColumnIndex("height"));
+
+ float screenWidth;
+
+ View parentView = (View) mImageView.getParent();
+
+ //differentiating between tablet and phone
+ if (this.isInLayout()) {
+ screenWidth = parentView.getMeasuredWidth();
+ } else {
+ screenWidth = getActivity().getResources().getDisplayMetrics().widthPixels;
+ }
+ float screenHeight = getActivity().getResources().getDisplayMetrics().heightPixels;
+
+ if (width > 0 && height > 0) {
+ String dimensions = width + "x" + height;
+ mDimensionsView.setText("Dimensions: " + dimensions);
+ mDimensionsView.setVisibility(View.VISIBLE);
+ } else {
+ mDimensionsView.setVisibility(View.GONE);
+ }
+
+ if (width > screenWidth) {
+ height = (int) (height / (width/screenWidth));
+ width = (int) screenWidth;
+ } else if (height > screenHeight) {
+ width = (int) (width / (height/screenHeight));
+ height = (int) screenHeight;
+ }
+
+ if (mIsLocal) {
+ final String filePath = cursor.getString(cursor.getColumnIndex("filePath"));
+ loadLocalImage(mImageView, filePath, width, height);
+ } else {
+ // Allow non-private wp.com and Jetpack blogs to use photon to get a higher res thumbnail
+ if (WordPress.getCurrentBlog() != null && WordPress.getCurrentBlog().isPhotonCapable()){
+ String thumbnailURL = StringUtils.getPhotonUrl(imageUri, (int)screenWidth);
+ ((NetworkImageView) mImageView).setImageUrl(thumbnailURL, mImageLoader);
+ } else {
+ ((NetworkImageView) mImageView).setImageUrl(imageUri + "?w=" + screenWidth, mImageLoader);
+ }
+ }
+ mImageView.setVisibility(View.VISIBLE);
+
+ mImageView.setLayoutParams(new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, height));
+
+ } else {
+ mImageView.setVisibility(View.GONE);
+ mDimensionsView.setVisibility(View.GONE);
+ }
+ }
+
+ private void inflateImageView() {
+ ViewStub viewStub = (ViewStub) mView.findViewById(R.id.media_listitem_details_stub);
+ if (viewStub != null) {
+ if (mIsLocal)
+ viewStub.setLayoutResource(R.layout.media_grid_image_local);
+ else
+ viewStub.setLayoutResource(R.layout.media_grid_image_network);
+ viewStub.inflate();
+ }
+
+ mImageView = (ImageView) mView.findViewById(R.id.media_listitem_details_image);
+
+ // add a background color so something appears while image is downloaded
+ mImageView.setImageDrawable(new ColorDrawable(getResources().getColor(R.color.grey_light)));
+ }
+
+ private synchronized void loadLocalImage(ImageView imageView, String filePath, int width, int height) {
+ if (MediaUtils.isValidImage(filePath)) {
+ imageView.setTag(filePath);
+
+ Bitmap bitmap = WordPress.getBitmapCache().get(filePath);
+ if (bitmap != null) {
+ imageView.setImageBitmap(bitmap);
+ } else {
+ BitmapWorkerTask task = new BitmapWorkerTask(imageView, width, height, new BitmapWorkerCallback() {
+ @Override
+ public void onBitmapReady(String path, ImageView imageView, Bitmap bitmap) {
+ imageView.setImageBitmap(bitmap);
+ WordPress.getBitmapCache().put(path, bitmap);
+ }
+ });
+ task.execute(filePath);
+ }
+ }
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ inflater.inflate(R.menu.media_details, menu);
+ }
+
+ @Override
+ public void onPrepareOptionsMenu(Menu menu) {
+ menu.findItem(R.id.menu_new_media).setVisible(false);
+ menu.findItem(R.id.menu_search).setVisible(false);
+
+ if (mIsLocal || ! MediaUtils.isWordPressVersionWithMediaEditingCapabilities() )
+ menu.findItem(R.id.menu_edit_media).setVisible(false);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ int itemId = item.getItemId();
+
+ if (itemId == R.id.menu_delete) {
+ String blogId = String.valueOf(WordPress.getCurrentBlog().getLocalTableBlogId());
+ boolean canDeleteMedia = MediaUtils.canDeleteMedia(blogId, getMediaId());
+ if (!canDeleteMedia) {
+ Toast.makeText(getActivity(), R.string.wait_until_upload_completes, Toast.LENGTH_LONG).show();
+ return true;
+ }
+
+ Builder builder = new AlertDialog.Builder(getActivity())
+ .setMessage(R.string.confirm_delete_media)
+ .setCancelable(true)
+ .setPositiveButton(R.string.delete, new OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ ArrayList<String> ids = new ArrayList<String>(1);
+ ids.add(getMediaId());
+ mCallback.onDeleteMedia(ids);
+ }
+ })
+ .setNegativeButton(R.string.cancel, null);
+ AlertDialog dialog = builder.create();
+ dialog.show();
+ return true;
+
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/BigBadgeFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/BigBadgeFragment.java
new file mode 100644
index 000000000..89b638cae
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/BigBadgeFragment.java
@@ -0,0 +1,122 @@
+package org.wordpress.android.ui.notifications;
+
+import android.app.Fragment;
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.Spanned;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import com.android.volley.toolbox.NetworkImageView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Note;
+import org.wordpress.android.ui.stats.StatsActivity;
+import org.wordpress.android.ui.stats.StatsWPLinkMovementMethod;
+import org.wordpress.android.util.HtmlUtils;
+import org.wordpress.android.util.JSONUtil;
+
+public class BigBadgeFragment extends Fragment implements NotificationFragment {
+ private Note mNote;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup parent, Bundle state) {
+ View view = inflater.inflate(R.layout.notifications_big_badge, parent, false);
+ NetworkImageView badgeImageView = (NetworkImageView) view.findViewById(R.id.badge);
+
+ TextView bodyTextView = (TextView) view.findViewById(R.id.body);
+ bodyTextView.setMovementMethod(StatsWPLinkMovementMethod.getInstance());
+
+ if (getNote() != null) {
+ String noteHTML = JSONUtil.queryJSON(getNote().toJSONObject(), "body.html", "");
+ if (noteHTML.equals("")) {
+ noteHTML = getNote().getSubject();
+ }
+ Spanned html = HtmlUtils.fromHtml(noteHTML);
+ bodyTextView.setText(html);
+
+ // Get the badge
+ String iconURL = getNote().getIconURL();
+ if (!iconURL.equals("")) {
+ badgeImageView.setImageUrl(iconURL, WordPress.imageLoader);
+ }
+
+ // if this is a stats-related note, show stats link and enable tapping badge
+ // to view stats - but only if the note is for a blog that's visible
+ if (isStatsNote()) {
+ final int remoteBlogId = getNote().getMetaValueAsInt("blog_id", -1);
+ if (WordPress.wpDB.isDotComAccountVisible(remoteBlogId)) {
+ TextView txtStats = (TextView) view.findViewById(R.id.text_stats_link);
+ txtStats.setVisibility(View.VISIBLE);
+ View.OnClickListener statsListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ showStatsActivity(remoteBlogId);
+ }
+ };
+ txtStats.setOnClickListener(statsListener);
+ badgeImageView.setOnClickListener(statsListener);
+ }
+ }
+ }
+
+ return view;
+ }
+
+ public void setNote(Note note) {
+ mNote = note;
+ }
+ public Note getNote() {
+ return mNote;
+ }
+
+ /*
+ * returns true if this is a stats-related notification - currently handles these types:
+ * followed_milestone_achievement
+ * post_milestone_achievement
+ * like_milestone_achievement
+ * traffic_surge
+ * best_followed_day_feat
+ * best_liked_day_feat
+ * most_liked_day
+ * most_followed_day
+ */
+ boolean isStatsNote() {
+ if (getNote() == null) {
+ return false;
+ }
+
+ String type = getNote().getType();
+ if (type == null) {
+ return false;
+ }
+
+ return (type.contains("_milestone_")
+ || type.startsWith("traffic_")
+ || type.startsWith("best_")
+ || type.startsWith("most_"));
+ }
+
+ /*
+ * show stats for the passed blog
+ */
+ private void showStatsActivity(int remoteBlogId) {
+ if (getActivity() == null || isRemoving()) {
+ return;
+ }
+
+ // stats activity is designed to work with the current blog, so switch blogs if necessary
+ if (WordPress.getCurrentRemoteBlogId() != remoteBlogId) {
+ // TODO: should we show a toast to let user know blog was switched?
+ int localBlogId = WordPress.wpDB.getLocalTableBlogIdForRemoteBlogId(remoteBlogId);
+ WordPress.setCurrentBlog(localBlogId);
+ }
+
+ Intent intent = new Intent(getActivity(), StatsActivity.class);
+ intent.putExtra(StatsActivity.ARG_NO_MENU_DRAWER, true);
+ getActivity().startActivity(intent);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/DetailHeader.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/DetailHeader.java
new file mode 100644
index 000000000..ac3f07915
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/DetailHeader.java
@@ -0,0 +1,81 @@
+/**
+ * Set a line of text and a URL to open in the browser when clicked
+ */
+package org.wordpress.android.ui.notifications;
+
+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 org.wordpress.android.R;
+import org.wordpress.android.models.Note;
+
+public class DetailHeader extends LinearLayout {
+ private NotificationFragment.OnPostClickListener mOnPostClickListener;
+ private NotificationFragment.OnCommentClickListener mOnCommentClickListener;
+
+ public DetailHeader(Context context){
+ super(context);
+ }
+ public DetailHeader(Context context, AttributeSet attributes){
+ super(context, attributes);
+ }
+ public DetailHeader(Context context, AttributeSet attributes, int defStyle){
+ super(context, attributes, defStyle);
+ }
+ TextView getTextView(){
+ return (TextView) findViewById(R.id.label);
+ }
+ public void setText(CharSequence text){
+ getTextView().setText(text);
+ }
+
+ /*
+ * set by the owning fragment, calls listener in NotificationsActivity to
+ * display the post/comment associated with this notification (if any)
+ */
+ public void setOnPostClickListener(NotificationFragment.OnPostClickListener listener) {
+ mOnPostClickListener = listener;
+ }
+ public void setOnCommentClickListener(NotificationFragment.OnCommentClickListener listener) {
+ mOnCommentClickListener = listener;
+ }
+
+ /*
+ * owning fragment calls this to pass it the note so the post or comment associated with
+ * the note can be opened. if there is no associated post or comment, then the passed
+ * url is navigated to instead.
+ */
+ public void setNote(final Note note, final String url) {
+ final boolean isComment = (note != null && note.getBlogId() != 0 && note.getPostId() != 0 && note.getCommentId() != 0);
+ final boolean isPost = (note != null && note.getBlogId() != 0 && note.getPostId() != 0 && note.getCommentId() == 0);
+
+ if (isPost || isComment) {
+ setClickable(true);
+ setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ if (isComment && mOnCommentClickListener != null) {
+ mOnCommentClickListener.onCommentClicked(note, note.getBlogId(), note.getCommentId());
+ } else if (isPost && mOnPostClickListener != null) {
+ mOnPostClickListener.onPostClicked(note, note.getBlogId(), note.getPostId());
+ }
+ }
+ });
+ } else if (!TextUtils.isEmpty(url)) {
+ setClickable(true);
+ setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ NotificationsWebViewActivity.openUrl(getContext(), url);
+ }
+ });
+ } else {
+ setClickable(false);
+ setOnClickListener(null);
+ }
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/FollowListener.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/FollowListener.java
new file mode 100644
index 000000000..e4ca3a110
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/FollowListener.java
@@ -0,0 +1,73 @@
+package org.wordpress.android.ui.notifications;
+
+import com.android.volley.VolleyError;
+import com.wordpress.rest.RestRequest.ErrorListener;
+import com.wordpress.rest.RestRequest.Listener;
+
+import org.json.JSONObject;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+
+class FollowListener implements FollowRow.OnFollowListener {
+ private final int mNoteId;
+
+ public FollowListener(int noteId) {
+ super();
+ mNoteId = noteId;
+ }
+
+ class FollowResponseHandler implements Listener, ErrorListener {
+ private final FollowRow mRow;
+ private final String mSiteId;
+ private final boolean mShouldFollow;
+
+ FollowResponseHandler(FollowRow row, String siteId, boolean shouldFollow) {
+ mRow = row;
+ mSiteId = siteId;
+ mShouldFollow = shouldFollow;
+ disableFollowButton();
+ }
+
+ @Override
+ public void onResponse(JSONObject response) {
+ if (mRow.isSiteId(mSiteId)) {
+ mRow.setFollowing(mShouldFollow);
+ }
+ enableFollowButton();
+
+ // update the associated note so it has the correct follow status
+ //NotificationUtils.updateNotification(mNoteId, null);
+ }
+
+ @Override
+ public void onErrorResponse(VolleyError error) {
+ enableFollowButton();
+ AppLog.d(T.NOTIFS, String.format("Failed to follow the blog: %s ", error));
+ }
+
+ public void disableFollowButton() {
+ if (mRow.isSiteId(mSiteId)) {
+ mRow.getFollowButton().setEnabled(false);
+ }
+ }
+
+ public void enableFollowButton() {
+ if (mRow.isSiteId(mSiteId)) {
+ mRow.getFollowButton().setEnabled(true);
+ }
+ }
+ }
+
+ @Override
+ public void onFollow(final FollowRow row, final String siteId) {
+ FollowResponseHandler handler = new FollowResponseHandler(row, siteId, true);
+ WordPress.getRestClientUtils().followSite(siteId, handler, handler);
+ }
+
+ @Override
+ public void onUnfollow(final FollowRow row, final String siteId) {
+ FollowResponseHandler handler = new FollowResponseHandler(row, siteId, false);
+ WordPress.getRestClientUtils().unfollowSite(siteId, handler, handler);
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/FollowRow.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/FollowRow.java
new file mode 100644
index 000000000..606a6a493
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/FollowRow.java
@@ -0,0 +1,237 @@
+/**
+ * A row with and avatar, name and follow button
+ *
+ * The follow button switches between "Follow" and "Unfollow" depending on the follow status
+ * and provides and interface to know when the user has tried to follow or unfollow by tapping
+ * the button.
+ *
+ * Potentially can integrate with Gravatar using the avatar url to find profile JSON.
+ */
+package org.wordpress.android.ui.notifications;
+
+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 android.widget.Toast;
+
+import com.android.volley.toolbox.NetworkImageView;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.R;
+import org.wordpress.android.ui.reader.ReaderAnim;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.HtmlUtils;
+import org.wordpress.android.util.NetworkUtils;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+
+public class FollowRow extends LinearLayout {
+ public static interface OnFollowListener {
+ public void onUnfollow(FollowRow row, String blogId);
+ public void onFollow(FollowRow row, String blogId);
+ }
+
+ private static final String PARAMS_FIELD = "params";
+ private static final String TYPE_FIELD = "type";
+ private static final String ACTION_TYPE = "follow";
+ private static final String BLOG_ID_PARAM = "blog_id";
+ private static final String IS_FOLLOWING_PARAM = "is_following";
+ private static final String BLOG_URL_PARAM = "blog_url";
+ private static final String BLOG_DOMAIN_PARAM = "blog_domain";
+
+ private OnFollowListener mFollowListener;
+ private JSONObject mParams;
+ private String mBlogURL;
+
+ public FollowRow(Context context) {
+ super(context);
+ }
+
+ public FollowRow(Context context, AttributeSet attributes) {
+ super(context, attributes);
+ }
+
+ public FollowRow(Context context, AttributeSet attributes, int defStyle) {
+ super(context, attributes, defStyle);
+ }
+
+ void setAction(JSONObject actionJSON) {
+ final TextView followButton = getFollowButton();
+
+ getImageView().setDefaultImageResId(R.drawable.placeholder);
+ try {
+ if (actionJSON.has(TYPE_FIELD) && actionJSON.getString(TYPE_FIELD).equals(ACTION_TYPE)) {
+ // get the params for following
+ mParams = actionJSON.getJSONObject(PARAMS_FIELD);
+ // show the button
+ followButton.setVisibility(VISIBLE);
+ followButton.setOnClickListener(new ClickListener());
+ followButton.setOnLongClickListener(new LongClickListener());
+ setClickable(true);
+ } else {
+ mParams = null;
+ followButton.setVisibility(GONE);
+ followButton.setOnClickListener(null);
+ setClickable(false);
+ }
+
+ if (hasParams()) {
+ setSiteUrl(mParams.optString(BLOG_URL_PARAM, null));
+ } else {
+ setSiteUrl(null);
+ }
+
+ updateFollowButton(isFollowing());
+
+ } catch (JSONException e) {
+ AppLog.e(T.NOTIFS, String.format("Could not set action from %s", actionJSON), e);
+ followButton.setVisibility(GONE);
+ setSiteUrl(null);
+ setClickable(false);
+ mParams = null;
+ }
+ }
+
+ private boolean hasParams() {
+ return mParams != null;
+ }
+
+ NetworkImageView getImageView() {
+ return (NetworkImageView) findViewById(R.id.avatar);
+ }
+
+ TextView getFollowButton() {
+ return (TextView) findViewById(R.id.text_follow);
+ }
+
+ TextView getNameTextView() {
+ return (TextView) findViewById(R.id.name);
+ }
+
+ TextView getSiteTextView() {
+ return (TextView) findViewById(R.id.url);
+ }
+
+ void setNameText(String text) {
+ TextView nameText = getNameTextView();
+ if (TextUtils.isEmpty(text)) {
+ nameText.setVisibility(View.GONE);
+ } else {
+ // text may contain html entities, so it must be unescaped for display
+ nameText.setText(HtmlUtils.fastUnescapeHtml(text));
+ nameText.setVisibility(View.VISIBLE);
+ }
+ }
+
+ boolean isSiteId(String siteId) {
+ String thisSiteId = getSiteId();
+ return (thisSiteId != null && thisSiteId.equals(siteId));
+ }
+
+ void setFollowing(boolean following) {
+ if (hasParams()) {
+ try {
+ mParams.putOpt(IS_FOLLOWING_PARAM, following);
+ } catch (JSONException e) {
+ AppLog.e(T.NOTIFS, String.format("Could not set following %b", following), e);
+ }
+ }
+ updateFollowButton(following);
+ }
+
+ boolean isFollowing() {
+ return hasParams() && mParams.optBoolean(IS_FOLLOWING_PARAM, false);
+ }
+
+ String getSiteId() {
+ if (hasParams()) {
+ return mParams.optString(BLOG_ID_PARAM, null);
+ } else {
+ return null;
+ }
+ }
+
+ void setSiteUrl(String url) {
+ mBlogURL = url;
+ final TextView siteTextView = getSiteTextView();
+
+ if (!TextUtils.isEmpty(url)) {
+ siteTextView.setText(getSiteDomain());
+ siteTextView.setVisibility(View.VISIBLE);
+ this.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (mBlogURL != null) {
+ NotificationsWebViewActivity.openUrl(getContext(), mBlogURL);
+ }
+ }
+ });
+ } else {
+ this.setOnClickListener(null);
+ siteTextView.setVisibility(View.GONE);
+ }
+ }
+
+ private String getSiteDomain() {
+ if (hasParams()) {
+ return mParams.optString(BLOG_DOMAIN_PARAM, null);
+ } else {
+ return null;
+ }
+ }
+
+ private OnFollowListener getFollowListener() {
+ return mFollowListener;
+ }
+
+ void setFollowListener(OnFollowListener listener) {
+ mFollowListener = listener;
+ }
+
+ private boolean hasFollowListener() {
+ return mFollowListener != null;
+ }
+
+ private void updateFollowButton(boolean isFollowing) {
+ final TextView followButton = getFollowButton();
+ int drawableId = (isFollowing ? R.drawable.note_icon_following : R.drawable.note_icon_follow);
+ followButton.setCompoundDrawablesWithIntrinsicBounds(drawableId, 0, 0, 0);
+ followButton.setSelected(isFollowing);
+ followButton.setText(isFollowing ? R.string.reader_btn_unfollow : R.string.reader_btn_follow);
+ }
+
+ private class ClickListener implements View.OnClickListener {
+ public void onClick(View v) {
+ if (!hasFollowListener()) {
+ return;
+ }
+
+ // first make sure we have a connection
+ if (!NetworkUtils.checkConnection(getContext()))
+ return;
+
+ // show new follow state and animate button right away (before network call)
+ updateFollowButton(!isFollowing());
+ ReaderAnim.animateFollowButton(getFollowButton());
+
+ if (isFollowing()) {
+ getFollowListener().onUnfollow(FollowRow.this, getSiteId());
+ } else {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATION_PERFORMED_ACTION);
+ getFollowListener().onFollow(FollowRow.this, getSiteId());
+ }
+ }
+ }
+
+ private class LongClickListener implements View.OnLongClickListener {
+ @Override
+ public boolean onLongClick(View v) {
+ Toast.makeText(getContext(), getResources().getString(R.string.tooltip_follow), Toast.LENGTH_SHORT).show();
+ return true;
+ }
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteCommentLikeFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteCommentLikeFragment.java
new file mode 100644
index 000000000..7499ec5cc
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteCommentLikeFragment.java
@@ -0,0 +1,102 @@
+/**
+ * Behaves much list a ListFragment
+ */
+package org.wordpress.android.ui.notifications;
+
+import android.app.ListFragment;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ListView;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.wordpress.android.R;
+import org.wordpress.android.models.Note;
+import org.wordpress.android.util.JSONUtil;
+
+public class NoteCommentLikeFragment extends ListFragment implements NotificationFragment {
+ private Note mNote;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.notifications_follow_list, container, false);
+ }
+
+ @Override
+ public void onActivityCreated(Bundle bundle){
+ super.onActivityCreated(bundle);
+
+ ListView list = getListView();
+ list.setDivider(getResources().getDrawable(R.drawable.list_divider));
+ list.setDividerHeight(1);
+ list.setHeaderDividersEnabled(false);
+
+ // No note? No service.
+ if (getNote() == null)
+ return;
+
+ JSONArray bodyItems = getNote().queryJSON("body.items", new JSONArray());
+ JSONObject bodyObject = getNote().queryJSON("body", new JSONObject());
+
+ // header subject will be the note subject ("These people like your comment"), header
+ // snippet will be a snippet of the comment
+ final String headerSubject = getHeaderText(bodyItems);
+ final String headerSnippet = getCommentSnippet(bodyItems);
+ final String headerLink = (bodyObject != null ? JSONUtil.getString(bodyObject, "header_link") : "");
+
+ final DetailHeader noteHeader = (DetailHeader) getView().findViewById(R.id.header);
+
+ // full header text is the subject + quoted snippet
+ if (TextUtils.isEmpty(headerSnippet)) {
+ noteHeader.setText(headerSubject);
+ } else {
+ noteHeader.setText(headerSubject + " \"" + headerSnippet + "\"");
+ }
+
+ noteHeader.setNote(getNote(), headerLink);
+
+ if (getActivity() instanceof OnPostClickListener) {
+ noteHeader.setOnPostClickListener(((OnPostClickListener)getActivity()));
+ }
+ if (getActivity() instanceof OnCommentClickListener) {
+ noteHeader.setOnCommentClickListener(((OnCommentClickListener)getActivity()));
+ }
+
+ setListAdapter(new NoteFollowAdapter(getActivity(), getNote(), true));
+ }
+
+ @Override
+ public void setNote(Note note){
+ mNote = note;
+ }
+
+ @Override
+ public Note getNote(){
+ return mNote;
+ }
+
+ private String getHeaderText(JSONArray bodyItems) {
+ if (bodyItems == null)
+ return "";
+ JSONObject noteItem = JSONUtil.queryJSON(bodyItems, String.format("[%d]", 0), new JSONObject());
+ return JSONUtil.getStringDecoded(noteItem, "header_text");
+ }
+
+ private String getCommentSnippet(JSONArray bodyItems) {
+ if (bodyItems == null)
+ return "";
+ JSONObject noteItem = JSONUtil.queryJSON(bodyItems, String.format("[%d]", 0), new JSONObject());
+ return JSONUtil.getStringDecoded(noteItem, "html");
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ if (outState.isEmpty()) {
+ outState.putBoolean("bug_19917_fix", true);
+ }
+ super.onSaveInstanceState(outState);
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteFollowAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteFollowAdapter.java
new file mode 100644
index 000000000..e3c785201
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteFollowAdapter.java
@@ -0,0 +1,142 @@
+package org.wordpress.android.ui.notifications;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Note;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.HtmlUtils;
+import org.wordpress.android.util.JSONUtil;
+import org.wordpress.android.util.PhotonUtils;
+import org.wordpress.android.util.StringUtils;
+
+import java.lang.ref.WeakReference;
+
+/**
+ * Adapter used by NoteSingleLineListFragment and
+ * NoteCommentLikeFragment to display list of liking/following users which enables
+ * following/unfollowing each of them
+ */
+public class NoteFollowAdapter extends BaseAdapter {
+ private JSONArray mItems;
+ private Note mNote;
+ private final boolean mDiscardFirstItem;
+ private final int mAvatarSz;
+ private final WeakReference<Context> mWeakContext;
+ private final LayoutInflater mInflater;
+
+ NoteFollowAdapter(Context context, Note note, boolean discardFirstItem) {
+ mWeakContext = new WeakReference<Context>(context);
+ mInflater = LayoutInflater.from(context);
+ mDiscardFirstItem = discardFirstItem;
+ mAvatarSz = context.getResources().getDimensionPixelSize(R.dimen.avatar_sz_medium);
+
+ setNote(note);
+
+ // request the latest version of this note to ensure follow statuses are correct
+ //NotificationUtils.updateNotification(getNoteId(), this);
+ }
+
+ /*
+ * fired by NotificationUtils.updateNotification() when this note has been updated
+ */
+ /*@Override
+ public void onNoteUpdated(int noteId) {
+ if (hasContext())
+ setNote(WordPress.wpDB.getNoteById(noteId));
+ }*/
+
+ private boolean hasContext() {
+ return (mWeakContext.get() != null);
+ }
+
+ private void setNote(Note note) {
+ boolean hasItems = (mItems != null);
+
+ mNote = note;
+
+ final JSONArray items;
+ if (mNote != null) {
+ items = mNote.queryJSON("body.items", new JSONArray());
+ } else {
+ items = new JSONArray();
+ }
+
+ // the first body item in comment likes is the header ("This person liked your comment")
+ // and should be discarded
+ if (mDiscardFirstItem && items.length() > 0) {
+ // can't use mItems.remove(0) since it requires API 19
+ mItems = new JSONArray();
+ for (int i = 1; i < items.length(); i++) {
+ try {
+ mItems.put(items.get(i));
+ } catch (JSONException e) {
+ AppLog.e(AppLog.T.NOTIFS, e);
+ }
+ }
+ } else {
+ mItems = items;
+ }
+
+ // if the adapter had existing items, make sure the changes are reflected
+ if (hasItems) {
+ notifyDataSetChanged();
+ }
+ }
+
+ public View getView(int position, View cachedView, ViewGroup parent){
+ View view;
+ if (cachedView == null) {
+ view = mInflater.inflate(R.layout.notifications_follow_row, null);
+ } else {
+ view = cachedView;
+ }
+
+ JSONObject noteItem = getItem(position);
+ JSONObject followAction = JSONUtil.queryJSON(noteItem, "action", new JSONObject());
+
+ FollowRow row = (FollowRow) view;
+ row.setFollowListener(new FollowListener(getNoteId()));
+ row.setAction(followAction);
+
+ String headerText = JSONUtil.queryJSON(noteItem, "header_text", "");
+ if (TextUtils.isEmpty(headerText)) {
+ // reblog notifications don't have "header_text" but they do have "header" which
+ // contains the user's name wrapped in a link, so strip the html to get the name
+ headerText = HtmlUtils.fastStripHtml(JSONUtil.queryJSON(noteItem, "header", ""));
+ }
+ row.setNameText(headerText);
+
+ String iconUrl = JSONUtil.queryJSON(noteItem, "icon", "");
+ row.getImageView().setImageUrl(PhotonUtils.fixAvatar(iconUrl, mAvatarSz), WordPress.imageLoader);
+
+ return view;
+ }
+
+ public long getItemId(int position){
+ return position;
+ }
+
+ public JSONObject getItem(int position){
+ return JSONUtil.queryJSON(mItems, String.format("[%d]", position), new JSONObject());
+ }
+
+ public int getCount(){
+ return (mItems != null ? mItems.length() : 0);
+ }
+
+ private int getNoteId() {
+ if (mNote == null)
+ return 0;
+ return StringUtils.stringToInt(mNote.getId());
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteMatcherFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteMatcherFragment.java
new file mode 100644
index 000000000..773fb3109
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteMatcherFragment.java
@@ -0,0 +1,77 @@
+package org.wordpress.android.ui.notifications;
+
+import android.app.Fragment;
+import android.os.Bundle;
+import android.text.Html;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import com.android.volley.toolbox.NetworkImageView;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Note;
+import org.wordpress.android.util.JSONUtil;
+import org.wordpress.android.util.WPLinkMovementMethod;
+
+public class NoteMatcherFragment extends Fragment implements NotificationFragment {
+ private Note mNote;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup parent, Bundle state){
+ View view = inflater.inflate(R.layout.notifications_matcher, parent, false);
+
+ // No note? No service.
+ if (getNote() == null)
+ return view;
+
+ JSONObject noteBody = getNote().queryJSON("body", new JSONObject());
+ JSONArray noteBodyItems = getNote().queryJSON("body.items", new JSONArray());
+ JSONObject noteBodyItemAtPositionZero = JSONUtil.queryJSON(noteBodyItems, String.format("[%d]", 0), new JSONObject());
+
+ DetailHeader noteHeader = (DetailHeader) view.findViewById(R.id.header);
+ JSONObject subject = getNote().queryJSON("subject", new JSONObject());
+ String headerText = JSONUtil.getStringDecoded(subject, "text");
+ noteHeader.setText(headerText);
+ noteHeader.setClickable(false);
+
+ String gravURL = JSONUtil.queryJSON(noteBodyItemAtPositionZero, "icon", "");
+ if (!gravURL.equals("")) {
+ NetworkImageView mBadgeImageView = (NetworkImageView) view.findViewById(R.id.gravatar);
+ mBadgeImageView.setImageUrl(gravURL, WordPress.imageLoader);
+ }
+
+ TextView bodyTextView = (TextView) view.findViewById(R.id.body);
+ bodyTextView.setMovementMethod(WPLinkMovementMethod.getInstance());
+ String noteHTML = JSONUtil.getString(noteBodyItemAtPositionZero, "html");
+ bodyTextView.setText(Html.fromHtml(noteHTML));
+
+ //setup the footer
+ DetailHeader noteFooter = (DetailHeader) view.findViewById(R.id.footer);
+ String footerText = JSONUtil.getStringDecoded(noteBody, "header_text");
+ noteFooter.setText(footerText);
+ JSONObject bodyObject = getNote().queryJSON("body", new JSONObject());
+ String itemURL = JSONUtil.getString(bodyObject, "header_link");
+ noteFooter.setNote(getNote(), itemURL);
+
+ if (getActivity() instanceof OnPostClickListener) {
+ noteFooter.setOnPostClickListener(((OnPostClickListener)getActivity()));
+ }
+ if (getActivity() instanceof OnCommentClickListener) {
+ noteFooter.setOnCommentClickListener(((OnCommentClickListener)getActivity()));
+ }
+
+ return view;
+ }
+
+ public void setNote(Note note){
+ mNote = note;
+ }
+ public Note getNote(){
+ return mNote;
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteSingleLineListFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteSingleLineListFragment.java
new file mode 100644
index 000000000..6a6fa96a0
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteSingleLineListFragment.java
@@ -0,0 +1,72 @@
+/**
+ * Behaves much list a ListFragment
+ */
+package org.wordpress.android.ui.notifications;
+
+import android.app.ListFragment;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ListView;
+
+import org.json.JSONObject;
+import org.wordpress.android.R;
+import org.wordpress.android.models.Note;
+import org.wordpress.android.util.JSONUtil;
+
+public class NoteSingleLineListFragment extends ListFragment implements NotificationFragment {
+ private Note mNote;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.notifications_follow_list, container, false);
+ }
+
+ @Override
+ public void onActivityCreated(Bundle bundle){
+ super.onActivityCreated(bundle);
+
+ ListView list = getListView();
+ list.setDivider(getResources().getDrawable(R.drawable.list_divider));
+ list.setDividerHeight(1);
+ list.setHeaderDividersEnabled(false);
+
+ // No note? No service.
+ if (getNote() == null)
+ return;
+
+ // set the header
+ final DetailHeader noteHeader = (DetailHeader) getView().findViewById(R.id.header);
+ noteHeader.setText(JSONUtil.getStringDecoded(getNote().queryJSON("subject", new JSONObject()), "text"));
+ String footerUrl = getNote().queryJSON("body.header_link", "");
+ noteHeader.setNote(getNote(), footerUrl);
+
+ if (getActivity() instanceof OnPostClickListener) {
+ noteHeader.setOnPostClickListener(((OnPostClickListener)getActivity()));
+ }
+ if (getActivity() instanceof OnCommentClickListener) {
+ noteHeader.setOnCommentClickListener(((OnCommentClickListener)getActivity()));
+ }
+
+ setListAdapter(new NoteFollowAdapter(getActivity(), getNote(), false));
+ }
+
+ @Override
+ public void setNote(Note note){
+ mNote = note;
+ }
+
+ @Override
+ public Note getNote(){
+ return mNote;
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ if (outState.isEmpty()) {
+ outState.putBoolean("bug_19917_fix", true);
+ }
+ super.onSaveInstanceState(outState);
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotesAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotesAdapter.java
new file mode 100644
index 000000000..e7d05f775
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotesAdapter.java
@@ -0,0 +1,145 @@
+package org.wordpress.android.ui.notifications;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.drawable.Drawable;
+import android.support.v4.widget.CursorAdapter;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.simperium.client.Bucket;
+import com.simperium.client.Query;
+
+import org.wordpress.android.R;
+import org.wordpress.android.models.Note;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.DisplayUtils;
+import org.wordpress.android.util.PhotonUtils;
+import org.wordpress.android.widgets.WPNetworkImageView;
+
+import java.util.HashMap;
+
+class NotesAdapter extends CursorAdapter {
+
+ private final int mAvatarSz;
+ private final Query mQuery;
+ private final Context mContext;
+
+ NotesAdapter(Context context, Bucket<Note> bucket) {
+ super(context, null, 0x0);
+
+ mContext = context;
+ // build a query that sorts by timestamp descending
+ mQuery = bucket.query().order(Note.Schema.TIMESTAMP_INDEX, Query.SortType.DESCENDING);
+
+ mAvatarSz = DisplayUtils.dpToPx(context, 48);
+ }
+
+ public void closeCursor() {
+ Cursor cursor = getCursor();
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ public void reloadNotes() {
+ changeCursor(mQuery.execute());
+ }
+
+ public Note getNote(int position) {
+ getCursor().moveToPosition(position);
+ return getNote();
+ }
+
+ private Note getNote() {
+ return ((Bucket.ObjectCursor<Note>) getCursor()).getObject();
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup parent) {
+ View view = LayoutInflater.from(context).inflate(R.layout.note_list_item, parent, false);
+ NoteViewHolder holder = new NoteViewHolder(view);
+ view.setTag(holder);
+
+ return view;
+ }
+
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ if (cursor.isClosed())
+ return;
+
+ Bucket.ObjectCursor<Note> bucketCursor = (Bucket.ObjectCursor<Note>) cursor;
+ Note note = bucketCursor.getObject();
+
+ NoteViewHolder noteViewHolder = (NoteViewHolder) view.getTag();
+
+ noteViewHolder.txtLabel.setText(note.getSubject());
+ if (note.isCommentType()) {
+ noteViewHolder.txtDetail.setText(note.getCommentPreview());
+ noteViewHolder.txtDetail.setVisibility(View.VISIBLE);
+ } else {
+ noteViewHolder.txtDetail.setVisibility(View.GONE);
+ }
+
+ noteViewHolder.txtDate.setText(note.getTimeSpan());
+
+ String avatarUrl = PhotonUtils.fixAvatar(note.getIconURL(), mAvatarSz);
+ noteViewHolder.imgAvatar.setImageUrl(avatarUrl, WPNetworkImageView.ImageType.AVATAR);
+
+ noteViewHolder.imgNoteIcon.setImageDrawable(getDrawableForType(note.getType()));
+
+ noteViewHolder.unreadIndicator.setVisibility(note.isUnread() ? View.VISIBLE : View.INVISIBLE);
+ }
+
+ // HashMap of drawables for note types
+ private final HashMap<String, Drawable> mNoteIcons = new HashMap<String, Drawable>();
+
+ private Drawable getDrawableForType(String noteType) {
+ if (mContext == null || noteType == null)
+ return null;
+
+ // use like icon for comment likes
+ if (noteType.equals(Note.NOTE_COMMENT_LIKE_TYPE))
+ noteType = Note.NOTE_LIKE_TYPE;
+
+ Drawable icon = mNoteIcons.get(noteType);
+ if (icon != null)
+ return icon;
+
+ int imageId = mContext.getResources().getIdentifier("note_icon_" + noteType, "drawable", mContext.getPackageName());
+ if (imageId == 0) {
+ Log.w(AppLog.TAG, "unknown note type - " + noteType);
+ return null;
+ }
+
+ icon = mContext.getResources().getDrawable(imageId);
+ if (icon == null)
+ return null;
+
+ mNoteIcons.put(noteType, icon);
+ return icon;
+ }
+
+ private static class NoteViewHolder {
+ private final TextView txtLabel;
+ private final TextView txtDetail;
+ private final TextView unreadIndicator;
+ private final TextView txtDate;
+ private final WPNetworkImageView imgAvatar;
+ private final ImageView imgNoteIcon;
+
+ NoteViewHolder(View view) {
+ txtLabel = (TextView) view.findViewById(R.id.note_label);
+ txtDetail = (TextView) view.findViewById(R.id.note_detail);
+ unreadIndicator = (TextView) view.findViewById(R.id.unread_indicator);
+ txtDate = (TextView) view.findViewById(R.id.text_date);
+ imgAvatar = (WPNetworkImageView) view.findViewById(R.id.note_avatar);
+ imgNoteIcon = (ImageView) view.findViewById(R.id.note_icon);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationFragment.java
new file mode 100644
index 000000000..a569d6223
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationFragment.java
@@ -0,0 +1,23 @@
+/**
+ * Provides a list view and list adapter to display a note. It will have a header view to show
+ * the avatar and other details for the post.
+ *
+ * More specialized note adapters will need to be made to provide the correct views for the type
+ * of note/note template it has.
+ */
+package org.wordpress.android.ui.notifications;
+
+import org.wordpress.android.models.Note;
+
+public interface NotificationFragment {
+ public static interface OnPostClickListener {
+ public void onPostClicked(Note note, int remoteBlogId, int postId);
+ }
+
+ public static interface OnCommentClickListener {
+ public void onCommentClicked(Note note, int remoteBlogId, long commentId);
+ }
+
+ public Note getNote();
+ public void setNote(Note note);
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationUtils.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationUtils.java
new file mode 100644
index 000000000..ee45e3a41
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationUtils.java
@@ -0,0 +1,204 @@
+package org.wordpress.android.ui.notifications;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.Build;
+import android.preference.PreferenceManager;
+import android.text.TextUtils;
+
+import com.android.volley.VolleyError;
+import com.google.android.gcm.GCMRegistrar;
+import com.google.gson.Gson;
+import com.google.gson.internal.StringMap;
+import com.wordpress.rest.RestRequest;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.BuildConfig;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.DeviceUtils;
+import org.wordpress.android.util.MapUtils;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Map;
+
+public class NotificationUtils {
+
+ public static final String WPCOM_PUSH_DEVICE_NOTIFICATION_SETTINGS = "wp_pref_notification_settings";
+ private static final String WPCOM_PUSH_DEVICE_SERVER_ID = "wp_pref_notifications_server_id";
+ public static final String WPCOM_PUSH_DEVICE_UUID = "wp_pref_notifications_uuid";
+
+ public static void getPushNotificationSettings(Context context, RestRequest.Listener listener,
+ RestRequest.ErrorListener errorListener) {
+ if (!WordPress.hasValidWPComCredentials(context)) {
+ return;
+ }
+
+ String gcmToken = GCMRegistrar.getRegistrationId(context);
+ if (TextUtils.isEmpty(gcmToken)) {
+ return;
+ }
+
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context);
+ String deviceID = settings.getString(WPCOM_PUSH_DEVICE_SERVER_ID, null);
+ if (TextUtils.isEmpty(deviceID)) {
+ AppLog.e(T.NOTIFS, "device_ID is null in preferences. Get device settings skipped.");
+ return;
+ }
+
+ WordPress.getRestClientUtils().get("/device/" + deviceID, listener, errorListener);
+ }
+
+ public static void setPushNotificationSettings(Context context) {
+ if (!WordPress.hasValidWPComCredentials(context)) {
+ return;
+ }
+
+ String gcmToken = GCMRegistrar.getRegistrationId(context);
+ if (TextUtils.isEmpty(gcmToken)) {
+ return;
+ }
+
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context);
+ String deviceID = settings.getString(WPCOM_PUSH_DEVICE_SERVER_ID, null);
+ if (TextUtils.isEmpty(deviceID)) {
+ AppLog.e(T.NOTIFS, "device_ID is null in preferences. Set device settings skipped.");
+ return;
+ }
+
+ String settingsJson = settings.getString(WPCOM_PUSH_DEVICE_NOTIFICATION_SETTINGS, null);
+ if (settingsJson == null)
+ return;
+
+ Gson gson = new Gson();
+ Map<String, StringMap<String>> notificationSettings = gson.fromJson(settingsJson, HashMap.class);
+ Map<String, Object> updatedSettings = new HashMap<String, Object>();
+ if (notificationSettings == null)
+ return;
+
+
+ // Build the settings object to send back to WP.com
+ StringMap<?> mutedBlogsMap = notificationSettings.get("muted_blogs");
+ StringMap<?> muteUntilMap = notificationSettings.get("mute_until");
+ ArrayList<StringMap<Double>> blogsList = (ArrayList<StringMap<Double>>) mutedBlogsMap.get("value");
+ notificationSettings.remove("muted_blogs");
+ notificationSettings.remove("mute_until");
+
+ for (Map.Entry<String, StringMap<String>> entry : notificationSettings.entrySet())
+ {
+ StringMap<String> setting = entry.getValue();
+ updatedSettings.put(entry.getKey(), setting.get("value"));
+ }
+
+ if (muteUntilMap != null && muteUntilMap.get("value") != null) {
+ updatedSettings.put("mute_until", muteUntilMap.get("value"));
+ }
+
+ ArrayList<StringMap<Double>> mutedBlogsList = new ArrayList<StringMap<Double>>();
+ for (StringMap<Double> userBlog : blogsList) {
+ if (MapUtils.getMapBool(userBlog, "value")) {
+ mutedBlogsList.add(userBlog);
+ }
+ }
+
+ if (updatedSettings.size() == 0 && mutedBlogsList.size() == 0)
+ return;
+
+ updatedSettings.put("muted_blogs", mutedBlogsList); //If muted blogs list is unchanged we can even skip this assignment.
+
+ Map<String, String> contentStruct = new HashMap<String, String>();
+ contentStruct.put("device_token", gcmToken);
+ contentStruct.put("device_family", "android");
+ contentStruct.put("app_secret_key", NotificationUtils.getAppPushNotificationsName());
+ contentStruct.put("settings", gson.toJson(updatedSettings));
+ WordPress.getRestClientUtils().post("/device/"+deviceID, contentStruct, null, null, null);
+ }
+
+ public static void registerDeviceForPushNotifications(final Context ctx, String token) {
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(ctx);
+ String uuid = settings.getString(WPCOM_PUSH_DEVICE_UUID, null);
+ if (uuid == null)
+ return;
+
+ String deviceName = DeviceUtils.getInstance().getDeviceName(ctx);
+ Map<String, String> contentStruct = new HashMap<String, String>();
+ contentStruct.put("device_token", token);
+ contentStruct.put("device_family", "android");
+ contentStruct.put("app_secret_key", NotificationUtils.getAppPushNotificationsName());
+ contentStruct.put("device_name", deviceName);
+ contentStruct.put("device_model", Build.MANUFACTURER + " " + Build.MODEL);
+ contentStruct.put("app_version", WordPress.versionName);
+ contentStruct.put("os_version", android.os.Build.VERSION.RELEASE);
+ contentStruct.put("device_uuid", uuid);
+ com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ AppLog.d(T.NOTIFS, "Register token action succeeded");
+ try {
+ String deviceID = jsonObject.getString("ID");
+ if (deviceID==null) {
+ AppLog.e(T.NOTIFS, "Server response is missing of the device_id. Registration skipped!!");
+ return;
+ }
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(ctx);
+ SharedPreferences.Editor editor = settings.edit();
+ editor.putString(WPCOM_PUSH_DEVICE_SERVER_ID, deviceID);
+ JSONObject settingsJSON = jsonObject.getJSONObject("settings");
+ editor.putString(WPCOM_PUSH_DEVICE_NOTIFICATION_SETTINGS, settingsJSON.toString());
+ editor.commit();
+ AppLog.d(T.NOTIFS, "Server response OK. The device_id : " + deviceID);
+ } catch (JSONException e1) {
+ AppLog.e(T.NOTIFS, "Server response is NOT ok. Registration skipped!!", e1);
+ }
+ }
+ };
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.e(T.NOTIFS, "Register token action failed", volleyError);
+ }
+ };
+
+ WordPress.getRestClientUtils().post("/devices/new", contentStruct, null, listener, errorListener);
+ }
+
+ public static void unregisterDevicePushNotifications(final Context ctx) {
+ com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ AppLog.d(T.NOTIFS, "Unregister token action succeeded");
+ SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(ctx).edit();
+ editor.remove(WPCOM_PUSH_DEVICE_SERVER_ID);
+ editor.remove(WPCOM_PUSH_DEVICE_NOTIFICATION_SETTINGS);
+ editor.remove(WPCOM_PUSH_DEVICE_UUID);
+ editor.commit();
+ }
+ };
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.e(T.NOTIFS, "Unregister token action failed", volleyError);
+ }
+ };
+
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(ctx);
+ String deviceID = settings.getString(WPCOM_PUSH_DEVICE_SERVER_ID, null );
+ if (TextUtils.isEmpty(deviceID)) {
+ return;
+ }
+ WordPress.getRestClientUtils().post("/devices/" + deviceID + "/delete", listener, errorListener);
+ }
+
+ public static String getAppPushNotificationsName(){
+ //white listing only few keys.
+ if (BuildConfig.APP_PN_KEY.equals("org.wordpress.android.beta.build"))
+ return "org.wordpress.android.beta.build";
+ if (BuildConfig.APP_PN_KEY.equals("org.wordpress.android.debug.build"))
+ return "org.wordpress.android.debug.build";
+
+ return "org.wordpress.android.playstore";
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsActivity.java
new file mode 100644
index 000000000..f3f11ff62
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsActivity.java
@@ -0,0 +1,397 @@
+package org.wordpress.android.ui.notifications;
+
+import android.app.ActionBar;
+import android.app.Dialog;
+import android.app.Fragment;
+import android.app.FragmentManager;
+import android.app.FragmentTransaction;
+import android.app.NotificationManager;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+
+import com.simperium.client.Bucket;
+import com.simperium.client.BucketObjectMissingException;
+import com.simperium.client.User;
+
+import org.wordpress.android.GCMIntentService;
+import org.wordpress.android.R;
+import org.wordpress.android.models.BlogPairId;
+import org.wordpress.android.models.Note;
+import org.wordpress.android.ui.WPActionBarActivity;
+import org.wordpress.android.ui.comments.CommentActions;
+import org.wordpress.android.ui.comments.CommentDetailFragment;
+import org.wordpress.android.ui.comments.CommentDialogs;
+import org.wordpress.android.ui.reader.ReaderPostDetailFragment;
+import org.wordpress.android.ui.reader.actions.ReaderAuthActions;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.SimperiumUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+
+public class NotificationsActivity extends WPActionBarActivity
+ implements CommentActions.OnCommentChangeListener, NotificationFragment.OnPostClickListener,
+ NotificationFragment.OnCommentClickListener {
+ public static final String NOTIFICATION_ACTION = "org.wordpress.android.NOTIFICATION";
+ public static final String NOTE_ID_EXTRA = "noteId";
+ public static final String FROM_NOTIFICATION_EXTRA = "fromNotification";
+ public static final String NOTE_INSTANT_REPLY_EXTRA = "instantReply";
+ private static final String KEY_INITIAL_UPDATE = "initial_update";
+ private static final String KEY_SELECTED_COMMENT_ID = "selected_comment_id";
+ private static final String KEY_SELECTED_POST_ID = "selected_post_id";
+
+ private NotificationsListFragment mNotesList;
+ private boolean mDualPane;
+ private int mSelectedNoteId;
+ private boolean mHasPerformedInitialUpdate;
+ private BlogPairId mTmpSelectedComment;
+ private BlogPairId mTmpSelectedReaderPost;
+ private BlogPairId mSelectedComment;
+ private BlogPairId mSelectedReaderPost;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(null);
+ createMenuDrawer(R.layout.notifications);
+ // savedInstanceState will be non-null if activity is being re-created
+ if (savedInstanceState == null) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATIONS_ACCESSED);
+ }
+
+ View fragmentContainer = findViewById(R.id.layout_fragment_container);
+ mDualPane = fragmentContainer != null && getString(R.string.dual_pane_mode).equals(fragmentContainer.getTag());
+
+ ActionBar actionBar = getActionBar();
+ actionBar.setDisplayShowTitleEnabled(true);
+ setTitle(getResources().getString(R.string.notifications));
+
+ FragmentManager fm = getFragmentManager();
+ fm.addOnBackStackChangedListener(mOnBackStackChangedListener);
+ mNotesList = (NotificationsListFragment) fm.findFragmentById(R.id.fragment_notes_list);
+ mNotesList.setOnNoteClickListener(new NoteClickListener());
+
+ GCMIntentService.clearNotificationsMap();
+
+ if (savedInstanceState != null) {
+ mHasPerformedInitialUpdate = savedInstanceState.getBoolean(KEY_INITIAL_UPDATE);
+ popNoteDetail();
+ } else {
+ launchWithNoteId();
+ }
+
+ // remove window background since background color is set in fragment (reduces overdraw)
+ getWindow().setBackgroundDrawable(null);
+
+ // Show an auth alert if we don't have an authorized Simperium user
+ if (SimperiumUtils.getSimperium() != null) {
+ User user = SimperiumUtils.getSimperium().getUser();
+ if (user != null && user.getStatus() == User.Status.NOT_AUTHORIZED) {
+ ToastUtils.showAuthErrorDialog(this, R.string.sign_in_again, R.string.simperium_connection_error);
+ }
+ }
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ GCMIntentService.clearNotificationsMap();
+ launchWithNoteId();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+
+ // Remove notification if it is showing when we resume this activity.
+ NotificationManager notificationManager = (NotificationManager)getSystemService(NOTIFICATION_SERVICE);
+ notificationManager.cancel(GCMIntentService.PUSH_NOTIFICATION_ID);
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (mNotesList != null) {
+ mNotesList.closeAdapterCursor();
+ }
+
+ super.onDestroy();
+ }
+
+ private final FragmentManager.OnBackStackChangedListener mOnBackStackChangedListener =
+ new FragmentManager.OnBackStackChangedListener() {
+ public void onBackStackChanged() {
+ int backStackEntryCount = getFragmentManager().getBackStackEntryCount();
+ // This is ugly, but onBackStackChanged is not called just after a fragment commit.
+ // In a 2 commits in a row case, onBackStackChanged is called twice but after the
+ // 2 commits. That's why mSelectedPostId can't be affected correctly after the first commit.
+ switch (backStackEntryCount) {
+ case 2:
+ mSelectedReaderPost = mTmpSelectedReaderPost;
+ mSelectedComment = mTmpSelectedComment;
+ mTmpSelectedReaderPost = null;
+ mTmpSelectedComment = null;
+ break;
+ case 1:
+ if (mDualPane) {
+ mSelectedReaderPost = mTmpSelectedReaderPost;
+ mSelectedComment = mTmpSelectedComment;
+ } else {
+ mSelectedReaderPost = null;
+ mSelectedComment = null;
+ }
+ break;
+ case 0:
+ mMenuDrawer.setDrawerIndicatorEnabled(true);
+ mSelectedReaderPost = null;
+ mSelectedComment = null;
+ break;
+ }
+ }
+ };
+
+ /**
+ * Detect if Intent has a noteId extra and display that specific note detail fragment
+ */
+ private void launchWithNoteId() {
+ Intent intent = getIntent();
+ if (intent.hasExtra(NOTE_ID_EXTRA)) {
+ String noteID = intent.getStringExtra(NOTE_ID_EXTRA);
+
+ Bucket<Note> notesBucket = SimperiumUtils.getNotesBucket();
+ try {
+ if (notesBucket != null) {
+ Note note = notesBucket.get(noteID);
+ if (note != null) {
+ openNote(note);
+ }
+ }
+ } catch (BucketObjectMissingException e) {
+ AppLog.e(T.NOTIFS, "Could not load notification from bucket.");
+ }
+ } else {
+ // Dual pane and no note specified then select the first note
+ if (mDualPane && mNotesList != null) {
+ mNotesList.setShouldLoadFirstNote(true);
+ }
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ if (mDualPane) {
+ // let WPActionBarActivity handle it (toggles menu drawer)
+ return super.onOptionsItemSelected(item);
+ } else {
+ FragmentManager fm = getFragmentManager();
+ if (fm.getBackStackEntryCount() > 0) {
+ popNoteDetail();
+ return true;
+ } else {
+ return super.onOptionsItemSelected(item);
+ }
+ }
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.notifications, menu);
+ return true;
+ }
+
+ /*
+ * triggered from the comment details fragment whenever a comment is changed (moderated, added,
+ * deleted, etc.) - refresh notifications so changes are reflected here
+ */
+ @Override
+ public void onCommentChanged(CommentActions.ChangedFrom changedFrom, CommentActions.ChangeType changeType) {
+ // remove the comment detail fragment if the comment was trashed
+ if (changeType == CommentActions.ChangeType.TRASHED && changedFrom == CommentActions.ChangedFrom.COMMENT_DETAIL) {
+ FragmentManager fm = getFragmentManager();
+ if (fm.getBackStackEntryCount() > 0) {
+ fm.popBackStack();
+ }
+ }
+
+ mNotesList.refreshNotes();
+ }
+
+ void popNoteDetail(){
+ FragmentManager fm = getFragmentManager();
+ Fragment f = fm.findFragmentById(R.id.fragment_comment_detail);
+ if (f == null) {
+ fm.popBackStack();
+ }
+ }
+
+ /**
+ * Tries to pick the correct fragment detail type for a given note
+ */
+ private Fragment getDetailFragmentForNote(Note note){
+ if (note == null)
+ return null;
+
+ if (note.isCommentType()) {
+ // show comment detail for comment notifications
+ return CommentDetailFragment.newInstance(note);
+ } else if (note.isCommentLikeType()) {
+ return new NoteCommentLikeFragment();
+ } else if (note.isAutomattcherType()) {
+ // show reader post detail for automattchers about posts - note that comment
+ // automattchers are handled by note.isCommentType() above
+ boolean isPost = (note.getBlogId() !=0 && note.getPostId() != 0 && note.getCommentId() == 0);
+ if (isPost) {
+ return ReaderPostDetailFragment.newInstance(note.getBlogId(), note.getPostId());
+ } else {
+ // right now we'll never get here
+ return new NoteMatcherFragment();
+ }
+ } else if (note.isSingleLineListTemplate()) {
+ return new NoteSingleLineListFragment();
+ } else if (note.isBigBadgeTemplate()) {
+ return new BigBadgeFragment();
+ }
+
+ return null;
+ }
+
+ /**
+ * Open a note fragment based on the type of note
+ */
+ private void openNote(final Note note) {
+ if (note == null || isFinishing() || isActivityDestroyed()) {
+ return;
+ }
+
+ mSelectedNoteId = StringUtils.stringToInt(note.getId());
+
+ // mark the note as read if it's unread
+ if (note.isUnread()) {
+ // mark as read which syncs with simperium
+ note.markAsRead();
+ }
+ FragmentManager fm = getFragmentManager();
+
+ // remove the note detail if it's already on there
+ if (fm.getBackStackEntryCount() > 0) {
+ fm.popBackStack();
+ }
+
+ // create detail fragment for this note type
+ Fragment detailFragment = getDetailFragmentForNote(note);
+ if (detailFragment == null) {
+ AppLog.d(T.NOTIFS, String.format("No fragment found for %s", note.toJSONObject()));
+ return;
+ }
+
+ // set the note if this is a NotificationFragment (ReaderPostDetailFragment is the only
+ // fragment used here that is not a NotificationFragment)
+ if (detailFragment instanceof NotificationFragment) {
+ ((NotificationFragment) detailFragment).setNote(note);
+ }
+
+ // swap the fragment
+ FragmentTransaction ft = fm.beginTransaction();
+ ft.replace(R.id.layout_fragment_container, detailFragment).setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE);
+
+ AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATIONS_OPENED_NOTIFICATION_DETAILS);
+ // only add to backstack if we're removing the list view from the fragment container
+ View container = findViewById(R.id.layout_fragment_container);
+ if (container.findViewById(R.id.fragment_notes_list) != null) {
+ mMenuDrawer.setDrawerIndicatorEnabled(false);
+ ft.addToBackStack(null);
+ if (mNotesList != null) {
+ ft.hide(mNotesList);
+ }
+ }
+ ft.commitAllowingStateLoss();
+ }
+
+ private class NoteClickListener implements NotificationsListFragment.OnNoteClickListener {
+ @Override
+ public void onClickNote(Note note){
+ if (note == null)
+ return;
+ // open the latest version of this note just in case it has changed - this can
+ // happen if the note was tapped from the list fragment after it was updated
+ // by another fragment (such as NotificationCommentLikeFragment)
+ //Note updatedNote = WordPress.wpDB.getNoteById(StringUtils.stringToInt(note.getId()));
+ openNote(note);
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ if (outState.isEmpty()) {
+ outState.putBoolean("bug_19917_fix", true);
+ }
+ outState.putBoolean(KEY_INITIAL_UPDATE, mHasPerformedInitialUpdate);
+ outState.putInt(NOTE_ID_EXTRA, mSelectedNoteId);
+ if (mSelectedReaderPost != null) {
+ outState.putSerializable(KEY_SELECTED_POST_ID, mSelectedReaderPost);
+ }
+ if (mSelectedComment != null) {
+ outState.putSerializable(KEY_SELECTED_COMMENT_ID, mSelectedComment);
+ }
+ super.onSaveInstanceState(outState);
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ if (!mHasPerformedInitialUpdate) {
+ mHasPerformedInitialUpdate = true;
+ ReaderAuthActions.updateCookies(this);
+ }
+ }
+
+ @Override
+ protected Dialog onCreateDialog(int id) {
+ Dialog dialog = CommentDialogs.createCommentDialog(this, id);
+ if (dialog != null)
+ return dialog;
+ return super.onCreateDialog(id);
+ }
+
+ /**
+ * called from fragment when a link to a post is tapped - shows the post in a reader
+ * detail fragment
+ */
+ @Override
+ public void onPostClicked(Note note, int remoteBlogId, int postId) {
+ mTmpSelectedReaderPost = new BlogPairId(remoteBlogId, postId);
+ ReaderPostDetailFragment readerFragment = ReaderPostDetailFragment.newInstance(remoteBlogId, postId);
+ String tagForFragment = getString(R.string.fragment_tag_reader_post_detail);
+ FragmentTransaction ft = getFragmentManager().beginTransaction();
+ ft.replace(R.id.layout_fragment_container, readerFragment, tagForFragment)
+ .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
+ .addToBackStack(tagForFragment)
+ .commit();
+ }
+
+ /**
+ * called from fragment when a link to a comment is tapped - shows the comment in the comment
+ * detail fragment
+ */
+ @Override
+ public void onCommentClicked(Note note, int remoteBlogId, long commentId) {
+ mTmpSelectedComment = new BlogPairId(remoteBlogId, commentId);
+ CommentDetailFragment commentFragment = CommentDetailFragment.newInstance(note);
+ String tagForFragment = getString(R.string.fragment_tag_comment_detail);
+ FragmentTransaction ft = getFragmentManager().beginTransaction();
+ ft.replace(R.id.layout_fragment_container, commentFragment, tagForFragment)
+ .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
+ .addToBackStack(tagForFragment)
+ .commit();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragment.java
new file mode 100644
index 000000000..3c8d2b773
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragment.java
@@ -0,0 +1,232 @@
+package org.wordpress.android.ui.notifications;
+
+import android.app.ListFragment;
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.os.Handler;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import android.widget.TextView;
+
+import com.simperium.client.Bucket;
+import com.simperium.client.BucketObject;
+import com.simperium.client.BucketObjectMissingException;
+
+import org.wordpress.android.R;
+import org.wordpress.android.models.Note;
+import org.wordpress.android.ui.PullToRefreshHelper;
+import org.wordpress.android.util.SimperiumUtils;
+import org.wordpress.android.util.ToastUtils;
+
+import uk.co.senab.actionbarpulltorefresh.library.PullToRefreshLayout;
+
+public class NotificationsListFragment extends ListFragment implements Bucket.Listener<Note> {
+ private PullToRefreshHelper mFauxPullToRefreshHelper;
+ private NotesAdapter mNotesAdapter;
+ private OnNoteClickListener mNoteClickListener;
+ private boolean mShouldLoadFirstNote;
+
+ Bucket<Note> mBucket;
+
+ /**
+ * For responding to tapping of notes
+ */
+ public interface OnNoteClickListener {
+ public void onClickNote(Note note);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View v = inflater.inflate(R.layout.empty_listview, container, false);
+ return v;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ // setup the initial notes adapter, starts listening to the bucket
+ mBucket = SimperiumUtils.getNotesBucket();
+
+ ListView listView = getListView();
+ listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
+ listView.setDivider(getResources().getDrawable(R.drawable.list_divider));
+ listView.setDividerHeight(1);
+ if (mBucket != null) {
+ mNotesAdapter = new NotesAdapter(getActivity(), mBucket);
+ setListAdapter(mNotesAdapter);
+ } else {
+ ToastUtils.showToast(getActivity(), R.string.error_refresh_notifications);
+ }
+
+ // Set empty text if no notifications
+ TextView textview = (TextView) listView.getEmptyView();
+ if (textview != null) {
+ textview.setText(getText(R.string.notifications_empty_list));
+ }
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ initPullToRefreshHelper();
+ mFauxPullToRefreshHelper.registerReceiver(getActivity());
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ refreshNotes();
+
+ // start listening to bucket change events
+ if (mBucket != null) {
+ mBucket.addListener(this);
+ }
+ }
+
+ @Override
+ public void onPause() {
+ // unregister the listener and close the cursor
+ if (mBucket != null) {
+ mBucket.removeListener(this);
+ }
+ super.onPause();
+ }
+
+ @Override
+ public void onDestroyView() {
+ mFauxPullToRefreshHelper.unregisterReceiver(getActivity());
+ super.onDestroyView();
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ boolean isRefreshing = mFauxPullToRefreshHelper.isRefreshing();
+ super.onConfigurationChanged(newConfig);
+ // Pull to refresh layout is destroyed onDetachedFromWindow,
+ // so we have to re-init the layout, via the helper here
+ initPullToRefreshHelper();
+ mFauxPullToRefreshHelper.setRefreshing(isRefreshing);
+ }
+
+ public void closeAdapterCursor() {
+ if (mNotesAdapter != null) {
+ mNotesAdapter.closeCursor();
+ }
+ }
+
+ private void initPullToRefreshHelper() {
+ mFauxPullToRefreshHelper = new PullToRefreshHelper(
+ getActivity(),
+ (PullToRefreshLayout) getActivity().findViewById(R.id.ptr_layout),
+ new PullToRefreshHelper.RefreshListener() {
+ @Override
+ public void onRefreshStarted(View view) {
+ // Show a fake refresh animation for a few seconds
+ new Handler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ if (hasActivity()) {
+ mFauxPullToRefreshHelper.setRefreshing(false);
+ }
+ }
+ }, 2000);
+ }
+ }, LinearLayout.class
+ );
+ }
+
+ @Override
+ public void onListItemClick(ListView l, View v, int position, long id) {
+ Note note = mNotesAdapter.getNote(position);
+ l.setItemChecked(position, true);
+ if (note != null && mNoteClickListener != null) {
+ mNoteClickListener.onClickNote(note);
+ }
+ }
+
+ public void setOnNoteClickListener(OnNoteClickListener listener) {
+ mNoteClickListener = listener;
+ }
+
+ protected void updateLastSeenTime() {
+ // set the timestamp to now
+ try {
+ if (mNotesAdapter != null && mNotesAdapter.getCount() > 0 && SimperiumUtils.getMetaBucket() != null) {
+ Note newestNote = mNotesAdapter.getNote(0);
+ BucketObject meta = SimperiumUtils.getMetaBucket().get("meta");
+ meta.setProperty("last_seen", newestNote.getTimestamp());
+ meta.save();
+ }
+ } catch (BucketObjectMissingException e) {
+ // try again later, meta is created by wordpress.com
+ }
+ }
+
+ public void refreshNotes() {
+ if (!hasActivity() || mNotesAdapter == null) {
+ return;
+ }
+
+ getActivity().runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mNotesAdapter.reloadNotes();
+ updateLastSeenTime();
+
+ // Show first note if we're on a landscape tablet
+ if (mShouldLoadFirstNote && mNotesAdapter.getCount() > 0) {
+ mShouldLoadFirstNote = false;
+ Note note = mNotesAdapter.getNote(0);
+ if (note != null && mNoteClickListener != null) {
+ mNoteClickListener.onClickNote(note);
+ getListView().setItemChecked(0, true);
+ }
+ }
+ }
+ });
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ if (outState.isEmpty()) {
+ outState.putBoolean("bug_19917_fix", true);
+ }
+ super.onSaveInstanceState(outState);
+ }
+
+ /**
+ * Simperium bucket listener methods
+ */
+ @Override
+ public void onSaveObject(Bucket<Note> bucket, Note object) {
+ refreshNotes();
+ }
+
+ @Override
+ public void onDeleteObject(Bucket<Note> bucket, Note object) {
+ refreshNotes();
+ }
+
+ @Override
+ public void onChange(Bucket<Note> bucket, Bucket.ChangeType type, String key) {
+ refreshNotes();
+ }
+
+ @Override
+ public void onBeforeUpdateObject(Bucket<Note> noteBucket, Note note) {
+ //noop
+ }
+
+ public void setShouldLoadFirstNote(boolean shouldLoad) {
+ mShouldLoadFirstNote = shouldLoad;
+ }
+
+ private boolean hasActivity() {
+ return getActivity() != null;
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsWebViewActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsWebViewActivity.java
new file mode 100644
index 000000000..19d840a06
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsWebViewActivity.java
@@ -0,0 +1,61 @@
+package org.wordpress.android.ui.notifications;
+
+import android.annotation.SuppressLint;
+import android.app.ActionBar;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.view.Menu;
+import android.view.MenuItem;
+
+import org.wordpress.android.R;
+import org.wordpress.android.ui.AuthenticatedWebViewActivity;
+
+@SuppressLint("SetJavaScriptEnabled")
+public class NotificationsWebViewActivity extends AuthenticatedWebViewActivity {
+ private static final String URL_TO_LOAD = "external_url";
+
+ public static void openUrl(Context context, String url) {
+ if (context == null || TextUtils.isEmpty(url))
+ return;
+ Intent intent = new Intent(context, NotificationsWebViewActivity.class);
+ intent.putExtra(NotificationsWebViewActivity.URL_TO_LOAD, url);
+ context.startActivity(intent);
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mWebView.getSettings().setJavaScriptEnabled(true);
+ mWebView.getSettings().setDisplayZoomControls(false);
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+
+ // load URL if one was provided in the intent
+ String url = getIntent().getStringExtra(URL_TO_LOAD);
+ if (!TextUtils.isEmpty(url)) {
+ loadUrl(url);
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ menu.findItem(R.id.menu_signout).setVisible(false);
+ menu.findItem(R.id.menu_settings).setVisible(false);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ int itemID = item.getItemId();
+ if (itemID == android.R.id.home) {
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/AddCategoryActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/AddCategoryActivity.java
new file mode 100644
index 000000000..928855b22
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/AddCategoryActivity.java
@@ -0,0 +1,118 @@
+package org.wordpress.android.ui.posts;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.Spinner;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.CategoryNode;
+
+import java.util.ArrayList;
+
+public class AddCategoryActivity extends Activity {
+ private int id;
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.add_category);
+
+ Bundle extras = getIntent().getExtras();
+ if(extras !=null)
+ {
+ id = extras.getInt("id");
+ }
+
+ loadCategories();
+
+ final Button cancelButton = (Button) findViewById(R.id.cancel);
+ final Button okButton = (Button) findViewById(R.id.ok);
+
+ okButton.setOnClickListener(new Button.OnClickListener() {
+ public void onClick(View v) {
+ EditText categoryNameET = (EditText)findViewById(R.id.category_name);
+ String category_name = categoryNameET.getText().toString();
+ EditText categorySlugET = (EditText)findViewById(R.id.category_slug);
+ String category_slug = categorySlugET.getText().toString();
+ EditText categoryDescET = (EditText)findViewById(R.id.category_desc);
+ String category_desc = categoryDescET.getText().toString();
+ Spinner sCategories = (Spinner) findViewById(R.id.parent_category);
+ String parent_category = "";
+ if (sCategories.getSelectedItem() != null)
+ parent_category = ((CategoryNode)sCategories.getSelectedItem()).getName().trim();
+ int parent_id = 0;
+ if (sCategories.getSelectedItemPosition() != 0){
+ parent_id = WordPress.wpDB.getCategoryId(id, parent_category);
+ }
+
+ if (category_name.replaceAll(" ", "").equals("")) {
+ // Name field cannot be empty
+
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(AddCategoryActivity.this);
+ dialogBuilder.setTitle(getResources().getText(R.string.required_field));
+ dialogBuilder.setMessage(getResources().getText(R.string.cat_name_required));
+ dialogBuilder.setPositiveButton("OK", new
+ DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ // Just close the window.
+
+ }
+ });
+ dialogBuilder.setCancelable(true);
+ dialogBuilder.create().show();
+ }
+ else {
+ Bundle bundle = new Bundle();
+
+ bundle.putString("category_name", category_name);
+ bundle.putString("category_slug", category_slug);
+ bundle.putString("category_desc", category_desc);
+ bundle.putInt("parent_id", parent_id);
+ bundle.putString("continue", "TRUE");
+ Intent mIntent = new Intent();
+ mIntent.putExtras(bundle);
+ setResult(RESULT_OK, mIntent);
+ finish();
+ }
+
+ }
+ });
+
+ cancelButton.setOnClickListener(new Button.OnClickListener() {
+ public void onClick(View v) {
+ Bundle bundle = new Bundle();
+
+ bundle.putString("continue", "FALSE");
+ Intent mIntent = new Intent();
+ mIntent.putExtras(bundle);
+ setResult(RESULT_OK, mIntent);
+ finish();
+ }
+ });
+ }
+
+ private void loadCategories() {
+ CategoryNode rootCategory = CategoryNode.createCategoryTreeFromDB(id);
+ ArrayList<CategoryNode> categoryLevels = CategoryNode.getSortedListOfCategoriesFromRoot(rootCategory);
+ categoryLevels.add(0, new CategoryNode(0, 0, getString(R.string.none)));
+ if (categoryLevels.size() > 0) {
+ ParentCategorySpinnerAdapter categoryAdapter = new ParentCategorySpinnerAdapter(this,
+ R.layout.categories_row_parent, categoryLevels);
+ Spinner sCategories = (Spinner) findViewById(R.id.parent_category);
+ sCategories.setAdapter(categoryAdapter);
+ }
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ //ignore orientation change
+ super.onConfigurationChanged(newConfig);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/CategoryArrayAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/CategoryArrayAdapter.java
new file mode 100644
index 000000000..eb3cfd350
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/CategoryArrayAdapter.java
@@ -0,0 +1,41 @@
+package org.wordpress.android.ui.posts;
+
+import android.content.Context;
+import android.text.Html;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.ImageView;
+import android.widget.TextView;
+import org.wordpress.android.R;
+import org.wordpress.android.models.CategoryNode;
+
+import java.util.List;
+
+public class CategoryArrayAdapter extends ArrayAdapter<CategoryNode> {
+ int mResourceId;
+
+ public CategoryArrayAdapter(Context context, int resource, List<CategoryNode> objects) {
+ super(context, resource, objects);
+ mResourceId = resource;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ LayoutInflater inflater = (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ View rowView = inflater.inflate(mResourceId, parent, false);
+ TextView textView = (TextView) rowView.findViewById(R.id.categoryRowText);
+ ImageView levelIndicatorView = (ImageView) rowView.findViewById(R.id.categoryRowLevelIndicator);
+ textView.setText(Html.fromHtml(getItem(position).getName()));
+ int level = getItem(position).getLevel();
+ if (level == 1) { // hide ImageView
+ levelIndicatorView.setVisibility(View.GONE);
+ } else {
+ ViewGroup.LayoutParams params = levelIndicatorView.getLayoutParams();
+ params.width = (params.width / 2) * level;
+ levelIndicatorView.setLayoutParams(params);
+ }
+ return rowView;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditLinkActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditLinkActivity.java
new file mode 100644
index 000000000..657a27f30
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditLinkActivity.java
@@ -0,0 +1,76 @@
+package org.wordpress.android.ui.posts;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.Button;
+import android.widget.EditText;
+
+import org.wordpress.android.R;
+
+public class EditLinkActivity extends Activity {
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.alert_create_link);
+
+ Bundle extras = getIntent().getExtras();
+ if (extras != null) {
+ String selectedText = extras.getString("selectedText");
+ if (selectedText != null) {
+ EditText linkTextET = (EditText) findViewById(R.id.linkText);
+ linkTextET.setText(selectedText);
+ }
+ }
+
+ final Button cancelButton = (Button) findViewById(R.id.cancel);
+ final Button okButton = (Button) findViewById(R.id.ok);
+
+ final EditText urlEditText = (EditText) findViewById(R.id.linkURL);
+ urlEditText.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (urlEditText.getText().toString().equals("")) {
+ urlEditText.setText("http://");
+ urlEditText.setSelection(7);
+ }
+ }
+
+ });
+
+ okButton.setOnClickListener(new Button.OnClickListener() {
+ public void onClick(View v) {
+ EditText linkURLET = (EditText) findViewById(R.id.linkURL);
+ String linkURL = linkURLET.getText().toString();
+
+ EditText linkTextET = (EditText) findViewById(R.id.linkText);
+ String linkText = linkTextET.getText().toString();
+
+ Bundle bundle = new Bundle();
+ bundle.putString("linkURL", linkURL);
+ if (!linkText.equals("")) {
+ bundle.putString("linkText", linkText);
+ }
+
+ Intent mIntent = new Intent();
+ mIntent.putExtras(bundle);
+ setResult(RESULT_OK, mIntent);
+ finish();
+
+ }
+ });
+
+ cancelButton.setOnClickListener(new Button.OnClickListener() {
+ public void onClick(View v) {
+ Intent mIntent = new Intent();
+ setResult(RESULT_CANCELED, mIntent);
+ finish();
+ }
+ });
+
+ // select end of url
+ urlEditText.performClick();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.java
new file mode 100644
index 000000000..573b1efbe
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.java
@@ -0,0 +1,404 @@
+package org.wordpress.android.ui.posts;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.app.Fragment;
+import android.app.FragmentManager;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.v13.app.FragmentPagerAdapter;
+import android.support.v4.view.ViewPager;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.ViewGroup;
+import android.widget.Toast;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.models.Post;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.PostUploadService;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.WPViewPager;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+
+import java.util.Timer;
+import java.util.TimerTask;
+
+public class EditPostActivity extends Activity {
+ public static final String EXTRA_POSTID = "postId";
+ public static final String EXTRA_IS_PAGE = "isPage";
+ public static final String EXTRA_IS_NEW_POST = "isNewPost";
+ public static final String EXTRA_IS_QUICKPRESS = "isQuickPress";
+ public static final String EXTRA_QUICKPRESS_BLOG_ID = "quickPressBlogId";
+ public static final String STATE_KEY_CURRENT_POST = "stateKeyCurrentPost";
+ public static final String STATE_KEY_ORIGINAL_POST = "stateKeyOriginalPost";
+
+ private static int PAGE_CONTENT = 0;
+ private static int PAGE_SETTINGS = 1;
+ private static int PAGE_PREVIEW = 2;
+
+ private static final int AUTOSAVE_INTERVAL_MILLIS = 10000;
+ private Timer mAutoSaveTimer;
+
+ /**
+ * The {@link android.support.v4.view.PagerAdapter} that will provide
+ * fragments for each of the sections. We use a
+ * {@link FragmentPagerAdapter} derivative, which will keep every
+ * loaded fragment in memory. If this becomes too memory intensive, it
+ * may be best to switch to a
+ * {@link android.support.v13.app.FragmentStatePagerAdapter}.
+ */
+ SectionsPagerAdapter mSectionsPagerAdapter;
+
+ /**
+ * The {@link ViewPager} that will host the section contents.
+ */
+ WPViewPager mViewPager;
+
+ private Post mPost;
+ private Post mOriginalPost;
+
+ private EditPostContentFragment mEditPostContentFragment;
+ private EditPostSettingsFragment mEditPostSettingsFragment;
+ private EditPostPreviewFragment mEditPostPreviewFragment;
+
+ private boolean mIsNewPost;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_new_edit_post);
+
+ // Set up the action bar.
+ final ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+
+ Bundle extras = getIntent().getExtras();
+ String action = getIntent().getAction();
+ if (savedInstanceState == null) {
+ if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)
+ || EditPostContentFragment.NEW_MEDIA_GALLERY.equals(action)
+ || EditPostContentFragment.NEW_MEDIA_POST.equals(action)
+ || getIntent().hasExtra(EXTRA_IS_QUICKPRESS)
+ || (extras != null && extras.getInt("quick-media", -1) > -1)) {
+ if (getIntent().hasExtra(EXTRA_QUICKPRESS_BLOG_ID)) {
+ // QuickPress might want to use a different blog than the current blog
+ int blogId = getIntent().getIntExtra(EXTRA_QUICKPRESS_BLOG_ID, -1);
+ Blog quickPressBlog = WordPress.wpDB.instantiateBlogByLocalId(blogId);
+ if (quickPressBlog == null) {
+ showErrorAndFinish(R.string.blog_not_found);
+ return;
+ }
+ if (quickPressBlog.isHidden()) {
+ showErrorAndFinish(R.string.error_blog_hidden);
+ return;
+ }
+ WordPress.currentBlog = quickPressBlog;
+ }
+
+ // Create a new post for share intents and QuickPress
+ mPost = new Post(WordPress.getCurrentLocalTableBlogId(), false);
+ WordPress.wpDB.savePost(mPost);
+ mIsNewPost = true;
+ } else if (extras != null) {
+ // Load post from the postId passed in extras
+ long localTablePostId = extras.getLong(EXTRA_POSTID, -1);
+ boolean isPage = extras.getBoolean(EXTRA_IS_PAGE);
+ mIsNewPost = extras.getBoolean(EXTRA_IS_NEW_POST);
+ mPost = WordPress.wpDB.getPostForLocalTablePostId(localTablePostId);
+ mOriginalPost = WordPress.wpDB.getPostForLocalTablePostId(localTablePostId);
+ } else {
+ // A postId extra must be passed to this activity
+ showErrorAndFinish(R.string.post_not_found);
+ return;
+ }
+ } else if (savedInstanceState.containsKey(STATE_KEY_ORIGINAL_POST)) {
+ try {
+ mPost = (Post) savedInstanceState.getSerializable(STATE_KEY_CURRENT_POST);
+ mOriginalPost = (Post) savedInstanceState.getSerializable(STATE_KEY_ORIGINAL_POST);
+ } catch (ClassCastException e) {
+ mPost = null;
+ }
+ }
+
+ // Ensure we have a valid blog
+ if (WordPress.getCurrentBlog() == null) {
+ showErrorAndFinish(R.string.blog_not_found);
+ return;
+ }
+
+ // Ensure we have a valid post
+ if (mPost == null) {
+ showErrorAndFinish(R.string.post_not_found);
+ return;
+ }
+
+ setTitle(StringUtils.unescapeHTML(WordPress.getCurrentBlog().getBlogName()));
+
+ mSectionsPagerAdapter = new SectionsPagerAdapter(getFragmentManager());
+
+ // Set up the ViewPager with the sections adapter.
+ mViewPager = (WPViewPager) findViewById(R.id.pager);
+ mViewPager.setAdapter(mSectionsPagerAdapter);
+ mViewPager.setOffscreenPageLimit(2);
+ mViewPager.setPagingEnabled(false);
+
+ // When swiping between different sections, select the corresponding
+ // tab. We can also use ActionBar.Tab#select() to do this if we have
+ // a reference to the Tab.
+ mViewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
+ @Override
+ public void onPageSelected(int position) {
+ invalidateOptionsMenu();
+ if (position == PAGE_CONTENT) {
+ setTitle(StringUtils.unescapeHTML(WordPress.getCurrentBlog().getBlogName()));
+ } else if (position == PAGE_SETTINGS) {
+ setTitle(mPost.isPage() ? R.string.page_settings : R.string.post_settings);
+ } else if (position == PAGE_PREVIEW) {
+ setTitle(mPost.isPage() ? R.string.preview_page : R.string.preview_post);
+ savePost(true);
+ if (mEditPostPreviewFragment != null) {
+ mEditPostPreviewFragment.loadPost();
+ }
+
+ }
+ }
+ });
+ }
+
+ class AutoSaveTask extends TimerTask {
+ public void run() {
+ savePost(true);
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ mAutoSaveTimer = new Timer();
+ mAutoSaveTimer.scheduleAtFixedRate(new AutoSaveTask(), AUTOSAVE_INTERVAL_MILLIS, AUTOSAVE_INTERVAL_MILLIS);
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ mAutoSaveTimer.cancel();
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ // Saves both post objects so we can restore them in onCreate()
+ savePost(true);
+ outState.putSerializable(STATE_KEY_CURRENT_POST, mPost);
+ outState.putSerializable(STATE_KEY_ORIGINAL_POST, mOriginalPost);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.edit_post, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ MenuItem previewMenuItem = menu.findItem(R.id.menu_preview_post);
+ if (mViewPager != null && mViewPager.getCurrentItem() > PAGE_CONTENT) {
+ previewMenuItem.setVisible(false);
+ } else {
+ previewMenuItem.setVisible(true);
+ }
+
+ // Set text of the save button in the ActionBar
+ if (mPost != null) {
+ MenuItem saveMenuItem = menu.findItem(R.id.menu_save_post);
+ switch (mPost.getStatusEnum()) {
+ case SCHEDULED:
+ saveMenuItem.setTitle(getString(R.string.schedule_verb));
+ break;
+ case PUBLISHED:
+ case UNKNOWN:
+ if (mPost.isLocalDraft()) {
+ saveMenuItem.setTitle(R.string.publish_post);
+ } else {
+ saveMenuItem.setTitle(R.string.update_verb);
+ }
+ break;
+ default:
+ if (mPost.isLocalDraft()) {
+ saveMenuItem.setTitle(R.string.save);
+ } else {
+ saveMenuItem.setTitle(R.string.update_verb);
+ }
+ }
+ }
+
+ return super.onPrepareOptionsMenu(menu);
+ }
+
+ // Menu actions
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ int itemId = item.getItemId();
+ if (itemId == R.id.menu_save_post) {
+ if (mPost.isUploaded()) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.EDITOR_UPDATED_POST);
+ } else {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.EDITOR_PUBLISHED_POST);
+ }
+
+ savePost(false);
+ PostUploadService.addPostToUpload(mPost);
+ startService(new Intent(this, PostUploadService.class));
+ Intent i = new Intent();
+ i.putExtra("shouldRefresh", true);
+ setResult(RESULT_OK, i);
+ finish();
+ return true;
+ } else if (itemId == R.id.menu_preview_post) {
+ mViewPager.setCurrentItem(PAGE_PREVIEW);
+ } else if (itemId == android.R.id.home) {
+ if (mViewPager.getCurrentItem() > PAGE_CONTENT) {
+ mViewPager.setCurrentItem(PAGE_CONTENT);
+ invalidateOptionsMenu();
+ } else {
+ saveAndFinish();
+ }
+ return true;
+ }
+ return false;
+ }
+
+ private void showErrorAndFinish(int errorMessageId) {
+ Toast.makeText(this, getResources().getText(errorMessageId), Toast.LENGTH_LONG).show();
+ finish();
+ }
+
+ public Post getPost() {
+ return mPost;
+ }
+
+ private void savePost(boolean isAutosave) {
+ if (mPost == null) {
+ AppLog.e(AppLog.T.POSTS, "Attempted to save an invalid Post.");
+ return;
+ }
+
+ // Update post object from fragment fields
+ if (mEditPostContentFragment != null) {
+ mEditPostContentFragment.updatePostContent(isAutosave);
+ }
+ if (mEditPostSettingsFragment != null) {
+ mEditPostSettingsFragment.updatePostSettings();
+ }
+
+ WordPress.wpDB.updatePost(mPost);
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (mViewPager.getCurrentItem() > PAGE_CONTENT) {
+ mViewPager.setCurrentItem(PAGE_CONTENT);
+ invalidateOptionsMenu();
+ return;
+ }
+
+ if (getActionBar() != null) {
+ if (getActionBar().isShowing()) {
+ saveAndFinish();
+ } else if (mEditPostContentFragment != null) {
+ mEditPostContentFragment.setContentEditingModeVisible(false);
+ }
+ }
+ }
+
+ private void saveAndFinish() {
+ savePost(true);
+ if (mEditPostContentFragment != null && mEditPostContentFragment.hasEmptyContentFields()) {
+ // new and empty post? delete it
+ if (mIsNewPost) {
+ WordPress.wpDB.deletePost(mPost);
+ }
+ } else if (mOriginalPost != null && !mPost.hasChanges(mOriginalPost)) {
+ // if no changes have been made to the post, set it back to the original don't save it
+ WordPress.wpDB.updatePost(mOriginalPost);
+ WordPress.currentPost = mOriginalPost;
+ } else {
+ // changes have been made, save the post and ask for the post list to refresh.
+ // We consider this being "manual save", it will replace some Android "spans" by an html
+ // or a shortcode replacement (for instance for images and galleries)
+ savePost(false);
+ WordPress.currentPost = mPost;
+ Intent i = new Intent();
+ i.putExtra("shouldRefresh", true);
+ setResult(RESULT_OK, i);
+ }
+ finish();
+ }
+
+ public void showPostSettings() {
+ mViewPager.setCurrentItem(PAGE_SETTINGS);
+ }
+
+ /**
+ * A {@link FragmentPagerAdapter} that returns a fragment corresponding to
+ * one of the sections/tabs/pages.
+ */
+ public class SectionsPagerAdapter extends FragmentPagerAdapter {
+ public SectionsPagerAdapter(FragmentManager fm) {
+ super(fm);
+ }
+
+ @Override
+ public Fragment getItem(int position) {
+ // getItem is called to instantiate the fragment for the given page.
+ switch (position) {
+ case 0:
+ return new EditPostContentFragment();
+ case 1:
+ return new EditPostSettingsFragment();
+ default:
+ return new EditPostPreviewFragment();
+ }
+ }
+
+ @Override
+ public Object instantiateItem(ViewGroup container, int position) {
+ Fragment fragment = (Fragment) super.instantiateItem(container, position);
+ switch (position) {
+ case 0:
+ mEditPostContentFragment = (EditPostContentFragment)fragment;
+ break;
+ case 1:
+ mEditPostSettingsFragment = (EditPostSettingsFragment)fragment;
+ break;
+ case 2:
+ mEditPostPreviewFragment = (EditPostPreviewFragment)fragment;
+ break;
+ }
+ return fragment;
+ }
+
+ @Override
+ public int getCount() {
+ // Show 3 total pages.
+ return 3;
+ }
+ }
+
+ public boolean isEditingPostContent() {
+ return (mViewPager.getCurrentItem() == PAGE_CONTENT);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostContentFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostContentFragment.java
new file mode 100644
index 000000000..9ab184d29
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostContentFragment.java
@@ -0,0 +1,1606 @@
+package org.wordpress.android.ui.posts;
+
+import android.app.ActionBar;
+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.Configuration;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Point;
+import android.graphics.Typeface;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Environment;
+import android.text.Editable;
+import android.text.Layout;
+import android.text.Selection;
+import android.text.Spannable;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.text.method.ArrowKeyMovementMethod;
+import android.text.style.AlignmentSpan;
+import android.text.style.CharacterStyle;
+import android.text.style.QuoteSpan;
+import android.text.style.StrikethroughSpan;
+import android.text.style.StyleSpan;
+import android.text.style.URLSpan;
+import android.view.ContextMenu;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.view.WindowManager;
+import android.view.animation.AlphaAnimation;
+import android.view.animation.Animation;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.SeekBar;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.Toast;
+import android.widget.ToggleButton;
+
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.ImageLoader;
+
+import org.wordpress.android.Constants;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.models.MediaFile;
+import org.wordpress.android.models.MediaGallery;
+import org.wordpress.android.models.Post;
+import org.wordpress.android.ui.media.MediaGalleryActivity;
+import org.wordpress.android.ui.media.MediaGalleryPickerActivity;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.CrashlyticsUtils;
+import org.wordpress.android.util.CrashlyticsUtils.ExceptionType;
+import org.wordpress.android.util.CrashlyticsUtils.ExtraKey;
+import org.wordpress.android.util.DeviceUtils;
+import org.wordpress.android.util.DisplayUtils;
+import org.wordpress.android.util.ImageHelper;
+import org.wordpress.android.util.MediaGalleryImageSpan;
+import org.wordpress.android.util.MediaUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.WPEditText;
+import org.wordpress.android.util.WPHtml;
+import org.wordpress.android.util.WPImageSpan;
+import org.wordpress.android.util.WPUnderlineSpan;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+import org.wordpress.passcodelock.AppLockManager;
+import org.xmlrpc.android.ApiHelper;
+
+import java.io.File;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Vector;
+
+public class EditPostContentFragment extends Fragment implements TextWatcher,
+ WPEditText.OnSelectionChangedListener, View.OnTouchListener {
+ EditPostActivity mActivity;
+
+ private static final int ACTIVITY_REQUEST_CODE_CREATE_LINK = 4;
+ public static final String NEW_MEDIA_GALLERY = "NEW_MEDIA_GALLERY";
+ public static final String NEW_MEDIA_GALLERY_EXTRA_IDS = "NEW_MEDIA_GALLERY_EXTRA_IDS";
+ public static final String NEW_MEDIA_POST = "NEW_MEDIA_POST";
+ public static final String NEW_MEDIA_POST_EXTRA = "NEW_MEDIA_POST_ID";
+
+ private static final String TAG_FORMAT_BAR_BUTTON_STRONG = "strong";
+ private static final String TAG_FORMAT_BAR_BUTTON_EM = "em";
+ private static final String TAG_FORMAT_BAR_BUTTON_UNDERLINE = "u";
+ private static final String TAG_FORMAT_BAR_BUTTON_STRIKE = "strike";
+ private static final String TAG_FORMAT_BAR_BUTTON_QUOTE = "blockquote";
+
+ private static final int CONTENT_ANIMATION_DURATION = 250;
+ private static final int MIN_THUMBNAIL_WIDTH = 200;
+
+ private View mRootView;
+ private WPEditText mContentEditText;
+ private Button mAddPictureButton;
+ private EditText mTitleEditText;
+ private ToggleButton mBoldToggleButton, mEmToggleButton, mBquoteToggleButton;
+ private ToggleButton mUnderlineToggleButton, mStrikeToggleButton;
+ private LinearLayout mFormatBar, mPostContentLinearLayout, mPostSettingsLinearLayout;
+ private boolean mIsBackspace;
+ private boolean mScrollDetected;
+
+ private String mMediaCapturePath = "";
+
+ private int mStyleStart, mSelectionStart, mSelectionEnd, mFullViewBottom, mMaximumThumbnailWidth;
+ private int mLastPosition = -1, mQuickMediaType = -1;
+
+ private float mLastYPos = 0;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ mActivity = (EditPostActivity) getActivity();
+
+ final ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.fragment_edit_post_content, container, false);
+
+ mFormatBar = (LinearLayout) rootView.findViewById(R.id.format_bar);
+ mTitleEditText = (EditText) rootView.findViewById(R.id.post_title);
+ mTitleEditText.setOnEditorActionListener(new TextView.OnEditorActionListener() {
+ @Override
+ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+ // Go to full screen editor when 'next' button is tapped on soft keyboard
+ if (actionId == EditorInfo.IME_ACTION_NEXT && mActivity.getActionBar() != null && mActivity
+ .getActionBar().isShowing()) {
+ setContentEditingModeVisible(true);
+ }
+ return false;
+ }
+ });
+ mContentEditText = (WPEditText) rootView.findViewById(R.id.post_content);
+ mPostContentLinearLayout = (LinearLayout) rootView.findViewById(R.id.post_content_wrapper);
+ mPostSettingsLinearLayout = (LinearLayout) rootView.findViewById(R.id.post_settings_wrapper);
+ Button postSettingsButton = (Button) rootView.findViewById(R.id.post_settings_button);
+ postSettingsButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ mActivity.showPostSettings();
+ }
+ });
+ mBoldToggleButton = (ToggleButton) rootView.findViewById(R.id.bold);
+ mEmToggleButton = (ToggleButton) rootView.findViewById(R.id.em);
+ mBquoteToggleButton = (ToggleButton) rootView.findViewById(R.id.bquote);
+ mUnderlineToggleButton = (ToggleButton) rootView.findViewById(R.id.underline);
+ mStrikeToggleButton = (ToggleButton) rootView.findViewById(R.id.strike);
+ mAddPictureButton = (Button) rootView.findViewById(R.id.addPictureButton);
+ Button linkButton = (Button) rootView.findViewById(R.id.link);
+ Button moreButton = (Button) rootView.findViewById(R.id.more);
+
+ registerForContextMenu(mAddPictureButton);
+ mContentEditText.setOnSelectionChangedListener(this);
+ mContentEditText.setOnTouchListener(this);
+ mContentEditText.addTextChangedListener(this);
+ mContentEditText.setOnEditTextImeBackListener(new WPEditText.EditTextImeBackListener() {
+ @Override
+ public void onImeBack(WPEditText ctrl, String text) {
+ // Go back to regular editor if IME keyboard is dismissed
+ // Bottom comparison is there to ensure that the keyboard is actually showing
+ if (mRootView.getBottom() < mFullViewBottom && mActivity.getActionBar() != null && !mActivity
+ .getActionBar().isShowing()) {
+ setContentEditingModeVisible(false);
+ }
+ }
+ });
+ mAddPictureButton.setOnClickListener(mFormatBarButtonClickListener);
+ mBoldToggleButton.setOnClickListener(mFormatBarButtonClickListener);
+ linkButton.setOnClickListener(mFormatBarButtonClickListener);
+ mEmToggleButton.setOnClickListener(mFormatBarButtonClickListener);
+ mUnderlineToggleButton.setOnClickListener(mFormatBarButtonClickListener);
+ mStrikeToggleButton.setOnClickListener(mFormatBarButtonClickListener);
+ mBquoteToggleButton.setOnClickListener(mFormatBarButtonClickListener);
+ moreButton.setOnClickListener(mFormatBarButtonClickListener);
+
+ Post post = mActivity.getPost();
+ if (post != null) {
+ if (!TextUtils.isEmpty(post.getContent())) {
+ if (post.isLocalDraft()) {
+ // Load local post content in the background, as it may take time to generate images
+ new LoadPostContentTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR,
+ post.getContent().replaceAll("\uFFFC", ""));
+ }
+ else {
+ mContentEditText.setText(post.getContent().replaceAll("\uFFFC", ""));
+ }
+ }
+ if (!TextUtils.isEmpty(post.getTitle())) {
+ mTitleEditText.setText(post.getTitle());
+ }
+
+ postSettingsButton.setText(post.isPage() ? R.string.page_settings : R.string.post_settings);
+ }
+
+ // Check for Android share action
+ String action = mActivity.getIntent().getAction();
+ if (mActivity.getIntent().getExtras() != null)
+ mQuickMediaType = mActivity.getIntent().getExtras().getInt("quick-media", -1);
+ if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action))
+ setPostContentFromShareAction();
+ else if (NEW_MEDIA_GALLERY.equals(action))
+ prepareMediaGallery();
+ else if (NEW_MEDIA_POST.equals(action))
+ prepareMediaPost();
+ else if (mQuickMediaType >= 0) {
+ // User selected a 'Quick (media type)' option in the menu drawer
+ if (mQuickMediaType == Constants.QUICK_POST_PHOTO_CAMERA)
+ launchCamera();
+ else if (mQuickMediaType == Constants.QUICK_POST_PHOTO_LIBRARY)
+ launchPictureLibrary();
+ else if (mQuickMediaType == Constants.QUICK_POST_VIDEO_CAMERA)
+ launchVideoCamera();
+ else if (mQuickMediaType == Constants.QUICK_POST_VIDEO_LIBRARY)
+ launchVideoLibrary();
+
+ if (post != null) {
+ if (mQuickMediaType == Constants.QUICK_POST_PHOTO_CAMERA || mQuickMediaType == Constants.QUICK_POST_PHOTO_LIBRARY)
+ post.setQuickPostType(Post.QUICK_MEDIA_TYPE_PHOTO);
+ else if (mQuickMediaType == Constants.QUICK_POST_VIDEO_CAMERA || mQuickMediaType == Constants.QUICK_POST_VIDEO_LIBRARY)
+ post.setQuickPostType(Post.QUICK_MEDIA_TYPE_VIDEO);
+ }
+ }
+
+ return rootView;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ mRootView = view;
+ mRootView.getViewTreeObserver().addOnGlobalLayoutListener(mGlobalLayoutListener);
+ }
+
+ private ViewTreeObserver.OnGlobalLayoutListener mGlobalLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() {
+ public void onGlobalLayout() {
+ mRootView.getViewTreeObserver().removeGlobalOnLayoutListener(this);
+ mFullViewBottom = mRootView.getBottom();
+ }
+ };
+
+ public void setContentEditingModeVisible(boolean isVisible) {
+ if (mActivity == null)
+ return;
+ ActionBar actionBar = mActivity.getActionBar();
+ if (isVisible) {
+ Animation fadeAnimation = new AlphaAnimation(1, 0);
+ fadeAnimation.setDuration(CONTENT_ANIMATION_DURATION);
+ fadeAnimation.setAnimationListener(new Animation.AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation animation) {
+ mTitleEditText.setVisibility(View.GONE);
+ }
+
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ mPostSettingsLinearLayout.setVisibility(View.GONE);
+ mFormatBar.setVisibility(View.VISIBLE);
+ }
+
+ @Override
+ public void onAnimationRepeat(Animation animation) {
+ }
+ });
+
+ mPostContentLinearLayout.startAnimation(fadeAnimation);
+ if (actionBar != null) {
+ actionBar.hide();
+ }
+ } else {
+ mTitleEditText.setVisibility(View.VISIBLE);
+ mFormatBar.setVisibility(View.GONE);
+ Animation fadeAnimation = new AlphaAnimation(0, 1);
+ fadeAnimation.setDuration(CONTENT_ANIMATION_DURATION);
+ fadeAnimation.setAnimationListener(new Animation.AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation animation) {
+ }
+
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ mPostSettingsLinearLayout.setVisibility(View.VISIBLE);
+ }
+
+ @Override
+ public void onAnimationRepeat(Animation animation) {
+ }
+ });
+ mPostContentLinearLayout.startAnimation(fadeAnimation);
+ mActivity.invalidateOptionsMenu();
+ if (actionBar != null) {
+ actionBar.show();
+ }
+ }
+ }
+
+ public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) {
+ menu.add(0, 0, 0, getResources().getText(R.string.select_photo));
+ if (DeviceUtils.getInstance().hasCamera(getActivity())) {
+ menu.add(0, 1, 0, getResources().getText(R.string.media_add_popup_capture_photo));
+ }
+ menu.add(0, 2, 0, getResources().getText(R.string.select_video));
+ if (DeviceUtils.getInstance().hasCamera(getActivity())) {
+ menu.add(0, 3, 0, getResources().getText(R.string.media_add_popup_capture_video));
+ }
+
+ menu.add(0, 4, 0, getResources().getText(R.string.media_add_new_media_gallery));
+ menu.add(0, 5, 0, getResources().getText(R.string.select_from_media_library));
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (data != null || ((requestCode == MediaUtils.RequestCode.ACTIVITY_REQUEST_CODE_TAKE_PHOTO || requestCode == MediaUtils.RequestCode.ACTIVITY_REQUEST_CODE_TAKE_VIDEO))) {
+ Bundle extras;
+ switch (requestCode) {
+ case MediaGalleryActivity.REQUEST_CODE:
+ if (resultCode == Activity.RESULT_OK) {
+ handleMediaGalleryResult(data);
+ }
+ break;
+ case MediaGalleryPickerActivity.REQUEST_CODE:
+ AnalyticsTracker.track(AnalyticsTracker.Stat.EDITOR_ADDED_PHOTO_VIA_WP_MEDIA_LIBRARY);
+ if (resultCode == Activity.RESULT_OK) {
+ handleMediaGalleryPickerResult(data);
+ }
+ break;
+ case MediaUtils.RequestCode.ACTIVITY_REQUEST_CODE_PICTURE_LIBRARY:
+ Uri imageUri = data.getData();
+ fetchMedia(imageUri);
+ AnalyticsTracker.track(AnalyticsTracker.Stat.EDITOR_ADDED_PHOTO_VIA_LOCAL_LIBRARY);
+ break;
+ case MediaUtils.RequestCode.ACTIVITY_REQUEST_CODE_TAKE_PHOTO:
+ if (resultCode == Activity.RESULT_OK) {
+ try {
+ File f = new File(mMediaCapturePath);
+ Uri capturedImageUri = Uri.fromFile(f);
+ if (!addMedia(capturedImageUri, null))
+ Toast.makeText(getActivity(), getResources().getText(R.string.gallery_error), Toast.LENGTH_SHORT).show();
+ getActivity().sendBroadcast(new Intent(Intent.ACTION_MEDIA_MOUNTED, Uri.parse("file://"
+ + Environment.getExternalStorageDirectory())));
+ AnalyticsTracker.track(AnalyticsTracker.Stat.EDITOR_ADDED_PHOTO_VIA_LOCAL_LIBRARY);
+ } catch (RuntimeException e) {
+ AppLog.e(T.POSTS, e);
+ } catch (OutOfMemoryError e) {
+ AppLog.e(T.POSTS, e);
+ }
+ } else if (mActivity != null && mQuickMediaType > -1 && TextUtils.isEmpty(mContentEditText.getText())) {
+ // Quick Photo was cancelled, delete post and finish activity
+ WordPress.wpDB.deletePost(mActivity.getPost());
+ mActivity.finish();
+ }
+ break;
+ case MediaUtils.RequestCode.ACTIVITY_REQUEST_CODE_VIDEO_LIBRARY:
+ Uri videoUri = data.getData();
+ fetchMedia(videoUri);
+ break;
+ case MediaUtils.RequestCode.ACTIVITY_REQUEST_CODE_TAKE_VIDEO:
+ if (resultCode == Activity.RESULT_OK) {
+ Uri capturedVideoUri = MediaUtils.getLastRecordedVideoUri(getActivity());
+ if (!addMedia(capturedVideoUri, null))
+ Toast.makeText(getActivity(), getResources().getText(R.string.gallery_error), Toast.LENGTH_SHORT).show();
+ } else if (mActivity != null && mQuickMediaType > -1 && TextUtils.isEmpty(mContentEditText.getText())) {
+ // Quick Photo was cancelled, delete post and finish activity
+ WordPress.wpDB.deletePost(mActivity.getPost());
+ mActivity.finish();
+ }
+ break;
+ case ACTIVITY_REQUEST_CODE_CREATE_LINK:
+ try {
+ extras = data.getExtras();
+ if (extras == null)
+ return;
+ String linkURL = extras.getString("linkURL");
+ if (linkURL != null && !linkURL.equals("http://") && !linkURL.equals("")) {
+ if (mSelectionStart > mSelectionEnd) {
+ int temp = mSelectionEnd;
+ mSelectionEnd = mSelectionStart;
+ mSelectionStart = temp;
+ }
+ Editable str = mContentEditText.getText();
+ if (str == null)
+ return;
+ if (mActivity.getPost().isLocalDraft()) {
+ if (extras.getString("linkText") == null) {
+ if (mSelectionStart < mSelectionEnd)
+ str.delete(mSelectionStart, mSelectionEnd);
+ str.insert(mSelectionStart, linkURL);
+ str.setSpan(new URLSpan(linkURL), mSelectionStart, mSelectionStart + linkURL.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ mContentEditText.setSelection(mSelectionStart + linkURL.length());
+ } else {
+ String linkText = extras.getString("linkText");
+ if (linkText == null)
+ return;
+ if (mSelectionStart < mSelectionEnd)
+ str.delete(mSelectionStart, mSelectionEnd);
+ str.insert(mSelectionStart, linkText);
+ str.setSpan(new URLSpan(linkURL), mSelectionStart, mSelectionStart + linkText.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ mContentEditText.setSelection(mSelectionStart + linkText.length());
+ }
+ } else {
+ if (extras.getString("linkText") == null) {
+ if (mSelectionStart < mSelectionEnd)
+ str.delete(mSelectionStart, mSelectionEnd);
+ String urlHTML = "<a href=\"" + linkURL + "\">" + linkURL + "</a>";
+ str.insert(mSelectionStart, urlHTML);
+ mContentEditText.setSelection(mSelectionStart + urlHTML.length());
+ } else {
+ String linkText = extras.getString("linkText");
+ if (mSelectionStart < mSelectionEnd)
+ str.delete(mSelectionStart, mSelectionEnd);
+ String urlHTML = "<a href=\"" + linkURL + "\">" + linkText + "</a>";
+ str.insert(mSelectionStart, urlHTML);
+ mContentEditText.setSelection(mSelectionStart + urlHTML.length());
+ }
+ }
+ }
+ } catch (RuntimeException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ break;
+ }
+ }
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case 0:
+ launchPictureLibrary();
+ return true;
+ case 1:
+ launchCamera();
+ return true;
+ case 2:
+ launchVideoLibrary();
+ return true;
+ case 3:
+ launchVideoCamera();
+ return true;
+ case 4:
+ startMediaGalleryActivity(null);
+ return true;
+ case 5:
+ startMediaGalleryAddActivity();
+ return true;
+ }
+ return false;
+ }
+
+ protected boolean hasActivity() {
+ return (getActivity() != null && !isRemoving());
+ }
+
+ protected void setPostContentFromShareAction() {
+ Intent intent = mActivity.getIntent();
+
+ // Check for shared text
+ String text = intent.getStringExtra(Intent.EXTRA_TEXT);
+ String title = intent.getStringExtra(Intent.EXTRA_SUBJECT);
+ if (text != null) {
+ if (title != null) {
+ mTitleEditText.setText(title);
+ }
+
+ if (text.contains("youtube_gdata")) {
+ // Just use the URL for YouTube links for oEmbed support
+ mContentEditText.setText(text);
+ } else {
+ // add link tag around URLs, trac #64
+ text = text.replaceAll("((http|https|ftp|mailto):\\S+)", "<a href=\"$1\">$1</a>");
+ mContentEditText.setText(
+ WPHtml.fromHtml(
+ StringUtils.addPTags(text),
+ getActivity(),
+ mActivity.getPost(),
+ getMaximumThumbnailWidth()
+ )
+ );
+ }
+ }
+
+ // Check for shared media
+ if (intent.hasExtra(Intent.EXTRA_STREAM)) {
+ String action = intent.getAction();
+ String type = intent.getType();
+ ArrayList<Uri> sharedUris;
+
+ if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
+ sharedUris = intent.getParcelableArrayListExtra((Intent.EXTRA_STREAM));
+ } else {
+ // For a single media share, we only allow images and video types
+ if (type != null && (type.startsWith("image") || type.startsWith("video"))) {
+ sharedUris = new ArrayList<Uri>();
+ sharedUris.add((Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM));
+ } else {
+ return;
+ }
+ }
+
+ if (sharedUris != null) {
+ List<Serializable> params = new Vector<Serializable>();
+ params.add(sharedUris);
+ params.add(type);
+ new processAttachmentsTask().execute(params);
+ }
+ }
+ }
+
+ /**
+ * Updates post object with content of this fragment
+ */
+ public void updatePostContent(boolean isAutoSave) {
+ Post post = mActivity.getPost();
+
+ if (post == null || mContentEditText.getText() == null)
+ return;
+
+ String title = (mTitleEditText.getText() != null) ? mTitleEditText.getText().toString() : "";
+
+ Editable postContentEditable;
+ try {
+ postContentEditable = new SpannableStringBuilder(mContentEditText.getText());
+ } catch (IndexOutOfBoundsException e) {
+ // A core android bug might cause an out of bounds exception, if so we'll just use the current editable
+ // See https://code.google.com/p/android/issues/detail?id=5164
+ postContentEditable = mContentEditText.getText();
+ }
+
+ if (postContentEditable == null)
+ return;
+
+ String content;
+ if (post.isLocalDraft()) {
+ // remove suggestion spans, they cause craziness in WPHtml.toHTML().
+ CharacterStyle[] characterStyles = postContentEditable.getSpans(0, postContentEditable.length(),
+ CharacterStyle.class);
+ for (CharacterStyle characterStyle : characterStyles) {
+ if (characterStyle.getClass().getName().equals("android.text.style.SuggestionSpan")) {
+ postContentEditable.removeSpan(characterStyle);
+ }
+ }
+ content = WPHtml.toHtml(postContentEditable);
+ // replace duplicate <p> tags so there's not duplicates, trac #86
+ content = content.replace("<p><p>", "<p>");
+ content = content.replace("</p></p>", "</p>");
+ content = content.replace("<br><br>", "<br>");
+ // sometimes the editor creates extra tags
+ content = content.replace("</strong><strong>", "").replace("</em><em>", "").replace("</u><u>", "")
+ .replace("</strike><strike>", "").replace("</blockquote><blockquote>", "");
+ } else {
+ if (!isAutoSave) {
+ // Add gallery shortcode
+ MediaGalleryImageSpan[] gallerySpans = postContentEditable.getSpans(0, postContentEditable.length(),
+ MediaGalleryImageSpan.class);
+ for (MediaGalleryImageSpan gallerySpan : gallerySpans) {
+ int start = postContentEditable.getSpanStart(gallerySpan);
+ postContentEditable.removeSpan(gallerySpan);
+ postContentEditable.insert(start, WPHtml.getGalleryShortcode(gallerySpan));
+ }
+ }
+
+ WPImageSpan[] imageSpans = postContentEditable.getSpans(0, postContentEditable.length(), WPImageSpan.class);
+ if (imageSpans.length != 0) {
+ for (WPImageSpan wpIS : imageSpans) {
+ MediaFile mediaFile = wpIS.getMediaFile();
+ if (mediaFile == null)
+ continue;
+ if (mediaFile.getMediaId() != null) {
+ updateMediaFileOnServer(wpIS);
+ } else {
+ mediaFile.setFileName(wpIS.getImageSource().toString());
+ mediaFile.setFilePath(wpIS.getImageSource().toString());
+ mediaFile.save();
+ }
+
+ int tagStart = postContentEditable.getSpanStart(wpIS);
+ if (!isAutoSave) {
+ postContentEditable.removeSpan(wpIS);
+
+ // network image has a mediaId
+ if (mediaFile.getMediaId() != null && mediaFile.getMediaId().length() > 0) {
+ postContentEditable.insert(tagStart, WPHtml.getContent(wpIS));
+ } else {
+ // local image for upload
+ postContentEditable.insert(tagStart,
+ "<img android-uri=\"" + wpIS.getImageSource().toString() + "\" />");
+ }
+ }
+ }
+ }
+ content = postContentEditable.toString();
+ }
+
+ String moreTag = "<!--more-->";
+
+ post.setTitle(title);
+ // split up the post content if there's a more tag
+ if (post.isLocalDraft() && content.contains(moreTag)) {
+ post.setDescription(content.substring(0, content.indexOf(moreTag)));
+ post.setMoreText(content.substring(content.indexOf(moreTag) + moreTag.length(), content.length()));
+ } else {
+ post.setDescription(content);
+ post.setMoreText("");
+ }
+
+ if (!post.isLocalDraft())
+ post.setLocalChange(true);
+ }
+
+ public boolean hasEmptyContentFields() {
+ return TextUtils.isEmpty(mTitleEditText.getText()) && TextUtils.isEmpty(mContentEditText.getText());
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+
+ mFullViewBottom = mRootView.getBottom();
+ }
+
+ /**
+ * Media
+ */
+
+ private class processAttachmentsTask extends AsyncTask<List<?>, Void, SpannableStringBuilder> {
+ protected void onPreExecute() {
+ Toast.makeText(getActivity(), R.string.loading, Toast.LENGTH_SHORT).show();
+ }
+
+ @Override
+ protected SpannableStringBuilder doInBackground(List<?>... args) {
+ ArrayList<?> multi_stream = (ArrayList<?>) args[0].get(0);
+ String type = (String) args[0].get(1);
+ SpannableStringBuilder ssb = new SpannableStringBuilder();
+ for (Object streamUri : multi_stream) {
+ if (streamUri instanceof Uri) {
+ Uri imageUri = (Uri) streamUri;
+ if (type != null) {
+ addMedia(imageUri, ssb);
+ }
+ }
+ }
+ return ssb;
+ }
+
+ protected void onPostExecute(SpannableStringBuilder ssb) {
+ if (!hasActivity()) {
+ return;
+ }
+ if (ssb != null && ssb.length() > 0) {
+ Editable postContentEditable = mContentEditText.getText();
+ if (postContentEditable != null) {
+ postContentEditable.insert(0, ssb);
+ }
+ } else {
+ Toast.makeText(getActivity(), getResources().getText(R.string.gallery_error), Toast.LENGTH_SHORT)
+ .show();
+ }
+ }
+ }
+
+ private void launchPictureLibrary() {
+ MediaUtils.launchPictureLibrary(this);
+ AppLockManager.getInstance().setExtendedTimeout();
+ }
+
+ private void launchCamera() {
+ MediaUtils.launchCamera(this, new MediaUtils.LaunchCameraCallback() {
+ @Override
+ public void onMediaCapturePathReady(String mediaCapturePath) {
+ mMediaCapturePath = mediaCapturePath;
+ AppLockManager.getInstance().setExtendedTimeout();
+ }
+ });
+ }
+
+ private void launchVideoLibrary() {
+ MediaUtils.launchVideoLibrary(this);
+ AppLockManager.getInstance().setExtendedTimeout();
+ }
+
+ private void launchVideoCamera() {
+ MediaUtils.launchVideoCamera(this);
+ AppLockManager.getInstance().setExtendedTimeout();
+ }
+
+ private void fetchMedia(Uri mediaUri) {
+ if (!MediaUtils.isInMediaStore(mediaUri)) {
+ // Create an AsyncTask to download the file
+ new DownloadMediaTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, mediaUri);
+ } else {
+ // It is a regular local image file
+ if (!addMedia(mediaUri, null)) {
+ Toast.makeText(getActivity(), getResources().getText(R.string.gallery_error), Toast.LENGTH_SHORT)
+ .show();
+ }
+ }
+ }
+
+ private class DownloadMediaTask extends AsyncTask<Uri, Integer, Uri> {
+ @Override
+ protected Uri doInBackground(Uri... uris) {
+ Uri imageUri = uris[0];
+ return MediaUtils.downloadExternalMedia(getActivity(), imageUri);
+ }
+
+ @Override
+ protected void onPreExecute() {
+ Toast.makeText(getActivity(), R.string.download, Toast.LENGTH_SHORT).show();
+ }
+
+ protected void onPostExecute(Uri newUri) {
+ if (!hasActivity()) {
+ return;
+ }
+
+ if (newUri != null) {
+ addMedia(newUri, null);
+ } else {
+ Toast.makeText(getActivity(), getString(R.string.error_downloading_image), Toast.LENGTH_SHORT).show();
+ }
+ }
+ }
+
+ private void prepareMediaGallery() {
+ MediaGallery mediaGallery = new MediaGallery();
+ mediaGallery.setIds(getActivity().getIntent().getStringArrayListExtra(NEW_MEDIA_GALLERY_EXTRA_IDS));
+
+ startMediaGalleryActivity(mediaGallery);
+ }
+
+ private void prepareMediaPost() {
+ String mediaId = getActivity().getIntent().getStringExtra(NEW_MEDIA_POST_EXTRA);
+ addExistingMediaToEditor(mediaId);
+ }
+
+ private void addExistingMediaToEditor(String mediaId) {
+ if (WordPress.getCurrentBlog() == null)
+ return;
+
+ String blogId = String.valueOf(WordPress.getCurrentBlog().getLocalTableBlogId());
+
+ WPImageSpan imageSpan = MediaUtils.prepareWPImageSpan(getActivity(), blogId, mediaId);
+ if (imageSpan == null)
+ return;
+
+ // based on addMedia()
+
+ int selectionStart = mContentEditText.getSelectionStart();
+ int selectionEnd = mContentEditText.getSelectionEnd();
+
+ if (selectionStart > selectionEnd) {
+ int temp = selectionEnd;
+ selectionEnd = selectionStart;
+ selectionStart = temp;
+ }
+
+ int line, column = 0;
+ if (mContentEditText.getLayout() != null) {
+ line = mContentEditText.getLayout().getLineForOffset(selectionStart);
+ column = mContentEditText.getSelectionStart() - mContentEditText.getLayout().getLineStart(line);
+ }
+
+ Editable s = mContentEditText.getText();
+ if (s != null) {
+ WPImageSpan[] gallerySpans = s.getSpans(selectionStart, selectionEnd, WPImageSpan.class);
+ if (gallerySpans.length != 0) {
+ // insert a few line breaks if the cursor is already on an image
+ s.insert(selectionEnd, "\n\n");
+ selectionStart = selectionStart + 2;
+ selectionEnd = selectionEnd + 2;
+ } else if (column != 0) {
+ // insert one line break if the cursor is not at the first column
+ s.insert(selectionEnd, "\n");
+ selectionStart = selectionStart + 1;
+ selectionEnd = selectionEnd + 1;
+ }
+
+ s.insert(selectionStart, " ");
+ s.setSpan(imageSpan, selectionStart, selectionEnd + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ AlignmentSpan.Standard as = new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER);
+ s.setSpan(as, selectionStart, selectionEnd + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ s.insert(selectionEnd + 1, "\n\n");
+ }
+
+ // load image from server
+ loadWPImageSpanThumbnail(imageSpan);
+ }
+
+ private void updateMediaFileOnServer(WPImageSpan wpIS) {
+ Blog currentBlog = WordPress.getCurrentBlog();
+ if (currentBlog == null || wpIS == null)
+ return;
+
+ MediaFile mf = wpIS.getMediaFile();
+
+ final String mediaId = mf.getMediaId();
+ final String title = mf.getTitle();
+ final String description = mf.getDescription();
+ final String caption = mf.getCaption();
+
+ ApiHelper.EditMediaItemTask task = new ApiHelper.EditMediaItemTask(mf.getMediaId(), mf.getTitle(),
+ mf.getDescription(), mf.getCaption(),
+ new ApiHelper.GenericCallback() {
+ @Override
+ public void onSuccess() {
+ if (WordPress.getCurrentBlog() == null) {
+ return;
+ }
+ String localBlogTableIndex = String.valueOf(WordPress.getCurrentBlog().getLocalTableBlogId());
+ WordPress.wpDB.updateMediaFile(localBlogTableIndex, mediaId, title, description, caption);
+ }
+
+ @Override
+ public void onFailure(ApiHelper.ErrorType errorType, String errorMessage, Throwable throwable) {
+ if (hasActivity()) {
+ Toast.makeText(getActivity(), R.string.media_edit_failure, Toast.LENGTH_LONG).show();
+ }
+ }
+ });
+
+ List<Object> apiArgs = new ArrayList<Object>();
+ apiArgs.add(currentBlog);
+ task.execute(apiArgs);
+ }
+
+ /** Loads the thumbnail url in the imagespan from a server **/
+ private void loadWPImageSpanThumbnail(WPImageSpan imageSpan) {
+
+ MediaFile mediaFile = imageSpan.getMediaFile();
+ if (mediaFile == null)
+ return;
+
+ final String mediaId = mediaFile.getMediaId();
+ if (mediaId == null)
+ return;
+
+ String imageURL;
+ if (WordPress.getCurrentBlog() != null && WordPress.getCurrentBlog().isPhotonCapable()) {
+ String photonUrl = imageSpan.getImageSource().toString();
+ imageURL = StringUtils.getPhotonUrl(photonUrl, getMaximumThumbnailWidth());
+ } else {
+ // Not a Jetpack or wpcom blog
+ // imageURL = mediaFile.getThumbnailURL(); //do not use fileURL here since downloading picture
+ // of big dimensions can result in OOM Exception
+ imageURL = mediaFile.getFileURL() != null ? mediaFile.getFileURL() : mediaFile.getThumbnailURL();
+ }
+
+ if (imageURL == null)
+ return;
+
+ WordPress.imageLoader.get(imageURL, new ImageLoader.ImageListener() {
+ @Override
+ public void onErrorResponse(VolleyError arg0) {
+ }
+
+ @Override
+ public void onResponse(ImageLoader.ImageContainer container, boolean arg1) {
+ if (!hasActivity()) {
+ return;
+ }
+
+ Bitmap downloadedBitmap = container.getBitmap();
+ if (downloadedBitmap == null) {
+ //no bitmap downloaded from the server.
+ return;
+ }
+
+ if (downloadedBitmap.getWidth() < MIN_THUMBNAIL_WIDTH) {
+ //Picture is too small. Show the placeholder in this case.
+ return;
+ }
+
+ Bitmap resizedBitmap;
+ int maxWidth = getMaximumThumbnailWidth();
+ //resize the downloaded bitmap
+ try {
+ resizedBitmap = ImageHelper.getScaledBitmapAtLongestSide(downloadedBitmap, maxWidth);
+ } catch (OutOfMemoryError er) {
+ CrashlyticsUtils.setInt(ExtraKey.IMAGE_WIDTH, downloadedBitmap.getWidth());
+ CrashlyticsUtils.setInt(ExtraKey.IMAGE_HEIGHT, downloadedBitmap.getHeight());
+ CrashlyticsUtils.setFloat(ExtraKey.IMAGE_RESIZE_SCALE,
+ ((float) maxWidth) / downloadedBitmap.getWidth());
+ CrashlyticsUtils.logException(er, ExceptionType.SPECIFIC, T.POSTS);
+ return;
+ }
+
+ if (resizedBitmap == null) return;
+
+ Editable s = mContentEditText.getText();
+ if (s == null) return;
+ WPImageSpan[] spans = s.getSpans(0, s.length(), WPImageSpan.class);
+ if (spans.length != 0) {
+ for (WPImageSpan is : spans) {
+ MediaFile mediaFile = is.getMediaFile();
+ if (mediaFile == null) continue;
+ if (mediaId.equals(mediaFile.getMediaId()) && !is.isNetworkImageLoaded() && hasActivity()) {
+ // replace the existing span with a new one with the correct image, re-add it to the same position.
+ int spanStart = s.getSpanStart(is);
+ int spanEnd = s.getSpanEnd(is);
+ WPImageSpan imageSpan = new WPImageSpan(getActivity(), resizedBitmap, is.getImageSource());
+ imageSpan.setMediaFile(is.getMediaFile());
+ imageSpan.setNetworkImageLoaded(true);
+ s.removeSpan(is);
+ s.setSpan(imageSpan, spanStart, spanEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ break;
+ }
+ }
+ }
+ }
+ }, 0, 0);
+ }
+
+ private void startMediaGalleryActivity(MediaGallery mediaGallery) {
+ Intent intent = new Intent(getActivity(), MediaGalleryActivity.class);
+ intent.putExtra(MediaGalleryActivity.PARAMS_MEDIA_GALLERY, mediaGallery);
+ if (mediaGallery == null)
+ intent.putExtra(MediaGalleryActivity.PARAMS_LAUNCH_PICKER, true);
+ startActivityForResult(intent, MediaGalleryActivity.REQUEST_CODE);
+ }
+
+ private void startMediaGalleryAddActivity() {
+ Intent intent = new Intent(getActivity(), MediaGalleryPickerActivity.class);
+ intent.putExtra(MediaGalleryPickerActivity.PARAM_SELECT_ONE_ITEM, true);
+ startActivityForResult(intent, MediaGalleryPickerActivity.REQUEST_CODE);
+ }
+
+ private void handleMediaGalleryPickerResult(Intent data) {
+ ArrayList<String> ids = data.getStringArrayListExtra(MediaGalleryPickerActivity.RESULT_IDS);
+ if (ids == null || ids.size() == 0)
+ return;
+
+ String mediaId = ids.get(0);
+ addExistingMediaToEditor(mediaId);
+ }
+
+ private void handleMediaGalleryResult(Intent data) {
+ MediaGallery gallery = (MediaGallery) data.getSerializableExtra(MediaGalleryActivity.RESULT_MEDIA_GALLERY);
+
+ // if blank gallery returned, don't add to span
+ if (gallery == null || gallery.getIds().size() == 0)
+ return;
+
+
+ int selectionStart = mContentEditText.getSelectionStart();
+ int selectionEnd = mContentEditText.getSelectionEnd();
+
+ if (selectionStart > selectionEnd) {
+ int temp = selectionEnd;
+ selectionEnd = selectionStart;
+ selectionStart = temp;
+ }
+
+ int line, column = 0;
+ if (mContentEditText.getLayout() != null) {
+ line = mContentEditText.getLayout().getLineForOffset(selectionStart);
+ column = mContentEditText.getSelectionStart() - mContentEditText.getLayout().getLineStart(line);
+ }
+
+ Editable s = mContentEditText.getText();
+ if (s == null)
+ return;
+ MediaGalleryImageSpan[] gallerySpans = s.getSpans(selectionStart, selectionEnd, MediaGalleryImageSpan.class);
+ if (gallerySpans.length != 0) {
+ for (MediaGalleryImageSpan gallerySpan : gallerySpans) {
+ if (gallerySpan.getMediaGallery().getUniqueId() == gallery.getUniqueId()) {
+ // replace the existing span with a new gallery, re-add it to the same position.
+ gallerySpan.setMediaGallery(gallery);
+ int spanStart = s.getSpanStart(gallerySpan);
+ int spanEnd = s.getSpanEnd(gallerySpan);
+ s.setSpan(gallerySpan, spanStart, spanEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+ return;
+ } else if (column != 0) {
+ // insert one line break if the cursor is not at the first column
+ s.insert(selectionEnd, "\n");
+ selectionStart = selectionStart + 1;
+ selectionEnd = selectionEnd + 1;
+ }
+
+ s.insert(selectionStart, " ");
+ MediaGalleryImageSpan is = new MediaGalleryImageSpan(getActivity(), gallery);
+ s.setSpan(is, selectionStart, selectionEnd + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ AlignmentSpan.Standard as = new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER);
+ s.setSpan(as, selectionStart, selectionEnd + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ s.insert(selectionEnd + 1, "\n\n");
+ }
+
+ private boolean addMedia(Uri imageUri, SpannableStringBuilder ssb) {
+ if (ssb != null && !MediaUtils.isInMediaStore(imageUri))
+ imageUri = MediaUtils.downloadExternalMedia(getActivity(), imageUri);
+
+ if (imageUri == null) {
+ return false;
+ }
+
+ Bitmap thumbnailBitmap;
+ String mediaTitle;
+ if (imageUri.toString().contains("video") && !MediaUtils.isInMediaStore(imageUri)) {
+ thumbnailBitmap = BitmapFactory.decodeResource(getActivity().getResources(), R.drawable.media_movieclip);
+ mediaTitle = getResources().getString(R.string.video);
+ } else {
+ thumbnailBitmap = ImageHelper.getWPImageSpanThumbnailFromFilePath(getActivity(), imageUri.getEncodedPath(),
+ getMaximumThumbnailWidth());
+ if (thumbnailBitmap == null) {
+ return false;
+ }
+ mediaTitle = ImageHelper.getTitleForWPImageSpan(getActivity(), imageUri.getEncodedPath());
+ }
+
+ WPImageSpan is = new WPImageSpan(getActivity(), thumbnailBitmap, imageUri);
+ MediaFile mediaFile = is.getMediaFile();
+ mediaFile.setPostID(mActivity.getPost().getLocalTablePostId());
+ mediaFile.setTitle(mediaTitle);
+ mediaFile.setFilePath(is.getImageSource().toString());
+ MediaUtils.setWPImageSpanWidth(getActivity(), imageUri, is);
+ if (imageUri.getEncodedPath() != null)
+ mediaFile.setVideo(imageUri.getEncodedPath().contains("video"));
+ mediaFile.save();
+
+ if (ssb != null) {
+ ssb.append(" ");
+ ssb.setSpan(is, ssb.length() - 1, ssb.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ AlignmentSpan.Standard as = new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER);
+ ssb.setSpan(as, ssb.length() - 1, ssb.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ ssb.append("\n");
+ } else {
+ int selectionStart = mContentEditText.getSelectionStart();
+ mStyleStart = selectionStart;
+ int selectionEnd = mContentEditText.getSelectionEnd();
+
+ if (selectionStart > selectionEnd) {
+ int temp = selectionEnd;
+ selectionEnd = selectionStart;
+ selectionStart = temp;
+ }
+
+ Editable s = mContentEditText.getText();
+ if (s == null)
+ return false;
+
+ int line, column = 0;
+ if (mContentEditText.getLayout() != null) {
+ line = mContentEditText.getLayout().getLineForOffset(selectionStart);
+ column = mContentEditText.getSelectionStart() - mContentEditText.getLayout().getLineStart(line);
+ }
+
+ WPImageSpan[] image_spans = s.getSpans(selectionStart, selectionEnd, WPImageSpan.class);
+ if (image_spans.length != 0) {
+ // insert a few line breaks if the cursor is already on an image
+ s.insert(selectionEnd, "\n\n");
+ selectionStart = selectionStart + 2;
+ selectionEnd = selectionEnd + 2;
+ } else if (column != 0) {
+ // insert one line break if the cursor is not at the first column
+ s.insert(selectionEnd, "\n");
+ selectionStart = selectionStart + 1;
+ selectionEnd = selectionEnd + 1;
+ }
+
+ s.insert(selectionStart, " ");
+ s.setSpan(is, selectionStart, selectionEnd + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ AlignmentSpan.Standard as = new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER);
+ s.setSpan(as, selectionStart, selectionEnd + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ s.insert(selectionEnd + 1, "\n\n");
+ }
+ // Show the soft keyboard after adding media
+ if (mActivity != null && mActivity.getActionBar() != null && !mActivity.getActionBar().isShowing()) {
+ ((InputMethodManager) mActivity.getSystemService(Context.INPUT_METHOD_SERVICE)).toggleSoftInput(
+ InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY);
+ }
+ return true;
+ }
+
+ /**
+ * Get the maximum size a thumbnail can be to fit in either portrait or landscape orientations.
+ */
+ public int getMaximumThumbnailWidth() {
+ if (mMaximumThumbnailWidth == 0 && hasActivity()) {
+ Point size = DisplayUtils.getDisplayPixelSize(getActivity());
+ int screenWidth = size.x;
+ int screenHeight = size.y;
+ mMaximumThumbnailWidth = (screenWidth > screenHeight) ? screenHeight : screenWidth;
+ // 48dp of padding on each side so you can still place the cursor next to the image.
+ int padding = DisplayUtils.dpToPx(getActivity(), 48) * 2;
+ mMaximumThumbnailWidth -= padding;
+ }
+
+ return mMaximumThumbnailWidth;
+ }
+
+ /**
+ * Formatting bar
+ */
+
+ private View.OnClickListener mFormatBarButtonClickListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ int id = v.getId();
+ if (id == R.id.bold) {
+ onFormatButtonClick(mBoldToggleButton, TAG_FORMAT_BAR_BUTTON_STRONG);
+ } else if (id == R.id.em) {
+ onFormatButtonClick(mEmToggleButton, TAG_FORMAT_BAR_BUTTON_EM);
+ } else if (id == R.id.underline) {
+ onFormatButtonClick(mUnderlineToggleButton, TAG_FORMAT_BAR_BUTTON_UNDERLINE);
+ } else if (id == R.id.strike) {
+ onFormatButtonClick(mStrikeToggleButton, TAG_FORMAT_BAR_BUTTON_STRIKE);
+ } else if (id == R.id.bquote) {
+ onFormatButtonClick(mBquoteToggleButton, TAG_FORMAT_BAR_BUTTON_QUOTE);
+ } else if (id == R.id.more) {
+ mSelectionEnd = mContentEditText.getSelectionEnd();
+ Editable str = mContentEditText.getText();
+ if (str != null) {
+ if (mSelectionEnd > str.length())
+ mSelectionEnd = str.length();
+ str.insert(mSelectionEnd, "\n<!--more-->\n");
+ }
+ } else if (id == R.id.link) {
+ mSelectionStart = mContentEditText.getSelectionStart();
+ mStyleStart = mSelectionStart;
+ mSelectionEnd = mContentEditText.getSelectionEnd();
+ if (mSelectionStart > mSelectionEnd) {
+ int temp = mSelectionEnd;
+ mSelectionEnd = mSelectionStart;
+ mSelectionStart = temp;
+ }
+ Intent i = new Intent(getActivity(), EditLinkActivity.class);
+ if (mSelectionEnd > mSelectionStart) {
+ if (mContentEditText.getText() != null) {
+ String selectedText = mContentEditText.getText().subSequence(mSelectionStart, mSelectionEnd).toString();
+ i.putExtra("selectedText", selectedText);
+ }
+ }
+ startActivityForResult(i, ACTIVITY_REQUEST_CODE_CREATE_LINK);
+ } else if (id == R.id.addPictureButton) {
+ mAddPictureButton.performLongClick();
+ }
+ }
+ };
+
+ /**
+ * Applies formatting to selected text, or marks the entry for a new text style
+ * at the current cursor position
+ * @param toggleButton button from formatting bar
+ * @param tag HTML tag name for text style
+ */
+ private void onFormatButtonClick(ToggleButton toggleButton, String tag) {
+ Spannable s = mContentEditText.getText();
+ if (s == null)
+ return;
+ int selectionStart = mContentEditText.getSelectionStart();
+ mStyleStart = selectionStart;
+ int selectionEnd = mContentEditText.getSelectionEnd();
+
+ if (selectionStart > selectionEnd) {
+ int temp = selectionEnd;
+ selectionEnd = selectionStart;
+ selectionStart = temp;
+ }
+
+ Class styleClass = null;
+ if (tag.equals(TAG_FORMAT_BAR_BUTTON_STRONG) || tag.equals(TAG_FORMAT_BAR_BUTTON_EM))
+ styleClass = StyleSpan.class;
+ else if (tag.equals(TAG_FORMAT_BAR_BUTTON_UNDERLINE))
+ styleClass = WPUnderlineSpan.class;
+ else if (tag.equals(TAG_FORMAT_BAR_BUTTON_STRIKE))
+ styleClass = StrikethroughSpan.class;
+ else if (tag.equals(TAG_FORMAT_BAR_BUTTON_QUOTE))
+ styleClass = QuoteSpan.class;
+
+ if (styleClass == null)
+ return;
+
+ Object[] allSpans = s.getSpans(selectionStart, selectionEnd, styleClass);
+ boolean textIsSelected = selectionEnd > selectionStart;
+ if (mActivity.getPost().isLocalDraft()) {
+ // Local drafts can use the rich text editor. Yay!
+ boolean shouldAddSpan = true;
+ for (Object span : allSpans) {
+ if (span instanceof StyleSpan) {
+ StyleSpan styleSpan = (StyleSpan)span;
+ if ((styleSpan.getStyle() == Typeface.BOLD && !tag.equals(TAG_FORMAT_BAR_BUTTON_STRONG))
+ || (styleSpan.getStyle() == Typeface.ITALIC && !tag.equals(TAG_FORMAT_BAR_BUTTON_EM))) {
+ continue;
+ }
+ }
+ if (!toggleButton.isChecked() && textIsSelected) {
+ // If span exists and text is selected, remove the span
+ s.removeSpan(span);
+ shouldAddSpan = false;
+ break;
+ } else if (!toggleButton.isChecked()) {
+ // Remove span at cursor point if button isn't checked
+ Object[] spans = s.getSpans(mStyleStart - 1, mStyleStart, styleClass);
+ for (Object removeSpan : spans) {
+ selectionStart = s.getSpanStart(removeSpan);
+ selectionEnd = s.getSpanEnd(removeSpan);
+ s.removeSpan(removeSpan);
+ }
+ }
+ }
+
+ if (shouldAddSpan) {
+ if (tag.equals(TAG_FORMAT_BAR_BUTTON_STRONG)) {
+ s.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), selectionStart, selectionEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ } else if (tag.equals(TAG_FORMAT_BAR_BUTTON_EM)) {
+ s.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), selectionStart, selectionEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ } else {
+ try {
+ s.setSpan(styleClass.newInstance(), selectionStart, selectionEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ } catch (java.lang.InstantiationException e) {
+ AppLog.e(T.POSTS, e);
+ } catch (IllegalAccessException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ }
+ }
+ } else {
+ // Add HTML tags when editing an existing post
+ String startTag = "<" + tag + ">";
+ String endTag = "</" + tag + ">";
+ Editable content = mContentEditText.getText();
+ if (textIsSelected) {
+ content.insert(selectionStart, startTag);
+ content.insert(selectionEnd + startTag.length(), endTag);
+ toggleButton.setChecked(false);
+ mContentEditText.setSelection(selectionEnd + startTag.length() + endTag.length());
+ } else if (toggleButton.isChecked()) {
+ content.insert(selectionStart, startTag);
+ mContentEditText.setSelection(selectionEnd + startTag.length());
+ } else if (!toggleButton.isChecked()) {
+ content.insert(selectionEnd, endTag);
+ mContentEditText.setSelection(selectionEnd + endTag.length());
+ }
+ }
+ }
+
+ /**
+ * Rich Text Editor
+ */
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ float pos = event.getY();
+
+ if (event.getAction() == 0)
+ mLastYPos = pos;
+
+ if (event.getAction() > 1) {
+ int scrollThreshold = DisplayUtils.dpToPx(getActivity(), 2);
+ if (((mLastYPos - pos) > scrollThreshold) || ((pos - mLastYPos) > scrollThreshold))
+ mScrollDetected = true;
+ }
+
+ mLastYPos = pos;
+
+ if (event.getAction() == MotionEvent.ACTION_UP) {
+ if (mActivity != null && mActivity.getActionBar() != null && mActivity.getActionBar().isShowing()) {
+ setContentEditingModeVisible(true);
+ return false;
+ }
+ }
+
+ if (event.getAction() == MotionEvent.ACTION_UP && !mScrollDetected) {
+ Layout layout = ((TextView) v).getLayout();
+ int x = (int) event.getX();
+ int y = (int) event.getY();
+
+ x += v.getScrollX();
+ y += v.getScrollY();
+ if (layout != null) {
+ int line = layout.getLineForVertical(y);
+ int charPosition = layout.getOffsetForHorizontal(line, x);
+
+ Spannable s = mContentEditText.getText();
+ if (s == null)
+ return false;
+ // check if image span was tapped
+ WPImageSpan[] image_spans = s.getSpans(charPosition, charPosition, WPImageSpan.class);
+
+ if (image_spans.length != 0) {
+ final WPImageSpan span = image_spans[0];
+ MediaFile mediaFile = span.getMediaFile();
+ if (mediaFile == null)
+ return false;
+ if (!mediaFile.isVideo()) {
+ LayoutInflater factory = LayoutInflater.from(getActivity());
+ final View alertView = factory.inflate(R.layout.alert_image_options, null);
+ if (alertView == null)
+ return false;
+ final EditText imageWidthText = (EditText) alertView.findViewById(R.id.imageWidthText);
+ final EditText titleText = (EditText) alertView.findViewById(R.id.title);
+ final EditText caption = (EditText) alertView.findViewById(R.id.caption);
+ final CheckBox featuredCheckBox = (CheckBox) alertView.findViewById(R.id.featuredImage);
+ final CheckBox featuredInPostCheckBox = (CheckBox) alertView.findViewById(R.id.featuredInPost);
+
+ // show featured image checkboxes if theme support it
+ if (WordPress.getCurrentBlog().isFeaturedImageCapable()) {
+ featuredCheckBox.setVisibility(View.VISIBLE);
+ featuredInPostCheckBox.setVisibility(View.VISIBLE);
+ }
+
+ featuredCheckBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ if (isChecked) {
+ featuredInPostCheckBox.setVisibility(View.VISIBLE);
+ } else {
+ featuredInPostCheckBox.setVisibility(View.GONE);
+ }
+
+ }
+ });
+
+ final SeekBar seekBar = (SeekBar) alertView.findViewById(R.id.imageWidth);
+ final Spinner alignmentSpinner = (Spinner) alertView.findViewById(R.id.alignment_spinner);
+ ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(getActivity(), R.array.alignment_array,
+ android.R.layout.simple_spinner_item);
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ alignmentSpinner.setAdapter(adapter);
+
+ imageWidthText.setText(String.valueOf(mediaFile.getWidth()) + "px");
+ seekBar.setProgress(mediaFile.getWidth());
+ titleText.setText(mediaFile.getTitle());
+ caption.setText(mediaFile.getCaption());
+ featuredCheckBox.setChecked(mediaFile.isFeatured());
+
+ if (mediaFile.isFeatured())
+ featuredInPostCheckBox.setVisibility(View.VISIBLE);
+ else
+ featuredInPostCheckBox.setVisibility(View.GONE);
+
+ featuredInPostCheckBox.setChecked(mediaFile.isFeaturedInPost());
+
+ alignmentSpinner.setSelection(mediaFile.getHorizontalAlignment(), true);
+
+ final int maxWidth = MediaUtils.getMinimumImageWidth(getActivity(), span.getImageSource());
+ seekBar.setMax(maxWidth / 10);
+ if (mediaFile.getWidth() != 0)
+ seekBar.setProgress(mediaFile.getWidth() / 10);
+ seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {
+ }
+
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ if (progress == 0)
+ progress = 1;
+ imageWidthText.setText(progress * 10 + "px");
+ }
+ });
+
+ imageWidthText.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (hasFocus) {
+ imageWidthText.setText("");
+ }
+ }
+ });
+
+ imageWidthText.setOnEditorActionListener(new TextView.OnEditorActionListener() {
+ @Override
+ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+ int width = getEditTextIntegerClamped(imageWidthText, 10, maxWidth);
+ seekBar.setProgress(width / 10);
+ imageWidthText.setSelection((String.valueOf(width).length()));
+
+ InputMethodManager imm = (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
+ imm.hideSoftInputFromWindow(imageWidthText.getWindowToken(),
+ InputMethodManager.RESULT_UNCHANGED_SHOWN);
+
+ return true;
+ }
+ });
+
+ AlertDialog ad = new AlertDialog.Builder(getActivity()).setTitle(getString(R.string.image_settings))
+ .setView(alertView).setPositiveButton(getString(R.string.ok), new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ String title = (titleText.getText() != null) ? titleText.getText().toString() : "";
+ MediaFile mediaFile = span.getMediaFile();
+ if (mediaFile == null)
+ return;
+ mediaFile.setTitle(title);
+ mediaFile.setHorizontalAlignment(alignmentSpinner.getSelectedItemPosition());
+ mediaFile.setWidth(getEditTextIntegerClamped(imageWidthText, 10, maxWidth));
+ String captionText = (caption.getText() != null) ? caption.getText().toString() : "";
+ mediaFile.setCaption(captionText);
+ mediaFile.setFeatured(featuredCheckBox.isChecked());
+ if (featuredCheckBox.isChecked()) {
+ // remove featured flag from all other images
+ Spannable contentSpannable = mContentEditText.getText();
+ WPImageSpan[] postImageSpans = contentSpannable.getSpans(0, contentSpannable.length(), WPImageSpan.class);
+ if (postImageSpans.length > 1) {
+ for (WPImageSpan postImageSpan : postImageSpans) {
+ if (postImageSpan != span) {
+ MediaFile postMediaFile = postImageSpan.getMediaFile();
+ postMediaFile.setFeatured(false);
+ postMediaFile.setFeaturedInPost(false);
+ postMediaFile.save();
+ }
+ }
+ }
+ }
+ mediaFile.setFeaturedInPost(featuredInPostCheckBox.isChecked());
+ mediaFile.save();
+ }
+ }).setNegativeButton(getString(R.string.cancel), new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ dialog.dismiss();
+ }
+ }).create();
+ ad.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN);
+ ad.show();
+ mScrollDetected = false;
+ return true;
+ }
+
+ } else {
+ mContentEditText.setMovementMethod(ArrowKeyMovementMethod.getInstance());
+ int selectionStart = mContentEditText.getSelectionStart();
+ if (selectionStart >= 0 && mContentEditText.getSelectionEnd() >= selectionStart)
+ mContentEditText.setSelection(selectionStart, mContentEditText.getSelectionEnd());
+ }
+
+ // get media gallery spans
+
+ MediaGalleryImageSpan[] gallerySpans = s.getSpans(charPosition, charPosition, MediaGalleryImageSpan.class);
+ if (gallerySpans.length > 0) {
+ final MediaGalleryImageSpan gallerySpan = gallerySpans[0];
+ startMediaGalleryActivity(gallerySpan.getMediaGallery());
+ }
+
+ }
+ } else if (event.getAction() == 1) {
+ mScrollDetected = false;
+ }
+ return false;
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ int position = Selection.getSelectionStart(mContentEditText.getText());
+ if ((mIsBackspace && position != 1) || mLastPosition == position || !mActivity.getPost().isLocalDraft())
+ return;
+
+ if (position < 0) {
+ position = 0;
+ }
+ mLastPosition = position;
+ if (position > 0) {
+ if (mStyleStart > position) {
+ mStyleStart = position - 1;
+ }
+
+ boolean shouldBold = mBoldToggleButton.isChecked();
+ boolean shouldEm = mEmToggleButton.isChecked();
+ boolean shouldUnderline = mUnderlineToggleButton.isChecked();
+ boolean shouldStrike = mStrikeToggleButton.isChecked();
+ boolean shouldQuote = mBquoteToggleButton.isChecked();
+
+ Object[] allSpans = s.getSpans(mStyleStart, position, Object.class);
+ for (Object span : allSpans) {
+ if (span instanceof StyleSpan) {
+ StyleSpan styleSpan = (StyleSpan) span;
+ if (styleSpan.getStyle() == Typeface.BOLD)
+ shouldBold = false;
+ else if (styleSpan.getStyle() == Typeface.ITALIC)
+ shouldEm = false;
+ } else if (span instanceof WPUnderlineSpan) {
+ shouldUnderline = false;
+ } else if (span instanceof StrikethroughSpan) {
+ shouldStrike = false;
+ } else if (span instanceof QuoteSpan) {
+ shouldQuote = false;
+ }
+ }
+
+ if (shouldBold)
+ s.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), mStyleStart, position, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+ if (shouldEm)
+ s.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), mStyleStart, position, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+ if (shouldUnderline)
+ s.setSpan(new WPUnderlineSpan(), mStyleStart, position, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+ if (shouldStrike)
+ s.setSpan(new StrikethroughSpan(), mStyleStart, position, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+ if (shouldQuote)
+ s.setSpan(new QuoteSpan(), mStyleStart, position, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+ }
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ mIsBackspace = (count - after == 1) || (s.length() == 0);
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+
+ @Override
+ public void onSelectionChanged() {
+ if (mActivity.getPost() == null || !mActivity.getPost().isLocalDraft())
+ return;
+
+ final Spannable s = mContentEditText.getText();
+ if (s == null)
+ return;
+ // set toggle buttons if cursor is inside of a matching span
+ mStyleStart = mContentEditText.getSelectionStart();
+ Object[] spans = s.getSpans(mContentEditText.getSelectionStart(), mContentEditText.getSelectionStart(), Object.class);
+
+ mBoldToggleButton.setChecked(false);
+ mEmToggleButton.setChecked(false);
+ mBquoteToggleButton.setChecked(false);
+ mUnderlineToggleButton.setChecked(false);
+ mStrikeToggleButton.setChecked(false);
+ for (Object span : spans) {
+ if (span instanceof StyleSpan) {
+ StyleSpan ss = (StyleSpan) span;
+ if (ss.getStyle() == android.graphics.Typeface.BOLD) {
+ mBoldToggleButton.setChecked(true);
+ }
+ if (ss.getStyle() == android.graphics.Typeface.ITALIC) {
+ mEmToggleButton.setChecked(true);
+ }
+ }
+ if (span instanceof QuoteSpan) {
+ mBquoteToggleButton.setChecked(true);
+ }
+ if (span instanceof WPUnderlineSpan) {
+ mUnderlineToggleButton.setChecked(true);
+ }
+ if (span instanceof StrikethroughSpan) {
+ mStrikeToggleButton.setChecked(true);
+ }
+ }
+ }
+
+ private int getEditTextIntegerClamped(EditText editText, int min, int max) {
+ int width = 10;
+ try {
+ if (editText.getText() != null)
+ width = Integer.parseInt(editText.getText().toString().replace("px", ""));
+ } catch (NumberFormatException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ width = Math.min(max, Math.max(width, min));
+ return width;
+ }
+
+ private class LoadPostContentTask extends AsyncTask<String, Spanned, Spanned> {
+
+ @Override
+ protected Spanned doInBackground(String... params) {
+ if (params.length < 1 || mActivity == null || mActivity.getPost() == null) {
+ return null;
+ }
+
+ String content = StringUtils.notNullStr(params[0]);
+
+ return WPHtml.fromHtml(
+ content,
+ mActivity,
+ mActivity.getPost(),
+ getMaximumThumbnailWidth()
+ );
+ }
+
+ @Override
+ protected void onPostExecute(Spanned spanned) {
+ if (mActivity != null && mContentEditText != null && spanned != null) {
+ mContentEditText.setText(spanned);
+ }
+ }
+ }
+
+
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostPreviewFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostPreviewFragment.java
new file mode 100644
index 000000000..daf816fa9
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostPreviewFragment.java
@@ -0,0 +1,110 @@
+package org.wordpress.android.ui.posts;
+
+import android.app.Fragment;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.text.SpannableString;
+import android.text.Spanned;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.webkit.WebView;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.models.Post;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.WPHtml;
+
+public class EditPostPreviewFragment extends Fragment {
+ private EditPostActivity mActivity;
+ private WebView mWebView;
+ private TextView mTextView;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ mActivity = (EditPostActivity)getActivity();
+
+ ViewGroup rootView = (ViewGroup) inflater
+ .inflate(R.layout.fragment_edit_post_preview, container, false);
+ mWebView = (WebView) rootView.findViewById(R.id.post_preview_webview);
+ mTextView = (TextView) rootView.findViewById(R.id.post_preview_textview);
+ mTextView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ if (mActivity != null) {
+ loadPost();
+ }
+ mTextView.getViewTreeObserver().removeGlobalOnLayoutListener(this);
+ }
+ });
+
+ return rootView;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ if (mActivity != null && !mTextView.isLayoutRequested()) {
+ loadPost();
+ }
+ }
+
+ public void loadPost() {
+ new LoadPostPreviewTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+
+ // Load post content in the background
+ private class LoadPostPreviewTask extends AsyncTask<Void, Void, Spanned> {
+
+ @Override
+ protected Spanned doInBackground(Void... params) {
+ Spanned contentSpannable;
+
+ if (mActivity == null || mActivity.getPost() == null) {
+ return null;
+ }
+
+ Post post = mActivity.getPost();
+
+ String postTitle = "<h1>" + post.getTitle() + "</h1>";
+ String postContent = postTitle + post.getDescription() + "\n\n" + post.getMoreText();
+
+ if (post.isLocalDraft()) {
+ contentSpannable = WPHtml.fromHtml(
+ postContent.replaceAll("\uFFFC", ""),
+ getActivity(),
+ post,
+ Math.min(mTextView.getWidth(), mTextView.getHeight())
+ );
+ } else {
+ String htmlText = "<?xml version=\"1.0\" encoding=\"UTF-8\" ?><html><head><link rel=\"stylesheet\" type=\"text/css\" href=\"webview.css\" /></head><body><div id=\"container\">%s</div></body></html>";
+ htmlText = String.format(htmlText, StringUtils.addPTags(postContent));
+ contentSpannable = new SpannableString(htmlText);
+ }
+
+ return contentSpannable;
+ }
+
+ @Override
+ protected void onPostExecute(Spanned spanned) {
+ if (mActivity != null && mActivity.getPost() != null && spanned != null) {
+ if (mActivity.getPost().isLocalDraft()) {
+ mTextView.setVisibility(View.VISIBLE);
+ mWebView.setVisibility(View.GONE);
+ mTextView.setText(spanned);
+ } else {
+ mTextView.setVisibility(View.GONE);
+ mWebView.setVisibility(View.VISIBLE);
+
+ mWebView.loadDataWithBaseURL("file:///android_asset/", spanned.toString(),
+ "text/html", "utf-8", null);
+ }
+ }
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostSettingsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostSettingsFragment.java
new file mode 100644
index 000000000..d291a746e
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostSettingsFragment.java
@@ -0,0 +1,880 @@
+package org.wordpress.android.ui.posts;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Fragment;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.location.Address;
+import android.location.Location;
+import android.location.LocationManager;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.Html;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.text.format.DateFormat;
+import android.text.format.DateUtils;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewStub;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+import android.view.inputmethod.EditorInfo;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.DatePicker;
+import android.widget.EditText;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.TimePicker;
+import android.widget.Toast;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+
+import org.apache.commons.lang.StringEscapeUtils;
+import org.json.JSONArray;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Post;
+import org.wordpress.android.models.PostLocation;
+import org.wordpress.android.models.PostStatus;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.EditTextUtils;
+import org.wordpress.android.util.GeocoderUtils;
+import org.wordpress.android.util.JSONUtil;
+import org.wordpress.android.util.LocationHelper;
+import org.wordpress.android.util.MediaUtils;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+import org.xmlrpc.android.ApiHelper;
+
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Vector;
+
+public class EditPostSettingsFragment extends Fragment
+ implements View.OnClickListener, TextView.OnEditorActionListener {
+ private static final int ACTIVITY_REQUEST_CODE_SELECT_CATEGORIES = 5;
+
+ private static final String CATEGORY_PREFIX_TAG = "category-";
+
+ private EditPostActivity mActivity;
+
+ private Spinner mStatusSpinner, mPostFormatSpinner;
+ private EditText mPasswordEditText, mTagsEditText, mExcerptEditText;
+ private TextView mPubDateText;
+ private ViewGroup mSectionCategories;
+
+ private ArrayList<String> mCategories;
+
+ private PostLocation mPostLocation;
+ private LocationHelper mLocationHelper;
+
+ private int mYear, mMonth, mDay, mHour, mMinute;
+ private long mCustomPubDate = 0;
+ private boolean mIsCustomPubDate;
+
+ private String[] mPostFormats;
+ private String[] mPostFormatTitles;
+
+ private static enum LocationStatus {NONE, FOUND, NOT_FOUND, SEARCHING}
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ Calendar c = Calendar.getInstance();
+ mYear = c.get(Calendar.YEAR);
+ mMonth = c.get(Calendar.MONTH);
+ mDay = c.get(Calendar.DAY_OF_MONTH);
+ mHour = c.get(Calendar.HOUR_OF_DAY);
+ mMinute = c.get(Calendar.MINUTE);
+ mCategories = new ArrayList<String>();
+
+ ViewGroup rootView = (ViewGroup) inflater
+ .inflate(R.layout.fragment_edit_post_settings, container, false);
+
+ if (rootView == null)
+ return null;
+
+ mActivity = (EditPostActivity) getActivity();
+
+ mExcerptEditText = (EditText) rootView.findViewById(R.id.postExcerpt);
+ mPasswordEditText = (EditText) rootView.findViewById(R.id.post_password);
+ Button mPubDateButton = (Button) rootView.findViewById(R.id.pubDateButton);
+ mPubDateText = (TextView) rootView.findViewById(R.id.pubDate);
+ mStatusSpinner = (Spinner) rootView.findViewById(R.id.status);
+ mStatusSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+ updatePostSettingsAndSaveButton();
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> parent) {
+
+ }
+ });
+ mTagsEditText = (EditText) rootView.findViewById(R.id.tags);
+ mSectionCategories = ((ViewGroup) rootView.findViewById(R.id.sectionCategories));
+
+ mPubDateButton.setOnClickListener(this);
+
+ // Set header labels to upper case
+ ((TextView) rootView.findViewById(R.id.categoryLabel)).setText(getResources().getString(R.string.categories).toUpperCase());
+ ((TextView) rootView.findViewById(R.id.statusLabel)).setText(getResources().getString(R.string.status).toUpperCase());
+ ((TextView) rootView.findViewById(R.id.postFormatLabel)).setText(getResources().getString(R.string.post_format).toUpperCase());
+ ((TextView) rootView.findViewById(R.id.pubDateLabel)).setText(getResources().getString(R.string.publish_date).toUpperCase());
+
+ if (mActivity.getPost().isPage()) { // remove post specific views
+ mExcerptEditText.setVisibility(View.GONE);
+ (rootView.findViewById(R.id.sectionTags)).setVisibility(View.GONE);
+ (rootView.findViewById(R.id.sectionCategories)).setVisibility(View.GONE);
+ (rootView.findViewById(R.id.postFormatLabel)).setVisibility(View.GONE);
+ (rootView.findViewById(R.id.postFormat)).setVisibility(View.GONE);
+ } else {
+ mPostFormatTitles = getResources().getStringArray(R.array.post_formats_array);
+ mPostFormats =
+ new String[]{"aside", "audio", "chat", "gallery", "image", "link", "quote", "standard", "status",
+ "video"};
+ if (WordPress.getCurrentBlog().getPostFormats().equals("")) {
+ List<Object> args = new Vector<Object>();
+ args.add(WordPress.getCurrentBlog());
+ args.add(mActivity);
+ new ApiHelper.GetPostFormatsTask().execute(args);
+ } else {
+ try {
+ Gson gson = new Gson();
+ Type type = new TypeToken<Map<String, String>>() {}.getType();
+ Map<String, String> jsonPostFormats = gson.fromJson(WordPress.getCurrentBlog().getPostFormats(),
+ type);
+ mPostFormats = new String[jsonPostFormats.size()];
+ mPostFormatTitles = new String[jsonPostFormats.size()];
+ int i = 0;
+ for (Map.Entry<String, String> entry : jsonPostFormats.entrySet()) {
+ String key = entry.getKey();
+ String val = entry.getValue();
+ mPostFormats[i] = key;
+ mPostFormatTitles[i] = StringEscapeUtils.unescapeHtml(val);
+ i++;
+ }
+ } catch (RuntimeException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ }
+ mPostFormatSpinner = (Spinner) rootView.findViewById(R.id.postFormat);
+ ArrayAdapter<String> pfAdapter = new ArrayAdapter<String>(getActivity(), android.R.layout.simple_spinner_item, mPostFormatTitles);
+ pfAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ mPostFormatSpinner.setAdapter(pfAdapter);
+ String activePostFormat = "standard";
+
+
+ if (!TextUtils.isEmpty(mActivity.getPost().getPostFormat())) {
+ activePostFormat = mActivity.getPost().getPostFormat();
+ }
+
+ for (int i = 0; i < mPostFormats.length; i++) {
+ if (mPostFormats[i].equals(activePostFormat))
+ mPostFormatSpinner.setSelection(i);
+ }
+
+ mPostFormatSpinner.setOnTouchListener(
+ new View.OnTouchListener() {
+ @Override
+ public boolean onTouch(View view, MotionEvent motionEvent) {
+ return false;
+ }
+ }
+ );
+ }
+
+ Post post = mActivity.getPost();
+ if (post != null) {
+ mExcerptEditText.setText(post.getPostExcerpt());
+
+ String[] items = new String[]{getResources().getString(R.string.publish_post), getResources().getString(R.string.draft),
+ getResources().getString(R.string.pending_review), getResources().getString(R.string.post_private)};
+
+ ArrayAdapter<String> adapter = new ArrayAdapter<String>(getActivity(), android.R.layout.simple_spinner_item, items);
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ mStatusSpinner.setAdapter(adapter);
+ mStatusSpinner.setOnTouchListener(
+ new View.OnTouchListener() {
+ @Override
+ public boolean onTouch(View view, MotionEvent motionEvent) {
+ return false;
+ }
+ }
+ );
+
+ if (post.isUploaded()) {
+ items = new String[]{
+ getResources().getString(R.string.publish_post),
+ getResources().getString(R.string.draft),
+ getResources().getString(R.string.pending_review),
+ getResources().getString(R.string.post_private)
+ };
+ adapter = new ArrayAdapter<String>(getActivity(), android.R.layout.simple_spinner_item, items);
+ mStatusSpinner.setAdapter(adapter);
+ }
+
+ long pubDate = post.getDate_created_gmt();
+ if (pubDate != 0) {
+ try {
+ int flags = 0;
+ flags |= android.text.format.DateUtils.FORMAT_SHOW_DATE;
+ flags |= android.text.format.DateUtils.FORMAT_ABBREV_MONTH;
+ flags |= android.text.format.DateUtils.FORMAT_SHOW_YEAR;
+ flags |= android.text.format.DateUtils.FORMAT_SHOW_TIME;
+ String formattedDate = DateUtils.formatDateTime(getActivity(), pubDate,
+ flags);
+ mPubDateText.setText(formattedDate);
+ } catch (RuntimeException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ }
+
+ if (!TextUtils.isEmpty(post.getPassword()))
+ mPasswordEditText.setText(post.getPassword());
+
+ switch (post.getStatusEnum()) {
+ case PUBLISHED:
+ case SCHEDULED:
+ case UNKNOWN:
+ mStatusSpinner.setSelection(0, true);
+ break;
+ case DRAFT:
+ mStatusSpinner.setSelection(1, true);
+ break;
+ case PENDING:
+ mStatusSpinner.setSelection(2, true);
+ break;
+ case PRIVATE:
+ mStatusSpinner.setSelection(3, true);
+ break;
+ }
+
+ if (!post.isPage()) {
+ if (post.getJSONCategories() != null) {
+ mCategories = JSONUtil.fromJSONArrayToStringList(post.getJSONCategories());
+ }
+ }
+ String tags = post.getKeywords();
+ if (!tags.equals("")) {
+ mTagsEditText.setText(tags);
+ }
+
+ populateSelectedCategories();
+ }
+
+ initLocation(rootView);
+
+ return rootView;
+ }
+
+
+
+ private String getPostStatusForSpinnerPosition(int position) {
+ switch (position) {
+ case 0:
+ return PostStatus.toString(PostStatus.PUBLISHED);
+ case 1:
+ return PostStatus.toString(PostStatus.DRAFT);
+ case 2:
+ return PostStatus.toString(PostStatus.PENDING);
+ case 3:
+ return PostStatus.toString(PostStatus.PRIVATE);
+ default:
+ return PostStatus.toString(PostStatus.UNKNOWN);
+ }
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (data != null || ((requestCode == MediaUtils.RequestCode.ACTIVITY_REQUEST_CODE_TAKE_PHOTO || requestCode == MediaUtils.RequestCode.ACTIVITY_REQUEST_CODE_TAKE_VIDEO))) {
+ Bundle extras;
+
+ switch (requestCode) {
+ case ACTIVITY_REQUEST_CODE_SELECT_CATEGORIES:
+ extras = data.getExtras();
+ if (extras != null && extras.containsKey("selectedCategories")) {
+ mCategories = (ArrayList<String>) extras.getSerializable("selectedCategories");
+ populateSelectedCategories();
+ }
+ break;
+ }
+ }
+ }
+
+ @Override
+ public void onClick(View v) {
+ int id = v.getId();
+ if (id == R.id.pubDateButton) {
+ showPostDateSelectionDialog();
+ } else if (id == R.id.selectCategories) {
+ Bundle bundle = new Bundle();
+ bundle.putInt("id", WordPress.getCurrentBlog().getLocalTableBlogId());
+ if (mCategories.size() > 0) {
+ bundle.putSerializable("categories", new HashSet<String>(mCategories));
+ }
+ Intent categoriesIntent = new Intent(getActivity(), SelectCategoriesActivity.class);
+ categoriesIntent.putExtras(bundle);
+ startActivityForResult(categoriesIntent, ACTIVITY_REQUEST_CODE_SELECT_CATEGORIES);
+ } else if (id == R.id.categoryButton) {
+ onCategoryButtonClick(v);
+ } else if (id == R.id.locationText) {
+ viewLocation();
+ } else if (id == R.id.updateLocation) {
+ showLocationSearch();
+ } else if (id == R.id.removeLocation) {
+ removeLocation();
+ showLocationAdd();
+ } else if (id == R.id.addLocation) {
+ showLocationSearch();
+ } else if (id == R.id.searchLocation) {
+ searchLocation();
+ }
+ }
+
+ @Override
+ public boolean onEditorAction(TextView view, int actionId, KeyEvent event) {
+ boolean handled = false;
+ int id = view.getId();
+ if (id == R.id.searchLocationText && actionId == EditorInfo.IME_ACTION_SEARCH) {
+ searchLocation();
+ handled = true;
+ }
+ return handled;
+ }
+
+ private void showPostDateSelectionDialog() {
+ final DatePicker datePicker = new DatePicker(getActivity());
+ datePicker.init(mYear, mMonth, mDay, null);
+ datePicker.setCalendarViewShown(false);
+
+ new AlertDialog.Builder(getActivity())
+ .setTitle(R.string.select_date)
+ .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ mYear = datePicker.getYear();
+ mMonth = datePicker.getMonth();
+ mDay = datePicker.getDayOfMonth();
+ showPostTimeSelectionDialog();
+ }
+ })
+ .setNeutralButton(getResources().getText(R.string.immediately),
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialogInterface,
+ int i) {
+ mIsCustomPubDate = true;
+ mPubDateText.setText(R.string.immediately);
+ updatePostSettingsAndSaveButton();
+ }
+ })
+ .setNegativeButton(android.R.string.cancel,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog,
+ int which) {
+ }
+ }
+ ).setView(datePicker).show();
+
+ }
+
+ private void showPostTimeSelectionDialog() {
+ final TimePicker timePicker = new TimePicker(getActivity());
+ timePicker.setIs24HourView(DateFormat.is24HourFormat(getActivity()));
+ timePicker.setCurrentHour(mHour);
+ timePicker.setCurrentMinute(mMinute);
+
+ new AlertDialog.Builder(getActivity())
+ .setTitle(R.string.select_time)
+ .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ mHour = timePicker.getCurrentHour();
+ mMinute = timePicker.getCurrentMinute();
+
+ Date d = new Date(mYear - 1900, mMonth, mDay, mHour, mMinute);
+ long timestamp = d.getTime();
+
+ try {
+ int flags = 0;
+ flags |= DateUtils.FORMAT_SHOW_DATE;
+ flags |= DateUtils.FORMAT_ABBREV_MONTH;
+ flags |= DateUtils.FORMAT_SHOW_YEAR;
+ flags |= DateUtils.FORMAT_SHOW_TIME;
+ String formattedDate = DateUtils.formatDateTime(getActivity(), timestamp, flags);
+ mCustomPubDate = timestamp;
+ mPubDateText.setText(formattedDate);
+ mIsCustomPubDate = true;
+
+ updatePostSettingsAndSaveButton();
+ AnalyticsTracker.track(AnalyticsTracker.Stat.EDITOR_SCHEDULED_POST);
+ } catch (RuntimeException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ }
+ })
+ .setNegativeButton(android.R.string.cancel,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog,
+ int which) {
+ }
+ }).setView(timePicker).show();
+ }
+
+ /**
+ * Updates post object with content of this fragment
+ */
+ public void updatePostSettings() {
+ Post post = mActivity.getPost();
+ if (post == null)
+ return;
+
+ String password = (mPasswordEditText.getText() != null) ? mPasswordEditText.getText().toString() : "";
+ String pubDate = (mPubDateText.getText() != null) ? mPubDateText.getText().toString() : "";
+ String excerpt = (mExcerptEditText.getText() != null) ? mExcerptEditText.getText().toString() : "";
+
+ long pubDateTimestamp = 0;
+ if (mIsCustomPubDate && pubDate.equals(getResources().getText(R.string.immediately)) && !post.isLocalDraft()) {
+ Date d = new Date();
+ pubDateTimestamp = d.getTime();
+ } else if (!pubDate.equals(getResources().getText(R.string.immediately))) {
+ if (mIsCustomPubDate)
+ pubDateTimestamp = mCustomPubDate;
+ else if (post.getDate_created_gmt() > 0)
+ pubDateTimestamp = post.getDate_created_gmt();
+ } else if (pubDate.equals(getResources().getText(R.string.immediately)) && post.isLocalDraft()) {
+ post.setDate_created_gmt(0);
+ post.setDateCreated(0);
+ }
+
+ String tags = "", postFormat = "";
+ if (!post.isPage()) {
+ tags = (mTagsEditText.getText() != null) ? mTagsEditText.getText().toString() : "";
+
+ // post format
+ if (mPostFormats != null && mPostFormatSpinner.getSelectedItemPosition() < mPostFormats.length) {
+ postFormat = mPostFormats[mPostFormatSpinner.getSelectedItemPosition()];
+ }
+ }
+
+ String status = getPostStatusForSpinnerPosition(mStatusSpinner.getSelectedItemPosition());
+
+ // We want to flag this post as having changed statuses from draft to published so that we
+ // properly track stats we care about for when users first publish posts.
+ if (post.isUploaded() && post.getPostStatus().equals(PostStatus.toString(PostStatus.DRAFT))
+ && status.equals(PostStatus.toString(PostStatus.PUBLISHED))) {
+ post.setChangedFromLocalDraftToPublished(true);
+ }
+
+ if (post.supportsLocation()) {
+ post.setLocation(mPostLocation);
+ }
+
+ post.setPostExcerpt(excerpt);
+ post.setDate_created_gmt(pubDateTimestamp);
+ post.setJSONCategories(new JSONArray(mCategories));
+ post.setKeywords(tags);
+ post.setPostStatus(status);
+ post.setPassword(password);
+ post.setPostFormat(postFormat);
+ }
+
+ /*
+ * Saves settings to post object and updates save button text in the ActionBar
+ */
+ private void updatePostSettingsAndSaveButton() {
+ if (mActivity != null) {
+ updatePostSettings();
+ mActivity.invalidateOptionsMenu();
+ }
+ }
+
+ /**
+ * Location methods
+ */
+
+ /*
+ * retrieves and displays the friendly address for a lat/long location
+ */
+ private class GetAddressTask extends AsyncTask<Double, Void, Address> {
+ double latitude;
+ double longitude;
+
+ @Override
+ protected void onPreExecute() {
+ setLocationStatus(LocationStatus.SEARCHING);
+ showLocationView();
+ }
+
+ @Override
+ protected Address doInBackground(Double... args) {
+ // args will be the latitude, longitude to look up
+ latitude = args[0];
+ longitude = args[1];
+
+ return GeocoderUtils.getAddressFromCoords(getActivity(), latitude, longitude);
+ }
+
+ protected void onPostExecute(Address address) {
+ setLocationStatus(LocationStatus.FOUND);
+ if (address == null) {
+ // show lat/long when Geocoder fails (ugly, but better than not showing anything
+ // or showing an error since the location has been assigned to the post already)
+ updateLocationText(Double.toString(latitude) + ", " + Double.toString(longitude));
+ } else {
+ String locationName = GeocoderUtils.getLocationNameFromAddress(address);
+ updateLocationText(locationName);
+ }
+ }
+ }
+
+ private class GetCoordsTask extends AsyncTask<String, Void, Address> {
+ @Override
+ protected void onPreExecute() {
+ setLocationStatus(LocationStatus.SEARCHING);
+ showLocationView();
+ }
+
+ @Override
+ protected Address doInBackground(String... args) {
+ String locationName = args[0];
+
+ return GeocoderUtils.getAddressFromLocationName(getActivity(), locationName);
+ }
+
+ @Override
+ protected void onPostExecute(Address address) {
+ setLocationStatus(LocationStatus.FOUND);
+ showLocationView();
+
+ if (address != null) {
+ double[] coordinates = GeocoderUtils.getCoordsFromAddress(address);
+ setLocation(coordinates[0], coordinates[1]);
+
+ String locationName = GeocoderUtils.getLocationNameFromAddress(address);
+ updateLocationText(locationName);
+ } else {
+ showLocationNotAvailableError();
+ showLocationSearch();
+ }
+ }
+ }
+
+ private LocationHelper.LocationResult locationResult = new LocationHelper.LocationResult() {
+ @Override
+ public void gotLocation(final Location location) {
+ if (getActivity() == null)
+ return;
+ // note that location will be null when requesting location fails
+ getActivity().runOnUiThread(new Runnable() {
+ public void run() {
+ setLocation(location);
+ }
+ });
+ }
+ };
+
+ private View mLocationAddSection;
+ private View mLocationSearchSection;
+ private View mLocationViewSection;
+ private TextView mLocationText;
+ private EditText mLocationEditText;
+ private Button mButtonSearchLocation;
+
+ private TextWatcher mLocationEditTextWatcher = new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) { }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ String buttonText;
+ if (s.length() > 0) {
+ buttonText = getResources().getString(R.string.search_location);
+ } else {
+ buttonText = getResources().getString(R.string.search_current_location);
+ }
+ mButtonSearchLocation.setText(buttonText);
+ }
+ };
+
+ /*
+ * called when activity is created to initialize the location provider, show views related
+ * to location if enabled for this blog, and retrieve the current location if necessary
+ */
+ private void initLocation(ViewGroup rootView) {
+ Post post = mActivity.getPost();
+
+ // show the location views if a provider was found and this is a post on a blog that has location enabled
+ if (hasLocationProvider() && post.supportsLocation()) {
+ View locationRootView = ((ViewStub) rootView.findViewById(R.id.stub_post_location_settings)).inflate();
+
+ TextView locationLabel = ((TextView) locationRootView.findViewById(R.id.locationLabel));
+ locationLabel.setText(getResources().getString(R.string.location).toUpperCase());
+
+ mLocationText = (TextView) locationRootView.findViewById(R.id.locationText);
+ mLocationText.setOnClickListener(this);
+
+ mLocationAddSection = locationRootView.findViewById(R.id.sectionLocationAdd);
+ mLocationSearchSection = locationRootView.findViewById(R.id.sectionLocationSearch);
+ mLocationViewSection = locationRootView.findViewById(R.id.sectionLocationView);
+
+ Button addLocation = (Button) locationRootView.findViewById(R.id.addLocation);
+ addLocation.setOnClickListener(this);
+
+ mButtonSearchLocation = (Button) locationRootView.findViewById(R.id.searchLocation);
+ mButtonSearchLocation.setOnClickListener(this);
+
+ mLocationEditText = (EditText) locationRootView.findViewById(R.id.searchLocationText);
+ mLocationEditText.setOnEditorActionListener(this);
+ mLocationEditText.addTextChangedListener(mLocationEditTextWatcher);
+
+ Button updateLocation = (Button) locationRootView.findViewById(R.id.updateLocation);
+ Button removeLocation = (Button) locationRootView.findViewById(R.id.removeLocation);
+ updateLocation.setOnClickListener(this);
+ removeLocation.setOnClickListener(this);
+
+ // if this post has location attached to it, look up the location address
+ if (post.hasLocation()) {
+ showLocationView();
+
+ PostLocation location = post.getLocation();
+ setLocation(location.getLatitude(), location.getLongitude());
+ } else {
+ showLocationAdd();
+ }
+ }
+ }
+
+ private boolean hasLocationProvider() {
+ boolean hasLocationProvider = false;
+ LocationManager locationManager = (LocationManager) getActivity().getSystemService(Activity.LOCATION_SERVICE);
+ List<String> providers = locationManager.getProviders(true);
+ if (providers != null) {
+ for (String providerName : providers) {
+ if (providerName.equals(LocationManager.GPS_PROVIDER)
+ || providerName.equals(LocationManager.NETWORK_PROVIDER)) {
+ hasLocationProvider = true;
+ }
+ }
+ }
+ return hasLocationProvider;
+ }
+
+ private void showLocationSearch() {
+ mLocationAddSection.setVisibility(View.GONE);
+ mLocationSearchSection.setVisibility(View.VISIBLE);
+ mLocationViewSection.setVisibility(View.GONE);
+
+ EditTextUtils.showSoftInput(mLocationEditText);
+ }
+
+ private void showLocationAdd() {
+ mLocationAddSection.setVisibility(View.VISIBLE);
+ mLocationSearchSection.setVisibility(View.GONE);
+ mLocationViewSection.setVisibility(View.GONE);
+ }
+
+ private void showLocationView() {
+ mLocationAddSection.setVisibility(View.GONE);
+ mLocationSearchSection.setVisibility(View.GONE);
+ mLocationViewSection.setVisibility(View.VISIBLE);
+ }
+
+ private void searchLocation() {
+ EditTextUtils.hideSoftInput(mLocationEditText);
+ String location = EditTextUtils.getText(mLocationEditText);
+
+ removeLocation();
+
+ if (location.isEmpty()) {
+ fetchCurrentLocation();
+ } else {
+ new GetCoordsTask().execute(location);
+ }
+ }
+
+ /*
+ * get the current location
+ */
+ private void fetchCurrentLocation() {
+ if (mLocationHelper == null)
+ mLocationHelper = new LocationHelper();
+ boolean canGetLocation = mLocationHelper.getLocation(getActivity(), locationResult);
+
+ if (canGetLocation) {
+ setLocationStatus(LocationStatus.SEARCHING);
+ showLocationView();
+ } else {
+ setLocation(null);
+ showLocationNotAvailableError();
+ showLocationAdd();
+ }
+ }
+
+ /*
+ * called when location is retrieved/updated for this post - looks up the address to
+ * display for the lat/long
+ */
+ private void setLocation(Location location) {
+ if (location != null) {
+ setLocation(location.getLatitude(), location.getLongitude());
+ } else {
+ updateLocationText(getString(R.string.location_not_found));
+ setLocationStatus(LocationStatus.NOT_FOUND);
+ }
+ }
+
+ private void setLocation(double latitude, double longitude) {
+ mPostLocation = new PostLocation(latitude, longitude);
+ new GetAddressTask().execute(mPostLocation.getLatitude(), mPostLocation.getLongitude());
+ }
+
+ private void removeLocation() {
+ mPostLocation = null;
+ mActivity.getPost().unsetLocation();
+
+ updateLocationText("");
+ setLocationStatus(LocationStatus.NONE);
+ }
+
+ private void viewLocation() {
+ if (mPostLocation != null && mPostLocation.isValid()) {
+ String uri = "geo:" + mPostLocation.getLatitude() + "," + mPostLocation.getLongitude();
+ startActivity(new Intent(android.content.Intent.ACTION_VIEW, Uri.parse(uri)));
+ } else {
+ showLocationNotAvailableError();
+ showLocationAdd();
+ }
+ }
+
+ private void showLocationNotAvailableError() {
+ Toast.makeText(getActivity(), getResources().getText(R.string.location_not_found), Toast.LENGTH_SHORT).show();
+ }
+
+ private void updateLocationText(String locationName) {
+ mLocationText.setText(locationName);
+ }
+
+ /*
+ * changes the left drawable on the location text to match the passed status
+ */
+ private void setLocationStatus(LocationStatus status) {
+ // animate location text when searching
+ if (status == LocationStatus.SEARCHING) {
+ updateLocationText(getString(R.string.loading));
+
+ Animation aniBlink = AnimationUtils.loadAnimation(getActivity(), R.anim.blink);
+ if (aniBlink != null)
+ mLocationText.startAnimation(aniBlink);
+ } else {
+ mLocationText.clearAnimation();
+ }
+
+ final int drawableId;
+ switch (status) {
+ case FOUND:
+ drawableId = R.drawable.ic_action_location_found;
+ break;
+ case NOT_FOUND:
+ drawableId = R.drawable.ic_action_location_off;
+ break;
+ case SEARCHING:
+ drawableId = R.drawable.ic_action_location_searching;
+ break;
+ case NONE:
+ drawableId = 0;
+ break;
+ default:
+ return;
+ }
+
+ mLocationText.setCompoundDrawablesWithIntrinsicBounds(drawableId, 0, 0, 0);
+ }
+
+ /**
+ * Categories
+ */
+
+ private void onCategoryButtonClick(View v) {
+ // Get category name by removing prefix from the tag
+ boolean listChanged = false;
+ String categoryName = (String) v.getTag();
+ categoryName = categoryName.replaceFirst(CATEGORY_PREFIX_TAG, "");
+
+ // Remove clicked category from list
+ for (int i = 0; i < mCategories.size(); i++) {
+ if (mCategories.get(i).equals(categoryName)) {
+ mCategories.remove(i);
+ listChanged = true;
+ break;
+ }
+ }
+
+ // Recreate category views
+ if (listChanged) {
+ populateSelectedCategories();
+ }
+ }
+
+ private void populateSelectedCategories() {
+ // Remove previous category buttons if any + select category button
+ List<View> viewsToRemove = new ArrayList<View>();
+ for (int i = 0; i < mSectionCategories.getChildCount(); i++) {
+ View v = mSectionCategories.getChildAt(i);
+ if (v == null)
+ return;
+ Object tag = v.getTag();
+ if (tag != null && tag.getClass() == String.class &&
+ (((String) tag).startsWith(CATEGORY_PREFIX_TAG) || tag.equals("select-category"))) {
+ viewsToRemove.add(v);
+ }
+ }
+ for (View viewToRemove : viewsToRemove) {
+ mSectionCategories.removeView(viewToRemove);
+ }
+ viewsToRemove.clear();
+
+ // New category buttons
+ LayoutInflater layoutInflater = getActivity().getLayoutInflater();
+ for (String categoryName : mCategories) {
+ Button buttonCategory = (Button) layoutInflater.inflate(R.layout.category_button, null);
+ if (categoryName != null && buttonCategory != null) {
+ buttonCategory.setText(Html.fromHtml(categoryName));
+ buttonCategory.setTag(CATEGORY_PREFIX_TAG + categoryName);
+ buttonCategory.setOnClickListener(this);
+ mSectionCategories.addView(buttonCategory);
+ }
+ }
+
+ // Add select category button
+ Button selectCategory = (Button) layoutInflater.inflate(R.layout.category_select_button, null);
+ if (selectCategory != null) {
+ selectCategory.setOnClickListener(this);
+ mSectionCategories.addView(selectCategory);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PagesActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/PagesActivity.java
new file mode 100644
index 000000000..e21285052
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PagesActivity.java
@@ -0,0 +1,5 @@
+package org.wordpress.android.ui.posts;
+
+public class PagesActivity extends PostsActivity {
+ // Exists to distinguish pages from posts in menu drawer
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/ParentCategorySpinnerAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/ParentCategorySpinnerAdapter.java
new file mode 100644
index 000000000..e87242df2
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/ParentCategorySpinnerAdapter.java
@@ -0,0 +1,70 @@
+package org.wordpress.android.ui.posts;
+
+import android.content.Context;
+import android.text.Html;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ImageView;
+import android.widget.SpinnerAdapter;
+import android.widget.TextView;
+import org.wordpress.android.R;
+import org.wordpress.android.models.CategoryNode;
+
+import java.util.List;
+
+
+public class ParentCategorySpinnerAdapter extends BaseAdapter implements SpinnerAdapter {
+ int mResourceId;
+ List<CategoryNode> mObjects;
+ Context mContext;
+
+ public int getCount() {
+ return mObjects.size();
+ }
+
+ public CategoryNode getItem(int position) {
+ return mObjects.get(position);
+ }
+
+ public long getItemId(int position) {
+ return position;
+ }
+
+ public ParentCategorySpinnerAdapter(Context context, int resource, List<CategoryNode> objects) {
+ super();
+ mContext = context;
+ mObjects = objects;
+ mResourceId = resource;
+ }
+
+ @Override
+ public View getDropDownView(int position, View convertView, ViewGroup parent) {
+ LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ View rowView = inflater.inflate(mResourceId, parent, false);
+ TextView textView = (TextView) rowView.findViewById(R.id.categoryRowText);
+ ImageView levelIndicatorView = (ImageView) rowView.findViewById(R.id.categoryRowLevelIndicator);
+ textView.setText(Html.fromHtml(getItem(position).getName()));
+ int level = getItem(position).getLevel();
+ if (level == 1) { // hide ImageView
+ levelIndicatorView.setVisibility(View.GONE);
+ } else {
+ ViewGroup.LayoutParams params = levelIndicatorView.getLayoutParams();
+ params.width = (params.width / 2) * level;
+ levelIndicatorView.setLayoutParams(params);
+ }
+ return rowView;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ View rowView = inflater.inflate(mResourceId, parent, false);
+ TextView textView = (TextView) rowView.findViewById(R.id.categoryRowText);
+ ImageView levelIndicatorView = (ImageView) rowView.findViewById(R.id.categoryRowLevelIndicator);
+ textView.setText(Html.fromHtml(getItem(position).getName()));
+ levelIndicatorView.setVisibility(View.GONE);
+ return rowView;
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsActivity.java
new file mode 100644
index 000000000..953a28dac
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsActivity.java
@@ -0,0 +1,630 @@
+package org.wordpress.android.ui.posts;
+
+import android.app.ActionBar;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.FragmentManager;
+import android.app.FragmentTransaction;
+import android.app.ProgressDialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.preference.PreferenceManager;
+import android.text.TextUtils;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.widget.Toast;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.models.Post;
+import org.wordpress.android.models.PostStatus;
+import org.wordpress.android.ui.MenuDrawerItem;
+import org.wordpress.android.ui.WPActionBarActivity;
+import org.wordpress.android.ui.notifications.NotificationsActivity;
+import org.wordpress.android.ui.posts.PostsListFragment.OnPostActionListener;
+import org.wordpress.android.ui.posts.PostsListFragment.OnPostSelectedListener;
+import org.wordpress.android.ui.posts.ViewPostFragment.OnDetailPostActionListener;
+import org.wordpress.android.util.AlertUtil;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.ProfilingUtils;
+import org.wordpress.android.util.WPAlertDialogFragment;
+import org.wordpress.android.util.WPMeShortlinks;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+import org.wordpress.passcodelock.AppLockManager;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlrpc.android.ApiHelper;
+import org.xmlrpc.android.XMLRPCClientInterface;
+import org.xmlrpc.android.XMLRPCException;
+import org.xmlrpc.android.XMLRPCFactory;
+
+import java.io.IOException;
+import java.util.Iterator;
+
+public class PostsActivity extends WPActionBarActivity
+ implements OnPostSelectedListener, PostsListFragment.OnSinglePostLoadedListener, OnPostActionListener,
+ OnDetailPostActionListener, WPAlertDialogFragment.OnDialogConfirmListener {
+ public static final String EXTRA_VIEW_PAGES = "viewPages";
+ public static final String EXTRA_ERROR_MSG = "errorMessage";
+ public static final String EXTRA_ERROR_INFO_TITLE = "errorInfoTitle";
+ public static final String EXTRA_ERROR_INFO_LINK = "errorInfoLink";
+
+ public static final int POST_DELETE = 0, POST_SHARE = 1, POST_EDIT = 2, POST_CLEAR = 3, POST_VIEW = 5;
+ public static final int ACTIVITY_EDIT_POST = 0;
+ private static final int ID_DIALOG_DELETING = 1, ID_DIALOG_SHARE = 2;
+ public ProgressDialog mLoadingDialog;
+ public boolean mIsPage = false;
+ public String mErrorMsg = "";
+ private PostsListFragment mPostList;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ ProfilingUtils.split("PostsActivity.onCreate");
+ ProfilingUtils.dump();
+ // Special check for a null database (see #507)
+ if (WordPress.wpDB == null) {
+ Toast.makeText(this, R.string.fatal_db_error, Toast.LENGTH_LONG).show();
+ finish();
+ return;
+ }
+
+ // Check if we came from a notification, if so let's launch NotificationsActivity
+ Bundle extras = getIntent().getExtras();
+ if (extras != null && extras.getBoolean(NotificationsActivity.FROM_NOTIFICATION_EXTRA)) {
+ startNotificationsActivity(extras);
+ return;
+ }
+
+ // Restore last selection on app creation
+ if (WordPress.shouldRestoreSelectedActivity && WordPress.getCurrentBlog() != null &&
+ !(this instanceof PagesActivity)) {
+
+ WordPress.shouldRestoreSelectedActivity = false;
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this);
+ int lastActivitySelection = settings.getInt(LAST_ACTIVITY_PREFERENCE, -1);
+ if (lastActivitySelection > MenuDrawerItem.NO_ITEM_ID &&
+ lastActivitySelection != WPActionBarActivity.DASHBOARD_ACTIVITY) {
+ Iterator<MenuDrawerItem> itemIterator = mMenuItems.iterator();
+ while (itemIterator.hasNext()) {
+ MenuDrawerItem item = itemIterator.next();
+ // if we have a matching item id, and it's not selected and it's visible, call it
+ if (item.hasItemId() && item.getItemId() == lastActivitySelection && !item.isSelected() &&
+ item.isVisible()) {
+ mFirstLaunch = true;
+ item.selectItem();
+ finish();
+ return;
+ }
+ }
+ }
+ }
+
+ createMenuDrawer(R.layout.posts);
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayShowTitleEnabled(true);
+ }
+
+ FragmentManager fm = getFragmentManager();
+ fm.addOnBackStackChangedListener(mOnBackStackChangedListener);
+ mPostList = (PostsListFragment) fm.findFragmentById(R.id.postList);
+
+ if (extras != null) {
+ mIsPage = extras.getBoolean(EXTRA_VIEW_PAGES);
+ showErrorDialogIfNeeded(extras);
+ }
+
+ if (mIsPage)
+ setTitle(getString(R.string.pages));
+ else
+ setTitle(getString(R.string.posts));
+
+ WordPress.currentPost = null;
+
+ if (savedInstanceState != null)
+ popPostDetail();
+
+ attemptToSelectPost();
+ }
+
+ private void showPostUploadErrorAlert(String errorMessage, String infoTitle,
+ final String infoURL) {
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(PostsActivity.this);
+ dialogBuilder.setTitle(getResources().getText(R.string.error));
+ dialogBuilder.setMessage(errorMessage);
+ dialogBuilder.setPositiveButton("OK", new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ // Just close the window.
+ }
+ }
+ );
+ if (infoTitle != null && infoURL != null) {
+ dialogBuilder.setNeutralButton(infoTitle,
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(infoURL)));
+ }
+ });
+ }
+ dialogBuilder.setCancelable(true);
+ if (!isFinishing())
+ dialogBuilder.create().show();
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+
+ Bundle extras = intent.getExtras();
+ if (extras != null) {
+ // Check if we came from a notification, if so let's launch NotificationsActivity
+ if (extras.getBoolean(NotificationsActivity.FROM_NOTIFICATION_EXTRA)) {
+ startNotificationsActivity(extras);
+ return;
+ }
+ }
+ }
+
+ private void showErrorDialogIfNeeded(Bundle extras) {
+ if (extras == null) {
+ return;
+ }
+ String errorMessage = extras.getString(EXTRA_ERROR_MSG);
+ if (!TextUtils.isEmpty(errorMessage)) {
+ String errorInfoTitle = extras.getString(EXTRA_ERROR_INFO_TITLE);
+ String errorInfoLink = extras.getString(EXTRA_ERROR_INFO_LINK);
+ showPostUploadErrorAlert(errorMessage, errorInfoTitle, errorInfoLink);
+ }
+ }
+
+ private void startNotificationsActivity(Bundle extras) {
+ // Manually set last selection to notifications
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this);
+ SharedPreferences.Editor editor = settings.edit();
+ editor.putInt(LAST_ACTIVITY_PREFERENCE, NOTIFICATIONS_ACTIVITY);
+ editor.commit();
+
+ Intent i = new Intent(this, NotificationsActivity.class);
+ i.putExtras(extras);
+ startActivity(i);
+ finish();
+ }
+
+ private FragmentManager.OnBackStackChangedListener mOnBackStackChangedListener = new FragmentManager.OnBackStackChangedListener() {
+ public void onBackStackChanged() {
+ if (getFragmentManager().getBackStackEntryCount() == 0)
+ mMenuDrawer.setDrawerIndicatorEnabled(true);
+ }
+ };
+
+ public boolean isRefreshing() {
+ return mPostList.isRefreshing();
+ }
+
+ public void checkForLocalChanges(boolean shouldPrompt) {
+ if (WordPress.getCurrentBlog() == null) {
+ return;
+ }
+ boolean hasLocalChanges = WordPress.wpDB.findLocalChanges(WordPress.getCurrentBlog().getLocalTableBlogId(),
+ mIsPage);
+ if (hasLocalChanges) {
+ if (!shouldPrompt) {
+ return;
+ }
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(PostsActivity.this);
+ dialogBuilder.setTitle(getResources().getText(R.string.local_changes));
+ dialogBuilder.setMessage(getResources().getText(R.string.remote_changes));
+ dialogBuilder.setPositiveButton(getResources().getText(R.string.yes),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ popPostDetail();
+ attemptToSelectPost();
+ mPostList.requestPosts(false);
+ }
+ }
+ );
+ dialogBuilder.setNegativeButton(getResources().getText(R.string.no), new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ mPostList.setRefreshing(false);
+ }
+ });
+ dialogBuilder.setCancelable(true);
+ if (!isFinishing()) {
+ dialogBuilder.create().show();
+ }
+ } else {
+ popPostDetail();
+ mPostList.requestPosts(false);
+ mPostList.setRefreshing(true);
+ }
+ }
+
+ protected void popPostDetail() {
+ if (isFinishing()) {
+ return;
+ }
+
+ FragmentManager fm = getFragmentManager();
+ ViewPostFragment f = (ViewPostFragment) fm.findFragmentById(R.id.postDetail);
+ if (f == null) {
+ try {
+ fm.popBackStack();
+ } catch (RuntimeException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+
+ // posts can't be shown if there aren't any visible blogs, so redirect to the reader and
+ // exit the post list in this situation
+ if (WordPress.isSignedIn(PostsActivity.this)) {
+ if (showReaderIfNoBlog()) {
+ finish();
+ }
+ }
+
+ if (WordPress.postsShouldRefresh) {
+ checkForLocalChanges(false);
+ mPostList.setRefreshing(true);
+ WordPress.postsShouldRefresh = false;
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.posts, menu);
+ if (mIsPage) {
+ menu.findItem(R.id.menu_new_post).setTitle(R.string.new_page);
+ }
+ return true;
+ }
+
+ public void newPost() {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.EDITOR_CREATED_POST);
+ if (WordPress.getCurrentBlog() == null) {
+ if (!isFinishing())
+ Toast.makeText(this, R.string.blog_not_found, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ // Create a new post object
+ Post newPost = new Post(WordPress.getCurrentBlog().getLocalTableBlogId(), mIsPage);
+ WordPress.wpDB.savePost(newPost);
+ Intent i = new Intent(this, EditPostActivity.class);
+ i.putExtra(EditPostActivity.EXTRA_POSTID, newPost.getLocalTablePostId());
+ i.putExtra(EditPostActivity.EXTRA_IS_PAGE, mIsPage);
+ i.putExtra(EditPostActivity.EXTRA_IS_NEW_POST, true);
+ startActivityForResult(i, ACTIVITY_EDIT_POST);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ int itemId = item.getItemId();
+ if (itemId == R.id.menu_new_post) {
+ newPost();
+ return true;
+ } else if (itemId == android.R.id.home) {
+ FragmentManager fm = getFragmentManager();
+ if (fm.getBackStackEntryCount() > 0) {
+ popPostDetail();
+ return true;
+ }
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ if (data != null) {
+ if (requestCode == ACTIVITY_EDIT_POST && resultCode == RESULT_OK) {
+ if (data.getBooleanExtra("shouldRefresh", false)) {
+ mPostList.getPostListAdapter().loadPosts();
+ }
+ }
+ }
+ super.onActivityResult(requestCode, resultCode, data);
+ }
+
+ protected void attemptToSelectPost() {
+ FragmentManager fm = getFragmentManager();
+ ViewPostFragment f = (ViewPostFragment) fm.findFragmentById(R.id.postDetail);
+ if (f != null && f.isInLayout()) {
+ mPostList.setShouldSelectFirstPost(true);
+ }
+ }
+
+ @Override
+ public void onPostSelected(Post post) {
+ FragmentManager fm = getFragmentManager();
+ ViewPostFragment f = (ViewPostFragment) fm
+ .findFragmentById(R.id.postDetail);
+
+ if (post != null) {
+ WordPress.currentPost = post;
+ if (f == null || !f.isInLayout()) {
+ FragmentTransaction ft = fm.beginTransaction();
+ ft.hide(mPostList);
+ f = new ViewPostFragment();
+ ft.add(R.id.postDetailFragmentContainer, f);
+ ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE);
+ ft.addToBackStack(null);
+ ft.commitAllowingStateLoss();
+ mMenuDrawer.setDrawerIndicatorEnabled(false);
+ } else {
+ f.loadPost(post);
+ }
+ }
+ }
+
+ @Override
+ protected Dialog onCreateDialog(int id) {
+ mLoadingDialog = new ProgressDialog(this);
+ if (id == ID_DIALOG_DELETING) {
+ mLoadingDialog.setMessage(getResources().getText(
+ mIsPage ? R.string.deleting_page : R.string.deleting_post));
+ mLoadingDialog.setCancelable(false);
+ return mLoadingDialog;
+ } else if (id == ID_DIALOG_SHARE) {
+ mLoadingDialog.setMessage(mIsPage ? getString(R.string.share_url_page) : getString(
+ R.string.share_url_post));
+ mLoadingDialog.setCancelable(false);
+ return mLoadingDialog;
+ }
+
+ return super.onCreateDialog(id);
+ }
+
+ public class deletePostTask extends AsyncTask<Post, Void, Boolean> {
+ Post post;
+
+ @Override
+ protected void onPreExecute() {
+ // pop out of the detail view if on a smaller screen
+ popPostDetail();
+ showDialog(ID_DIALOG_DELETING);
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ dismissDialog(ID_DIALOG_DELETING);
+ attemptToSelectPost();
+ if (result) {
+ Toast.makeText(PostsActivity.this, getResources().getText((mIsPage) ?
+ R.string.page_deleted : R.string.post_deleted),
+ Toast.LENGTH_SHORT).show();
+ checkForLocalChanges(false);
+ WordPress.wpDB.deletePost(post);
+ mPostList.requestPosts(false);
+ mPostList.setRefreshing(true);
+ } else {
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(PostsActivity.this);
+ dialogBuilder.setTitle(getResources().getText(R.string.connection_error));
+ dialogBuilder.setMessage(mErrorMsg);
+ dialogBuilder.setPositiveButton("OK",
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ // Just close the window.
+ }
+ });
+ dialogBuilder.setCancelable(true);
+ if (!isFinishing()) {
+ dialogBuilder.create().show();
+ }
+ }
+ }
+
+ @Override
+ protected Boolean doInBackground(Post... params) {
+ boolean result = false;
+ post = params[0];
+ Blog blog = WordPress.currentBlog;
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+
+ Object[] postParams = { "", post.getRemotePostId(),
+ WordPress.currentBlog.getUsername(),
+ WordPress.currentBlog.getPassword() };
+ Object[] pageParams = { WordPress.currentBlog.getRemoteBlogId(),
+ WordPress.currentBlog.getUsername(),
+ WordPress.currentBlog.getPassword(), post.getRemotePostId() };
+
+ try {
+ client.call((mIsPage) ? "wp.deletePage" : "blogger.deletePost", (mIsPage) ? pageParams : postParams);
+ result = true;
+ } catch (final XMLRPCException e) {
+ mErrorMsg = prepareErrorMessage(e);
+ } catch (IOException e) {
+ mErrorMsg = prepareErrorMessage(e);
+ } catch (XmlPullParserException e) {
+ mErrorMsg = prepareErrorMessage(e);
+ }
+ return result;
+ }
+
+ private String prepareErrorMessage(Exception e) {
+ AppLog.e(AppLog.T.POSTS, "Error while deleting post or page", e);
+ return String.format(getResources().getString(R.string.error_delete_post),
+ (mIsPage) ? getResources().getText(R.string.page)
+ : getResources().getText(R.string.post));
+ }
+ }
+
+ public class refreshCommentsTask extends AsyncTask<Void, Void, Void> {
+ @Override
+ protected Void doInBackground(Void... params) {
+ Object[] commentParams = { WordPress.currentBlog.getRemoteBlogId(),
+ WordPress.currentBlog.getUsername(),
+ WordPress.currentBlog.getPassword() };
+
+ try {
+ ApiHelper.refreshComments(PostsActivity.this, WordPress.currentBlog, commentParams);
+ } catch (final Exception e) {
+ mErrorMsg = getResources().getText(R.string.error_generic).toString();
+ }
+ return null;
+ }
+ }
+
+ protected void refreshComments() {
+ new refreshCommentsTask().execute();
+ }
+
+ @Override
+ public void onPostAction(int action, final Post post) {
+ // No post? No service.
+ if (post == null) {
+ Toast.makeText(PostsActivity.this, R.string.post_not_found, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ if (action == POST_DELETE) {
+ if (post.isLocalDraft()) {
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(
+ PostsActivity.this);
+ dialogBuilder.setTitle(getResources().getText(
+ R.string.delete_draft));
+
+ String deleteDraftMessage = getResources().getText(R.string.delete_sure).toString();
+ if (!post.getTitle().isEmpty()) {
+ String postTitleEnclosedByQuotes = "'" + post.getTitle() + "'";
+ deleteDraftMessage += " " + postTitleEnclosedByQuotes;
+ }
+
+ dialogBuilder.setMessage(deleteDraftMessage + "?");
+ dialogBuilder.setPositiveButton(
+ getResources().getText(R.string.yes),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog,
+ int whichButton) {
+ WordPress.wpDB.deletePost(post);
+ popPostDetail();
+ attemptToSelectPost();
+ mPostList.getPostListAdapter().loadPosts();
+ }
+ });
+ dialogBuilder.setNegativeButton(
+ getResources().getText(R.string.no),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog,
+ int whichButton) {
+ // Just close the window.
+ }
+ });
+ dialogBuilder.setCancelable(true);
+ if (!isFinishing()) {
+ dialogBuilder.create().show();
+ }
+ } else {
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(
+ PostsActivity.this);
+ dialogBuilder.setTitle(getResources().getText(
+ (post.isPage()) ? R.string.delete_page
+ : R.string.delete_post));
+ dialogBuilder.setMessage(getResources().getText(
+ (post.isPage()) ? R.string.delete_sure_page
+ : R.string.delete_sure_post)
+ + " '" + post.getTitle() + "'?");
+ dialogBuilder.setPositiveButton(
+ getResources().getText(R.string.yes),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog,
+ int whichButton) {
+ new deletePostTask().execute(post);
+ }
+ });
+ dialogBuilder.setNegativeButton(
+ getResources().getText(R.string.no),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog,
+ int whichButton) {
+ // Just close the window.
+ }
+ });
+ dialogBuilder.setCancelable(true);
+ if (!isFinishing()) {
+ dialogBuilder.create().show();
+ }
+ }
+ } else if (action == POST_SHARE) {
+ // Only share published posts
+ if (post.getStatusEnum() != PostStatus.PUBLISHED && post.getStatusEnum() != PostStatus.SCHEDULED) {
+ AlertUtil.showAlert(this, R.string.error,
+ post.isPage() ? R.string.page_not_published : R.string.post_not_published);
+ return;
+ }
+
+ Intent share = new Intent(Intent.ACTION_SEND);
+ share.setType("text/plain");
+ share.putExtra(Intent.EXTRA_SUBJECT, post.getTitle());
+ String shortlink = WPMeShortlinks.getPostShortlink(WordPress.getCurrentBlog(), post);
+ share.putExtra(Intent.EXTRA_TEXT, shortlink != null ? shortlink : post.getPermaLink());
+ startActivity(Intent.createChooser(share, getResources()
+ .getText(R.string.share_url)));
+ AppLockManager.getInstance().setExtendedTimeout();
+ } else if (action == POST_CLEAR) {
+ FragmentManager fm = getFragmentManager();
+ ViewPostFragment f = (ViewPostFragment) fm
+ .findFragmentById(R.id.postDetail);
+ if (f != null) {
+ f.clearContent();
+ }
+ }
+ }
+
+ @Override
+ public void onDetailPostAction(int action, Post post) {
+ onPostAction(action, post);
+ }
+
+ @Override
+ public void onDialogConfirm() {
+ mPostList.requestPosts(true);
+ mPostList.setRefreshing(true);
+ }
+
+ @Override
+ public void onSinglePostLoaded() {
+ popPostDetail();
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ if (outState.isEmpty()) {
+ outState.putBoolean("bug_19917_fix", true);
+ }
+ super.onSaveInstanceState(outState);
+ }
+
+ @Override
+ public void onBlogChanged() {
+ super.onBlogChanged();
+ popPostDetail();
+ attemptToSelectPost();
+ mPostList.clear();
+ mPostList.getPostListAdapter().loadPosts();
+ mPostList.onBlogChanged();
+ }
+
+ public void setRefreshing(boolean refreshing) {
+ mPostList.setRefreshing(refreshing);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListFragment.java
new file mode 100644
index 000000000..52140da6f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListFragment.java
@@ -0,0 +1,437 @@
+package org.wordpress.android.ui.posts;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.FragmentTransaction;
+import android.app.ListFragment;
+import android.content.DialogInterface;
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.models.Post;
+import org.wordpress.android.models.PostsListPost;
+import org.wordpress.android.ui.PullToRefreshHelper;
+import org.wordpress.android.ui.PullToRefreshHelper.RefreshListener;
+import org.wordpress.android.ui.posts.adapters.PostsListAdapter;
+import org.wordpress.android.util.NetworkUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.ToastUtils.Duration;
+import org.wordpress.android.util.Utils;
+import org.wordpress.android.util.WPAlertDialogFragment;
+import org.xmlrpc.android.ApiHelper;
+import org.xmlrpc.android.ApiHelper.ErrorType;
+
+import java.util.List;
+import java.util.Vector;
+
+import uk.co.senab.actionbarpulltorefresh.library.PullToRefreshLayout;
+
+public class PostsListFragment extends ListFragment implements WordPress.OnPostUploadedListener {
+ public static final int POSTS_REQUEST_COUNT = 20;
+
+ private PullToRefreshHelper mPullToRefreshHelper;
+ private OnPostSelectedListener mOnPostSelectedListener;
+ private OnSinglePostLoadedListener mOnSinglePostLoadedListener;
+ private PostsListAdapter mPostsListAdapter;
+ private ApiHelper.FetchPostsTask mCurrentFetchPostsTask;
+ private ApiHelper.FetchSinglePostTask mCurrentFetchSinglePostTask;
+ private View mProgressFooterView;
+ private boolean mCanLoadMorePosts = true;
+ private boolean mIsPage, mShouldSelectFirstPost, mIsFetchingPosts;
+
+ @Override
+ public void onCreate(Bundle icicle) {
+ super.onCreate(icicle);
+ Bundle extras = getActivity().getIntent().getExtras();
+ if (extras != null) {
+ mIsPage = extras.getBoolean(PostsActivity.EXTRA_VIEW_PAGES);
+ }
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ boolean isRefreshing = mPullToRefreshHelper.isRefreshing();
+ super.onConfigurationChanged(newConfig);
+ // Pull to refresh layout is destroyed onDetachedFromWindow,
+ // so we have to re-init the layout, via the helper here
+ initPullToRefreshHelper();
+ mPullToRefreshHelper.setRefreshing(isRefreshing);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.post_listview, container, false);
+ }
+
+ private void initPullToRefreshHelper() {
+ mPullToRefreshHelper = new PullToRefreshHelper(
+ getActivity(),
+ (PullToRefreshLayout) getActivity().findViewById(R.id.ptr_layout),
+ new RefreshListener() {
+ @Override
+ public void onRefreshStarted(View view) {
+ if (getActivity() == null || !NetworkUtils.checkConnection(getActivity())) {
+ mPullToRefreshHelper.setRefreshing(false);
+ return;
+ }
+ refreshPosts((PostsActivity) getActivity());
+ }
+ }, LinearLayout.class);
+ }
+
+ private void refreshPosts(PostsActivity postsActivity) {
+ Blog currentBlog = WordPress.getCurrentBlog();
+ if (currentBlog == null) {
+ ToastUtils.showToast(getActivity(), mIsPage ? R.string.error_refresh_pages : R.string.error_refresh_posts,
+ Duration.LONG);
+ return;
+ }
+ boolean hasLocalChanges = WordPress.wpDB.findLocalChanges(currentBlog.getLocalTableBlogId(), mIsPage);
+ if (hasLocalChanges) {
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(postsActivity);
+ dialogBuilder.setTitle(getResources().getText(R.string.local_changes));
+ dialogBuilder.setMessage(getResources().getText(R.string.remote_changes));
+ dialogBuilder.setPositiveButton(getResources().getText(R.string.yes),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ mPullToRefreshHelper.setRefreshing(true);
+ requestPosts(false);
+ }
+ }
+ );
+ dialogBuilder.setNegativeButton(getResources().getText(R.string.no), new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ mPullToRefreshHelper.setRefreshing(false);
+ }
+ });
+ dialogBuilder.setCancelable(true);
+ dialogBuilder.create().show();
+ } else {
+ mPullToRefreshHelper.setRefreshing(true);
+ requestPosts(false);
+ }
+ }
+
+ public PostsListAdapter getPostListAdapter() {
+ if (mPostsListAdapter == null) {
+ PostsListAdapter.OnLoadMoreListener loadMoreListener = new PostsListAdapter.OnLoadMoreListener() {
+ @Override
+ public void onLoadMore() {
+ if (mCanLoadMorePosts && !mIsFetchingPosts)
+ requestPosts(true);
+ }
+ };
+
+ PostsListAdapter.OnPostsLoadedListener postsLoadedListener = new PostsListAdapter.OnPostsLoadedListener() {
+ @Override
+ public void onPostsLoaded(int postCount) {
+ if (postCount == 0 && mCanLoadMorePosts) {
+ // No posts, let's request some
+ requestPosts(false);
+ setRefreshing(true);
+ } else if (mShouldSelectFirstPost) {
+ // Select the first row on a tablet, if requested
+ mShouldSelectFirstPost = false;
+ if (mPostsListAdapter.getCount() > 0) {
+ PostsListPost postsListPost = (PostsListPost) mPostsListAdapter.getItem(0);
+ if (postsListPost != null) {
+ showPost(postsListPost.getPostId());
+ getListView().setItemChecked(0, true);
+ }
+ }
+ } else if (Utils.isTablet()) {
+ // Reload the last selected position, if available
+ int selectedPosition = getListView().getCheckedItemPosition();
+ if (selectedPosition != ListView.INVALID_POSITION && selectedPosition < mPostsListAdapter.getCount()) {
+ PostsListPost postsListPost = (PostsListPost) mPostsListAdapter.getItem(selectedPosition);
+ if (postsListPost != null) {
+ showPost(postsListPost.getPostId());
+ }
+ }
+ }
+ }
+ };
+ mPostsListAdapter = new PostsListAdapter(getActivity(), mIsPage, loadMoreListener, postsLoadedListener);
+ }
+
+ return mPostsListAdapter;
+ }
+
+ @Override
+ public void onActivityCreated(Bundle bundle) {
+ super.onActivityCreated(bundle);
+ getListView().setChoiceMode(ListView.CHOICE_MODE_SINGLE);
+ mProgressFooterView = View.inflate(getActivity(), R.layout.list_footer_progress, null);
+ getListView().addFooterView(mProgressFooterView, null, false);
+ mProgressFooterView.setVisibility(View.GONE);
+ getListView().setDivider(getResources().getDrawable(R.drawable.list_divider));
+ getListView().setDividerHeight(1);
+
+ getListView().setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ public void onItemClick(AdapterView<?> arg0, View v, int position, long id) {
+ if (position >= getPostListAdapter().getCount()) //out of bounds
+ return;
+ if (v == null) //view is gone
+ return;
+ PostsListPost postsListPost = (PostsListPost) getPostListAdapter().getItem(position);
+ if (postsListPost == null)
+ return;
+ if (!mIsFetchingPosts || isLoadingMorePosts()) {
+ showPost(postsListPost.getPostId());
+ } else if (hasActivity()) {
+ Toast.makeText(getActivity(), mIsPage ? R.string.loading_pages : R.string.loading_posts,
+ Toast.LENGTH_SHORT).show();
+ }
+ }
+ });
+
+ TextView textView = (TextView) getActivity().findViewById(R.id.title_empty);
+ if (textView != null) {
+ if (mIsPage) {
+ textView.setText(getText(R.string.pages_empty_list));
+ } else {
+ textView.setText(getText(R.string.posts_empty_list));
+ }
+ }
+ initPullToRefreshHelper();
+ mPullToRefreshHelper.registerReceiver(getActivity());
+ WordPress.setOnPostUploadedListener(this);
+ }
+
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+ try {
+ // check that the containing activity implements our callback
+ mOnPostSelectedListener = (OnPostSelectedListener) activity;
+ mOnSinglePostLoadedListener = (OnSinglePostLoadedListener) activity;
+ } catch (ClassCastException e) {
+ activity.finish();
+ throw new ClassCastException(activity.toString()
+ + " must implement Callback");
+ }
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ mPullToRefreshHelper.unregisterReceiver(getActivity());
+ }
+
+ public void onResume() {
+ super.onResume();
+ if (WordPress.getCurrentBlog() != null) {
+ if (getListView().getAdapter() == null) {
+ getListView().setAdapter(getPostListAdapter());
+ }
+
+ getPostListAdapter().loadPosts();
+ }
+ }
+
+ public boolean isRefreshing() {
+ return mPullToRefreshHelper.isRefreshing();
+ }
+
+ public void setRefreshing(boolean refreshing) {
+ mPullToRefreshHelper.setRefreshing(refreshing);
+ }
+
+ private void showPost(long selectedId) {
+ if (WordPress.getCurrentBlog() == null)
+ return;
+
+ Post post = WordPress.wpDB.getPostForLocalTablePostId(selectedId);
+ if (post != null) {
+ WordPress.currentPost = post;
+ mOnPostSelectedListener.onPostSelected(post);
+ } else {
+ if (!getActivity().isFinishing()) {
+ FragmentTransaction ft = getFragmentManager().beginTransaction();
+ WPAlertDialogFragment alert = WPAlertDialogFragment.newAlertDialog(getString(R.string.post_not_found));
+ ft.add(alert, "alert");
+ ft.commitAllowingStateLoss();
+ }
+ }
+ }
+
+ public boolean isLoadingMorePosts() {
+ return mIsFetchingPosts && (mProgressFooterView != null && mProgressFooterView.getVisibility() == View.VISIBLE);
+ }
+
+ public void requestPosts(boolean loadMore) {
+ if (!hasActivity() || WordPress.getCurrentBlog() == null || mIsFetchingPosts)
+ return;
+
+ if (!NetworkUtils.checkConnection(getActivity()))
+ return;
+
+ int postCount = getPostListAdapter().getRemotePostCount() + POSTS_REQUEST_COUNT;
+ if (!loadMore) {
+ mCanLoadMorePosts = true;
+ postCount = POSTS_REQUEST_COUNT;
+ }
+ List<Object> apiArgs = new Vector<Object>();
+ apiArgs.add(WordPress.getCurrentBlog());
+ apiArgs.add(mIsPage);
+ apiArgs.add(postCount);
+ apiArgs.add(loadMore);
+ if (mProgressFooterView != null && loadMore) {
+ mProgressFooterView.setVisibility(View.VISIBLE);
+ }
+
+ mCurrentFetchPostsTask = new ApiHelper.FetchPostsTask(new ApiHelper.FetchPostsTask.Callback() {
+ @Override
+ public void onSuccess(int postCount) {
+ mCurrentFetchPostsTask = null;
+ mIsFetchingPosts = false;
+ if (!hasActivity())
+ return;
+ mPullToRefreshHelper.setRefreshing(false);
+ if (mProgressFooterView != null) {
+ mProgressFooterView.setVisibility(View.GONE);
+ }
+
+ if (postCount == 0) {
+ mCanLoadMorePosts = false;
+ } else if (postCount == getPostListAdapter().getRemotePostCount() && postCount != POSTS_REQUEST_COUNT) {
+ mCanLoadMorePosts = false;
+ }
+
+ getPostListAdapter().loadPosts();
+ }
+
+ @Override
+ public void onFailure(ApiHelper.ErrorType errorType, String errorMessage, Throwable throwable) {
+ mCurrentFetchPostsTask = null;
+ mIsFetchingPosts = false;
+ if (!hasActivity()) {
+ return;
+ }
+ mPullToRefreshHelper.setRefreshing(false);
+ if (mProgressFooterView != null) {
+ mProgressFooterView.setVisibility(View.GONE);
+ }
+ if (errorType != ErrorType.TASK_CANCELLED) {
+ ToastUtils.showToast(getActivity(),
+ mIsPage ? R.string.error_refresh_pages : R.string.error_refresh_posts, Duration.LONG);
+ }
+ }
+ });
+
+ mIsFetchingPosts = true;
+ mCurrentFetchPostsTask.execute(apiArgs);
+ }
+
+ protected void clear() {
+ if (getPostListAdapter() != null) {
+ getPostListAdapter().clear();
+ }
+ mCanLoadMorePosts = true;
+ if (mProgressFooterView != null && mProgressFooterView.getVisibility() == View.VISIBLE) {
+ mProgressFooterView.setVisibility(View.GONE);
+ }
+ }
+
+ public void setShouldSelectFirstPost(boolean shouldSelect) {
+ mShouldSelectFirstPost = shouldSelect;
+ }
+
+ private boolean hasActivity() {
+ return getActivity() != null;
+ }
+
+ @Override
+ public void OnPostUploaded(int localBlogId, String postId, boolean isPage) {
+ if (!hasActivity()) {
+ return;
+ }
+
+ // If the user switched to a different blog while uploading his post, don't reload posts and refresh the view
+ boolean sameBlogId = true;
+ if (WordPress.getCurrentBlog() == null || WordPress.getCurrentBlog().getLocalTableBlogId() != localBlogId) {
+ sameBlogId = false;
+ }
+
+ if (!NetworkUtils.checkConnection(getActivity())) {
+ mPullToRefreshHelper.setRefreshing(false);
+ return;
+ }
+
+ // Fetch the newly uploaded post
+ if (!TextUtils.isEmpty(postId)) {
+ final boolean reloadPosts = sameBlogId;
+ List<Object> apiArgs = new Vector<Object>();
+ apiArgs.add(WordPress.wpDB.instantiateBlogByLocalId(localBlogId));
+ apiArgs.add(postId);
+ apiArgs.add(isPage);
+
+ mCurrentFetchSinglePostTask = new ApiHelper.FetchSinglePostTask(
+ new ApiHelper.FetchSinglePostTask.Callback() {
+ @Override
+ public void onSuccess() {
+ mCurrentFetchSinglePostTask = null;
+ mIsFetchingPosts = false;
+ if (!hasActivity() || !reloadPosts) {
+ return;
+ }
+ mPullToRefreshHelper.setRefreshing(false);
+ getPostListAdapter().loadPosts();
+ mOnSinglePostLoadedListener.onSinglePostLoaded();
+ }
+
+ @Override
+ public void onFailure(ApiHelper.ErrorType errorType, String errorMessage, Throwable throwable) {
+ mCurrentFetchSinglePostTask = null;
+ mIsFetchingPosts = false;
+ if (!hasActivity() || !reloadPosts) {
+ return;
+ }
+ if (errorType != ErrorType.TASK_CANCELLED) {
+ ToastUtils.showToast(getActivity(),
+ mIsPage ? R.string.error_refresh_pages : R.string.error_refresh_posts, Duration.LONG);
+ }
+ mPullToRefreshHelper.setRefreshing(false);
+ }
+ });
+
+ mPullToRefreshHelper.setRefreshing(true);
+ mIsFetchingPosts = true;
+ mCurrentFetchSinglePostTask.execute(apiArgs);
+ }
+ }
+
+ public void onBlogChanged() {
+ if (mCurrentFetchPostsTask != null) {
+ mCurrentFetchPostsTask.cancel(true);
+ }
+ if (mCurrentFetchSinglePostTask != null) {
+ mCurrentFetchSinglePostTask.cancel(true);
+ }
+ mIsFetchingPosts = false;
+ mPullToRefreshHelper.setRefreshing(false);
+ }
+
+ public interface OnPostSelectedListener {
+ public void onPostSelected(Post post);
+ }
+
+ public interface OnPostActionListener {
+ public void onPostAction(int action, Post post);
+ }
+
+ public interface OnSinglePostLoadedListener {
+ public void onSinglePostLoaded();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PreviewPostActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/PreviewPostActivity.java
new file mode 100644
index 000000000..4c7e33872
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PreviewPostActivity.java
@@ -0,0 +1,77 @@
+package org.wordpress.android.ui.posts;
+
+import android.annotation.SuppressLint;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Post;
+import org.wordpress.android.models.PostStatus;
+import org.wordpress.android.ui.AuthenticatedWebViewActivity;
+import org.wordpress.android.util.StringUtils;
+
+/**
+ * Activity for previewing a post or page in a webview.
+ */
+public class PreviewPostActivity extends AuthenticatedWebViewActivity {
+ @SuppressLint("SetJavaScriptEnabled")
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ Bundle extras = getIntent().getExtras();
+
+ boolean isPage = getIntent().getBooleanExtra("isPage", false);
+ if (isPage) {
+ this.setTitle(StringUtils.unescapeHTML(WordPress.getCurrentBlog().getBlogName())
+ + " - " + getResources().getText(R.string.preview_page));
+ } else {
+ this.setTitle(StringUtils.unescapeHTML(WordPress.getCurrentBlog().getBlogName())
+ + " - " + getResources().getText(R.string.preview_post));
+ }
+
+ mWebView.getSettings().setJavaScriptEnabled(true);
+
+ if (extras != null) {
+ long mPostID = extras.getLong("postID");
+
+ Post post = WordPress.wpDB.getPostForLocalTablePostId(mPostID);
+ if (post == null)
+ Toast.makeText(this, R.string.post_not_found, Toast.LENGTH_SHORT).show();
+ else
+ loadPostPreview(post);
+ } else if (WordPress.currentPost != null) {
+ loadPostPreview(WordPress.currentPost);
+ }
+ else {
+ Toast.makeText(this, R.string.post_not_found, Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ /**
+ * Load the post preview. If the post is in a non-public state (e.g. draft status, part of a
+ * non-public blog, etc), load the preview as an authenticated URL. Otherwise, just load the
+ * preview normally.
+ *
+ * @param post Post to load the preview for.
+ */
+ private void loadPostPreview(Post post) {
+ if (post != null) {
+ String url = post.getPermaLink();
+
+ if ( WordPress.getCurrentBlog().isPrivate() //blog private
+ || post.isLocalDraft()
+ || post.isLocalChange()
+ || post.getStatusEnum() != PostStatus.PUBLISHED) {
+ if (-1 == url.indexOf('?')) {
+ url = url.concat("?preview=true");
+ } else {
+ url = url.concat("&preview=true");
+ }
+ loadAuthenticatedUrl(url);
+ } else {
+ loadUrl(url);
+ }
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/SelectCategoriesActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/SelectCategoriesActivity.java
new file mode 100644
index 000000000..197a12acd
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/SelectCategoriesActivity.java
@@ -0,0 +1,432 @@
+package org.wordpress.android.ui.posts;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.app.ListActivity;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.os.Handler;
+import android.util.SparseBooleanArray;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.ListView;
+import android.widget.Toast;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.models.CategoryNode;
+import org.wordpress.android.ui.PullToRefreshHelper;
+import org.wordpress.android.ui.PullToRefreshHelper.RefreshListener;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.ListScrollPositionManager;
+import org.wordpress.android.util.NetworkUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.ToastUtils.Duration;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlrpc.android.XMLRPCClientInterface;
+import org.xmlrpc.android.XMLRPCException;
+import org.xmlrpc.android.XMLRPCFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+
+import uk.co.senab.actionbarpulltorefresh.library.PullToRefreshLayout;
+
+public class SelectCategoriesActivity extends ListActivity {
+ String finalResult = "";
+ private final Handler mHandler = new Handler();
+ private Blog blog;
+ private ListView mListView;
+ private ListScrollPositionManager mListScrollPositionManager;
+ private PullToRefreshHelper mPullToRefreshHelper;
+ private HashSet<String> mSelectedCategories;
+ private CategoryNode mCategories;
+ private ArrayList<CategoryNode> mCategoryLevels;
+ private Map<String, Integer> mCategoryNames = new HashMap<String, Integer>();
+ XMLRPCClientInterface mClient;
+
+ @Override
+ public void onCreate(Bundle icicle) {
+ super.onCreate(icicle);
+
+ setContentView(R.layout.select_categories);
+ setTitle(getResources().getString(R.string.select_categories));
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setHomeButtonEnabled(true);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+
+ mListView = getListView();
+ mListScrollPositionManager = new ListScrollPositionManager(mListView, false);
+ mListView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
+ mListView.setItemsCanFocus(false);
+
+ mListView.setOnItemClickListener(new AdapterView.OnItemClickListener(){
+ @Override
+ public void onItemClick(AdapterView<?> arg0, View arg1, int position, long arg3) {
+ if (getCheckedItemCount(mListView) > 1) {
+ boolean uncategorizedNeedToBeSelected = false;
+ for (int i = 0; i < mCategoryLevels.size(); i++) {
+ if (mCategoryLevels.get(i).getName().equalsIgnoreCase("uncategorized")) {
+ mListView.setItemChecked(i, uncategorizedNeedToBeSelected);
+ }
+ }
+ }
+ }
+ });
+
+ mSelectedCategories = new HashSet<String>();
+
+ Bundle extras = getIntent().getExtras();
+ if (extras != null) {
+ int blogId = extras.getInt("id");
+ blog = WordPress.wpDB.instantiateBlogByLocalId(blogId);
+ if (blog == null) {
+ Toast.makeText(this, getResources().getText(R.string.blog_not_found), Toast.LENGTH_SHORT).show();
+ finish();
+ }
+ if (extras.containsKey("categories")) {
+ mSelectedCategories = (HashSet<String>) extras.getSerializable("categories");
+ }
+ }
+ if (mSelectedCategories == null) {
+ mSelectedCategories = new HashSet<String>();
+ }
+
+ // pull to refresh setup
+ mPullToRefreshHelper = new PullToRefreshHelper(this, (PullToRefreshLayout) findViewById(R.id.ptr_layout),
+ new RefreshListener() {
+ @Override
+ public void onRefreshStarted(View view) {
+ if (!NetworkUtils.checkConnection(getBaseContext())) {
+ mPullToRefreshHelper.setRefreshing(false);
+ return;
+ }
+ refreshCategories();
+ }
+ });
+
+ populateOrFetchCategories();
+ }
+
+ private void populateCategoryList() {
+ mCategoryLevels = CategoryNode.getSortedListOfCategoriesFromRoot(mCategories);
+ for (int i = 0; i < mCategoryLevels.size(); i++) {
+ mCategoryNames.put( StringUtils.unescapeHTML( mCategoryLevels.get(i).getName() ), i);
+ }
+
+ CategoryArrayAdapter categoryAdapter = new CategoryArrayAdapter(this, R.layout.categories_row, mCategoryLevels);
+ this.setListAdapter(categoryAdapter);
+ if (mSelectedCategories != null) {
+ ListView lv = getListView();
+ for (String selectedCategory : mSelectedCategories) {
+ if (mCategoryNames.keySet().contains(selectedCategory)) {
+ lv.setItemChecked(mCategoryNames.get(selectedCategory), true);
+ }
+ }
+ }
+ mListScrollPositionManager.restoreScrollOffset();
+ }
+
+
+ private void populateOrFetchCategories() {
+ mCategories = CategoryNode.createCategoryTreeFromDB(blog.getLocalTableBlogId());
+ if (mCategories.getChildren().size() > 0) {
+ populateCategoryList();
+ } else {
+ mPullToRefreshHelper.setRefreshing(true);
+ refreshCategories();
+ }
+ }
+
+ final Runnable mUpdateResults = new Runnable() {
+ public void run() {
+ mPullToRefreshHelper.setRefreshing(false);
+ if (finalResult.equals("addCategory_success")) {
+ populateOrFetchCategories();
+ if (!isFinishing()) {
+ ToastUtils.showToast(SelectCategoriesActivity.this, R.string.adding_cat_success, Duration.SHORT);
+ }
+ } else if (finalResult.equals("addCategory_failed")) {
+ if (!isFinishing()) {
+ ToastUtils.showToast(SelectCategoriesActivity.this, R.string.adding_cat_failed, Duration.LONG);
+ }
+ } else if (finalResult.equals("gotCategories")) {
+ populateOrFetchCategories();
+ } else if (finalResult.equals("FAIL")) {
+ if (!isFinishing()) {
+ ToastUtils.showToast(SelectCategoriesActivity.this, R.string.category_refresh_error, Duration.LONG);
+ }
+ }
+ }
+ };
+
+ /**
+ * Gets the categories via a xmlrpc call
+ * @return result message
+ */
+ public String fetchCategories() {
+ String returnMessage;
+ Object result[] = null;
+ Object[] params = {blog.getRemoteBlogId(), blog.getUsername(), blog.getPassword(),};
+ mClient = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(), blog.getHttppassword());
+ boolean success = false;
+ try {
+ result = (Object[]) mClient.call("wp.getCategories", params);
+ success = true;
+ } catch (XMLRPCException e) {
+ AppLog.e(AppLog.T.POSTS, e);
+ } catch (IOException e) {
+ AppLog.e(AppLog.T.POSTS, e);
+ } catch (XmlPullParserException e) {
+ AppLog.e(AppLog.T.POSTS, e);
+ }
+
+ if (success) {
+ // wipe out the categories table
+ WordPress.wpDB.clearCategories(blog.getLocalTableBlogId());
+
+ for (Object aResult : result) {
+ Map<?, ?> curHash = (Map<?, ?>) aResult;
+ String categoryName = curHash.get("categoryName").toString();
+ String categoryID = curHash.get("categoryId").toString();
+ String categoryParentID = curHash.get("parentId").toString();
+ int convertedCategoryID = Integer.parseInt(categoryID);
+ int convertedCategoryParentID = Integer.parseInt(categoryParentID);
+ WordPress.wpDB.insertCategory(blog.getLocalTableBlogId(), convertedCategoryID, convertedCategoryParentID, categoryName);
+ }
+ returnMessage = "gotCategories";
+ } else {
+ returnMessage = "FAIL";
+ }
+ return returnMessage;
+ }
+
+ /**
+ * function addCategory
+ *
+ * @param String
+ * category_name
+ * @return
+ * @description Adds a new category
+ */
+ public String addCategory(final String category_name, String category_slug, String category_desc, int parent_id) {
+ // Return string
+ String returnString = "addCategory_failed";
+
+ // Save selected categories
+ updateSelectedCategoryList();
+ mListScrollPositionManager.saveScrollOffset();
+
+ // Store the parameters for wp.addCategory
+ Map<String, Object> struct = new HashMap<String, Object>();
+ struct.put("name", category_name);
+ struct.put("slug", category_slug);
+ struct.put("description", category_desc);
+ struct.put("parent_id", parent_id);
+ mClient = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(), blog.getHttppassword());
+ Object[] params = { blog.getRemoteBlogId(), blog.getUsername(), blog.getPassword(), struct };
+
+ Object result = null;
+ try {
+ result = mClient.call("wp.newCategory", params);
+ } catch (XMLRPCException e) {
+ AppLog.e(AppLog.T.POSTS, e);
+ } catch (IOException e) {
+ AppLog.e(AppLog.T.POSTS, e);
+ } catch (XmlPullParserException e) {
+ AppLog.e(AppLog.T.POSTS, e);
+ }
+
+ if (result != null) {
+ // Category successfully created. "result" is the ID of the new category
+ // Initialize the category database
+ // Convert "result" (= category_id) from type Object to int
+ int category_id = Integer.parseInt(result.toString());
+
+ // Fetch canonical name, can't to do this asynchronously because the new category_name is needed for
+ // insertCategory
+ final String new_category_name = getCanonicalCategoryName(category_id);
+ if (new_category_name == null) {
+ return returnString;
+ }
+ final Activity that = this;
+ if (!new_category_name.equals(category_name)) {
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ Toast.makeText(that, String.format(String.valueOf(getText(R.string.category_automatically_renamed)),
+ category_name, new_category_name), Toast.LENGTH_LONG).show();
+ }
+ });
+ }
+
+ // Insert the new category into database
+ WordPress.wpDB.insertCategory(blog.getLocalTableBlogId(), category_id, parent_id, new_category_name);
+ returnString = "addCategory_success";
+ // auto select new category
+ mSelectedCategories.add(new_category_name);
+ }
+
+ return returnString;
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (data != null) {
+ final Bundle extras = data.getExtras();
+
+ switch (requestCode) {
+ case 0: // Add category
+ // Does the user want to continue, or did he press "dismiss"?
+ if (extras.getString("continue").equals("TRUE")) {
+ // Get name, slug and desc from Intent
+ final String category_name = extras.getString("category_name");
+ final String category_slug = extras.getString("category_slug");
+ final String category_desc = extras.getString("category_desc");
+ final int parent_id = extras.getInt("parent_id");
+
+ // Check if the category name already exists
+ if (!mCategoryNames.keySet().contains(category_name)) {
+ mPullToRefreshHelper.setRefreshing(true);
+ Thread th = new Thread() {
+ public void run() {
+ finalResult = addCategory(category_name, category_slug, category_desc, parent_id);
+ mHandler.post(mUpdateResults);
+ }
+ };
+ th.start();
+ }
+ break;
+ }
+ }// end null check
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.categories, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ int itemId = item.getItemId();
+ if (itemId == R.id.menu_new_category) {
+ Bundle bundle = new Bundle();
+ bundle.putInt("id", blog.getLocalTableBlogId());
+ Intent i = new Intent(SelectCategoriesActivity.this, AddCategoryActivity.class);
+ i.putExtras(bundle);
+ startActivityForResult(i, 0);
+ return true;
+ } else if (itemId == android.R.id.home) {
+ saveAndFinish();
+ return true;
+ } else if (item.getItemId() == R.id.menu_refresh) {
+ // Broadcast a refresh action, PullToRefreshHelper should trigger the default pull to refresh action
+ WordPress.sendLocalBroadcast(this, WordPress.BROADCAST_ACTION_REFRESH_MENU_PRESSED);
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ private String getCanonicalCategoryName(int category_id) {
+ String new_category_name = null;
+ Map<?, ?> result = null;
+ Object[] params = { blog.getRemoteBlogId(), blog.getUsername(), blog.getPassword(), "category", category_id };
+ mClient = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(), blog.getHttppassword());
+ try {
+ result = (Map<?, ?>) mClient.call("wp.getTerm", params);
+ } catch (XMLRPCException e) {
+ AppLog.e(AppLog.T.POSTS, e);
+ } catch (IOException e) {
+ AppLog.e(AppLog.T.POSTS, e);
+ } catch (XmlPullParserException e) {
+ AppLog.e(AppLog.T.POSTS, e);
+ }
+
+ if (result != null) {
+ if (result.containsKey("name")) {
+ new_category_name = result.get("name").toString();
+ }
+ }
+ return new_category_name;
+ }
+
+ private void refreshCategories() {
+ mListScrollPositionManager.saveScrollOffset();
+ updateSelectedCategoryList();
+ Thread th = new Thread() {
+ public void run() {
+ finalResult = fetchCategories();
+ mHandler.post(mUpdateResults);
+ }
+ };
+ th.start();
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ // ignore orientation change
+ super.onConfigurationChanged(newConfig);
+ }
+
+ @Override
+ public void onBackPressed() {
+ saveAndFinish();
+ super.onBackPressed();
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ mPullToRefreshHelper.unregisterReceiver(this);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ mPullToRefreshHelper.registerReceiver(this);
+ }
+
+ private void updateSelectedCategoryList() {
+ SparseBooleanArray selectedItems = mListView.getCheckedItemPositions();
+ for (int i = 0; i < selectedItems.size(); i++) {
+ String currentName = StringUtils.unescapeHTML(mCategoryLevels.get(selectedItems.keyAt(i)).getName());
+ if (selectedItems.get(selectedItems.keyAt(i))) {
+ mSelectedCategories.add(currentName);
+ } else {
+ mSelectedCategories.remove(currentName);
+ }
+ }
+ }
+
+ private void saveAndFinish() {
+ Bundle bundle = new Bundle();
+ updateSelectedCategoryList();
+ bundle.putSerializable("selectedCategories", new ArrayList<String>(mSelectedCategories));
+ Intent mIntent = new Intent();
+ mIntent.putExtras(bundle);
+ setResult(RESULT_OK, mIntent);
+ finish();
+ }
+
+ private int getCheckedItemCount(ListView listView) {
+ return listView.getCheckedItemCount();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/ViewPostActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/ViewPostActivity.java
new file mode 100644
index 000000000..4e961b77d
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/ViewPostActivity.java
@@ -0,0 +1,26 @@
+package org.wordpress.android.ui.posts;
+
+import android.app.Activity;
+import android.os.Bundle;
+
+public class ViewPostActivity extends Activity {
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (savedInstanceState == null) {
+ // During initial setup, plug in the details fragment.
+ ViewPostFragment postFragment = new ViewPostFragment();
+ getFragmentManager().beginTransaction().add(
+ android.R.id.content, postFragment).commitAllowingStateLoss();
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ if (outState.isEmpty()) {
+ outState.putBoolean("bug_19917_fix", true);
+ }
+ super.onSaveInstanceState(outState);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/ViewPostFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/ViewPostFragment.java
new file mode 100644
index 000000000..3e88da105
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/ViewPostFragment.java
@@ -0,0 +1,371 @@
+package org.wordpress.android.ui.posts;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.content.Intent;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.os.Handler;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.view.inputmethod.EditorInfo;
+import android.webkit.WebView;
+import android.widget.EditText;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Post;
+import org.wordpress.android.ui.comments.CommentActions;
+import org.wordpress.android.util.EditTextUtils;
+import org.wordpress.android.util.NetworkUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.WPHtml;
+import org.wordpress.android.util.WPWebViewClient;
+
+public class ViewPostFragment extends Fragment {
+ /** Called when the activity is first created. */
+
+ private OnDetailPostActionListener onDetailPostActionListener;
+ PostsActivity parentActivity;
+
+ private ViewGroup mLayoutCommentBox;
+ private EditText mEditComment;
+ private ImageButton mAddCommentButton, mShareUrlButton, mViewPostButton;
+ private TextView mTitleTextView, mContentTextView;
+ private boolean mShouldLoadPost = true;
+
+ @Override
+ public void onActivityCreated(Bundle bundle) {
+ super.onActivityCreated(bundle);
+
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ // Don't load the post until we know the width of mContentTextView
+ // GlobalLayoutListener on mContentTextView will load the post once it gets laid out
+ if (WordPress.currentPost != null && !getView().isLayoutRequested()) {
+ loadPost(WordPress.currentPost);
+ }
+
+ parentActivity = (PostsActivity) getActivity();
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ final View v = inflater.inflate(R.layout.viewpost, container, false);
+ v.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ loadPost(WordPress.currentPost);
+ v.getViewTreeObserver().removeGlobalOnLayoutListener(this);
+ }
+ });
+
+ mTitleTextView = (TextView) v.findViewById(R.id.postTitle);
+ mContentTextView = (TextView) v.findViewById(R.id.viewPostTextView);
+ mShareUrlButton = (ImageButton) v.findViewById(R.id.sharePostLink);
+ mViewPostButton = (ImageButton) v.findViewById(R.id.viewPost);
+
+ // comment views
+ mLayoutCommentBox = (ViewGroup) v.findViewById(R.id.layout_comment_box);
+ mEditComment = (EditText) mLayoutCommentBox.findViewById(R.id.edit_comment);
+ mEditComment.setHint(R.string.reader_hint_comment_on_post);
+
+ // button listeners here
+ ImageButton editPostButton = (ImageButton) v.findViewById(R.id.editPost);
+ editPostButton.setOnClickListener(new ImageButton.OnClickListener() {
+ public void onClick(View v) {
+ if (WordPress.currentPost != null && !parentActivity.isRefreshing()) {
+ onDetailPostActionListener.onDetailPostAction(PostsActivity.POST_EDIT, WordPress.currentPost);
+ Intent i = new Intent(getActivity().getApplicationContext(), EditPostActivity.class);
+ i.putExtra(EditPostActivity.EXTRA_IS_PAGE, WordPress.currentPost.isPage());
+ i.putExtra(EditPostActivity.EXTRA_POSTID, WordPress.currentPost.getLocalTablePostId());
+ getActivity().startActivityForResult(i, PostsActivity.ACTIVITY_EDIT_POST);
+ }
+ }
+ });
+
+
+ mShareUrlButton.setOnClickListener(new ImageButton.OnClickListener() {
+ public void onClick(View v) {
+ if (!parentActivity.isRefreshing()) {
+ onDetailPostActionListener.onDetailPostAction(PostsActivity.POST_SHARE, WordPress.currentPost);
+ }
+ }
+ });
+
+ ImageButton deletePostButton = (ImageButton) v.findViewById(R.id.deletePost);
+ deletePostButton.setOnClickListener(new ImageButton.OnClickListener() {
+ public void onClick(View v) {
+ if (!parentActivity.isRefreshing()) {
+ onDetailPostActionListener.onDetailPostAction(PostsActivity.POST_DELETE, WordPress.currentPost);
+ }
+ }
+ });
+
+ mViewPostButton.setOnClickListener(new ImageButton.OnClickListener() {
+ public void onClick(View v) {
+ onDetailPostActionListener.onDetailPostAction(PostsActivity.POST_VIEW, WordPress.currentPost);
+ if (!parentActivity.isRefreshing()) {
+ loadPostPreview();
+ }
+ }
+ });
+
+ mAddCommentButton = (ImageButton) v.findViewById(R.id.addComment);
+ // Tint the comment icon to match the other icons in the toolbar
+ mAddCommentButton.setColorFilter(Color.argb(255, 132, 132, 132));
+ mAddCommentButton.setOnClickListener(new ImageButton.OnClickListener() {
+ public void onClick(View v) {
+ if (!parentActivity.isRefreshing()) {
+ toggleCommentBox();
+ }
+ }
+ });
+
+ return v;
+
+ }
+
+ protected void loadPostPreview() {
+ if (WordPress.currentPost != null && !TextUtils.isEmpty(WordPress.currentPost.getPermaLink())) {
+ Intent i = new Intent(getActivity(), PreviewPostActivity.class);
+ startActivity(i);
+ }
+ }
+
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+ try {
+ // check that the containing activity implements our callback
+ onDetailPostActionListener = (OnDetailPostActionListener) activity;
+ } catch (ClassCastException e) {
+ activity.finish();
+ throw new ClassCastException(activity.toString()
+ + " must implement Callback");
+ }
+ }
+
+ public void loadPost(final Post post) {
+ // Don't load if the Post object or title are null, see #395
+ if (post == null || post.getTitle() == null)
+ return;
+ if (!hasActivity() || getView() == null)
+ return;
+
+ // create handler on UI thread
+ final Handler handler = new Handler();
+
+ // locate views and determine content in the background to avoid ANR - especially
+ // important when using WPHtml.fromHtml() for drafts that contain images since
+ // thumbnails may take some time to create
+ final WebView webView = (WebView) getView().findViewById(R.id.viewPostWebView);
+ webView.setWebViewClient(new WPWebViewClient(WordPress.getCurrentBlog()));
+ new Thread() {
+ @Override
+ public void run() {
+
+ final String title = (TextUtils.isEmpty(post.getTitle())
+ ? "(" + getResources().getText(R.string.untitled) + ")"
+ : StringUtils.unescapeHTML(post.getTitle()));
+
+ final String postContent = post.getDescription() + "\n\n" + post.getMoreText();
+
+ final Spanned draftContent;
+ final String htmlContent;
+ if (post.isLocalDraft()) {
+ View view = getView();
+ int maxWidth = Math.min(view.getWidth(), view.getHeight());
+
+ draftContent = WPHtml.fromHtml(postContent.replaceAll("\uFFFC", ""), getActivity(), post, maxWidth);
+ htmlContent = null;
+ } else {
+ draftContent = null;
+ htmlContent = "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>"
+ + "<html><head><link rel=\"stylesheet\" type=\"text/css\" href=\"webview.css\" /></head>"
+ + "<body><div id=\"container\">"
+ + StringUtils.addPTags(postContent)
+ + "</div></body></html>";
+ }
+
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ // make sure activity is still valid
+ if (!hasActivity())
+ return;
+
+ mTitleTextView.setText(title);
+
+ if (post.isLocalDraft()) {
+ mContentTextView.setVisibility(View.VISIBLE);
+ webView.setVisibility(View.GONE);
+ mShareUrlButton.setVisibility(View.GONE);
+ mViewPostButton.setVisibility(View.GONE);
+ mAddCommentButton.setVisibility(View.GONE);
+ mContentTextView.setText(draftContent);
+ } else {
+ mContentTextView.setVisibility(View.GONE);
+ webView.setVisibility(View.VISIBLE);
+ mShareUrlButton.setVisibility(View.VISIBLE);
+ mViewPostButton.setVisibility(View.VISIBLE);
+ mAddCommentButton.setVisibility(post.isAllowComments() ? View.VISIBLE : View.GONE);
+ webView.loadDataWithBaseURL("file:///android_asset/",
+ htmlContent,
+ "text/html",
+ "utf-8",
+ null);
+ }
+ }
+ });
+ }
+ }.start();
+ }
+
+ public interface OnDetailPostActionListener {
+ public void onDetailPostAction(int action, Post post);
+ }
+
+ public void clearContent() {
+ TextView txtTitle = (TextView) getView().findViewById(R.id.postTitle);
+ WebView webView = (WebView) getView().findViewById(R.id.viewPostWebView);
+ TextView txtContent = (TextView) getView().findViewById(R.id.viewPostTextView);
+ txtTitle.setText("");
+ txtContent.setText("");
+ String htmlText = "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>"
+ + "<html><head><link rel=\"stylesheet\" type=\"text/css\" href=\"webview.css\" /></head>"
+ + "<body><div id=\"container\"></div></body></html>";
+ webView.loadDataWithBaseURL("file:///android_asset/", htmlText,
+ "text/html", "utf-8", null);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ if (outState.isEmpty()) {
+ outState.putBoolean("bug_19917_fix", true);
+ }
+ super.onSaveInstanceState(outState);
+ }
+
+ boolean mIsCommentBoxShowing = false;
+ boolean mIsSubmittingComment = false;
+
+ private boolean hasActivity() {
+ return (getActivity() != null && !isRemoving());
+ }
+
+ private void showCommentBox() {
+ // skip if it's already showing or a comment is being submitted
+ if (mIsCommentBoxShowing || mIsSubmittingComment)
+ return;
+ if (!hasActivity())
+ return;
+
+ // show the comment box in, force keyboard to appear and highlight the comment button
+ mLayoutCommentBox.setVisibility(View.VISIBLE);
+ mEditComment.requestFocus();
+
+ // submit comment when done/send tapped on the keyboard
+ mEditComment.setOnEditorActionListener(new TextView.OnEditorActionListener() {
+ @Override
+ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+ if (actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_ACTION_SEND)
+ submitComment();
+ return false;
+ }
+ });
+
+ // submit comment when send icon tapped
+ final ImageView imgPostComment = (ImageView) mLayoutCommentBox.findViewById(R.id.image_post_comment);
+ imgPostComment.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ submitComment();
+ }
+ });
+ EditTextUtils.showSoftInput(mEditComment);
+ mIsCommentBoxShowing = true;
+ }
+
+ private void hideCommentBox() {
+ if (!mIsCommentBoxShowing)
+ return;
+ if (!hasActivity())
+ return;
+
+ EditTextUtils.hideSoftInput(mEditComment);
+ mLayoutCommentBox.setVisibility(View.GONE);
+
+ mIsCommentBoxShowing = false;
+ }
+
+ private void toggleCommentBox() {
+ if (mIsCommentBoxShowing) {
+ hideCommentBox();
+ } else {
+ showCommentBox();
+ }
+ }
+
+ private void submitComment() {
+ if (!hasActivity() || mIsSubmittingComment || WordPress.currentPost == null || !NetworkUtils.checkConnection(
+ getActivity())) {
+ return;
+ }
+ final String commentText = EditTextUtils.getText(mEditComment);
+ if (TextUtils.isEmpty(commentText)) {
+ return;
+ }
+
+ final ImageView imgPostComment = (ImageView) mLayoutCommentBox.findViewById(R.id.image_post_comment);
+ final ProgressBar progress = (ProgressBar) mLayoutCommentBox.findViewById(R.id.progress_submit_comment);
+
+ // disable editor & comment button, hide soft keyboard, hide submit icon, and show progress spinner while submitting
+ mEditComment.setEnabled(false);
+ mAddCommentButton.setEnabled(false);
+ EditTextUtils.hideSoftInput(mEditComment);
+ imgPostComment.setVisibility(View.GONE);
+ progress.setVisibility(View.VISIBLE);
+
+ CommentActions.CommentActionListener actionListener = new CommentActions.CommentActionListener() {
+ @Override
+ public void onActionResult(boolean succeeded) {
+ mIsSubmittingComment = false;
+ if (!hasActivity())
+ return;
+
+ parentActivity.attemptToSelectPost();
+
+ mEditComment.setEnabled(true);
+ mAddCommentButton.setEnabled(true);
+ imgPostComment.setVisibility(View.VISIBLE);
+ progress.setVisibility(View.GONE);
+
+ if (succeeded) {
+ ToastUtils.showToast(getActivity(), R.string.comment_added);
+ hideCommentBox();
+ mEditComment.setText(null);
+ parentActivity.refreshComments();
+ } else {
+ ToastUtils.showToast(getActivity(), R.string.reader_toast_err_comment_failed, ToastUtils.Duration.LONG);
+ }
+ }
+ };
+
+ int accountId = WordPress.getCurrentLocalTableBlogId();
+ CommentActions.addComment(accountId, WordPress.currentPost.getRemotePostId(), commentText, actionListener);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/adapters/PostsListAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/adapters/PostsListAdapter.java
new file mode 100644
index 000000000..8e5b2fde3
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/adapters/PostsListAdapter.java
@@ -0,0 +1,260 @@
+package org.wordpress.android.ui.posts.adapters;
+
+import android.content.Context;
+import android.os.AsyncTask;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.PostStatus;
+import org.wordpress.android.models.PostsListPost;
+import org.wordpress.android.ui.posts.PostsListFragment;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Adapter for Posts/Pages list
+ */
+public class PostsListAdapter extends BaseAdapter {
+ public static interface OnLoadMoreListener {
+ public void onLoadMore();
+ }
+
+ public static interface OnPostsLoadedListener {
+ public void onPostsLoaded(int postCount);
+ }
+
+ private final OnLoadMoreListener mOnLoadMoreListener;
+ private final OnPostsLoadedListener mOnPostsLoadedListener;
+ private Context mContext;
+ private boolean mIsPage;
+ private LayoutInflater mLayoutInflater;
+
+ private List<PostsListPost> mPosts = new ArrayList<PostsListPost>();
+
+
+ public PostsListAdapter(Context context, boolean isPage, OnLoadMoreListener onLoadMoreListener, OnPostsLoadedListener onPostsLoadedListener) {
+ mContext = context;
+ mIsPage = isPage;
+ mOnLoadMoreListener = onLoadMoreListener;
+ mOnPostsLoadedListener = onPostsLoadedListener;
+ mLayoutInflater = LayoutInflater.from(mContext);
+ }
+
+ public List<PostsListPost> getPosts() {
+ return mPosts;
+ }
+
+ public void setPosts(List<PostsListPost> postsList) {
+ if (postsList != null)
+ this.mPosts = postsList;
+ }
+
+ @Override
+ public int getCount() {
+ return mPosts.size();
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return mPosts.get(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return mPosts.get(position).getPostId();
+ }
+
+ @Override
+ public View getView(int position, View view, ViewGroup parent) {
+ PostsListPost post = mPosts.get(position);
+ PostViewWrapper wrapper;
+ if (view == null) {
+ view = mLayoutInflater.inflate(R.layout.post_list_row, parent, false);
+ wrapper = new PostViewWrapper(view);
+ view.setTag(wrapper);
+ } else {
+ wrapper = (PostViewWrapper) view.getTag();
+ }
+
+ String date = post.getFormattedDate();
+
+ String titleText = post.getTitle();
+ if (titleText.equals(""))
+ titleText = "(" + mContext.getResources().getText(R.string.untitled) + ")";
+ wrapper.getTitle().setText(titleText);
+
+ if (post.isLocalDraft()) {
+ wrapper.getDate().setVisibility(View.GONE);
+ } else {
+ wrapper.getDate().setText(date);
+ wrapper.getDate().setVisibility(View.VISIBLE);
+ }
+
+ String formattedStatus = "";
+ if ((post.getStatusEnum() == PostStatus.PUBLISHED) && !post.isLocalDraft() && !post.hasLocalChanges()) {
+ wrapper.getStatus().setVisibility(View.GONE);
+ } else {
+ wrapper.getStatus().setVisibility(View.VISIBLE);
+ if (post.isLocalDraft()) {
+ formattedStatus = mContext.getResources().getString(R.string.local_draft);
+ } else if (post.hasLocalChanges()) {
+ formattedStatus = mContext.getResources().getString(R.string.local_changes);
+ } else {
+ switch (post.getStatusEnum()) {
+ case DRAFT:
+ formattedStatus = mContext.getResources().getString(R.string.draft);
+ break;
+ case PRIVATE:
+ formattedStatus = mContext.getResources().getString(R.string.post_private);
+ break;
+ case PENDING:
+ formattedStatus = mContext.getResources().getString(R.string.pending_review);
+ break;
+ case SCHEDULED:
+ formattedStatus = mContext.getResources().getString(R.string.scheduled);
+ break;
+ default:
+ break;
+ }
+ }
+
+ // Set post status TextView color
+ if (post.isLocalDraft() || post.getStatusEnum() == PostStatus.DRAFT || post.hasLocalChanges()) {
+ wrapper.getStatus().setTextColor(mContext.getResources().getColor(R.color.orange_dark));
+ } else {
+ wrapper.getStatus().setTextColor(mContext.getResources().getColor(R.color.grey_medium));
+ }
+
+ // Make status upper-case and add line break to stack vertically
+ formattedStatus = formattedStatus.toUpperCase(Locale.getDefault()).replace(" ", "\n");
+ wrapper.getStatus().setText(formattedStatus);
+ }
+
+ // load more posts when we near the end
+ if (mOnLoadMoreListener != null && position >= getCount() - 1
+ && position >= PostsListFragment.POSTS_REQUEST_COUNT - 1) {
+ mOnLoadMoreListener.onLoadMore();
+ }
+
+ return view;
+ }
+
+ public void loadPosts() {
+ if (WordPress.getCurrentBlog() == null) {
+ return;
+ }
+
+ // load posts from db
+ new LoadPostsTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ public void clear() {
+ if (mPosts.size() > 0) {
+ mPosts.clear();
+ notifyDataSetChanged();
+ }
+ }
+
+ class PostViewWrapper {
+ View base;
+ TextView title = null;
+ TextView date = null;
+ TextView status = null;
+
+ PostViewWrapper(View base) {
+ this.base = base;
+ }
+
+ TextView getTitle() {
+ if (title == null) {
+ title = (TextView) base.findViewById(R.id.post_list_title);
+ }
+ return (title);
+ }
+
+ TextView getDate() {
+ if (date == null) {
+ date = (TextView) base.findViewById(R.id.post_list_date);
+ }
+ return (date);
+ }
+
+ TextView getStatus() {
+ if (status == null) {
+ status = (TextView) base.findViewById(R.id.post_list_status);
+ }
+ return (status);
+ }
+ }
+
+ private class LoadPostsTask extends AsyncTask <Void, Void, Boolean> {
+ List<PostsListPost> loadedPosts;
+
+ @Override
+ protected Boolean doInBackground(Void... nada) {
+ loadedPosts = WordPress.wpDB.getPostsListPosts(WordPress.getCurrentLocalTableBlogId(), mIsPage);
+ if (postsListMatch(loadedPosts)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ if (result) {
+ setPosts(loadedPosts);
+ notifyDataSetChanged();
+
+ if (mOnPostsLoadedListener != null && mPosts != null) {
+ mOnPostsLoadedListener.onPostsLoaded(mPosts.size());
+ }
+ }
+ }
+ }
+
+ public boolean postsListMatch(List<PostsListPost> newPostsList) {
+ if (newPostsList == null || newPostsList.size() == 0 || mPosts == null || mPosts.size() != newPostsList.size())
+ return false;
+
+ for (int i = 0; i < newPostsList.size(); i++) {
+ PostsListPost newPost = newPostsList.get(i);
+ PostsListPost currentPost = mPosts.get(i);
+
+ if (newPost.getPostId() != currentPost.getPostId())
+ return false;
+ if (!newPost.getTitle().equals(currentPost.getTitle()))
+ return false;
+ if (newPost.getDateCreatedGmt() != currentPost.getDateCreatedGmt())
+ return false;
+ if (!newPost.getOriginalStatus().equals(currentPost.getOriginalStatus()))
+ return false;
+ if (newPost.isLocalDraft() != currentPost.isLocalDraft())
+ return false;
+ if (newPost.hasLocalChanges() != currentPost.hasLocalChanges())
+ return false;
+ }
+
+ return true;
+ }
+
+ public int getRemotePostCount() {
+ if (mPosts == null)
+ return 0;
+
+ int remotePostCount = 0;
+ for (PostsListPost post : mPosts) {
+ if (!post.isLocalDraft())
+ remotePostCount++;
+ }
+
+ return remotePostCount;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AboutActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AboutActivity.java
new file mode 100644
index 000000000..4ab162274
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AboutActivity.java
@@ -0,0 +1,72 @@
+package org.wordpress.android.ui.prefs;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.Button;
+import android.widget.TextView;
+
+import org.wordpress.passcodelock.AppLockManager;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+
+public class AboutActivity extends Activity implements OnClickListener {
+ private static final String URL_TOS = "http://en.wordpress.com/tos";
+ private static final String URL_AUTOMATTIC = "http://automattic.com";
+ private static final String URL_PRIVACY_POLICY = "/privacy";
+
+ @Override
+ public void onCreate(Bundle icicle) {
+ super.onCreate(icicle);
+ setContentView(R.layout.about);
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+
+ TextView version = (TextView) findViewById(R.id.about_version);
+ version.setText(getString(R.string.version) + " "
+ + WordPress.versionName);
+
+ Button tos = (Button) findViewById(R.id.about_tos);
+ tos.setOnClickListener(this);
+
+ Button pp = (Button) findViewById(R.id.about_privacy);
+ pp.setOnClickListener(this);
+ }
+
+ @Override
+ public void onClick(View v) {
+ Uri uri;
+ int id = v.getId();
+ if (id == R.id.about_url) {
+ uri = Uri.parse(URL_AUTOMATTIC);
+ } else if (id == R.id.about_tos) {
+ uri = Uri.parse(URL_TOS);
+ } else if (id == R.id.about_privacy) {
+ uri = Uri.parse(URL_AUTOMATTIC + URL_PRIVACY_POLICY);
+ } else {
+ return;
+ }
+ AppLockManager.getInstance().setExtendedTimeout();
+ startActivity(new Intent(Intent.ACTION_VIEW, uri));
+ }
+
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ finish();
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/BlogPreferencesActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/BlogPreferencesActivity.java
new file mode 100644
index 000000000..3e26d1b27
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/BlogPreferencesActivity.java
@@ -0,0 +1,328 @@
+package org.wordpress.android.ui.prefs;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemSelectedListener;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.EditText;
+import android.widget.RelativeLayout;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.ui.DashboardActivity;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+
+import java.util.Locale;
+
+/**
+ * Activity for configuring blog specific settings.
+ */
+public class BlogPreferencesActivity extends Activity {
+ private boolean mIsViewingAdmin;
+
+ /** The blog this activity is managing settings for. */
+ private Blog blog;
+ private boolean mBlogDeleted;
+ private EditText mUsernameET;
+ private EditText mPasswordET;
+ private EditText mHttpUsernameET;
+ private EditText mHttpPasswordET;
+ private CheckBox mFullSizeCB;
+ private CheckBox mScaledCB;
+ private Spinner mImageWidthSpinner;
+ private EditText mScaledImageWidthET;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.blog_preferences);
+
+ Integer id = getIntent().getIntExtra("id", -1);
+ blog = WordPress.getBlog(id);
+
+ if (blog == null) {
+ Toast.makeText(this, getString(R.string.blog_not_found), Toast.LENGTH_SHORT).show();
+ finish();
+ return;
+ }
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setTitle(StringUtils.unescapeHTML(blog.getBlogName()));
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+
+ mUsernameET = (EditText) findViewById(R.id.username);
+ mPasswordET = (EditText) findViewById(R.id.password);
+ mHttpUsernameET = (EditText) findViewById(R.id.httpuser);
+ mHttpPasswordET = (EditText) findViewById(R.id.httppassword);
+ mScaledImageWidthET = (EditText) findViewById(R.id.scaledImageWidth);
+ mFullSizeCB = (CheckBox) findViewById(R.id.fullSizeImage);
+ mScaledCB = (CheckBox) findViewById(R.id.scaledImage);
+ mImageWidthSpinner = (Spinner) findViewById(R.id.maxImageWidth);
+ Button removeBlogButton = (Button) findViewById(R.id.remove_account);
+
+ if (blog.isDotcomFlag()) {
+ // Hide credentials section
+ RelativeLayout credentialsRL = (RelativeLayout)findViewById(R.id.sectionContent);
+ credentialsRL.setVisibility(View.GONE);
+ removeBlogButton.setVisibility(View.GONE);
+ }
+ loadSettingsForBlog();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ mIsViewingAdmin = false;
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+
+ if (mBlogDeleted || mIsViewingAdmin)
+ return;
+
+ blog.setUsername(mUsernameET.getText().toString());
+ blog.setPassword(mPasswordET.getText().toString());
+ blog.setHttpuser(mHttpUsernameET.getText().toString());
+ blog.setHttppassword(mHttpPasswordET.getText().toString());
+
+ blog.setFullSizeImage(mFullSizeCB.isChecked());
+ blog.setScaledImage(mScaledCB.isChecked());
+ if (blog.isScaledImage()) {
+ EditText scaledImgWidth = (EditText) findViewById(R.id.scaledImageWidth);
+
+ boolean error = false;
+ int width = 0;
+ try {
+ width = Integer.parseInt(scaledImgWidth.getText().toString().trim());
+ } catch (NumberFormatException e) {
+ error = true;
+ }
+
+ if (width == 0)
+ error = true;
+
+ if (error) {
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(BlogPreferencesActivity.this);
+ dialogBuilder.setTitle(getResources().getText(R.string.error));
+ dialogBuilder.setMessage(getResources().getText(R.string.scaled_image_error));
+ dialogBuilder.setPositiveButton("OK", new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ }
+ });
+ dialogBuilder.setCancelable(true);
+ dialogBuilder.create().show();
+ return;
+ } else {
+ blog.setScaledImageWidth(width);
+ }
+ }
+
+ blog.setMaxImageWidth(mImageWidthSpinner.getSelectedItem().toString());
+
+ WordPress.wpDB.saveBlog(blog);
+
+ if (WordPress.getCurrentBlog().getLocalTableBlogId() == blog.getLocalTableBlogId())
+ WordPress.currentBlog = blog;
+
+ // exit settings screen
+ Bundle bundle = new Bundle();
+
+ bundle.putString("returnStatus", "SAVE");
+ Intent mIntent = new Intent();
+ mIntent.putExtras(bundle);
+ setResult(RESULT_OK, mIntent);
+ finish();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ int itemID = item.getItemId();
+ if (itemID == android.R.id.home) {
+ finish();
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void loadSettingsForBlog() {
+ // Set header labels to upper case
+ ((TextView) findViewById(R.id.l_section1)).setText(getResources().getString(R.string.account_details).toUpperCase(Locale.getDefault()));
+ ((TextView) findViewById(R.id.l_section2)).setText(getResources().getString(R.string.media).toUpperCase(Locale.getDefault()));
+ ((TextView) findViewById(R.id.l_maxImageWidth)).setText(getResources().getString(R.string.max_thumbnail_px_width).toUpperCase(Locale.getDefault()));
+ ((TextView) findViewById(R.id.l_httpuser)).setText(getResources().getString(R.string.http_credentials).toUpperCase(Locale.getDefault()));
+
+ ArrayAdapter<Object> spinnerArrayAdapter = new ArrayAdapter<Object>(this,
+ R.layout.spinner_textview, new String[] {
+ "Original Size", "100", "200", "300", "400", "500", "600", "700", "800",
+ "900", "1000", "1100", "1200", "1300", "1400", "1500", "1600", "1700",
+ "1800", "1900", "2000"
+ });
+ spinnerArrayAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ mImageWidthSpinner.setAdapter(spinnerArrayAdapter);
+ mImageWidthSpinner.setOnItemSelectedListener(new OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+ CheckBox fullSizeImageCheckBox = (CheckBox)findViewById(R.id.fullSizeImage);
+ if(id == 0) //Original size selected. Do not show the link to full image.
+ fullSizeImageCheckBox.setVisibility(View.GONE);
+ else
+ fullSizeImageCheckBox.setVisibility(View.VISIBLE);
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> arg0) {
+ }
+ });
+
+
+ mUsernameET.setText(blog.getUsername());
+ mPasswordET.setText(blog.getPassword());
+ mHttpUsernameET.setText(blog.getHttpuser());
+ mHttpPasswordET.setText(blog.getHttppassword());
+ TextView httpUserLabel = (TextView)findViewById(R.id.l_httpuser);
+ if (blog.isDotcomFlag()) {
+ mHttpUsernameET.setVisibility(View.GONE);
+ mHttpPasswordET.setVisibility(View.GONE);
+ httpUserLabel.setVisibility(View.GONE);
+ } else {
+ mHttpUsernameET.setVisibility(View.VISIBLE);
+ mHttpPasswordET.setVisibility(View.VISIBLE);
+ httpUserLabel.setVisibility(View.VISIBLE);
+ }
+
+ mFullSizeCB.setChecked(blog.isFullSizeImage());
+ mScaledCB.setChecked(blog.isScaledImage());
+
+ this.mScaledImageWidthET.setText("" + blog.getScaledImageWidth());
+ showScaledSetting(blog.isScaledImage());
+
+ CheckBox scaledImage = (CheckBox) findViewById(R.id.scaledImage);
+ scaledImage.setChecked(false);
+ scaledImage.setVisibility(View.GONE);
+
+ // sets up a state listener for the scaled image checkbox
+ /* ((CheckBox) findViewById(R.id.scaledImage)).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ CheckBox scaledImage = (CheckBox) findViewById(R.id.scaledImage);
+ showScaledSetting(scaledImage.isChecked());
+ if (scaledImage.isChecked()) {
+ CheckBox fullSize = (CheckBox) findViewById(R.id.fullSizeImage);
+ fullSize.setChecked(false);
+ }
+ }
+ });*/
+ // sets up a state listener for the fullsize checkbox
+ CheckBox fullSizeImageCheckBox = (CheckBox)findViewById(R.id.fullSizeImage);
+ fullSizeImageCheckBox.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ CheckBox fullSize = (CheckBox) findViewById(R.id.fullSizeImage);
+ if (fullSize.isChecked()) {
+ CheckBox scaledImage = (CheckBox) findViewById(R.id.scaledImage);
+ if (scaledImage.isChecked()) {
+ scaledImage.setChecked(false);
+ showScaledSetting(false);
+ }
+ }
+ }
+ });
+
+ int imageWidthPosition = spinnerArrayAdapter.getPosition(blog.getMaxImageWidth());
+ mImageWidthSpinner.setSelection((imageWidthPosition >= 0) ? imageWidthPosition : 0);
+ if (mImageWidthSpinner.getSelectedItemPosition() == 0) //Original size selected. Do not show the link to full image.
+ fullSizeImageCheckBox.setVisibility(View.GONE);
+ else
+ fullSizeImageCheckBox.setVisibility(View.VISIBLE);
+ }
+
+ /**
+ * Hides / shows the scaled image settings
+ *
+ * @param show
+ */
+ private void showScaledSetting(boolean show) {
+ TextView tw = (TextView) findViewById(R.id.l_scaledImage);
+ EditText et = (EditText) findViewById(R.id.scaledImageWidth);
+ tw.setVisibility(show ? View.VISIBLE : View.GONE);
+ et.setVisibility(show ? View.VISIBLE : View.GONE);
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ // ignore orientation change
+ super.onConfigurationChanged(newConfig);
+ }
+
+ /**
+ * Remove the blog this activity is managing settings for.
+ */
+ public void removeBlog(View view) {
+ final BlogPreferencesActivity activity = this;
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(this);
+ dialogBuilder.setTitle(getResources().getText(R.string.remove_account));
+ dialogBuilder.setMessage(getResources().getText(R.string.sure_to_remove_account));
+ dialogBuilder.setPositiveButton(getResources().getText(R.string.yes), new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ boolean deleteSuccess = WordPress.wpDB.deleteAccount(BlogPreferencesActivity.this, blog.getLocalTableBlogId());
+ if (deleteSuccess) {
+ Toast.makeText(activity, getResources().getText(R.string.blog_removed_successfully), Toast.LENGTH_SHORT)
+ .show();
+ WordPress.wpDB.deleteLastBlogId();
+ WordPress.currentBlog = null;
+ mBlogDeleted = true;
+ activity.finish();
+ } else {
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(activity);
+ dialogBuilder.setTitle(getResources().getText(R.string.error));
+ dialogBuilder.setMessage(getResources().getText(R.string.could_not_remove_account));
+ dialogBuilder.setPositiveButton("OK", new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ // just close the dialog
+ }
+ });
+ dialogBuilder.setCancelable(true);
+ dialogBuilder.create().show();
+ }
+ }
+ });
+ dialogBuilder.setNegativeButton(getResources().getText(R.string.no), new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ // just close the window
+ }
+ });
+ dialogBuilder.setCancelable(false);
+ dialogBuilder.create().show();
+ }
+
+ /**
+ * View the blog admin area in the web browser
+ */
+ public void viewAdmin(View view) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.OPENED_VIEW_ADMIN);
+ mIsViewingAdmin = true;
+ Intent i = new Intent(this, DashboardActivity.class);
+ i.putExtra("blogID", blog.getLocalTableBlogId());
+ startActivity(i);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/LicensesActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/LicensesActivity.java
new file mode 100644
index 000000000..f87aec4d4
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/LicensesActivity.java
@@ -0,0 +1,18 @@
+package org.wordpress.android.ui.prefs;
+
+import android.os.Bundle;
+
+import org.wordpress.android.R;
+import org.wordpress.android.ui.WebViewActivity;
+
+/**
+ * Display open source licenses for the application.
+ */
+public class LicensesActivity extends WebViewActivity {
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setTitle(getResources().getText(R.string.open_source_licenses));
+ loadUrl("file:///android_asset/licenses.html");
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/PreferencesActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/PreferencesActivity.java
new file mode 100644
index 000000000..5ca30656e
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/PreferencesActivity.java
@@ -0,0 +1,742 @@
+package org.wordpress.android.ui.prefs;
+
+import android.app.ActionBar;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.preference.CheckBoxPreference;
+import android.preference.Preference;
+import android.preference.Preference.OnPreferenceChangeListener;
+import android.preference.Preference.OnPreferenceClickListener;
+import android.preference.PreferenceActivity;
+import android.preference.PreferenceCategory;
+import android.preference.PreferenceGroup;
+import android.preference.PreferenceManager;
+import android.preference.PreferenceScreen;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.Button;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+
+import com.android.volley.VolleyError;
+import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.internal.StringMap;
+import com.wordpress.rest.RestRequest;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.ui.ShareIntentReceiverActivity;
+import org.wordpress.android.ui.accounts.ManageBlogsActivity;
+import org.wordpress.android.ui.accounts.NewBlogActivity;
+import org.wordpress.android.ui.accounts.WelcomeActivity;
+import org.wordpress.android.ui.notifications.NotificationUtils;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.MapUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.WPEditTextPreference;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+import org.wordpress.passcodelock.AppLockManager;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@SuppressWarnings("deprecation")
+public class PreferencesActivity extends PreferenceActivity {
+ private ArrayList<StringMap<Double>> mMutedBlogsList;
+ private Map<String, Object> mNotificationSettings;
+ private SharedPreferences mSettings;
+ private boolean mNotificationSettingsChanged;
+
+ private PreferenceGroup mNotificationsGroup;
+ WPEditTextPreference mTaglineTextPreference;
+ private Blog mCurrentBlogOnCreate;
+
+ public static final int RESULT_SIGNED_OUT = RESULT_FIRST_USER;
+ public static final String CURRENT_BLOG_CHANGED = "CURRENT_BLOG_CHANGED";
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (savedInstanceState == null) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.OPENED_SETTINGS);
+ }
+
+ overridePendingTransition(R.anim.slide_up, R.anim.do_nothing);
+
+ setTitle(getResources().getText(R.string.settings));
+ mCurrentBlogOnCreate = WordPress.getCurrentBlog();
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+
+ addPreferencesFromResource(R.xml.preferences);
+
+ mNotificationsGroup = (PreferenceGroup)findPreference("wp_pref_notifications_category");
+
+ OnPreferenceChangeListener preferenceChangeListener = new OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newValue) {
+ if (newValue != null) { // cancelled dismiss keyboard
+ preference.setSummary(newValue.toString());
+ }
+ InputMethodManager imm = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE);
+ imm.hideSoftInputFromWindow(getListView().getWindowToken(), 0);
+ return true;
+ }
+ };
+
+ mTaglineTextPreference = (WPEditTextPreference) findPreference("wp_pref_post_signature");
+ if (mTaglineTextPreference != null) {
+ mTaglineTextPreference.setOnPreferenceChangeListener(preferenceChangeListener);
+ }
+ Preference signOutPreference = findPreference("wp_pref_sign_out");
+ signOutPreference.setOnPreferenceClickListener(signOutPreferenceClickListener);
+
+ Preference resetAutoShare = findPreference("wp_reset_share_pref");
+ resetAutoShare.setOnPreferenceClickListener(resetAUtoSharePreferenceClickListener);
+
+ mSettings = PreferenceManager.getDefaultSharedPreferences(this);
+
+ // AuthenticatorRequest notification settings if needed
+ if (WordPress.hasValidWPComCredentials(PreferencesActivity.this)) {
+ String settingsJson = mSettings.getString(NotificationUtils.WPCOM_PUSH_DEVICE_NOTIFICATION_SETTINGS, null);
+ if (settingsJson == null) {
+ com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ AppLog.d(T.NOTIFS, "Get settings action succeeded");
+ Editor editor = mSettings.edit();
+ try {
+ JSONObject settingsJSON = jsonObject.getJSONObject("settings");
+ editor.putString(NotificationUtils.WPCOM_PUSH_DEVICE_NOTIFICATION_SETTINGS, settingsJSON.toString());
+ editor.commit();
+ } catch (JSONException e) {
+ AppLog.e(T.NOTIFS, "Can't parse the JSON object returned from the server that contains PN settings.", e);
+ }
+ refreshWPComAuthCategory();
+ }
+ };
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.e(T.NOTIFS, "Get settings action failed", volleyError); }
+ };
+ NotificationUtils.getPushNotificationSettings(PreferencesActivity.this, listener, errorListener);
+ }
+ }
+
+ //Passcode Lock not supported
+ if( AppLockManager.getInstance().isAppLockFeatureEnabled() == false ) {
+ PreferenceScreen rootScreen = (PreferenceScreen)findPreference("wp_pref_root");
+ PreferenceGroup passcodeGroup = (PreferenceGroup)findPreference("wp_passcode_lock_category");
+ rootScreen.removePreference(passcodeGroup);
+ } else {
+ final CheckBoxPreference passcodeEnabledCheckBoxPreference = (CheckBoxPreference) findPreference("wp_pref_passlock_enabled");
+ //disable on-click changes on the property
+ passcodeEnabledCheckBoxPreference.setOnPreferenceClickListener(
+ new OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ passcodeEnabledCheckBoxPreference.setChecked( AppLockManager.getInstance().getCurrentAppLock().isPasswordLocked() );
+ return false;
+ }
+ }
+ );
+ }
+
+ displayPreferences();
+ }
+
+ private void hidePostSignatureCategory() {
+ PreferenceScreen preferenceScreen = (PreferenceScreen) findPreference("wp_pref_root");
+ PreferenceCategory postSignature = (PreferenceCategory) findPreference("wp_post_signature");
+ if (preferenceScreen != null && postSignature != null) {
+ preferenceScreen.removePreference(postSignature);
+ }
+ }
+
+ private void hideNotificationBlogsCategory() {
+ PreferenceScreen preferenceScreen = (PreferenceScreen)
+ findPreference("wp_pref_notifications");
+ PreferenceCategory blogs = (PreferenceCategory)
+ findPreference("wp_pref_notification_blogs");
+ if (preferenceScreen != null && blogs != null) {
+ preferenceScreen.removePreference(blogs);
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ // the set of blogs may have changed while we were away
+ updateSelfHostedBlogsPreferenceCategory();
+ refreshWPComAuthCategory();
+
+ //update Passcode lock row if available
+ if( AppLockManager.getInstance().isAppLockFeatureEnabled() ) {
+ CheckBoxPreference passcodeEnabledCheckBoxPreference = (CheckBoxPreference) findPreference("wp_pref_passlock_enabled");
+ if ( AppLockManager.getInstance().getCurrentAppLock().isPasswordLocked() ) {
+ passcodeEnabledCheckBoxPreference.setChecked(true);
+ } else {
+ passcodeEnabledCheckBoxPreference.setChecked(false);
+ }
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ overridePendingTransition(R.anim.do_nothing, R.anim.slide_down);
+ super.onPause();
+ }
+
+ @Override
+ public void finish() {
+ Intent data = new Intent();
+ boolean currentBlogChanged = false;
+ if (mCurrentBlogOnCreate != null) {
+ if (mCurrentBlogOnCreate.isDotcomFlag()) {
+ if (!WordPress.wpDB.isDotComAccountVisible(mCurrentBlogOnCreate.getRemoteBlogId())) {
+ // dotcom blog has been hidden or removed
+ currentBlogChanged = true;
+ }
+ } else {
+ if (!WordPress.wpDB.isBlogInDatabase(mCurrentBlogOnCreate.getRemoteBlogId(), mCurrentBlogOnCreate.getUrl())) {
+ // self hosted blog has been removed
+ currentBlogChanged = true;
+ }
+ }
+ } else {
+ // no visible blogs when preferences opened
+ if (WordPress.wpDB.getNumVisibleAccounts() != 0) {
+ // now at least one blog could be selected
+ currentBlogChanged = true;
+ }
+ }
+ data.putExtra(CURRENT_BLOG_CHANGED, currentBlogChanged);
+ setResult(RESULT_OK, data);
+ AnalyticsTracker.loadPrefHasUserOptedOut(true);
+ super.finish();
+ }
+
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ finish();
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ public boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen, Preference preference)
+ {
+ super.onPreferenceTreeClick(preferenceScreen, preference);
+ // Workaround for Action Bar Home Button not functional with nested PreferenceScreen
+ if (preference!=null && preference instanceof PreferenceScreen) {
+ // If the user has clicked on a preference screen, set up the action bar
+ if (preference instanceof PreferenceScreen) {
+ initializeActionBar((PreferenceScreen) preference);
+ }
+ }
+ return false;
+ }
+
+ /** Sets up the action bar for an {@link PreferenceScreen} */
+ public static void initializeActionBar(PreferenceScreen preferenceScreen) {
+ final Dialog dialog = preferenceScreen.getDialog();
+
+ if (dialog != null) {
+ // Initialize the action bar
+ if (dialog.getActionBar() != null) {
+ dialog.getActionBar().setDisplayHomeAsUpEnabled(true);
+ }
+
+ // Apply custom home button area click listener to close the PreferenceScreen because PreferenceScreens are dialogs which swallow
+ // events instead of passing to the activity
+ // Related Issue: https://code.google.com/p/android/issues/detail?id=4611
+ View homeBtn = dialog.findViewById(android.R.id.home);
+
+ if (homeBtn != null) {
+ OnClickListener dismissDialogClickListener = new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ dialog.dismiss();
+ }
+ };
+
+ // Prepare yourselves for some hacky programming
+ ViewParent homeBtnContainer = homeBtn.getParent();
+
+ // The home button is an ImageView inside a FrameLayout
+ if (homeBtnContainer instanceof FrameLayout) {
+ ViewGroup containerParent = (ViewGroup) homeBtnContainer.getParent();
+
+ if (containerParent instanceof LinearLayout) {
+ // This view also contains the title text, set the whole view as clickable
+ ((LinearLayout) containerParent).setOnClickListener(dismissDialogClickListener);
+ } else {
+ // Just set it on the home button
+ ((FrameLayout) homeBtnContainer).setOnClickListener(dismissDialogClickListener);
+ }
+ } else {
+ // The 'If all else fails' default case
+ homeBtn.setOnClickListener(dismissDialogClickListener);
+ }
+ }
+ }
+ }
+
+ /**
+ * Update the "wpcom blogs" preference category to contain a preference for each blog to configure
+ * blog-specific settings.
+ */
+ protected void updateSelfHostedBlogsPreferenceCategory() {
+ PreferenceCategory blogsCategory = (PreferenceCategory) findPreference("wp_pref_self_hosted_blogs");
+ blogsCategory.removeAll();
+ int order = 0;
+
+ // Add self-hosted blog button
+ Preference addBlogPreference = new Preference(this);
+ addBlogPreference.setTitle(R.string.add_self_hosted_blog);
+ Intent intentWelcome = new Intent(this, WelcomeActivity.class);
+ intentWelcome.putExtra(WelcomeActivity.START_FRAGMENT_KEY,
+ WelcomeActivity.ADD_SELF_HOSTED_BLOG);
+ addBlogPreference.setIntent(intentWelcome);
+ addBlogPreference.setOrder(order++);
+ blogsCategory.addPreference(addBlogPreference);
+
+ // Add self hosted list
+ List<Map<String, Object>> accounts = WordPress.wpDB.getAccountsBy("dotcomFlag=0", null);
+ addAccounts(blogsCategory, accounts, order);
+ }
+
+ protected int getEnabledBlogsCount() {
+ PreferenceScreen selectBlogsCategory = (PreferenceScreen) findPreference("wp_pref_notification_blogs");
+ int enabledBlogCtr = 0;
+ for (int i = 0; i < selectBlogsCategory.getPreferenceCount(); i++) {
+ CheckBoxPreference blogPreference = (CheckBoxPreference) selectBlogsCategory.getPreference(i);
+ if (blogPreference.isChecked())
+ enabledBlogCtr++;
+ }
+ return enabledBlogCtr;
+ }
+
+ public void displayPreferences() {
+ // Post signature
+ if (WordPress.wpDB.getNumVisibleAccounts() == 0) {
+ hidePostSignatureCategory();
+ hideNotificationBlogsCategory();
+ } else {
+ if (mTaglineTextPreference.getText() == null || mTaglineTextPreference.getText().equals("")) {
+ mTaglineTextPreference.setSummary(R.string.posted_from);
+ mTaglineTextPreference.setText(getString(R.string.posted_from));
+ } else {
+ mTaglineTextPreference.setSummary(mTaglineTextPreference.getText());
+ }
+ }
+ }
+
+ /**
+ * Listens for changes to notification type settings
+ */
+ private OnPreferenceChangeListener mTypeChangeListener = new OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newValue) {
+ // Update the mNoteSettings map with the new value
+ if (preference instanceof CheckBoxPreference) {
+ CheckBoxPreference checkBoxPreference = (CheckBoxPreference) preference;
+ boolean isChecked = (Boolean) newValue;
+ String key = preference.getKey();
+ StringMap<Integer> typeMap = (StringMap<Integer>) mNotificationSettings.get(key);
+ typeMap.put("value", (isChecked) ? 1 : 0);
+ mNotificationSettings.put(key, typeMap);
+ checkBoxPreference.setChecked(isChecked);
+ mNotificationSettingsChanged = true;
+ }
+ return false;
+ }
+ };
+
+ /**
+ * Listens for changes to notification blogs settings
+ */
+ private OnPreferenceChangeListener mMuteBlogChangeListener = new OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newValue) {
+ if (preference instanceof CheckBoxPreference) {
+ CheckBoxPreference checkBoxPreference = (CheckBoxPreference) preference;
+ boolean isChecked = (Boolean) newValue;
+ int id = checkBoxPreference.getOrder();
+ StringMap<Double> blogMap = (StringMap<Double>) mMutedBlogsList.get(id);
+ blogMap.put("value", (!isChecked) ? 1.0 : 0.0);
+ mMutedBlogsList.set(id, blogMap);
+ StringMap<ArrayList> mutedBlogsMap = (StringMap<ArrayList>) mNotificationSettings.get("muted_blogs");
+ mutedBlogsMap.put("value", mMutedBlogsList);
+ mNotificationSettings.put("muted_blogs", mutedBlogsMap);
+ checkBoxPreference.setChecked(isChecked);
+ mNotificationSettingsChanged = true;
+ }
+ return false;
+ }
+ };
+
+ /**
+ * Listens for changes to notification enabled toggle
+ */
+ private OnPreferenceChangeListener mNotificationsEnabledChangeListener = new OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newValue) {
+ if (preference instanceof CheckBoxPreference) {
+ final boolean isChecked = (Boolean) newValue;
+ if (isChecked) {
+ StringMap<String> muteUntilMap = (StringMap<String>) mNotificationSettings.get("mute_until");
+ muteUntilMap.put("value", "0");
+ mNotificationSettings.put("mute_until", muteUntilMap);
+ mNotificationSettingsChanged = true;
+ return true;
+ } else {
+ final Dialog dialog = new Dialog(PreferencesActivity.this);
+ dialog.setContentView(R.layout.notifications_enabled_dialog);
+ dialog.setTitle(R.string.notifications);
+ dialog.setCancelable(true);
+
+ Button offButton = (Button) dialog.findViewById(R.id.notificationsOff);
+ offButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ enabledButtonClick(v);
+ dialog.dismiss();
+ }
+ });
+ Button oneHourButton = (Button) dialog.findViewById(R.id.notifications1Hour);
+ oneHourButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ enabledButtonClick(v);
+ dialog.dismiss();
+ }
+ });
+ Button eightHoursButton = (Button) dialog.findViewById(R.id.notifications8Hours);
+ eightHoursButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ enabledButtonClick(v);
+ dialog.dismiss();
+ }
+ });
+ dialog.show();
+ }
+
+ }
+ return false;
+ }
+ };
+
+ private void enabledButtonClick(View v) {
+ StringMap<String> muteUntilMap = (StringMap<String>) mNotificationSettings
+ .get("mute_until");
+ if (muteUntilMap != null) {
+ if (v.getId() == R.id.notificationsOff) {
+ muteUntilMap.put("value", "forever");
+ } else if (v.getId() == R.id.notifications1Hour) {
+ muteUntilMap.put("value",
+ String.valueOf((System.currentTimeMillis() / 1000) + 3600));
+ } else if (v.getId() == R.id.notifications8Hours) {
+ muteUntilMap.put("value",
+ String.valueOf((System.currentTimeMillis() / 1000) + (3600 * 8)));
+ }
+ CheckBoxPreference enabledCheckBoxPreference = (CheckBoxPreference) findPreference("wp_pref_notifications_enabled");
+ enabledCheckBoxPreference.setChecked(false);
+ mNotificationSettings.put("mute_until", muteUntilMap);
+ mNotificationSettingsChanged = true;
+ }
+ }
+
+ private void sendNotificationsSettings() {
+ AppLog.d(T.NOTIFS, "Send push notification settings");
+ new sendNotificationSettingsTask().execute();
+ }
+
+ /**
+ * Performs the notification settings save in the background
+ */
+ private class sendNotificationSettingsTask extends AsyncTask<Void, Void, Void> {
+ // Sends updated notification settings to WP.com
+ @Override
+ protected Void doInBackground(Void... params) {
+ if (mNotificationSettings != null) {
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(PreferencesActivity.this);
+ SharedPreferences.Editor editor = settings.edit();
+ Gson gson = new Gson();
+ String settingsJson = gson.toJson(mNotificationSettings);
+ editor.putString(NotificationUtils.WPCOM_PUSH_DEVICE_NOTIFICATION_SETTINGS, settingsJson);
+ editor.commit();
+ NotificationUtils.setPushNotificationSettings(PreferencesActivity.this);
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void result) {
+ mNotificationSettingsChanged = false;
+ }
+ }
+
+ private void addWpComSignIn(PreferenceCategory wpComCategory, int order) {
+ if (WordPress.hasValidWPComCredentials(PreferencesActivity.this)) {
+ String username = mSettings.getString(WordPress.WPCOM_USERNAME_PREFERENCE, null);
+ Preference usernamePref = new Preference(this);
+ usernamePref.setTitle(getString(R.string.username));
+ usernamePref.setSummary(username);
+ usernamePref.setSelectable(false);
+ usernamePref.setOrder(order);
+ wpComCategory.addPreference(usernamePref);
+
+ Preference createWPComBlogPref = new Preference(this);
+ createWPComBlogPref.setTitle(getString(R.string.create_new_blog_wpcom));
+ Intent intent = new Intent(this, NewBlogActivity.class);
+ createWPComBlogPref.setIntent(intent);
+ createWPComBlogPref.setOrder(order + 1);
+ wpComCategory.addPreference(createWPComBlogPref);
+
+ loadNotifications();
+ } else {
+ Preference signInPref = new Preference(this);
+ signInPref.setTitle(getString(R.string.sign_in));
+ signInPref.setOnPreferenceClickListener(signInPreferenceClickListener);
+ wpComCategory.addPreference(signInPref);
+
+ PreferenceScreen rootScreen = (PreferenceScreen)findPreference("wp_pref_root");
+ rootScreen.removePreference(mNotificationsGroup);
+ }
+ }
+
+ private void addWpComShowHideButton(PreferenceCategory wpComCategory, int order) {
+ if (WordPress.wpDB.getNumDotComAccounts() > 0) {
+ Preference manageBlogPreference = new Preference(this);
+ manageBlogPreference.setTitle(R.string.show_and_hide_blogs);
+ Intent intentManage = new Intent(this, ManageBlogsActivity.class);
+ manageBlogPreference.setIntent(intentManage);
+ manageBlogPreference.setOrder(order);
+ wpComCategory.addPreference(manageBlogPreference);
+ }
+ }
+
+ private void addAccounts(PreferenceCategory category, List<Map<String, Object>> blogs, int order) {
+ for (Map<String, Object> account : blogs) {
+ String blogName = StringUtils.unescapeHTML(account.get("blogName").toString());
+ int accountId = (Integer) account.get("id");
+
+ Preference blogSettingsPreference = new Preference(this);
+ blogSettingsPreference.setTitle(blogName);
+
+ try {
+ // set blog hostname as preference summary if it differs from the blog name
+ URL blogUrl = new URL(account.get("url").toString());
+ if (!blogName.equals(blogUrl.getHost())) {
+ blogSettingsPreference.setSummary(blogUrl.getHost());
+ }
+ } catch (MalformedURLException e) {
+ // do nothing
+ }
+
+ Intent intent = new Intent(this, BlogPreferencesActivity.class);
+ intent.putExtra("id", accountId);
+ blogSettingsPreference.setIntent(intent);
+ blogSettingsPreference.setOrder(order++);
+ category.addPreference(blogSettingsPreference);
+ }
+ }
+
+ private void refreshWPComAuthCategory() {
+ PreferenceCategory wpComCategory = (PreferenceCategory) findPreference("wp_pref_wpcom");
+ wpComCategory.removeAll();
+ addWpComSignIn(wpComCategory, 0);
+ addWpComShowHideButton(wpComCategory, 5);
+ List<Map<String, Object>> accounts = WordPress.wpDB.getAccountsBy("dotcomFlag = 1 AND isHidden = 0", null);
+ addAccounts(wpComCategory, accounts, 10);
+ }
+
+ private static Comparator<StringMap<?>> BlogNameComparatorForMutedBlogsList = new Comparator<StringMap<?>>() {
+ public int compare(StringMap<?> blog1, StringMap<?> blog2) {
+ StringMap<?> blogMap1 = (StringMap<?>)blog1;
+ StringMap<?> blogMap2 = (StringMap<?>)blog2;
+
+ String blogName1 = blogMap1.get("blog_name").toString();
+ if (blogName1.length() == 0) {
+ blogName1 = blogMap1.get("url").toString();
+ }
+
+ String blogName2 = blogMap2.get("blog_name").toString();
+ if (blogName2.length() == 0) {
+ blogName2 = blogMap2.get("url").toString();
+ }
+
+ return blogName1.compareToIgnoreCase(blogName2);
+
+ }
+
+ };
+
+ private void loadNotifications() {
+ AppLog.d(T.NOTIFS, "Preferences > loading notification settings");
+
+ // Add notifications group back in case it was previously removed from being logged out
+ PreferenceScreen rootScreen = (PreferenceScreen)findPreference("wp_pref_root");
+ rootScreen.addPreference(mNotificationsGroup);
+ PreferenceCategory notificationTypesCategory = (PreferenceCategory) findPreference("wp_pref_notification_types");
+ notificationTypesCategory.removeAll();
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this);
+
+ String settingsJson = settings.getString(NotificationUtils.WPCOM_PUSH_DEVICE_NOTIFICATION_SETTINGS, null);
+ if (settingsJson == null) {
+ rootScreen.removePreference(mNotificationsGroup);
+ return;
+ } else {
+ try {
+ Gson gson = new Gson();
+ mNotificationSettings = gson.fromJson(settingsJson, HashMap.class);
+ StringMap<?> mutedBlogsMap = (StringMap<?>) mNotificationSettings.get("muted_blogs");
+ mMutedBlogsList = (ArrayList<StringMap<Double>>) mutedBlogsMap.get("value");
+ Collections.sort(mMutedBlogsList, this.BlogNameComparatorForMutedBlogsList);
+
+ Object[] mTypeList = mNotificationSettings.keySet().toArray();
+
+ for (int i = 0; i < mTypeList.length; i++) {
+ if (!mTypeList[i].equals("muted_blogs") && !mTypeList[i].equals("mute_until")) {
+ StringMap<?> typeMap = (StringMap<?>) mNotificationSettings
+ .get(mTypeList[i].toString());
+ CheckBoxPreference typePreference = new CheckBoxPreference(this);
+ typePreference.setKey(mTypeList[i].toString());
+ typePreference.setChecked(MapUtils.getMapBool(typeMap, "value"));
+ typePreference.setTitle(typeMap.get("desc").toString());
+ typePreference.setOnPreferenceChangeListener(mTypeChangeListener);
+ notificationTypesCategory.addPreference(typePreference);
+ }
+ }
+
+ PreferenceCategory selectBlogsCategory = (PreferenceCategory) findPreference("wp_pref_notification_blogs");
+ selectBlogsCategory.removeAll();
+ for (int i = 0; i < mMutedBlogsList.size(); i++) {
+ StringMap<?> blogMap = (StringMap<?>) mMutedBlogsList.get(i);
+ String blogName = (String) blogMap.get("blog_name");
+ if (blogName == null || blogName.trim().equals(""))
+ blogName = (String) blogMap.get("url");
+ CheckBoxPreference blogPreference = new CheckBoxPreference(this);
+ blogPreference.setChecked(!MapUtils.getMapBool(blogMap, "value"));
+ blogPreference.setTitle(StringUtils.unescapeHTML(blogName));
+ blogPreference.setOnPreferenceChangeListener(mMuteBlogChangeListener);
+ // set the order here so it matches the key in mMutedBlogsList since
+ // mMuteBlogChangeListener uses the order to locate the clicked blog
+ blogPreference.setOrder(i);
+ selectBlogsCategory.addPreference(blogPreference);
+ }
+
+ } catch (JsonSyntaxException e) {
+ AppLog.v(T.NOTIFS, "Notification Settings Json could not be parsed.");
+ return;
+ } catch (Exception e) {
+ AppLog.v(T.NOTIFS, "Failed to load notification settings.");
+ return;
+ }
+
+ CheckBoxPreference notificationsEnabledCheckBox = (CheckBoxPreference) findPreference("wp_pref_notifications_enabled");
+ notificationsEnabledCheckBox.setOnPreferenceChangeListener(mNotificationsEnabledChangeListener);
+
+ }
+ }
+
+ private OnPreferenceClickListener signInPreferenceClickListener = new OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ Intent i = new Intent(PreferencesActivity.this, WelcomeActivity.class);
+ i.putExtra("wpcom", true);
+ i.putExtra("auth-only", true);
+ startActivityForResult(i, 0);
+ return true;
+ }
+ };
+
+ private OnPreferenceClickListener resetAUtoSharePreferenceClickListener =
+ new OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ Editor editor = mSettings.edit();
+ editor.remove(ShareIntentReceiverActivity.SHARE_IMAGE_BLOG_ID_KEY);
+ editor.remove(ShareIntentReceiverActivity.SHARE_IMAGE_ADDTO_KEY);
+ editor.remove(ShareIntentReceiverActivity.SHARE_TEXT_BLOG_ID_KEY);
+ editor.commit();
+ ToastUtils.showToast(getBaseContext(), R.string.auto_sharing_preference_reset,
+ ToastUtils.Duration.SHORT);
+ return true;
+ }
+ };
+
+ private OnPreferenceClickListener signOutPreferenceClickListener = new OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(PreferencesActivity.this);
+ dialogBuilder.setTitle(getResources().getText(R.string.sign_out));
+ dialogBuilder.setMessage(getString(R.string.sign_out_confirm));
+ dialogBuilder.setPositiveButton(R.string.sign_out,
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog,
+ int whichButton) {
+ // set the result code so caller knows the user signed out
+ setResult(RESULT_SIGNED_OUT);
+ WordPress.signOut(PreferencesActivity.this);
+ finish();
+ }
+ });
+ dialogBuilder.setNegativeButton(R.string.cancel,
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog,
+ int whichButton) {
+ // Just close the window.
+ }
+ });
+ dialogBuilder.setCancelable(true);
+ if (!isFinishing())
+ dialogBuilder.create().show();
+ return true;
+ }
+ };
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ refreshWPComAuthCategory();
+ super.onActivityResult(requestCode, resultCode, data);
+ }
+
+ public void onStop() {
+ super.onStop();
+ if (mNotificationSettingsChanged) {
+ sendNotificationsSettings();
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/UserPrefs.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/UserPrefs.java
new file mode 100644
index 000000000..ab205737d
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/UserPrefs.java
@@ -0,0 +1,142 @@
+package org.wordpress.android.ui.prefs;
+
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+import android.text.TextUtils;
+
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.ReaderTag;
+import org.wordpress.android.models.ReaderTagType;
+
+public class UserPrefs {
+ // id of the current user
+ private static final String PREFKEY_USER_ID = "wp_userid";
+
+ // last selected tag in the reader
+ private static final String PREFKEY_READER_TAG_NAME = "reader_tag_name";
+ private static final String PREFKEY_READER_TAG_TYPE = "reader_tag_type";
+
+ // title of the last active page in ReaderSubsActivity
+ private static final String PREFKEY_READER_SUBS_PAGE_TITLE = "reader_subs_page_title";
+
+ // offset when showing recommended blogs
+ private static final String PREFKEY_READER_RECOMMENDED_OFFSET = "reader_recommended_offset";
+
+ private static SharedPreferences prefs() {
+ return PreferenceManager.getDefaultSharedPreferences(WordPress.getContext());
+ }
+
+ /*
+ * remove all reader-related preferences
+ */
+ public static void reset() {
+ prefs().edit()
+ .remove(PREFKEY_USER_ID)
+ .remove(PREFKEY_READER_TAG_NAME)
+ .remove(PREFKEY_READER_TAG_TYPE)
+ .remove(PREFKEY_READER_RECOMMENDED_OFFSET)
+ .remove(PREFKEY_READER_SUBS_PAGE_TITLE)
+ .commit();
+ }
+
+
+ private static String getString(String key) {
+ return getString(key, "");
+ }
+ private static String getString(String key, String defaultValue) {
+ return prefs().getString(key, defaultValue);
+ }
+ private static void setString(String key, String value) {
+ SharedPreferences.Editor editor = prefs().edit();
+ if (TextUtils.isEmpty(value)) {
+ editor.remove(key);
+ } else {
+ editor.putString(key, value);
+ }
+ editor.commit();
+ }
+
+ private static long getLong(String key) {
+ try {
+ String value = getString(key);
+ return Long.parseLong(value);
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+ private static void setLong(String key, long value) {
+ setString(key, Long.toString(value));
+ }
+
+ private static int getInt(String key) {
+ try {
+ String value = getString(key);
+ return Integer.parseInt(value);
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+ private static void setInt(String key, int value) {
+ setString(key, Integer.toString(value));
+ }
+
+ private static void remove(String key) {
+ prefs().edit().remove(key).commit();
+ }
+
+ public static long getCurrentUserId() {
+ return getLong(PREFKEY_USER_ID);
+ }
+ public static void setCurrentUserId(long userId) {
+ if (userId == 0) {
+ remove(PREFKEY_USER_ID);
+ } else {
+ setLong(PREFKEY_USER_ID, userId);
+ }
+ }
+
+ public static ReaderTag getReaderTag() {
+ String tagName = getString(PREFKEY_READER_TAG_NAME);
+ if (TextUtils.isEmpty(tagName)) {
+ return null;
+ }
+ int tagType = getInt(PREFKEY_READER_TAG_TYPE);
+ return new ReaderTag(tagName, ReaderTagType.fromInt(tagType));
+ }
+ public static void setReaderTag(ReaderTag tag) {
+ if (tag != null && !TextUtils.isEmpty(tag.getTagName())) {
+ setString(PREFKEY_READER_TAG_NAME, tag.getTagName());
+ setInt(PREFKEY_READER_TAG_TYPE, tag.tagType.toInt());
+ } else {
+ prefs().edit()
+ .remove(PREFKEY_READER_TAG_NAME)
+ .remove(PREFKEY_READER_TAG_TYPE)
+ .commit();
+ }
+ }
+
+ /*
+ * offset used along with a SQL LIMIT to enable user to page through recommended blogs
+ */
+ public static int getReaderRecommendedBlogOffset() {
+ return getInt(PREFKEY_READER_RECOMMENDED_OFFSET);
+ }
+ public static void setReaderRecommendedBlogOffset(int offset) {
+ if (offset == 0) {
+ remove(PREFKEY_READER_RECOMMENDED_OFFSET);
+ } else {
+ setInt(PREFKEY_READER_RECOMMENDED_OFFSET, offset);
+ }
+ }
+
+ /*
+ * title of the last active page in ReaderSubsActivity - this is stored rather than
+ * the index of the page so we can re-order pages without affecting this value
+ */
+ public static String getReaderSubsPageTitle() {
+ return getString(PREFKEY_READER_SUBS_PAGE_TITLE);
+ }
+ public static void setReaderSubsPageTitle(String pageTitle) {
+ setString(PREFKEY_READER_SUBS_PAGE_TITLE, pageTitle);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java
new file mode 100644
index 000000000..33f615990
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java
@@ -0,0 +1,178 @@
+package org.wordpress.android.ui.reader;
+
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.support.v4.app.ActivityCompat;
+import android.support.v4.app.ActivityOptionsCompat;
+import android.text.TextUtils;
+import android.view.View;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.ReaderPost;
+import org.wordpress.android.models.ReaderTag;
+import org.wordpress.android.ui.notifications.NotificationsWebViewActivity;
+import org.wordpress.android.ui.reader.ReaderTypes.ReaderPostListType;
+import org.wordpress.android.ui.reader.models.ReaderBlogIdPostId;
+import org.wordpress.android.ui.reader.models.ReaderBlogIdPostIdList;
+import org.wordpress.android.util.ToastUtils;
+
+public class ReaderActivityLauncher {
+
+ /*
+ * show a single reader post in the detail view - simply calls showReaderPostPager
+ * with a single post
+ */
+ public static void showReaderPostDetail(Activity activity, long blogId, long postId) {
+ ReaderBlogIdPostIdList idList = new ReaderBlogIdPostIdList();
+ idList.add(new ReaderBlogIdPostId(blogId, postId));
+ showReaderPostPager(activity, null, 0, idList, null);
+ }
+
+ /*
+ * show a list of posts in the post pager with the post at the passed position made active
+ */
+ public static void showReaderPostPager(Activity activity,
+ String title,
+ int position,
+ ReaderBlogIdPostIdList idList,
+ ReaderPostListType postListType) {
+ Intent intent = new Intent(activity, ReaderPostPagerActivity.class);
+ intent.putExtra(ReaderPostPagerActivity.ARG_POSITION, position);
+ intent.putExtra(ReaderPostPagerActivity.ARG_BLOG_POST_ID_LIST, idList);
+ if (!TextUtils.isEmpty(title)) {
+ intent.putExtra(ReaderPostPagerActivity.ARG_TITLE, title);
+ }
+ if (postListType != null) {
+ intent.putExtra(ReaderConstants.ARG_POST_LIST_TYPE, postListType);
+ }
+ ActivityOptionsCompat options = ActivityOptionsCompat.makeCustomAnimation(
+ activity,
+ R.anim.reader_detail_in,
+ 0);
+ ActivityCompat.startActivity(activity, intent, options.toBundle());
+ }
+
+ /*
+ * show a list of posts in a specific blog
+ */
+ public static void showReaderBlogPreview(Context context, long blogId, String blogUrl) {
+ Intent intent = new Intent(context, ReaderPostListActivity.class);
+ intent.putExtra(ReaderConstants.ARG_BLOG_ID, blogId);
+ intent.putExtra(ReaderConstants.ARG_BLOG_URL, blogUrl);
+ intent.putExtra(ReaderConstants.ARG_POST_LIST_TYPE, ReaderTypes.ReaderPostListType.BLOG_PREVIEW);
+ context.startActivity(intent);
+ }
+
+ /*
+ * show a list of posts with a specific tag
+ */
+ public static void showReaderTagPreview(Context context, ReaderTag tag) {
+ if (tag == null) {
+ return;
+ }
+ Intent intent = new Intent(context, ReaderPostListActivity.class);
+ intent.putExtra(ReaderConstants.ARG_TAG, tag);
+ intent.putExtra(ReaderConstants.ARG_POST_LIST_TYPE, ReaderTypes.ReaderPostListType.TAG_PREVIEW);
+ context.startActivity(intent);
+ }
+
+ /*
+ * show users who liked the passed post
+ */
+ public static void showReaderLikingUsers(Context context, ReaderPost post) {
+ if (post == null) {
+ return;
+ }
+ Intent intent = new Intent(context, ReaderUserListActivity.class);
+ intent.putExtra(ReaderConstants.ARG_BLOG_ID, post.blogId);
+ intent.putExtra(ReaderConstants.ARG_POST_ID, post.postId);
+ context.startActivity(intent);
+ }
+
+ /*
+ * show followed tags & blogs
+ */
+ public static void showReaderSubsForResult(Activity activity) {
+ Intent intent = new Intent(activity, ReaderSubsActivity.class);
+ ActivityOptionsCompat options = ActivityOptionsCompat.makeCustomAnimation(
+ activity,
+ R.anim.reader_flyin,
+ 0);
+ ActivityCompat.startActivityForResult(activity, intent, ReaderConstants.INTENT_READER_SUBS, options.toBundle());
+ }
+
+ /*
+ * show the passed imageUrl in the fullscreen photo activity
+ */
+ public static void showReaderPhotoViewer(Activity activity,
+ String imageUrl,
+ View source,
+ int startX,
+ int startY) {
+ if (TextUtils.isEmpty(imageUrl)) {
+ return;
+ }
+
+ Intent intent = new Intent(activity, ReaderPhotoViewerActivity.class);
+ intent.putExtra(ReaderConstants.ARG_IMAGE_URL, imageUrl);
+
+ // use built-in scale animation on jb+, fall back to our own animation on pre-jb
+ if (source != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
+ ActivityOptionsCompat options =
+ ActivityOptionsCompat.makeScaleUpAnimation(source, startX, startY, 0, 0);
+ ActivityCompat.startActivity(activity, intent, options.toBundle());
+ } else {
+ activity.startActivity(intent);
+ activity.overridePendingTransition(R.anim.reader_photo_in, 0);
+ }
+ }
+
+ /*
+ * show the reblog activity for the passed post
+ */
+ public static void showReaderReblogForResult(Activity activity, ReaderPost post, View source) {
+ if (activity == null || post == null) {
+ return;
+ }
+ Intent intent = new Intent(activity, ReaderReblogActivity.class);
+ intent.putExtra(ReaderConstants.ARG_BLOG_ID, post.blogId);
+ intent.putExtra(ReaderConstants.ARG_POST_ID, post.postId);
+ ActivityOptionsCompat options;
+ if (source != null) {
+ int startX = source.getLeft();
+ int startY = source.getTop();
+ options = ActivityOptionsCompat.makeScaleUpAnimation(source, startX, startY, 0, 0);
+ } else {
+ options = ActivityOptionsCompat.makeCustomAnimation(activity, R.anim.reader_flyin, 0);
+ }
+ ActivityCompat.startActivityForResult(activity, intent, ReaderConstants.INTENT_READER_REBLOG, options.toBundle());
+
+ }
+
+ public static enum OpenUrlType { INTERNAL, EXTERNAL }
+ public static void openUrl(Context context, String url) {
+ openUrl(context, url, OpenUrlType.INTERNAL);
+ }
+ public static void openUrl(Context context, String url, OpenUrlType openUrlType) {
+ if (TextUtils.isEmpty(url)) {
+ return;
+ }
+
+ // TODO: NotificationsWebViewActivity will fail without a current blog
+ if (openUrlType == OpenUrlType.INTERNAL && WordPress.getCurrentBlog() != null) {
+ NotificationsWebViewActivity.openUrl(context, url);
+ } else {
+ try {
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
+ context.startActivity(intent);
+ } catch (ActivityNotFoundException e) {
+ ToastUtils.showToast(context, context.getString(R.string.reader_toast_err_url_intent, url), ToastUtils.Duration.LONG);
+ }
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderAnim.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderAnim.java
new file mode 100644
index 000000000..b807f8a28
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderAnim.java
@@ -0,0 +1,197 @@
+package org.wordpress.android.ui.reader;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.animation.ValueAnimator;
+import android.content.Context;
+import android.view.View;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+import android.view.animation.LinearInterpolator;
+import android.widget.ListView;
+
+public class ReaderAnim {
+
+ public static enum Duration {
+ SHORT,
+ MEDIUM,
+ LONG;
+
+ private long toMillis(Context context) {
+ switch (this) {
+ case LONG:
+ return context.getResources().getInteger(android.R.integer.config_longAnimTime);
+ case MEDIUM:
+ return context.getResources().getInteger(android.R.integer.config_mediumAnimTime);
+ default:
+ return context.getResources().getInteger(android.R.integer.config_shortAnimTime);
+ }
+ }
+ }
+
+ private static ObjectAnimator getFadeInAnim(final View target, Duration duration) {
+ ObjectAnimator fadeIn = ObjectAnimator.ofFloat(target, View.ALPHA, 0.0f, 1.0f);
+ fadeIn.setDuration(duration.toMillis(target.getContext()));
+ fadeIn.setInterpolator(new LinearInterpolator());
+ fadeIn.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animation) {
+ target.setVisibility(View.VISIBLE);
+ }
+ });
+ return fadeIn;
+ }
+
+ private static ObjectAnimator getFadeOutAnim(final View target, Duration duration) {
+ ObjectAnimator fadeOut = ObjectAnimator.ofFloat(target, View.ALPHA, 1.0f, 0.0f);
+ fadeOut.setDuration(duration.toMillis(target.getContext()));
+ fadeOut.setInterpolator(new LinearInterpolator());
+ fadeOut.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ target.setVisibility(View.GONE);
+ }
+ });
+ return fadeOut;
+ }
+
+ public static void fadeIn(final View target, Duration duration) {
+ if (target == null || duration == null) {
+ return;
+ }
+ getFadeInAnim(target, duration).start();
+ }
+
+ public static void fadeOut(final View target, Duration duration) {
+ if (target == null || duration == null) {
+ return;
+ }
+ getFadeOutAnim(target, duration).start();
+ }
+
+ public static void fadeInFadeOut(final View target, Duration duration) {
+ if (target == null || duration == null) {
+ return;
+ }
+
+ ObjectAnimator fadeIn = getFadeInAnim(target, duration);
+ ObjectAnimator fadeOut = getFadeOutAnim(target, duration);
+
+ // keep view visible for passed duration before fading it out
+ fadeOut.setStartDelay(duration.toMillis(target.getContext()));
+
+ AnimatorSet set = new AnimatorSet();
+ set.play(fadeOut).after(fadeIn);
+ set.start();
+ }
+
+ public static void scaleInScaleOut(final View target, Duration duration) {
+ if (target == null || duration == null) {
+ return;
+ }
+
+ ObjectAnimator animX = ObjectAnimator.ofFloat(target, View.SCALE_X, 0f, 1f);
+ animX.setRepeatMode(ValueAnimator.REVERSE);
+ animX.setRepeatCount(1);
+ ObjectAnimator animY = ObjectAnimator.ofFloat(target, View.SCALE_Y, 0f, 1f);
+ animY.setRepeatMode(ValueAnimator.REVERSE);
+ animY.setRepeatCount(1);
+
+ AnimatorSet set = new AnimatorSet();
+ set.play(animX).with(animY);
+ set.setDuration(duration.toMillis(target.getContext()));
+ set.setInterpolator(new AccelerateDecelerateInterpolator());
+ set.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animation) {
+ super.onAnimationStart(animation);
+ target.setVisibility(View.VISIBLE);
+ }
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ super.onAnimationEnd(animation);
+ target.setVisibility(View.GONE);
+ }
+ });
+ set.start();
+ }
+
+ /*
+ * animation when user taps a like/follow/reblog button
+ */
+ private static enum ReaderButton { LIKE, REBLOG, FOLLOW }
+ public static void animateLikeButton(final View target) {
+ animateButton(target, ReaderButton.LIKE);
+ }
+ public static void animateReblogButton(final View target) {
+ animateButton(target, ReaderButton.REBLOG);
+ }
+ public static void animateFollowButton(final View target) {
+ animateButton(target, ReaderButton.FOLLOW);
+ }
+ private static void animateButton(final View target, ReaderButton button) {
+ if (target == null) {
+ return;
+ }
+
+ // follow button uses different scaling and doesn't rotate
+ float startScale = 1f;
+ float endScale = (button == ReaderButton.FOLLOW ? 0.75f : 1.5f);
+ boolean rotate = (button != ReaderButton.FOLLOW);
+
+ ObjectAnimator animX = ObjectAnimator.ofFloat(target, View.SCALE_X, startScale, endScale);
+ animX.setRepeatMode(ValueAnimator.REVERSE);
+ animX.setRepeatCount(1);
+
+ ObjectAnimator animY = ObjectAnimator.ofFloat(target, View.SCALE_Y, startScale, endScale);
+ animY.setRepeatMode(ValueAnimator.REVERSE);
+ animY.setRepeatCount(1);
+
+ AnimatorSet set = new AnimatorSet();
+
+ if (rotate) {
+ ObjectAnimator animRotate = ObjectAnimator.ofFloat(target, View.ROTATION, 0f, 60f);
+ animRotate.setRepeatMode(ValueAnimator.REVERSE);
+ animRotate.setRepeatCount(1);
+ set.play(animX).with(animY).with(animRotate);
+ } else {
+ set.play(animX).with(animY);
+ }
+
+ long durationMillis = Duration.SHORT.toMillis(target.getContext());
+ set.setDuration(durationMillis);
+ set.setInterpolator(new AccelerateDecelerateInterpolator());
+
+ set.start();
+ }
+
+ public static void animateListItem(ListView listView,
+ int positionAbsolute,
+ Animation.AnimationListener listener,
+ int animResId) {
+ if (listView == null) {
+ return;
+ }
+
+ // passed value is the absolute position of this item, convert to relative or else we'll
+ // remove the wrong item if list is scrolled
+ int firstVisible = listView.getFirstVisiblePosition();
+ int positionRelative = positionAbsolute - firstVisible;
+
+ View listItem = listView.getChildAt(positionRelative);
+ if (listItem == null) {
+ return;
+ }
+
+ Animation animation = AnimationUtils.loadAnimation(listView.getContext(), animResId);
+ if (listener != null) {
+ animation.setAnimationListener(listener);
+ }
+
+ listItem.startAnimation(animation);
+ }
+
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderBlogFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderBlogFragment.java
new file mode 100644
index 000000000..6cb5192fb
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderBlogFragment.java
@@ -0,0 +1,177 @@
+package org.wordpress.android.ui.reader;
+
+import android.app.Fragment;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ListView;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.ui.prefs.UserPrefs;
+import org.wordpress.android.ui.reader.adapters.ReaderBlogAdapter;
+import org.wordpress.android.ui.reader.adapters.ReaderBlogAdapter.BlogFollowChangeListener;
+import org.wordpress.android.ui.reader.adapters.ReaderBlogAdapter.ReaderBlogType;
+import org.wordpress.android.util.AppLog;
+
+/*
+ * fragment hosted by ReaderSubsActivity which shows either recommended blogs and followed blogs
+ */
+public class ReaderBlogFragment extends Fragment
+ implements BlogFollowChangeListener {
+ private ListView mListView;
+ private ReaderBlogAdapter mAdapter;
+ private ReaderBlogType mBlogType;
+ private boolean mWasPaused;
+ private static final String ARG_BLOG_TYPE = "blog_type";
+
+ static ReaderBlogFragment newInstance(ReaderBlogType blogType) {
+ AppLog.d(AppLog.T.READER, "reader blog fragment > newInstance");
+ Bundle args = new Bundle();
+ args.putSerializable(ARG_BLOG_TYPE, blogType);
+ ReaderBlogFragment fragment = new ReaderBlogFragment();
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public void setArguments(Bundle args) {
+ super.setArguments(args);
+ restoreState(args);
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (savedInstanceState != null) {
+ AppLog.d(AppLog.T.READER, "reader blog fragment > restoring instance state");
+ restoreState(savedInstanceState);
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ final View view = inflater.inflate(R.layout.reader_fragment_list, container, false);
+ mListView = (ListView) view.findViewById(android.R.id.list);
+
+ final TextView emptyView = (TextView)view.findViewById(R.id.text_empty);
+ switch (getBlogType()) {
+ case RECOMMENDED:
+ emptyView.setText(R.string.reader_empty_recommended_blogs);
+ // add footer to load more recommendations
+ ViewGroup footerLoadMore = (ViewGroup) inflater.inflate(R.layout.reader_footer_recommendations, mListView, false);
+ mListView.addFooterView(footerLoadMore);
+ footerLoadMore.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ loadMoreRecommendations();
+ }
+ });
+ break;
+
+ case FOLLOWED:
+ emptyView.setText(R.string.reader_empty_followed_blogs_title);
+ break;
+ }
+
+ mListView.setEmptyView(emptyView);
+
+ return view;
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ mListView.setAdapter(getBlogAdapter());
+ refresh();
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putSerializable(ARG_BLOG_TYPE, getBlogType());
+ outState.putBoolean(ReaderConstants.KEY_WAS_PAUSED, mWasPaused);
+ }
+
+ private void restoreState(Bundle args) {
+ if (args != null) {
+ mWasPaused = args.getBoolean(ReaderConstants.KEY_WAS_PAUSED);
+ if (args.containsKey(ARG_BLOG_TYPE)) {
+ mBlogType = (ReaderBlogType) args.getSerializable(ARG_BLOG_TYPE);
+ }
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ mWasPaused = true;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ // if the fragment is resuming from a paused state, reload the adapter to make sure
+ // the follow status of all blogs is accurate - this is necessary in case the user
+ // returned from an activity where the follow status may have been changed
+ if (mWasPaused) {
+ mWasPaused = false;
+ if (hasBlogAdapter()) {
+ getBlogAdapter().checkFollowStatus();
+ }
+ }
+ }
+
+ /*
+ * user tapped to view more recommended blogs - increase the offset when requesting
+ * recommendations from local db and refresh the adapter
+ */
+ private void loadMoreRecommendations() {
+ if (!hasBlogAdapter() || getBlogAdapter().isEmpty()) {
+ return;
+ }
+
+ int currentOffset = UserPrefs.getReaderRecommendedBlogOffset();
+ int newOffset = currentOffset + ReaderConstants.READER_MAX_RECOMMENDED_TO_DISPLAY;
+
+ // start over if we've reached the max
+ if (newOffset >= ReaderConstants.READER_MAX_RECOMMENDED_TO_REQUEST) {
+ newOffset = 0;
+ }
+
+ UserPrefs.setReaderRecommendedBlogOffset(newOffset);
+ refresh();
+ }
+
+ void refresh() {
+ if (hasBlogAdapter()) {
+ getBlogAdapter().refresh();
+ }
+ }
+
+ private boolean hasBlogAdapter() {
+ return (mAdapter != null);
+ }
+
+ private ReaderBlogAdapter getBlogAdapter() {
+ if (mAdapter == null) {
+ mAdapter = new ReaderBlogAdapter(getActivity(), getBlogType(), this);
+ }
+ return mAdapter;
+ }
+
+ public ReaderBlogType getBlogType() {
+ return mBlogType;
+ }
+
+ /*
+ * called from the adapter when a blog is followed or unfollowed - note that the network
+ * request has already occurred by the time this is called
+ */
+ public void onFollowBlogChanged() {
+ if (getActivity() instanceof BlogFollowChangeListener) {
+ ((BlogFollowChangeListener) getActivity()).onFollowBlogChanged();
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderBlogInfoView.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderBlogInfoView.java
new file mode 100644
index 000000000..0a20719d1
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderBlogInfoView.java
@@ -0,0 +1,262 @@
+package org.wordpress.android.ui.reader;
+
+import android.content.Context;
+import android.graphics.Matrix;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.ReaderBlogTable;
+import org.wordpress.android.models.ReaderBlog;
+import org.wordpress.android.ui.reader.actions.ReaderActions;
+import org.wordpress.android.ui.reader.actions.ReaderBlogActions;
+import org.wordpress.android.util.FormatUtils;
+import org.wordpress.android.util.UrlUtils;
+import org.wordpress.android.widgets.WPNetworkImageView;
+
+/*
+ * header view showing blog name, description, follower count, follow button, and
+ * mshot of the blog - designed specifically for use in ReaderPostListFragment
+ * when previewing posts in a blog (blog preview)
+ */
+class ReaderBlogInfoView extends FrameLayout {
+ public interface BlogInfoListener {
+ void onBlogInfoLoaded();
+ void onBlogInfoFailed();
+ }
+ private BlogInfoListener mBlogInfoListener;
+
+ private final WPNetworkImageView mImageMshot;
+ private final ViewGroup mInfoContainerView;
+ private final ProgressBar mMshotProgress;
+
+ private final int mMshotWidth;
+ private final int mMshotDefaultHeight;
+
+ private float mCurrentMshotScale = 1.0f;
+ private ReaderBlog mBlogInfo;
+
+ public ReaderBlogInfoView(Context context){
+ super(context);
+
+ LayoutInflater inflater = LayoutInflater.from(context);
+ View view = inflater.inflate(R.layout.reader_blog_info_view, this, true);
+ view.setId(R.id.layout_blog_info_view);
+
+ mMshotDefaultHeight = context.getResources().getDimensionPixelSize(R.dimen.reader_mshot_image_height);
+ mMshotWidth = (int) (mMshotDefaultHeight * 1.33f);
+
+ mImageMshot = (WPNetworkImageView) view.findViewById(R.id.image_mshot);
+ mInfoContainerView = (ViewGroup) view.findViewById(R.id.layout_bloginfo_container);
+
+ // position the progressBar halfway down the mshot - done this way to avoid it
+ // moving when the mshot container is resized
+ mMshotProgress = (ProgressBar) view.findViewById(R.id.progress_mshot);
+ mMshotProgress.setTranslationY(mMshotDefaultHeight / 2);
+ }
+
+ /*
+ * shows the blogInfo from local db (if available) then request latest blogInfo from server
+ */
+ public void loadBlogInfo(long blogId, String blogUrl, BlogInfoListener blogInfoListener) {
+ mBlogInfoListener = blogInfoListener;
+ showBlogInfo(ReaderBlogTable.getBlogInfo(blogId, blogUrl));
+ requestBlogInfo(blogId, blogUrl);
+ }
+
+ /*
+ * show blog header with info from passed blog filled in
+ */
+ private void showBlogInfo(final ReaderBlog blogInfo) {
+ final ViewGroup layoutInner = (ViewGroup) findViewById(R.id.layout_bloginfo_container_inner);
+
+ if (blogInfo == null) {
+ layoutInner.setVisibility(View.INVISIBLE);
+ return;
+ }
+
+ // do nothing if blogInfo hasn't changed
+ if (mBlogInfo != null && mBlogInfo.isSameAs(blogInfo)) {
+ return;
+ }
+
+ mBlogInfo = blogInfo;
+ layoutInner.setVisibility(View.VISIBLE);
+
+ final TextView txtBlogName = (TextView) findViewById(R.id.text_blog_name);
+ final TextView txtDescription = (TextView) findViewById(R.id.text_blog_description);
+ final TextView txtFollowCnt = (TextView) findViewById(R.id.text_follow_count);
+ final TextView txtFollowBtn = (TextView) findViewById(R.id.text_follow_blog);
+
+ if (blogInfo.hasUrl()) {
+ // clicking the blog name or mshot shows the blog in the browser
+ View.OnClickListener urlClickListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ ReaderActivityLauncher.openUrl(getContext(), blogInfo.getUrl());
+ }
+ };
+ txtBlogName.setOnClickListener(urlClickListener);
+ mImageMshot.setOnClickListener(urlClickListener);
+ }
+
+ if (blogInfo.hasName()) {
+ txtBlogName.setText(blogInfo.getName());
+ }
+
+ if (blogInfo.hasDescription()) {
+ txtDescription.setText(blogInfo.getDescription());
+ txtDescription.setVisibility(View.VISIBLE);
+ } else if (blogInfo.hasUrl()) {
+ txtDescription.setText(UrlUtils.getDomainFromUrl(blogInfo.getUrl()));
+ txtDescription.setVisibility(View.VISIBLE);
+ } else {
+ txtDescription.setVisibility(View.GONE);
+ }
+
+ // only show the follower count if there are subscribers
+ if (blogInfo.numSubscribers > 0) {
+ String numFollowers = getResources().getString(R.string.reader_label_followers,
+ FormatUtils.formatInt(blogInfo.numSubscribers));
+ txtFollowCnt.setText(numFollowers);
+ txtFollowCnt.setVisibility(View.VISIBLE);
+ } else {
+ txtFollowCnt.setVisibility(View.INVISIBLE);
+ }
+
+ ReaderUtils.showFollowStatus(txtFollowBtn, blogInfo.isFollowing);
+ txtFollowBtn.setVisibility(View.VISIBLE);
+ txtFollowBtn.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ toggleBlogFollowStatus(txtFollowBtn, blogInfo);
+ }
+ });
+
+ // show the mshot if it hasn't already been shown
+ if (mImageMshot.getUrl() == null) {
+ loadMshotImage(blogInfo);
+ }
+
+ if (mBlogInfoListener != null) {
+ mBlogInfoListener.onBlogInfoLoaded();
+ }
+ }
+
+ public boolean isEmpty() {
+ return mBlogInfo == null;
+ }
+
+ /*
+ * request latest info for this blog
+ */
+ private void requestBlogInfo(long blogId, String blogUrl) {
+ ReaderActions.UpdateBlogInfoListener listener = new ReaderActions.UpdateBlogInfoListener() {
+ @Override
+ public void onResult(ReaderBlog blogInfo) {
+ if (blogInfo != null) {
+ showBlogInfo(blogInfo);
+ } else {
+ hideProgress();
+ if (isEmpty() && mBlogInfoListener != null) {
+ mBlogInfoListener.onBlogInfoFailed();
+ }
+
+ }
+ }
+ };
+ ReaderBlogActions.updateBlogInfo(blogId, blogUrl, listener);
+ }
+
+ private void toggleBlogFollowStatus(TextView txtFollow, ReaderBlog blogInfo) {
+ if (blogInfo == null || txtFollow == null) {
+ return;
+ }
+
+ ReaderAnim.animateFollowButton(txtFollow);
+
+ boolean isAskingToFollow = !blogInfo.isFollowing;
+ if (ReaderBlogActions.performFollowAction(blogInfo.blogId, blogInfo.getUrl(), isAskingToFollow, null)) {
+ ReaderUtils.showFollowStatus(txtFollow, isAskingToFollow);
+ }
+ }
+
+ private void loadMshotImage(final ReaderBlog blogInfo) {
+ if (blogInfo == null || !blogInfo.hasUrl()) {
+ hideProgress();
+ return;
+ }
+
+ // mshot for private blogs will just be a login screen, so show a lock icon
+ // instead of requesting the mshot
+ if (blogInfo.isPrivate) {
+ hideProgress();
+ mImageMshot.setScaleType(ImageView.ScaleType.CENTER);
+ mImageMshot.setImageResource(R.drawable.ic_action_secure);
+ return;
+ }
+
+ WPNetworkImageView.ImageListener imageListener = new WPNetworkImageView.ImageListener() {
+ @Override
+ public void onImageLoaded(boolean succeeded) {
+ hideProgress();
+ }
+ };
+ final String imageUrl = blogInfo.getMshotsUrl(mMshotWidth);
+ mImageMshot.setImageUrl(imageUrl, WPNetworkImageView.ImageType.MSHOT, imageListener);
+ }
+
+ /*
+ * hide the progress bar that appears on the mshot - note that it's set to visible at
+ * design time, so it'll stay visible until this is called
+ */
+ private void hideProgress() {
+ mMshotProgress.setVisibility(View.GONE);
+ }
+
+ /*
+ * scale the mshot image based on the scroll position of ReaderPostListFragment's listView
+ */
+ public void scaleMshotImageBasedOnScrollPos(int scrollPos) {
+ float scale = Math.max(0f, 0.9f + (-scrollPos * 0.0025f));
+ if (scale != mCurrentMshotScale) {
+ float centerX = mMshotWidth * 0.5f;
+ float centerY = mMshotDefaultHeight * 0.5f;
+ Matrix matrix = new Matrix();
+ matrix.setScale(scale, scale, centerX, centerY);
+ mImageMshot.setImageMatrix(matrix);
+ mCurrentMshotScale = scale;
+ }
+ }
+
+ /*
+ * sets the top of the container view holding the info (ie: everything except the mshot)
+ */
+ public void moveInfoContainer(int top) {
+ if (mInfoContainerView.getTranslationY() != top) {
+ mInfoContainerView.setTranslationY(top);
+
+ // force the container to match the bottom of the info container to
+ // prevent the bottom of the mshot from appearing below the info
+ int infoBottom = top + mInfoContainerView.getHeight();
+ ViewGroup.LayoutParams params = this.getLayoutParams();
+ if (params.height != infoBottom) {
+ params.height = infoBottom;
+ requestLayout();
+ }
+ }
+ }
+
+ public int getInfoContainerHeight() {
+ return mInfoContainerView.getHeight();
+ }
+
+ public int getMshotHeight() {
+ return mMshotDefaultHeight;
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderConstants.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderConstants.java
new file mode 100644
index 000000000..235940a40
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderConstants.java
@@ -0,0 +1,30 @@
+package org.wordpress.android.ui.reader;
+
+public class ReaderConstants {
+ public static final int READER_MAX_POSTS_TO_REQUEST = 20; // max # posts to request when updating posts
+ public static final int READER_MAX_POSTS_TO_DISPLAY = 200; // max # posts to display
+ public static final int READER_MAX_COMMENTS_TO_REQUEST = 20; // max # comments to request when updating comments
+ public static final int READER_MAX_USERS_TO_DISPLAY = 500; // max # users to show in ReaderUserListActivity
+ public static final long READER_AUTO_UPDATE_DELAY_MINUTES = 10; // 10 minute delay between automatic updates
+
+ public static final int READER_MAX_RECOMMENDED_TO_REQUEST = 40; // max # of recommended blogs to request
+ public static final int READER_MAX_RECOMMENDED_TO_DISPLAY = 5; // max # of recommended blogs to display
+
+ // intent IDs
+ static final int INTENT_READER_SUBS = 1000;
+ static final int INTENT_READER_REBLOG = 1001;
+
+ // intent arguments / keys
+ static final String ARG_TAG = "tag";
+ static final String ARG_BLOG_ID = "blog_id";
+ static final String ARG_BLOG_URL = "blog_url";
+ static final String ARG_POST_ID = "post_id";
+ static final String ARG_IMAGE_URL = "image_url";
+ static final String ARG_IS_PRIVATE = "is_private";
+ static final String ARG_POST_LIST_TYPE = "post_list_type";
+
+ static final String KEY_ALREADY_UPDATED = "already_updated";
+ static final String KEY_ALREADY_REQUESTED = "already_requested";
+ static final String KEY_LIST_STATE = "list_state";
+ static final String KEY_WAS_PAUSED = "was_paused";
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPhotoViewerActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPhotoViewerActivity.java
new file mode 100644
index 000000000..81cd0c6cc
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPhotoViewerActivity.java
@@ -0,0 +1,113 @@
+package org.wordpress.android.ui.reader;
+
+import android.app.Activity;
+import android.graphics.Point;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+
+import org.wordpress.android.R;
+import org.wordpress.android.util.DisplayUtils;
+import org.wordpress.android.util.PhotonUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.widgets.WPNetworkImageView;
+import org.wordpress.android.widgets.WPNetworkImageView.ImageListener;
+import org.wordpress.android.widgets.WPNetworkImageView.ImageType;
+
+import uk.co.senab.photoview.PhotoViewAttacher;
+
+/**
+ * Full-screen photo viewer
+ */
+public class ReaderPhotoViewerActivity extends Activity {
+
+ private String mImageUrl;
+ private boolean mIsPrivate;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.reader_activity_photo_viewer);
+
+ if (savedInstanceState != null) {
+ mImageUrl = savedInstanceState.getString(ReaderConstants.ARG_IMAGE_URL);
+ mIsPrivate = savedInstanceState.getBoolean(ReaderConstants.ARG_IS_PRIVATE);
+ } else if (getIntent() != null) {
+ mImageUrl = getIntent().getStringExtra(ReaderConstants.ARG_IMAGE_URL);
+ mIsPrivate = getIntent().getBooleanExtra(ReaderConstants.ARG_IS_PRIVATE, false);
+ }
+
+ loadImage(mImageUrl);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putString(ReaderConstants.ARG_IMAGE_URL, mImageUrl);
+ outState.putBoolean(ReaderConstants.ARG_IS_PRIVATE, mIsPrivate);
+ }
+
+ private void loadImage(String imageUrl) {
+ if (TextUtils.isEmpty(imageUrl)) {
+ handleImageLoadFailure();
+ return;
+ }
+
+ Point pt = DisplayUtils.getDisplayPixelSize(this);
+ int maxWidth = Math.max(pt.x, pt.y);
+ if (mIsPrivate) {
+ imageUrl = ReaderUtils.getPrivateImageForDisplay(imageUrl, maxWidth, 0);
+ } else {
+ imageUrl = PhotonUtils.getPhotonImageUrl(imageUrl, maxWidth, 0);
+ }
+
+ final ProgressBar progress = (ProgressBar) findViewById(R.id.progress);
+ progress.setVisibility(View.VISIBLE);
+
+ final WPNetworkImageView imageView = (WPNetworkImageView) findViewById(R.id.image_photo);
+ imageView.setImageUrl(imageUrl, ImageType.PHOTO_FULL, new ImageListener() {
+ @Override
+ public void onImageLoaded(boolean succeeded) {
+ progress.setVisibility(View.GONE);
+ if (succeeded) {
+ createAttacher(imageView);
+ } else {
+ handleImageLoadFailure();
+ }
+ }
+ });
+ }
+
+ private void createAttacher(ImageView imageView) {
+ PhotoViewAttacher attacher = new PhotoViewAttacher(imageView);
+
+ // tapping outside the photo closes the activity
+ attacher.setOnViewTapListener(new PhotoViewAttacher.OnViewTapListener() {
+ @Override
+ public void onViewTap(View view, float v, float v2) {
+ finish();
+ }
+ });
+ attacher.setOnPhotoTapListener(new PhotoViewAttacher.OnPhotoTapListener() {
+ @Override
+ public void onPhotoTap(View view, float v, float v2) {
+ // do nothing - photo tap listener must be assigned or else tapping the photo
+ // will fire the onViewTapListener() above
+ }
+ });
+ }
+
+ private void handleImageLoadFailure() {
+ ToastUtils.showToast(this, R.string.reader_toast_err_view_image, ToastUtils.Duration.LONG);
+ finish();
+ }
+
+ @Override
+ public void finish() {
+ super.finish();
+ overridePendingTransition(0, 0);
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.java
new file mode 100644
index 000000000..3d93a2654
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.java
@@ -0,0 +1,1490 @@
+package org.wordpress.android.ui.reader;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.app.Fragment;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Parcelable;
+import android.text.TextUtils;
+import android.view.KeyEvent;
+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.view.animation.Animation;
+import android.view.animation.TranslateAnimation;
+import android.view.inputmethod.EditorInfo;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.ReaderCommentTable;
+import org.wordpress.android.datasets.ReaderLikeTable;
+import org.wordpress.android.datasets.ReaderPostTable;
+import org.wordpress.android.datasets.ReaderUserTable;
+import org.wordpress.android.models.ReaderComment;
+import org.wordpress.android.models.ReaderPost;
+import org.wordpress.android.models.ReaderUserIdList;
+import org.wordpress.android.ui.WPActionBarActivity;
+import org.wordpress.android.ui.reader.ReaderActivityLauncher.OpenUrlType;
+import org.wordpress.android.ui.reader.ReaderTypes.ReaderPostListType;
+import org.wordpress.android.ui.reader.ReaderWebView.ReaderCustomViewListener;
+import org.wordpress.android.ui.reader.ReaderWebView.ReaderWebViewUrlClickListener;
+import org.wordpress.android.ui.reader.actions.ReaderActions;
+import org.wordpress.android.ui.reader.actions.ReaderBlogActions;
+import org.wordpress.android.ui.reader.actions.ReaderCommentActions;
+import org.wordpress.android.ui.reader.actions.ReaderPostActions;
+import org.wordpress.android.ui.reader.adapters.ReaderCommentAdapter;
+import org.wordpress.android.util.AniUtils;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.DateTimeUtils;
+import org.wordpress.android.util.DisplayUtils;
+import org.wordpress.android.util.EditTextUtils;
+import org.wordpress.android.util.HtmlUtils;
+import org.wordpress.android.util.PhotonUtils;
+import org.wordpress.android.util.ReaderVideoUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.UrlUtils;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+import org.wordpress.android.widgets.WPListView;
+import org.wordpress.android.widgets.WPNetworkImageView;
+
+import java.util.ArrayList;
+
+public class ReaderPostDetailFragment extends Fragment
+ implements WPListView.OnScrollDirectionListener,
+ ReaderCustomViewListener,
+ ReaderWebViewUrlClickListener {
+
+ private static final String KEY_SHOW_COMMENT_BOX = "show_comment_box";
+ private static final String KEY_REPLY_TO_COMMENT_ID = "reply_to_comment_id";
+
+ private long mPostId;
+ private long mBlogId;
+ private ReaderPost mPost;
+
+ private ReaderPostListType mPostListType;
+
+ private ViewGroup mLayoutIcons;
+ private ViewGroup mLayoutLikes;
+ private WPListView mListView;
+ private ViewGroup mCommentFooter;
+ private ProgressBar mProgressFooter;
+ private ReaderWebView mReaderWebView;
+
+ private boolean mIsAddCommentBoxShowing;
+ private long mReplyToCommentId = 0;
+ private boolean mHasAlreadyUpdatedPost;
+ private boolean mHasAlreadyRequestedPost;
+ private boolean mIsUpdatingComments;
+
+ private Parcelable mListState;
+ private final Handler mHandler = new Handler();
+
+ private ReaderUtils.FullScreenListener mFullScreenListener;
+
+ public static ReaderPostDetailFragment newInstance(long blogId, long postId) {
+ return newInstance(blogId, postId, null);
+ }
+
+ public static ReaderPostDetailFragment newInstance(long blogId,
+ long postId,
+ ReaderPostListType postListType) {
+ AppLog.d(T.READER, "reader post detail > newInstance");
+ AnalyticsTracker.track(AnalyticsTracker.Stat.READER_OPENED_ARTICLE);
+
+ Bundle args = new Bundle();
+ args.putLong(ReaderConstants.ARG_BLOG_ID, blogId);
+ args.putLong(ReaderConstants.ARG_POST_ID, postId);
+ if (postListType != null) {
+ args.putSerializable(ReaderConstants.ARG_POST_LIST_TYPE, postListType);
+ }
+
+ ReaderPostDetailFragment fragment = new ReaderPostDetailFragment();
+ fragment.setArguments(args);
+
+ return fragment;
+ }
+
+ /*
+ * adapter containing comments for this post
+ */
+ private ReaderCommentAdapter mAdapter;
+
+ private ReaderCommentAdapter getCommentAdapter() {
+ if (mAdapter == null) {
+ ReaderActions.DataLoadedListener dataLoadedListener = new ReaderActions.DataLoadedListener() {
+ @Override
+ public void onDataLoaded(boolean isEmpty) {
+ if (hasActivity()) {
+ // show footer below comments when comments exist
+ mCommentFooter.setVisibility(isEmpty ? View.GONE : View.VISIBLE);
+ // restore listView state (scroll position) if it was saved during rotation
+ if (mListState != null) {
+ if (!isEmpty) {
+ getListView().onRestoreInstanceState(mListState);
+ }
+ mListState = null;
+ }
+ }
+ }
+ };
+
+ // adapter calls this when user taps reply icon
+ ReaderCommentAdapter.RequestReplyListener replyListener = new ReaderCommentAdapter.RequestReplyListener() {
+ @Override
+ public void onRequestReply(long commentId) {
+ if (!mIsAddCommentBoxShowing) {
+ showAddCommentBox(commentId);
+ } else {
+ hideAddCommentBox();
+ }
+ }
+ };
+
+ // adapter uses this to request more comments from server when it reaches the end and
+ // detects that more comments exist on the server than are stored locally
+ ReaderActions.DataRequestedListener dataRequestedListener = new ReaderActions.DataRequestedListener() {
+ @Override
+ public void onRequestData() {
+ if (!mIsUpdatingComments) {
+ AppLog.i(T.READER, "reader post detail > requesting newer comments");
+ updateComments();
+ }
+ }
+ };
+ mAdapter = new ReaderCommentAdapter(getActivity(), mPost, replyListener, dataLoadedListener, dataRequestedListener);
+ }
+ return mAdapter;
+ }
+
+ private boolean isCommentAdapterEmpty() {
+ return (mAdapter == null || mAdapter.isEmpty());
+ }
+
+ @Override
+ public void setArguments(Bundle args) {
+ super.setArguments(args);
+ if (args != null) {
+ mBlogId = args.getLong(ReaderConstants.ARG_BLOG_ID);
+ mPostId = args.getLong(ReaderConstants.ARG_POST_ID);
+ if (args.containsKey(ReaderConstants.ARG_POST_LIST_TYPE)) {
+ mPostListType = (ReaderPostListType) args.getSerializable(ReaderConstants.ARG_POST_LIST_TYPE);
+ }
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ final View view = inflater.inflate(R.layout.reader_fragment_post_detail, container, false);
+
+ // locate & init listView
+ mListView = (WPListView) view.findViewById(android.R.id.list);
+ if (isFullScreenSupported()) {
+ mListView.setOnScrollDirectionListener(this);
+ ReaderUtils.addListViewHeader(mListView, DisplayUtils.getActionBarHeight(container.getContext()));
+ }
+
+ // add post detail as header to listView - must be done before setting adapter
+ ViewGroup headerDetail = (ViewGroup) inflater.inflate(R.layout.reader_listitem_post_detail, mListView, false);
+ mListView.addHeaderView(headerDetail, null, false);
+
+ // add listView footer containing progress bar - footer appears whenever there are comments,
+ // progress bar appears when loading new comments
+ mCommentFooter = (ViewGroup) inflater.inflate(R.layout.reader_footer_progress, mListView, false);
+ mCommentFooter.setVisibility(View.GONE);
+ mCommentFooter.setBackgroundColor(getResources().getColor(R.color.grey_extra_light));
+ mProgressFooter = (ProgressBar) mCommentFooter.findViewById(R.id.progress_footer);
+ mProgressFooter.setVisibility(View.INVISIBLE);
+ mListView.addFooterView(mCommentFooter);
+
+ mLayoutIcons = (ViewGroup) view.findViewById(R.id.layout_actions);
+ mLayoutLikes = (ViewGroup) view.findViewById(R.id.layout_likes);
+
+ // setup the ReaderWebView
+ mReaderWebView = (ReaderWebView) view.findViewById(R.id.webView);
+ mReaderWebView.setCustomViewListener(this);
+ mReaderWebView.setUrlClickListener(this);
+
+ // hide these views until the post is loaded
+ mListView.setVisibility(View.INVISIBLE);
+ mReaderWebView.setVisibility(View.INVISIBLE);
+ mLayoutIcons.setVisibility(View.INVISIBLE);
+
+ return view;
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (mReaderWebView != null) {
+ mReaderWebView.destroy();
+ }
+ }
+
+ private WPListView getListView() {
+ return mListView;
+ }
+
+ @Override
+ public void onScrollUp() {
+ // return from full screen when scrolling up unless user is typing a comment
+ if (isFullScreen() && !mIsAddCommentBoxShowing) {
+ setIsFullScreen(false);
+ }
+ }
+
+ @Override
+ public void onScrollDown() {
+ // don't change fullscreen if user is typing a comment
+ if (mIsAddCommentBoxShowing) {
+ return;
+ }
+
+ boolean isFullScreen = isFullScreen();
+ boolean canScrollDown = mListView.canScrollDown();
+ boolean canScrollUp = mListView.canScrollUp();
+
+ if (isFullScreen && !canScrollDown) {
+ // disable full screen once user hits the bottom
+ setIsFullScreen(false);
+ } else if (!isFullScreen && canScrollDown && canScrollUp) {
+ // enable full screen when scrolling down
+ setIsFullScreen(true);
+ }
+ }
+
+ private boolean hasPost() {
+ return (mPost != null);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ super.onCreateOptionsMenu(menu, inflater);
+ menu.clear();
+ inflater.inflate(R.menu.reader_native_detail, menu);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.menu_browse:
+ if (hasPost()) {
+ ReaderActivityLauncher.openUrl(getActivity(), mPost.getUrl(), OpenUrlType.EXTERNAL);
+ }
+ return true;
+ case R.id.menu_share:
+ AnalyticsTracker.track(AnalyticsTracker.Stat.SHARED_ITEM);
+ sharePage();
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ private boolean hasActivity() {
+ return isAdded() && !isRemoving();
+ }
+
+ /*
+ * full-screen mode hides the ActionBar and icon bar
+ */
+ private boolean isFullScreen() {
+ return (mFullScreenListener != null && mFullScreenListener.isFullScreen());
+ }
+
+ private boolean isFullScreenSupported() {
+ return (mFullScreenListener != null && mFullScreenListener.isFullScreenSupported());
+ }
+
+ private void setIsFullScreen(boolean enableFullScreen) {
+ // this tells the host activity to enable/disable fullscreen
+ if (mFullScreenListener != null && mFullScreenListener.onRequestFullScreen(enableFullScreen)) {
+ if (mPost.isWP()) {
+ animateIconBar(!enableFullScreen);
+ }
+ }
+ }
+
+ private ReaderPostListType getPostListType() {
+ return (mPostListType != null ? mPostListType : ReaderTypes.DEFAULT_POST_LIST_TYPE);
+ }
+
+ private boolean isBlogPreview() {
+ return (getPostListType() == ReaderTypes.ReaderPostListType.BLOG_PREVIEW);
+ }
+
+ /*
+ * animate in/out the layout containing the reblog/comment/like icons
+ */
+ private void animateIconBar(boolean isAnimatingIn) {
+ if (isAnimatingIn && mLayoutIcons.getVisibility() == View.VISIBLE) {
+ return;
+ }
+ if (!isAnimatingIn && mLayoutIcons.getVisibility() != View.VISIBLE) {
+ return;
+ }
+
+ final Animation animation;
+ if (isAnimatingIn) {
+ animation = new TranslateAnimation(Animation.RELATIVE_TO_SELF, 0.0f,
+ Animation.RELATIVE_TO_SELF, 0.0f, Animation.RELATIVE_TO_SELF,
+ 1.0f, Animation.RELATIVE_TO_SELF, 0.0f);
+ } else {
+ animation = new TranslateAnimation(Animation.RELATIVE_TO_SELF, 0.0f,
+ Animation.RELATIVE_TO_SELF, 0.0f, Animation.RELATIVE_TO_SELF,
+ 0.0f, Animation.RELATIVE_TO_SELF, 1.0f);
+ }
+
+ animation.setDuration(getResources().getInteger(android.R.integer.config_mediumAnimTime));
+
+ mLayoutIcons.clearAnimation();
+ mLayoutIcons.startAnimation(animation);
+ mLayoutIcons.setVisibility(isAnimatingIn ? View.VISIBLE : View.GONE);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+
+ outState.putLong(ReaderConstants.ARG_BLOG_ID, mBlogId);
+ outState.putLong(ReaderConstants.ARG_POST_ID, mPostId);
+
+ outState.putBoolean(ReaderConstants.KEY_ALREADY_UPDATED, mHasAlreadyUpdatedPost);
+ outState.putBoolean(ReaderConstants.KEY_ALREADY_REQUESTED, mHasAlreadyRequestedPost);
+ outState.putBoolean(KEY_SHOW_COMMENT_BOX, mIsAddCommentBoxShowing);
+ outState.putSerializable(ReaderConstants.ARG_POST_LIST_TYPE, getPostListType());
+
+ if (mIsAddCommentBoxShowing) {
+ outState.putLong(KEY_REPLY_TO_COMMENT_ID, mReplyToCommentId);
+ }
+
+ // retain listView state if a comment has been scrolled to - this enables us to restore
+ // the scroll position after comment data is reloaded
+ if (getListView() != null && getListView().getFirstVisiblePosition() > 0) {
+ mListState = getListView().onSaveInstanceState();
+ outState.putParcelable(ReaderConstants.KEY_LIST_STATE, mListState);
+ } else {
+ mListState = null;
+ }
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ if (activity instanceof ReaderUtils.FullScreenListener) {
+ mFullScreenListener = (ReaderUtils.FullScreenListener) activity;
+ }
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ setHasOptionsMenu(true);
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayShowTitleEnabled(true);
+ actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
+ }
+
+ restoreState(savedInstanceState);
+ }
+
+ private void restoreState(Bundle savedInstanceState) {
+ if (savedInstanceState != null) {
+ mBlogId = savedInstanceState.getLong(ReaderConstants.ARG_BLOG_ID);
+ mPostId = savedInstanceState.getLong(ReaderConstants.ARG_POST_ID);
+ mHasAlreadyUpdatedPost = savedInstanceState.getBoolean(ReaderConstants.KEY_ALREADY_UPDATED);
+ mHasAlreadyRequestedPost = savedInstanceState.getBoolean(ReaderConstants.KEY_ALREADY_REQUESTED);
+ if (savedInstanceState.getBoolean(KEY_SHOW_COMMENT_BOX)) {
+ long replyToCommentId = savedInstanceState.getLong(KEY_REPLY_TO_COMMENT_ID);
+ showAddCommentBox(replyToCommentId);
+ }
+ if (savedInstanceState.containsKey(ReaderConstants.ARG_POST_LIST_TYPE)) {
+ mPostListType = (ReaderPostListType) savedInstanceState.getSerializable(ReaderConstants.ARG_POST_LIST_TYPE);
+ }
+ mListState = savedInstanceState.getParcelable(ReaderConstants.KEY_LIST_STATE);
+ }
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ if (!hasPost()) {
+ showPost();
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+
+ // this ensures embedded videos don't continue to play when the fragment is no longer
+ // active or has been detached
+ pauseWebView();
+ }
+
+ /*
+ * changes the like on the passed post
+ */
+ private void togglePostLike(ReaderPost post, View likeButton) {
+ boolean isSelected = likeButton.isSelected();
+ likeButton.setSelected(!isSelected);
+ ReaderAnim.animateLikeButton(likeButton);
+
+ boolean isAskingToLike = !post.isLikedByCurrentUser;
+
+ if (!ReaderPostActions.performLikeAction(post, isAskingToLike)) {
+ likeButton.setSelected(isSelected);
+ return;
+ }
+
+ // get the post again since it has changed, then refresh to show changes
+ mPost = ReaderPostTable.getPost(mBlogId, mPostId);
+ refreshLikes();
+
+ if (isAskingToLike) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.READER_LIKED_ARTICLE);
+ }
+ }
+
+ /*
+ * change the follow state of the blog the passed post is in
+ */
+ private void togglePostFollowed(ReaderPost post, View followButton) {
+ boolean isSelected = followButton.isSelected();
+ followButton.setSelected(!isSelected);
+ ReaderAnim.animateFollowButton(followButton);
+
+ final boolean isAskingToFollow = !post.isFollowedByCurrentUser;
+ ReaderActions.ActionListener actionListener = new ReaderActions.ActionListener() {
+ @Override
+ public void onActionResult(boolean succeeded) {
+ if (!succeeded && hasActivity()) {
+ int resId = (isAskingToFollow ? R.string.reader_toast_err_follow_blog : R.string.reader_toast_err_unfollow_blog);
+ ToastUtils.showToast(getActivity(), resId);
+ }
+ }
+ };
+ if (!ReaderBlogActions.performFollowAction(post, isAskingToFollow, actionListener)) {
+ followButton.setSelected(isSelected);
+ return;
+ }
+
+ // get the post again, since it has changed
+ mPost = ReaderPostTable.getPost(mBlogId, mPostId);
+
+ // call returns before api completes, but local version of post will have been changed
+ // so refresh to show those changes
+ refreshFollowed();
+ }
+
+ /*
+ * called when user chooses to reblog the post
+ */
+ private void doPostReblog(ImageView imgBtnReblog, ReaderPost post) {
+ if (!hasActivity()) {
+ return;
+ }
+
+ if (post.isRebloggedByCurrentUser) {
+ ToastUtils.showToast(getActivity(), R.string.reader_toast_err_already_reblogged);
+ return;
+ }
+
+ imgBtnReblog.setSelected(true);
+ ReaderAnim.animateReblogButton(imgBtnReblog);
+ ReaderActivityLauncher.showReaderReblogForResult(getActivity(), post, imgBtnReblog);
+ }
+
+ /*
+ * display the standard Android share chooser to share a link to this post
+ */
+ private void sharePage() {
+ if (!hasActivity() || !hasPost())
+ return;
+ Intent intent = new Intent(Intent.ACTION_SEND);
+ intent.setType("text/plain");
+ intent.putExtra(Intent.EXTRA_TEXT, mPost.getUrl());
+ intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.reader_share_subject, getString(R.string.app_name)));
+ try {
+ startActivity(Intent.createChooser(intent, getString(R.string.reader_share_link)));
+ } catch (android.content.ActivityNotFoundException ex) {
+ ToastUtils.showToast(getActivity(), R.string.reader_toast_err_share_intent);
+ }
+ }
+
+ /*
+ * get the latest version of this post so we can show the latest likes/comments
+ */
+ private void updatePost() {
+ if (!hasPost() || !mPost.isWP()) {
+ return;
+ }
+
+ // remember the original like/comment count for this post - note that these values
+ // come from the stored likes/comments and NOT from the like/comment count itself
+ final int origNumLikes = ReaderLikeTable.getNumLikesForPost(mPost);
+ final int origNumReplies = ReaderCommentTable.getNumCommentsForPost(mPost);
+
+ ReaderActions.UpdateResultListener resultListener = new ReaderActions.UpdateResultListener() {
+ @Override
+ public void onUpdateResult(ReaderActions.UpdateResult result) {
+ if (result != ReaderActions.UpdateResult.FAILED) {
+ mPost = ReaderPostTable.getPost(mBlogId, mPostId);
+ if (origNumLikes != mPost.numLikes) {
+ refreshLikes();
+ }
+ if (mPost.numReplies != origNumReplies) {
+ updateComments();
+ }
+ }
+ }
+ };
+ ReaderPostActions.updatePost(mPost, resultListener);
+ }
+
+ /*
+ * request comments for this post
+ */
+ private void updateComments() {
+ if (!hasPost() || !mPost.isWP()) {
+ return;
+ }
+ if (mIsUpdatingComments) {
+ AppLog.w(T.READER, "reader post detail > already updating comments");
+ return;
+ }
+
+ AppLog.d(T.READER, "reader post detail > updateComments");
+ mIsUpdatingComments = true;
+
+ if (!isCommentAdapterEmpty()) {
+ showProgressFooter();
+ }
+
+ ReaderActions.UpdateResultListener resultListener = new ReaderActions.UpdateResultListener() {
+ @Override
+ public void onUpdateResult(ReaderActions.UpdateResult result) {
+ mIsUpdatingComments = false;
+ if (!hasActivity()) {
+ return;
+ }
+ hideProgressFooter();
+ if (result == ReaderActions.UpdateResult.CHANGED) {
+ refreshComments();
+ }
+ }
+ };
+ ReaderCommentActions.updateCommentsForPost(mPost, resultListener);
+ }
+
+ /*
+ * show progress bar at the bottom of the screen - used when getting newer comments
+ */
+ private void showProgressFooter() {
+ if (mProgressFooter != null && mProgressFooter.getVisibility() != View.VISIBLE) {
+ mProgressFooter.setVisibility(View.VISIBLE);
+ }
+ }
+
+ /*
+ * hide the footer progress bar if it's showing
+ */
+ private void hideProgressFooter() {
+ if (mProgressFooter != null && mProgressFooter.getVisibility() == View.VISIBLE) {
+ mProgressFooter.setVisibility(View.INVISIBLE);
+ }
+ }
+
+ /*
+ * refresh adapter so latest comments appear
+ */
+ private void refreshComments() {
+ AppLog.d(T.READER, "reader post detail > refreshComments");
+ getCommentAdapter().refreshComments();
+ }
+
+ /*
+ * show latest likes for this post
+ */
+ private void refreshLikes() {
+ AppLog.d(T.READER, "reader post detail > refreshLikes");
+ if (!hasActivity() || !hasPost() || !mPost.isWP()) {
+ return;
+ }
+
+ new Thread() {
+ @Override
+ public void run() {
+ if (getView() == null) {
+ return;
+ }
+
+ final ImageView imgBtnLike = (ImageView) getView().findViewById(R.id.image_like_btn);
+ final TextView txtLikeCount = (TextView) mLayoutLikes.findViewById(R.id.text_like_count);
+
+ final int marginExtraSmall = getResources().getDimensionPixelSize(R.dimen.margin_extra_small);
+ final int marginLarge = getResources().getDimensionPixelSize(R.dimen.margin_large);
+ final int likeAvatarSize = getResources().getDimensionPixelSize(R.dimen.avatar_sz_small);
+ final int likeAvatarSizeWithMargin = likeAvatarSize + (marginExtraSmall * 2);
+
+ // determine how many avatars will fit the space
+ final int displayWidth = DisplayUtils.getDisplayPixelWidth(getActivity());
+ final int spaceForAvatars = displayWidth - (marginLarge * 2);
+ final int maxAvatars = spaceForAvatars / likeAvatarSizeWithMargin;
+
+ // get avatar URLs of liking users up to the max, sized to fit
+ ReaderUserIdList avatarIds = ReaderLikeTable.getLikesForPost(mPost);
+ final ArrayList<String> avatars = ReaderUserTable.getAvatarUrls(avatarIds, maxAvatars, likeAvatarSize);
+
+ mHandler.post(new Runnable() {
+ public void run() {
+ if (!hasActivity()) {
+ return;
+ }
+
+ imgBtnLike.setSelected(mPost.isLikedByCurrentUser);
+ imgBtnLike.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ togglePostLike(mPost, imgBtnLike);
+ }
+ });
+
+ // nothing more to do if no likes or liking avatars haven't been retrieved yet
+ if (avatars.size() == 0 || mPost.numLikes == 0) {
+ if (mLayoutLikes.getVisibility() != View.GONE) {
+ ReaderAnim.fadeOut(mLayoutLikes, ReaderAnim.Duration.SHORT);
+ }
+ return;
+ }
+
+ // set the like count text
+ if (mPost.isLikedByCurrentUser) {
+ if (mPost.numLikes == 1) {
+ txtLikeCount.setText(R.string.reader_likes_only_you);
+ } else {
+ txtLikeCount.setText(mPost.numLikes == 2 ? getString(R.string.reader_likes_you_and_one) : getString(R.string.reader_likes_you_and_multi, mPost.numLikes - 1));
+ }
+ } else {
+ txtLikeCount.setText(mPost.numLikes == 1 ? getString(R.string.reader_likes_one) : getString(R.string.reader_likes_multi, mPost.numLikes));
+ }
+
+ // clicking likes view shows activity displaying all liking users
+ mLayoutLikes.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ ReaderActivityLauncher.showReaderLikingUsers(getActivity(), mPost);
+ }
+ });
+
+ if (mLayoutLikes.getVisibility() != View.VISIBLE) {
+ ReaderAnim.fadeIn(mLayoutLikes, ReaderAnim.Duration.SHORT);
+ }
+
+ showLikingAvatars(avatars);
+ }
+ });
+ }
+ }.start();
+ }
+
+ /*
+ * used by refreshLikes() to display the liking avatars - called only when there are avatars to
+ * display (never called when there are no likes) - note that the passed list of avatar urls
+ * has already been Photon-ized, so there's no need to do that here
+ */
+ private void showLikingAvatars(final ArrayList<String> avatarUrls) {
+ ViewGroup layoutLikingAvatars = (ViewGroup) mLayoutLikes.findViewById(R.id.layout_liking_avatars);
+ LayoutInflater inflater = getActivity().getLayoutInflater();
+
+ // remove excess existing views
+ int numExistingViews = layoutLikingAvatars.getChildCount();
+ if (numExistingViews > avatarUrls.size()) {
+ int numToRemove = numExistingViews - avatarUrls.size();
+ layoutLikingAvatars.removeViews(numExistingViews - numToRemove, numToRemove);
+ }
+
+ int index = 0;
+ for (String url : avatarUrls) {
+ WPNetworkImageView imgAvatar;
+ // reuse existing view when possible, otherwise inflate a new one
+ if (index < numExistingViews) {
+ imgAvatar = (WPNetworkImageView) layoutLikingAvatars.getChildAt(index);
+ } else {
+ imgAvatar = (WPNetworkImageView) inflater.inflate(R.layout.reader_like_avatar, layoutLikingAvatars, false);
+ layoutLikingAvatars.addView(imgAvatar);
+ }
+ imgAvatar.setImageUrl(url, WPNetworkImageView.ImageType.AVATAR);
+ index++;
+ }
+ }
+
+ /*
+ * show the view enabling adding a comment - triggered when user hits comment icon/count in header
+ * note that this view is hidden at design time, so it will be shown the first time user taps icon.
+ * pass 0 for the replyToCommentId to add a parent-level comment to the post, or pass a real
+ * comment id to reply to a specific comment
+ */
+ private void showAddCommentBox(final long replyToCommentId) {
+ if (!hasActivity())
+ return;
+
+ // skip if it's already showing or if a comment is currently being submitted
+ if (mIsAddCommentBoxShowing || mIsSubmittingComment) {
+ return;
+ }
+
+ final ViewGroup layoutCommentBox = (ViewGroup) getView().findViewById(R.id.layout_comment_box);
+ final EditText editComment = (EditText) layoutCommentBox.findViewById(R.id.edit_comment);
+ final ImageView imgBtnComment = (ImageView) getView().findViewById(R.id.image_comment_btn);
+
+ // disable full-screen when comment box is showing
+ if (isFullScreen()) {
+ setIsFullScreen(false);
+ }
+
+ // different hint depending on whether user is replying to a comment or commenting on the post
+ editComment.setHint(replyToCommentId == 0 ? R.string.reader_hint_comment_on_post : R.string.reader_hint_comment_on_comment);
+
+ imgBtnComment.setSelected(true);
+ AniUtils.flyIn(layoutCommentBox);
+
+ editComment.requestFocus();
+ editComment.setOnEditorActionListener(new TextView.OnEditorActionListener() {
+ @Override
+ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+ if (actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_ACTION_SEND) {
+ submitComment(replyToCommentId);
+ }
+ return false;
+ }
+ });
+
+ // submit comment when image tapped
+ final ImageView imgPostComment = (ImageView) getView().findViewById(R.id.image_post_comment);
+ imgPostComment.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ submitComment(replyToCommentId);
+ }
+ });
+
+ EditTextUtils.showSoftInput(editComment);
+
+ // if user is replying to another comment, highlight the comment being replied to and scroll
+ // it to the top so the user can see which comment they're replying to - note that scrolling
+ // is delayed to give time for listView to reposition due to soft keyboard appearing
+ if (replyToCommentId != 0) {
+ getCommentAdapter().setHighlightCommentId(replyToCommentId, false);
+ getListView().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ scrollToCommentId(replyToCommentId);
+ }
+ }, 300);
+ }
+
+ // mReplyToCommentId must be saved here so it can be stored by onSaveInstanceState()
+ mReplyToCommentId = replyToCommentId;
+ mIsAddCommentBoxShowing = true;
+ }
+
+ private void hideAddCommentBox() {
+ if (!hasActivity() || !mIsAddCommentBoxShowing) {
+ return;
+ }
+
+ final ViewGroup layoutCommentBox = (ViewGroup) getView().findViewById(R.id.layout_comment_box);
+ final EditText editComment = (EditText) layoutCommentBox.findViewById(R.id.edit_comment);
+ final ImageView imgBtnComment = (ImageView) getView().findViewById(R.id.image_comment_btn);
+
+ imgBtnComment.setSelected(false);
+ AniUtils.flyOut(layoutCommentBox);
+ EditTextUtils.hideSoftInput(editComment);
+
+ getCommentAdapter().setHighlightCommentId(0, false);
+
+ mIsAddCommentBoxShowing = false;
+ mReplyToCommentId = 0;
+ }
+
+ private void toggleShowAddCommentBox() {
+ if (mIsAddCommentBoxShowing) {
+ hideAddCommentBox();
+ } else {
+ showAddCommentBox(0);
+ }
+ }
+
+ /*
+ * scrolls the passed comment to the top of the listView
+ */
+ private void scrollToCommentId(long commentId) {
+ int position = getCommentAdapter().indexOfCommentId(commentId);
+ if (position > -1) {
+ getListView().setSelectionFromTop(position + getListView().getHeaderViewsCount(), 0);
+ }
+ }
+
+ /*
+ * submit the text typed into the comment box as a comment on the current post
+ */
+ private boolean mIsSubmittingComment = false;
+
+ private void submitComment(final long replyToCommentId) {
+ final EditText editComment = (EditText) getView().findViewById(R.id.edit_comment);
+ final String commentText = EditTextUtils.getText(editComment);
+ if (TextUtils.isEmpty(commentText)) {
+ return;
+ }
+
+ AnalyticsTracker.track(AnalyticsTracker.Stat.READER_COMMENTED_ON_ARTICLE);
+
+ // hide the comment box - this provides immediate indication that comment is being posted
+ // and prevents users from submitting the same comment twice
+ hideAddCommentBox();
+
+ // generate a "fake" comment id to assign to the new comment so we can add it to the db
+ // and reflect it in the adapter before the API call returns
+ final long fakeCommentId = ReaderCommentActions.generateFakeCommentId();
+
+ mIsSubmittingComment = true;
+ ReaderActions.CommentActionListener actionListener = new ReaderActions.CommentActionListener() {
+ @Override
+ public void onActionResult(boolean succeeded, ReaderComment newComment) {
+ mIsSubmittingComment = false;
+ if (!hasActivity()) {
+ return;
+ }
+ if (succeeded) {
+ // comment posted successfully so stop highlighting the fake one and replace
+ // it with the real one
+ getCommentAdapter().setHighlightCommentId(0, false);
+ getCommentAdapter().replaceComment(fakeCommentId, newComment);
+ getListView().invalidateViews();
+ } else {
+ // comment failed to post - show the comment box again with the comment text intact,
+ // and remove the "fake" comment from the adapter
+ editComment.setText(commentText);
+ showAddCommentBox(replyToCommentId);
+ getCommentAdapter().removeComment(fakeCommentId);
+ ToastUtils.showToast(getActivity(), R.string.reader_toast_err_comment_failed, ToastUtils.Duration.LONG);
+ }
+ }
+ };
+
+ final ReaderComment newComment = ReaderCommentActions.submitPostComment(mPost,
+ fakeCommentId,
+ commentText,
+ replyToCommentId,
+ actionListener);
+ if (newComment != null) {
+ editComment.setText(null);
+ // add the "fake" comment to the adapter, highlight it, and show a progress bar
+ // next to it while it's submitted
+ getCommentAdapter().setHighlightCommentId(newComment.commentId, true);
+ getCommentAdapter().addComment(newComment);
+ // make sure it's scrolled into view
+ scrollToCommentId(fakeCommentId);
+ }
+ }
+
+ /*
+ * refresh the follow button based on whether this is a followed blog
+ */
+ private void refreshFollowed() {
+ if (!hasActivity()) {
+ return;
+ }
+
+ final TextView txtFollow = (TextView) getView().findViewById(R.id.text_follow);
+ final boolean isFollowed = ReaderPostTable.isPostFollowed(mPost);
+
+ ReaderUtils.showFollowStatus(txtFollow, isFollowed);
+ }
+
+ /*
+ * creates formatted div for passed video with passed (optional) thumbnail
+ */
+ private static final String OVERLAY_IMG = "file:///android_asset/ic_reader_video_overlay.png";
+
+ private String makeVideoDiv(String videoUrl, String thumbnailUrl) {
+ if (TextUtils.isEmpty(videoUrl)) {
+ return "";
+ }
+
+ // sometimes we get src values like "//player.vimeo.com/video/70534716" - prefix these with http:
+ if (videoUrl.startsWith("//")) {
+ videoUrl = "http:" + videoUrl;
+ }
+
+ int overlaySz = getResources().getDimensionPixelSize(R.dimen.reader_video_overlay_size) / 2;
+
+ if (TextUtils.isEmpty(thumbnailUrl)) {
+ return String.format("<div class='wpreader-video' align='center'><a href='%s'><img style='width:%dpx; height:%dpx; display:block;' src='%s' /></a></div>", videoUrl, overlaySz, overlaySz, OVERLAY_IMG);
+ } else {
+ return "<div style='position:relative'>"
+ + String.format("<a href='%s'><img src='%s' style='width:100%%; height:auto;' /></a>", videoUrl, thumbnailUrl)
+ + String.format("<a href='%s'><img src='%s' style='width:%dpx; height:%dpx; position:absolute; left:0px; right:0px; top:0px; bottom:0px; margin:auto;'' /></a>", videoUrl, OVERLAY_IMG, overlaySz, overlaySz)
+ + "</div>";
+ }
+ }
+
+ private boolean showPhotoViewer(String imageUrl, View source, int startX, int startY) {
+ if (!hasActivity() || TextUtils.isEmpty(imageUrl)) {
+ return false;
+ }
+
+ // make sure this is a valid web image (could be file: or data:)
+ if (!imageUrl.startsWith("http")) {
+ return false;
+ }
+
+ // images in private posts must use https for auth token to be sent with request
+ if (hasPost() && mPost.isPrivate) {
+ imageUrl = UrlUtils.makeHttps(imageUrl);
+ }
+
+ ReaderActivityLauncher.showReaderPhotoViewer(getActivity(), imageUrl, source, startX, startY);
+ return true;
+ }
+
+ private boolean hasStaticMenuDrawer() {
+ return (getActivity() instanceof WPActionBarActivity)
+ && (((WPActionBarActivity) getActivity()).isStaticMenuDrawer());
+ }
+
+ /*
+ * size to use for images that fit the full width of the listView item
+ */
+ private int getFullSizeImageWidth(Context context) {
+ int displayWidth = DisplayUtils.getDisplayPixelWidth(context);
+ int marginWidth = getResources().getDimensionPixelOffset(R.dimen.reader_list_margin);
+ int imageWidth = displayWidth - (marginWidth * 2);
+ if (hasStaticMenuDrawer()) {
+ int drawerWidth = getResources().getDimensionPixelOffset(R.dimen.menu_drawer_width);
+ imageWidth -= drawerWidth;
+ }
+ return imageWidth;
+ }
+
+ /*
+ * build html for post's content
+ */
+ private String getPostHtml(Context context) {
+ if (mPost == null || context == null) {
+ return "";
+ }
+
+ String content;
+ if (mPost.hasText()) {
+ // some content (such as Vimeo embeds) don't have "http:" before links, correct this here
+ content = mPost.getText().replace("src=\"//", "src=\"http://");
+ // insert video div before content if this is a VideoPress post (video otherwise won't appear)
+ if (mPost.isVideoPress) {
+ content = makeVideoDiv(mPost.getFeaturedVideo(), mPost.getFeaturedImage()) + content;
+ }
+ } else if (mPost.hasFeaturedImage()) {
+ // some photo blogs have posts with empty content but still have a featured image, so
+ // use the featured image as the content
+ content = String.format("<p><img class='img.size-full' src='%s' /></p>", mPost.getFeaturedImage());
+ } else {
+ content = "";
+ }
+
+ int marginLarge = context.getResources().getDimensionPixelSize(R.dimen.margin_large);
+ int marginSmall = context.getResources().getDimensionPixelSize(R.dimen.margin_small);
+ int marginExtraSmall = context.getResources().getDimensionPixelSize(R.dimen.margin_extra_small);
+ int fullSizeImageWidth = getFullSizeImageWidth(context);
+
+ final String linkColor = HtmlUtils.colorResToHtmlColor(context, R.color.reader_hyperlink);
+ final String greyLight = HtmlUtils.colorResToHtmlColor(context, R.color.grey_light);
+ final String greyExtraLight = HtmlUtils.colorResToHtmlColor(context, R.color.grey_extra_light);
+
+ StringBuilder sbHtml = new StringBuilder("<!DOCTYPE html><html><head><meta charset='UTF-8' />");
+
+ // title isn't strictly necessary, but source is invalid html5 without one
+ sbHtml.append("<title>Reader Post</title>");
+
+ // https://developers.google.com/chrome/mobile/docs/webview/pixelperfect
+ sbHtml.append("<meta name='viewport' content='width=device-width, initial-scale=1'>");
+
+ // use "Open Sans" Google font
+ sbHtml.append("<link rel='stylesheet' type='text/css' href='http://fonts.googleapis.com/css?family=Open+Sans' />");
+
+ sbHtml.append("<style type='text/css'>")
+ .append(" body { font-family: 'Open Sans', sans-serif; margin: 0px; padding: 0px;}")
+ .append(" body, p, div { max-width: 100% !important; word-wrap: break-word; }")
+ .append(" p, div { line-height: 1.6em; font-size: 1em; }")
+ .append(" h1, h2 { line-height: 1.2em; }");
+
+ // make sure long strings don't force the user to scroll horizontally
+ sbHtml.append(" body, p, div, a { word-wrap: break-word; }");
+
+ // use a consistent top/bottom margin for paragraphs, with no top margin for the first one
+ sbHtml.append(String.format(" p { margin-top: %dpx; margin-bottom: %dpx; }", marginSmall, marginSmall))
+ .append(" p:first-child { margin-top: 0px; }");
+
+ // add border, background color, and padding to pre blocks, and add overflow scrolling
+ // so user can scroll the block if it's wider than the display
+ sbHtml.append(" pre { overflow-x: scroll;")
+ .append(" border: 1px solid ").append(greyLight).append("; ")
+ .append(" background-color: ").append(greyExtraLight).append("; ")
+ .append(" padding: ").append(marginSmall).append("px; }");
+
+ // add a left border to blockquotes
+ sbHtml.append(" blockquote { margin-left: ").append(marginSmall).append("px; ")
+ .append(" padding-left: ").append(marginSmall).append("px; ")
+ .append(" border-left: 3px solid ").append(greyLight).append("; }");
+
+ // show links in the same color they are elsewhere in the app
+ sbHtml.append(" a { text-decoration: none; color: ").append(linkColor).append("; }");
+
+ // if javascript is allowed, make sure embedded videos fit the browser width and
+ // use 16:9 ratio (YouTube standard) - if not allowed, hide iframes/embeds
+ if (canEnableJavaScript()) {
+ int videoWidth = DisplayUtils.pxToDp(context, fullSizeImageWidth - (marginLarge * 2));
+ int videoHeight = (int) (videoWidth * 0.5625f);
+ sbHtml.append(" iframe, embed { width: ").append(videoWidth).append("px !important;")
+ .append(" height: ").append(videoHeight).append("px !important; }");
+ } else {
+ sbHtml.append(" iframe, embed { display: none; }");
+ }
+
+ // don't allow any image to be wider than the screen
+ sbHtml.append(" img { max-width: 100% !important; height: auto;}");
+
+ // show large wp images full-width (unnecessary in most cases since they'll already be at least
+ // as wide as the display, except maybe when viewed on a large landscape tablet)
+ sbHtml.append(" img.size-full, img.size-large { display: block; width: 100% !important; height: auto; }");
+
+ // center medium-sized wp image
+ sbHtml.append(" img.size-medium { display: block; margin-left: auto !important; margin-right: auto !important; }");
+
+ // tiled image galleries look bad on mobile due to their hard-coded DIV and IMG sizes, so if
+ // content contains a tiled image gallery, remove the height params and replace the width
+ // params with ones that make images fit the width of the listView item, then adjust the
+ // relevant CSS classes so their height/width are auto, and add top/bottom margin to images
+ if (content.contains("tiled-gallery-item")) {
+ String widthParam = "w=" + Integer.toString(fullSizeImageWidth);
+ content = content.replaceAll("w=[0-9]+", widthParam).replaceAll("h=[0-9]+", "");
+ sbHtml.append(" div.gallery-row, div.gallery-group { width: auto !important; height: auto !important; }")
+ .append(" div.tiled-gallery-item img { ")
+ .append(" width: auto !important; height: auto !important;")
+ .append(" margin-top: ").append(marginExtraSmall).append("px; ")
+ .append(" margin-bottom: ").append(marginExtraSmall).append("px; ")
+ .append(" }")
+ .append(" div.tiled-gallery-caption { clear: both; }");
+ }
+
+ sbHtml.append("</style></head><body>")
+ .append(content)
+ .append("</body></html>");
+
+ return sbHtml.toString();
+ }
+
+ /*
+ * called when the post doesn't exist in local db, need to get it from server
+ */
+ private void requestPost() {
+ final ProgressBar progress = (ProgressBar) getView().findViewById(R.id.progress_loading);
+ progress.setVisibility(View.VISIBLE);
+ progress.bringToFront();
+
+ ReaderActions.ActionListener actionListener = new ReaderActions.ActionListener() {
+ @Override
+ public void onActionResult(boolean succeeded) {
+ if (hasActivity()) {
+ progress.setVisibility(View.GONE);
+ if (succeeded) {
+ showPost();
+ } else {
+ postFailed();
+ }
+ }
+ }
+ };
+ ReaderPostActions.requestPost(mBlogId, mPostId, actionListener);
+ }
+
+ /*
+ * called when post couldn't be loaded and failed to be returned from server
+ */
+ private void postFailed() {
+ if (hasActivity()) {
+ ToastUtils.showToast(getActivity(), R.string.reader_toast_err_get_post, ToastUtils.Duration.LONG);
+ }
+ }
+
+ /*
+ * javascript should only be enabled for wp blogs (not external feeds)
+ */
+ private boolean canEnableJavaScript() {
+ return (mPost != null && mPost.isWP());
+ }
+
+ private void showPost() {
+ if (mIsPostTaskRunning) {
+ AppLog.w(T.READER, "reader post detail > show post task already running");
+ }
+
+ new ShowPostTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ /*
+ * AsyncTask to retrieve this post from SQLite and display it
+ */
+ private boolean mIsPostTaskRunning = false;
+
+ private class ShowPostTask extends AsyncTask<Void, Void, Boolean> {
+ TextView txtTitle;
+ TextView txtBlogName;
+ TextView txtDateAndAuthor;
+ TextView txtFollow;
+
+ ImageView imgBtnReblog;
+ ImageView imgBtnComment;
+
+ WPNetworkImageView imgAvatar;
+ WPNetworkImageView imgFeatured;
+
+ ViewGroup layoutDetailHeader;
+
+ String postHtml;
+ String featuredImageUrl;
+ boolean showFeaturedImage;
+
+ @Override
+ protected void onPreExecute() {
+ mIsPostTaskRunning = true;
+ }
+
+ @Override
+ protected void onCancelled() {
+ mIsPostTaskRunning = false;
+ }
+
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ final View container = getView();
+ if (container == null) {
+ return false;
+ }
+
+ mPost = ReaderPostTable.getPost(mBlogId, mPostId);
+ if (mPost == null) {
+ return false;
+ }
+
+ txtTitle = (TextView) container.findViewById(R.id.text_title);
+ txtBlogName = (TextView) container.findViewById(R.id.text_blog_name);
+ txtFollow = (TextView) container.findViewById(R.id.text_follow);
+ txtDateAndAuthor = (TextView) container.findViewById(R.id.text_date_and_author);
+
+ imgAvatar = (WPNetworkImageView) container.findViewById(R.id.image_avatar);
+ imgFeatured = (WPNetworkImageView) container.findViewById(R.id.image_featured);
+
+ imgBtnReblog = (ImageView) mLayoutIcons.findViewById(R.id.image_reblog_btn);
+ imgBtnComment = (ImageView) mLayoutIcons.findViewById(R.id.image_comment_btn);
+
+ layoutDetailHeader = (ViewGroup) container.findViewById(R.id.layout_detail_header);
+
+ postHtml = getPostHtml(container.getContext());
+
+ // detect whether the post has a featured image that's not in the content - if so,
+ // it will be shown between the post's title and its content (but skip mshots)
+ if (mPost.hasFeaturedImage() && !PhotonUtils.isMshotsUrl(mPost.getFeaturedImage())) {
+ Uri uri = Uri.parse(mPost.getFeaturedImage());
+ String path = StringUtils.notNullStr(uri.getLastPathSegment());
+ if (!mPost.getText().contains(path)) {
+ showFeaturedImage = true;
+ // note that only the width is used here - the imageView will adjust
+ // the height to match that of the image once loaded
+ featuredImageUrl = mPost.getFeaturedImageForDisplay(getFullSizeImageWidth(container.getContext()), 0);
+ }
+ }
+
+ return true;
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ mIsPostTaskRunning = false;
+
+ if (!hasActivity()) {
+ return;
+ }
+
+ if (!result) {
+ // post couldn't be loaded which means it doesn't exist in db, so request it from
+ // the server if it hasn't already been requested
+ if (!mHasAlreadyRequestedPost) {
+ mHasAlreadyRequestedPost = true;
+ requestPost();
+ }
+ return;
+ }
+
+ txtTitle.setText(mPost.hasTitle() ? mPost.getTitle() : getString(R.string.reader_untitled_post));
+
+ ReaderUtils.showFollowStatus(txtFollow, mPost.isFollowedByCurrentUser);
+ txtFollow.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ togglePostFollowed(mPost, txtFollow);
+ }
+ });
+
+ if (mPost.hasBlogName()) {
+ txtBlogName.setText(mPost.getBlogName());
+ txtBlogName.setVisibility(View.VISIBLE);
+ } else if (mPost.hasBlogUrl()) {
+ txtBlogName.setText(UrlUtils.getDomainFromUrl(mPost.getBlogUrl()));
+ txtBlogName.setVisibility(View.VISIBLE);
+ } else {
+ txtBlogName.setVisibility(View.GONE);
+ }
+
+ // show date and author name if author name exists and is different than the blog name,
+ // otherwise just show the date
+ if (mPost.hasAuthorName() && !mPost.getAuthorName().equals(mPost.getBlogName())) {
+ txtDateAndAuthor.setText(DateTimeUtils.javaDateToTimeSpan(mPost.getDatePublished()) + " / " + mPost.getAuthorName());
+ } else {
+ txtDateAndAuthor.setText(DateTimeUtils.javaDateToTimeSpan(mPost.getDatePublished()));
+ }
+
+ if (mPost.hasPostAvatar()) {
+ int avatarSz = getResources().getDimensionPixelSize(R.dimen.avatar_sz_medium);
+ imgAvatar.setImageUrl(mPost.getPostAvatarForDisplay(avatarSz), WPNetworkImageView.ImageType.AVATAR);
+ imgAvatar.setVisibility(View.VISIBLE);
+ } else {
+ imgAvatar.setVisibility(View.GONE);
+ }
+
+ // hide blog name, avatar & follow button if this fragment was shown from blog preview
+ if (isBlogPreview()) {
+ layoutDetailHeader.setVisibility(View.GONE);
+ }
+
+ if (showFeaturedImage) {
+ imgFeatured.setVisibility(View.VISIBLE);
+ imgFeatured.setImageUrl(featuredImageUrl, WPNetworkImageView.ImageType.PHOTO);
+ imgFeatured.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ int startX = (DisplayUtils.getDisplayPixelWidth(getActivity()) / 2);
+ int startY = (DisplayUtils.getDisplayPixelWidth(getActivity()) / 2);
+ showPhotoViewer(mPost.getFeaturedImage(), view, startX, startY);
+ }
+ });
+ } else {
+ imgFeatured.setVisibility(View.GONE);
+ }
+
+ // enable reblogging wp posts
+ if (mPost.canReblog()) {
+ imgBtnReblog.setVisibility(View.VISIBLE);
+ imgBtnReblog.setSelected(mPost.isRebloggedByCurrentUser);
+ imgBtnReblog.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ doPostReblog(imgBtnReblog, mPost);
+ }
+ });
+ } else {
+ imgBtnReblog.setVisibility(View.GONE);
+ }
+
+ // enable adding a comment if comments are open on this post
+ if (mPost.isWP() && mPost.isCommentsOpen) {
+ imgBtnComment.setVisibility(View.VISIBLE);
+ imgBtnComment.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ toggleShowAddCommentBox();
+ }
+ });
+ } else {
+ imgBtnComment.setVisibility(View.GONE);
+ }
+
+ // if we know refreshLikes() is going to show the liking layout, force it to take up
+ // space right now
+ if (mPost.numLikes > 0 && mLayoutLikes.getVisibility() == View.GONE) {
+ mLayoutLikes.setVisibility(View.INVISIBLE);
+ }
+
+ // external blogs (feeds) don't support action icons
+ mLayoutIcons.setVisibility(mPost.isExternal ? View.GONE : View.VISIBLE);
+
+ // enable JavaScript in the webView if it's safe to do so
+ mReaderWebView.getSettings().setJavaScriptEnabled(canEnableJavaScript());
+
+ // IMPORTANT: use loadDataWithBaseURL() since loadData() may fail
+ // https://code.google.com/p/android/issues/detail?id=4401
+ mReaderWebView.loadDataWithBaseURL(null, postHtml, "text/html", "UTF-8", null);
+
+ // only show action buttons for WP posts
+ mLayoutIcons.setVisibility(mPost.isWP() ? View.VISIBLE : View.GONE);
+
+ // make sure the adapter is assigned
+ if (getListView().getAdapter() == null) {
+ getListView().setAdapter(getCommentAdapter());
+ }
+
+ // listView is hidden in onCreateView()
+ if (getListView().getVisibility() != View.VISIBLE) {
+ getListView().setVisibility(View.VISIBLE);
+ }
+
+ // webView is hidden in onCreateView() and will be made visible by readerWebViewClient
+ // once it finishes loading, so if it's already visible go ahead and show likes/comments
+ // right away, otherwise show them after a brief delay - this gives content time to
+ // load before likes/comments appear
+ if (mReaderWebView.getVisibility() == View.VISIBLE) {
+ showContent();
+ } else {
+ showContentDelayed();
+ }
+ }
+ }
+
+ /*
+ * webView is hidden in onCreateView() and then shown after a brief delay once post is loaded
+ * to give webView content a short time to load before it appears - after it appears we can
+ * then get likes & comments
+ */
+ private void showContentDelayed() {
+ mHandler.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ showContent();
+ }
+ }, 1000L);
+ }
+
+ private void showContent() {
+ if (!hasActivity()) {
+ return;
+ }
+
+ mReaderWebView.setVisibility(View.VISIBLE);
+
+ // show likes & comments
+ refreshLikes();
+ refreshComments();
+
+ // request the latest info for this post if we haven't updated it already
+ if (!mHasAlreadyUpdatedPost) {
+ updatePost();
+ mHasAlreadyUpdatedPost = true;
+ }
+ }
+
+ /*
+ * return the container view that should host the fullscreen video
+ */
+ @Override
+ public ViewGroup onRequestCustomView() {
+ if (hasActivity()) {
+ return (ViewGroup) getView().findViewById(R.id.layout_custom_view_container);
+ } else {
+ return null;
+ }
+ }
+
+ /*
+ * return the container view that should be hidden when fullscreen video is shown
+ */
+ @Override
+ public ViewGroup onRequestContentView() {
+ if (hasActivity()) {
+ return (ViewGroup) getView().findViewById(R.id.layout_post_detail_container);
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ public void onCustomViewShown() {
+ // fullscreen video has just been shown so hide the ActionBar
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.hide();
+ }
+ }
+
+ @Override
+ public void onCustomViewHidden() {
+ // user returned from fullscreen video so re-display the ActionBar
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.show();
+ }
+ }
+
+ boolean isCustomViewShowing() {
+ return mReaderWebView != null && mReaderWebView.isCustomViewShowing();
+ }
+
+ void hideCustomView() {
+ if (mReaderWebView != null) {
+ mReaderWebView.hideCustomView();
+ }
+ }
+
+ @Override
+ public boolean onUrlClick(String url) {
+ // open YouTube videos in external app so they launch the YouTube player, open all other
+ // urls using an AuthenticatedWebViewActivity
+ final ReaderActivityLauncher.OpenUrlType openUrlType;
+ if (ReaderVideoUtils.isYouTubeVideoLink(url)) {
+ openUrlType = ReaderActivityLauncher.OpenUrlType.EXTERNAL;
+ } else {
+ openUrlType = ReaderActivityLauncher.OpenUrlType.INTERNAL;
+ }
+ ReaderActivityLauncher.openUrl(getActivity(), url, openUrlType);
+ return true;
+ }
+
+ @Override
+ public boolean onImageUrlClick(String imageUrl, View view, int x, int y) {
+ return showPhotoViewer(imageUrl, view, x, y);
+ }
+
+ private ActionBar getActionBar() {
+ if (hasActivity()) {
+ return getActivity().getActionBar();
+ } else {
+ AppLog.w(T.READER, "reader post detail > getActionBar called with no activity");
+ return null;
+ }
+ }
+
+ void pauseWebView() {
+ if (mReaderWebView != null) {
+ mReaderWebView.hideCustomView();
+ mReaderWebView.onPause();
+ } else {
+ AppLog.i(T.READER, "reader post detail > attempt to pause webView when null");
+ }
+ }
+
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListActivity.java
new file mode 100644
index 000000000..2f6eaac83
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListActivity.java
@@ -0,0 +1,351 @@
+package org.wordpress.android.ui.reader;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.content.Intent;
+import android.os.Bundle;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.ReaderDatabase;
+import org.wordpress.android.datasets.ReaderPostTable;
+import org.wordpress.android.datasets.ReaderTagTable;
+import org.wordpress.android.models.ReaderTag;
+import org.wordpress.android.models.ReaderTagType;
+import org.wordpress.android.ui.WPActionBarActivity;
+import org.wordpress.android.ui.accounts.WPComLoginActivity;
+import org.wordpress.android.ui.prefs.UserPrefs;
+import org.wordpress.android.ui.reader.ReaderPostListFragment.OnPostSelectedListener;
+import org.wordpress.android.ui.reader.ReaderPostListFragment.OnTagSelectedListener;
+import org.wordpress.android.ui.reader.actions.ReaderActions;
+import org.wordpress.android.ui.reader.actions.ReaderActions.RequestDataAction;
+import org.wordpress.android.ui.reader.actions.ReaderActions.UpdateResult;
+import org.wordpress.android.ui.reader.actions.ReaderAuthActions;
+import org.wordpress.android.ui.reader.actions.ReaderBlogActions;
+import org.wordpress.android.ui.reader.actions.ReaderTagActions;
+import org.wordpress.android.ui.reader.actions.ReaderUserActions;
+import org.wordpress.android.ui.reader.models.ReaderBlogIdPostIdList;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.NetworkUtils;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+
+/*
+ * this activity serves as the host for ReaderPostListFragment
+ */
+
+public class ReaderPostListActivity extends WPActionBarActivity
+ implements OnPostSelectedListener,
+ OnTagSelectedListener {
+
+ private static boolean mHasPerformedInitialUpdate;
+ private static boolean mHasPerformedPurge;
+
+ private ReaderTypes.ReaderPostListType mPostListType;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ readIntent(getIntent(), savedInstanceState);
+ }
+
+ private void readIntent(Intent intent, Bundle savedInstanceState) {
+ if (intent == null) {
+ return;
+ }
+
+ if (intent.hasExtra(ReaderConstants.ARG_POST_LIST_TYPE)) {
+ mPostListType = (ReaderTypes.ReaderPostListType) intent.getSerializableExtra(ReaderConstants.ARG_POST_LIST_TYPE);
+ } else {
+ mPostListType = ReaderTypes.DEFAULT_POST_LIST_TYPE;
+ }
+
+ // no menu drawer if this is blog preview or tag preview
+ if (mPostListType.isPreviewType()) {
+ setContentView(R.layout.reader_activity_post_list);
+ } else {
+ createMenuDrawer(R.layout.reader_activity_post_list);
+ }
+
+ switch (mPostListType) {
+ case TAG_PREVIEW:
+ setTitle(R.string.reader_title_tag_preview);
+ break;
+ case BLOG_PREVIEW:
+ setTitle(R.string.reader_title_blog_preview);
+ break;
+ default:
+ break;
+ }
+
+ if (savedInstanceState == null) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.READER_ACCESSED);
+
+ if (mPostListType == ReaderTypes.ReaderPostListType.BLOG_PREVIEW) {
+ long blogId = intent.getLongExtra(ReaderConstants.ARG_BLOG_ID, 0);
+ String blogUrl = intent.getStringExtra(ReaderConstants.ARG_BLOG_URL);
+ showListFragmentForBlog(blogId, blogUrl);
+ } else {
+ // get the tag name from the intent, if not there get it from prefs
+ ReaderTag tag;
+ if (intent.hasExtra(ReaderConstants.ARG_TAG)) {
+ tag = (ReaderTag) intent.getSerializableExtra(ReaderConstants.ARG_TAG);
+ } else {
+ tag = UserPrefs.getReaderTag();
+ }
+ // if this is a followed tag and it doesn't exist, revert to default tag
+ if (mPostListType == ReaderTypes.ReaderPostListType.TAG_FOLLOWED && !ReaderTagTable.tagExists(tag)) {
+ tag = ReaderTag.getDefaultTag();
+ }
+
+ showListFragmentForTag(tag, mPostListType);
+ }
+ }
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+
+ // at startup, purge the database of older data and perform an initial update - note that
+ // these booleans are static
+ if (!mHasPerformedPurge) {
+ mHasPerformedPurge = true;
+ ReaderDatabase.purgeAsync();
+ }
+ if (!mHasPerformedInitialUpdate) {
+ performInitialUpdate();
+ }
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (mMenuDrawer != null && mMenuDrawer.isMenuVisible()) {
+ super.onBackPressed();
+ } else {
+ ReaderPostListFragment fragment = getListFragment();
+ if (fragment == null || !fragment.goBackInTagHistory()) {
+ super.onBackPressed();
+ }
+ }
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ boolean isResultOK = (resultCode == Activity.RESULT_OK);
+ final ReaderPostListFragment listFragment = getListFragment();
+
+ switch (requestCode) {
+ // user just returned from the tag editor
+ case ReaderConstants.INTENT_READER_SUBS :
+ if (isResultOK && listFragment != null && data != null) {
+ if (data.getBooleanExtra(ReaderSubsActivity.KEY_TAGS_CHANGED, false)) {
+ // reload tags if they were changed, and set the last tag added as the current one
+ String lastAddedTag = data.getStringExtra(ReaderSubsActivity.KEY_LAST_ADDED_TAG_NAME);
+ listFragment.doTagsChanged(lastAddedTag);
+ } else if (data.getBooleanExtra(ReaderSubsActivity.KEY_BLOGS_CHANGED, false)) {
+ // update posts if any blog was followed or unfollowed and user is viewing "Blogs I Follow"
+ if (listFragment.getPostListType().isTagType()
+ && ReaderTag.TAG_NAME_FOLLOWING.equals(listFragment.getCurrentTagName())) {
+ listFragment.updatePostsWithTag(
+ listFragment.getCurrentTag(),
+ RequestDataAction.LOAD_NEWER,
+ ReaderTypes.RefreshType.AUTOMATIC);
+ }
+ }
+ }
+ break;
+
+ // user just returned from reblogging activity, reload the displayed post if reblogging
+ // succeeded
+ case ReaderConstants.INTENT_READER_REBLOG:
+ if (isResultOK && data != null && listFragment != null) {
+ long blogId = data.getLongExtra(ReaderConstants.ARG_BLOG_ID, 0);
+ long postId = data.getLongExtra(ReaderConstants.ARG_POST_ID, 0);
+ listFragment.reloadPost(ReaderPostTable.getPost(blogId, postId));
+ }
+ break;
+
+ // user just returned from the login dialog, need to perform initial update again
+ // since creds have changed
+ case WPComLoginActivity.REQUEST_CODE:
+ if (isResultOK) {
+ removeListFragment();
+ mHasPerformedInitialUpdate = false;
+ performInitialUpdate();
+ }
+ break;
+ }
+ }
+
+ @Override
+ public void onSignout() {
+ super.onSignout();
+
+ AppLog.i(T.READER, "user signed out");
+ mHasPerformedInitialUpdate = false;
+
+ // reader database will have been cleared by the time this is called, but the fragment must
+ // be removed or else they will continue to show the same articles - onResume() will take
+ // care of re-displaying the correct fragment if necessary
+ removeListFragment();
+ }
+
+ ReaderTypes.ReaderPostListType getPostListType() {
+ return (mPostListType != null ? mPostListType : ReaderTypes.DEFAULT_POST_LIST_TYPE);
+ }
+
+ private void removeListFragment() {
+ Fragment listFragment = getListFragment();
+ if (listFragment != null) {
+ getFragmentManager()
+ .beginTransaction()
+ .remove(listFragment)
+ .commit();
+ }
+ }
+
+ /*
+ * show fragment containing list of latest posts for a specific tag
+ */
+ private void showListFragmentForTag(final ReaderTag tag, ReaderTypes.ReaderPostListType listType) {
+ Fragment fragment = ReaderPostListFragment.newInstance(tag, listType);
+ getFragmentManager()
+ .beginTransaction()
+ .replace(R.id.fragment_container, fragment, getString(R.string.fragment_tag_reader_post_list))
+ .commit();
+ }
+
+ /*
+ * show fragment containing list of latest posts in a specific blog
+ */
+ private void showListFragmentForBlog(long blogId, String blogUrl) {
+ Fragment fragment = ReaderPostListFragment.newInstance(blogId, blogUrl);
+ getFragmentManager()
+ .beginTransaction()
+ .replace(R.id.fragment_container, fragment, getString(R.string.fragment_tag_reader_post_list))
+ .commit();
+ }
+
+ private ReaderPostListFragment getListFragment() {
+ Fragment fragment = getFragmentManager().findFragmentByTag(getString(R.string.fragment_tag_reader_post_list));
+ if (fragment == null) {
+ return null;
+ }
+ return ((ReaderPostListFragment) fragment);
+ }
+
+ private boolean hasListFragment() {
+ return (getListFragment() != null);
+ }
+
+ /*
+ * initial update performed at startup to ensure we have the latest reader-related info
+ */
+ private void performInitialUpdate() {
+ if (!NetworkUtils.isNetworkAvailable(this)) {
+ return;
+ }
+
+ // remember whether we have any tags and posts before updating
+ final boolean isTagTableEmpty = ReaderTagTable.isEmpty();
+ final boolean isPostTableEmpty = ReaderPostTable.isEmpty();
+
+ // request the list of tags first and don't perform other calls until it returns - this
+ // way changes to tags can be shown as quickly as possible (esp. important when tags
+ // don't already exist)
+ ReaderActions.UpdateResultListener listener = new ReaderActions.UpdateResultListener() {
+ @Override
+ public void onUpdateResult(UpdateResult result) {
+ if (isFinishing()) {
+ return;
+ }
+ if (result != UpdateResult.FAILED) {
+ mHasPerformedInitialUpdate = true;
+ }
+ if (result == UpdateResult.CHANGED) {
+ // if the post list fragment is viewing followed tags, tell it to refresh
+ // the list of tags
+ ReaderPostListFragment listFragment = getListFragment();
+ if (listFragment == null) {
+ // list fragment doesn't exist yet (can happen if user signed out) - create
+ // it now showing the default tag
+ showListFragmentForTag(ReaderTag.getDefaultTag(), ReaderTypes.ReaderPostListType.TAG_FOLLOWED);
+ } else if (listFragment.getPostListType() == ReaderTypes.ReaderPostListType.TAG_FOLLOWED) {
+ listFragment.refreshTags();
+ // if the tag and posts tables were empty (first run), tell the list
+ // fragment to get posts with the current tag now that we have tags
+ if (isTagTableEmpty && isPostTableEmpty) {
+ listFragment.updatePostsWithTag(
+ listFragment.getCurrentTag(),
+ RequestDataAction.LOAD_NEWER,
+ ReaderTypes.RefreshType.AUTOMATIC);
+ }
+ }
+ }
+
+ // now that tags have been retrieved, perform the other requests - first update
+ // the current user to ensure we have their user_id as well as their latest info
+ // in case they changed their avatar, name, etc. since last time
+ AppLog.i(T.READER, "reader activity > updating current user");
+ ReaderUserActions.updateCurrentUser(null);
+
+ // update followed blogs
+ AppLog.i(T.READER, "reader activity > updating followed blogs");
+ ReaderBlogActions.updateFollowedBlogs(null);
+
+ // update cookies so that we can show authenticated images in WebViews
+ AppLog.i(T.READER, "reader activity > updating cookies");
+ ReaderAuthActions.updateCookies(ReaderPostListActivity.this);
+ }
+ };
+ ReaderTagActions.updateTags(listener);
+ }
+
+ /*
+ * user tapped a post in the list fragment
+ */
+ @Override
+ public void onPostSelected(long blogId, long postId) {
+ // skip if this activity no longer has the focus - this prevents the post detail from
+ // being shown multiple times if the user quickly taps a post more than once
+ if (!this.hasWindowFocus()) {
+ AppLog.i(T.READER, "post selected when activity not focused");
+ return;
+ }
+
+ ReaderPostListFragment listFragment = getListFragment();
+ if (listFragment != null) {
+ ReaderBlogIdPostIdList idList = listFragment.getBlogIdPostIdList();
+ int position = idList.indexOf(blogId, postId);
+
+ final String title;
+ switch (getPostListType()) {
+ case TAG_FOLLOWED:
+ case TAG_PREVIEW:
+ title = listFragment.getCurrentTagName();
+ break;
+ default:
+ title = (String)this.getTitle();
+ break;
+ }
+ ReaderActivityLauncher.showReaderPostPager(this, title, position, idList, getPostListType());
+ }
+ }
+
+ /*
+ * user tapped a tag in the list fragment
+ */
+ @Override
+ public void onTagSelected(String tagName) {
+ ReaderTag tag = new ReaderTag(tagName, ReaderTagType.FOLLOWED);
+ if (hasListFragment() && getListFragment().getPostListType().equals(ReaderTypes.ReaderPostListType.TAG_PREVIEW)) {
+ // user is already previewing a tag, so change current tag in existing preview
+ getListFragment().setCurrentTag(tag);
+ } else {
+ // user isn't previewing a tag, so open in tag preview
+ ReaderActivityLauncher.showReaderTagPreview(this, tag);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.java
new file mode 100644
index 000000000..8a5659614
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.java
@@ -0,0 +1,1219 @@
+package org.wordpress.android.ui.reader;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.app.Fragment;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Parcelable;
+import android.text.Html;
+import android.text.TextUtils;
+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.view.ViewTreeObserver;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+import android.widget.AbsListView;
+import android.widget.AdapterView;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.ReaderPostTable;
+import org.wordpress.android.datasets.ReaderTagTable;
+import org.wordpress.android.models.ReaderPost;
+import org.wordpress.android.models.ReaderTag;
+import org.wordpress.android.models.ReaderTagType;
+import org.wordpress.android.ui.PullToRefreshHelper;
+import org.wordpress.android.ui.PullToRefreshHelper.RefreshListener;
+import org.wordpress.android.ui.WPActionBarActivity;
+import org.wordpress.android.ui.prefs.UserPrefs;
+import org.wordpress.android.ui.reader.ReaderTypes.ReaderPostListType;
+import org.wordpress.android.ui.reader.actions.ReaderActions;
+import org.wordpress.android.ui.reader.actions.ReaderActions.RequestDataAction;
+import org.wordpress.android.ui.reader.actions.ReaderPostActions;
+import org.wordpress.android.ui.reader.actions.ReaderTagActions;
+import org.wordpress.android.ui.reader.actions.ReaderTagActions.TagAction;
+import org.wordpress.android.ui.reader.adapters.ReaderActionBarTagAdapter;
+import org.wordpress.android.ui.reader.adapters.ReaderPostAdapter;
+import org.wordpress.android.ui.reader.models.ReaderBlogIdPostIdList;
+import org.wordpress.android.util.AniUtils;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.DisplayUtils;
+import org.wordpress.android.util.HtmlUtils;
+import org.wordpress.android.util.NetworkUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+import org.wordpress.android.widgets.WPListView;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Stack;
+
+import uk.co.senab.actionbarpulltorefresh.library.PullToRefreshLayout;
+
+public class ReaderPostListFragment extends Fragment
+ implements AbsListView.OnScrollListener,
+ ViewTreeObserver.OnScrollChangedListener,
+ ActionBar.OnNavigationListener {
+
+ static interface OnPostSelectedListener {
+ public void onPostSelected(long blogId, long postId);
+ }
+
+ public static interface OnTagSelectedListener {
+ public void onTagSelected(String tagName);
+ }
+
+ private ReaderActionBarTagAdapter mActionBarAdapter;
+ private ReaderPostAdapter mPostAdapter;
+ private OnPostSelectedListener mPostSelectedListener;
+ private OnTagSelectedListener mOnTagSelectedListener;
+
+ private PullToRefreshHelper mPullToRefreshHelper;
+ private WPListView mListView;
+ private TextView mNewPostsBar;
+ private View mEmptyView;
+ private ProgressBar mProgress;
+
+ private ViewGroup mTagInfoView;
+
+ private ReaderBlogInfoView mBlogInfoView;
+ private View mMshotSpacerView;
+ private static final String MSHOT_SPACER_TAG = "mshot_spacer";
+
+ private ReaderTag mCurrentTag;
+ private long mCurrentBlogId;
+ private String mCurrentBlogUrl;
+ private ReaderPostListType mPostListType;
+
+ private boolean mIsUpdating;
+ private boolean mIsFlinging;
+ private boolean mWasPaused;
+
+ private Parcelable mListState = null;
+
+ private final Stack<String> mTagPreviewHistory = new Stack<String>();
+ private static final String KEY_TAG_PREVIEW_HISTORY = "tag_preview_history";
+
+ /*
+ * show posts with a specific tag
+ */
+ static ReaderPostListFragment newInstance(ReaderTag tag, ReaderPostListType listType) {
+ AppLog.d(T.READER, "reader post list > newInstance (tag)");
+
+ Bundle args = new Bundle();
+ args.putSerializable(ReaderConstants.ARG_TAG, tag);
+ args.putSerializable(ReaderConstants.ARG_POST_LIST_TYPE, listType);
+
+ ReaderPostListFragment fragment = new ReaderPostListFragment();
+ fragment.setArguments(args);
+
+ return fragment;
+ }
+
+ /*
+ * show posts in a specific blog
+ */
+ static ReaderPostListFragment newInstance(long blogId, String blogUrl) {
+ AppLog.d(T.READER, "reader post list > newInstance (blog)");
+
+ Bundle args = new Bundle();
+ args.putLong(ReaderConstants.ARG_BLOG_ID, blogId);
+ args.putString(ReaderConstants.ARG_BLOG_URL, blogUrl);
+ args.putSerializable(ReaderConstants.ARG_POST_LIST_TYPE, ReaderTypes.ReaderPostListType.BLOG_PREVIEW);
+
+ ReaderPostListFragment fragment = new ReaderPostListFragment();
+ fragment.setArguments(args);
+
+ return fragment;
+ }
+
+ @Override
+ public void setArguments(Bundle args) {
+ super.setArguments(args);
+
+ if (args != null) {
+ if (args.containsKey(ReaderConstants.ARG_TAG)) {
+ mCurrentTag = (ReaderTag) args.getSerializable(ReaderConstants.ARG_TAG);
+ }
+ if (args.containsKey(ReaderConstants.ARG_POST_LIST_TYPE)) {
+ mPostListType = (ReaderPostListType) args.getSerializable(ReaderConstants.ARG_POST_LIST_TYPE);
+ }
+ mCurrentBlogId = args.getLong(ReaderConstants.ARG_BLOG_ID);
+ mCurrentBlogUrl = args.getString(ReaderConstants.ARG_BLOG_URL);
+
+ if (getPostListType() == ReaderPostListType.TAG_PREVIEW && hasCurrentTag()) {
+ mTagPreviewHistory.push(getCurrentTagName());
+ }
+ }
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (savedInstanceState != null) {
+ AppLog.d(T.READER, "reader post list > restoring instance state");
+ if (savedInstanceState.containsKey(ReaderConstants.ARG_TAG)) {
+ mCurrentTag = (ReaderTag) savedInstanceState.getSerializable(ReaderConstants.ARG_TAG);
+ }
+ if (savedInstanceState.containsKey(ReaderConstants.ARG_BLOG_ID)) {
+ mCurrentBlogId = savedInstanceState.getLong(ReaderConstants.ARG_BLOG_ID);
+ }
+ if (savedInstanceState.containsKey(ReaderConstants.ARG_BLOG_URL)) {
+ mCurrentBlogUrl = savedInstanceState.getString(ReaderConstants.ARG_BLOG_URL);
+ }
+ if (savedInstanceState.containsKey(ReaderConstants.KEY_LIST_STATE)) {
+ mListState = savedInstanceState.getParcelable(ReaderConstants.KEY_LIST_STATE);
+ }
+ if (savedInstanceState.containsKey(ReaderConstants.ARG_POST_LIST_TYPE)) {
+ mPostListType = (ReaderPostListType) savedInstanceState.getSerializable(ReaderConstants.ARG_POST_LIST_TYPE);
+ }
+ if (savedInstanceState.containsKey(KEY_TAG_PREVIEW_HISTORY)) {
+ Stack<String> backStack = (Stack<String>) savedInstanceState.getSerializable(KEY_TAG_PREVIEW_HISTORY);
+ mTagPreviewHistory.clear();
+ mTagPreviewHistory.addAll(backStack);
+ }
+ mWasPaused = savedInstanceState.getBoolean(ReaderConstants.KEY_WAS_PAUSED);
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ mWasPaused = true;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ // if the fragment is resuming from a paused state, refresh the adapter to make sure
+ // the follow status of all posts is accurate - this is necessary in case the user
+ // returned from an activity where the follow status may have been changed
+ if (mWasPaused) {
+ AppLog.d(T.READER, "reader post list > resumed from paused state");
+ mWasPaused = false;
+ if (hasPostAdapter()) {
+ getPostAdapter().checkFollowStatusForAllPosts();
+ }
+
+ // likewise for tags
+ refreshTags();
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ AppLog.d(T.READER, "reader post list > saving instance state");
+
+ if (mCurrentTag != null) {
+ outState.putSerializable(ReaderConstants.ARG_TAG, mCurrentTag);
+ }
+ if (!mTagPreviewHistory.empty()) {
+ outState.putSerializable(KEY_TAG_PREVIEW_HISTORY, mTagPreviewHistory);
+ }
+
+ outState.putLong(ReaderConstants.ARG_BLOG_ID, mCurrentBlogId);
+ outState.putString(ReaderConstants.ARG_BLOG_URL, mCurrentBlogUrl);
+ outState.putBoolean(ReaderConstants.KEY_WAS_PAUSED, mWasPaused);
+ outState.putSerializable(ReaderConstants.ARG_POST_LIST_TYPE, getPostListType());
+
+ // retain list state so we can return to this position
+ // http://stackoverflow.com/a/5694441/1673548
+ if (mListView != null && mListView.getFirstVisiblePosition() > 0) {
+ outState.putParcelable(ReaderConstants.KEY_LIST_STATE, mListView.onSaveInstanceState());
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ final ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.reader_fragment_post_list, container, false);
+ mListView = (WPListView) rootView.findViewById(android.R.id.list);
+
+ // bar that appears at top when new posts are downloaded
+ mNewPostsBar = (TextView) rootView.findViewById(R.id.text_new_posts);
+ mNewPostsBar.setVisibility(View.GONE);
+ mNewPostsBar.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ reloadPosts();
+ hideNewPostsBar();
+ }
+ });
+
+ switch (getPostListType()) {
+ case TAG_FOLLOWED:
+ // this is the default, nothing extra needed
+ break;
+
+ case TAG_PREVIEW:
+ // add the tag header to the view, then tell the ptr layout to appear below the header
+ mTagInfoView = (ViewGroup) inflater.inflate(R.layout.reader_tag_info_view, container, false);
+ rootView.addView(mTagInfoView);
+ ReaderUtils.layoutBelow(rootView, R.id.ptr_layout, mTagInfoView.getId());
+ break;
+
+ case BLOG_PREVIEW:
+ // inflate the blog info and make it full size
+ mBlogInfoView = new ReaderBlogInfoView(container.getContext());
+ rootView.addView(mBlogInfoView);
+ mBlogInfoView.setLayoutParams(new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.MATCH_PARENT,
+ RelativeLayout.LayoutParams.MATCH_PARENT));
+
+ // add a blank header to the listView that's the same height as the mshot with a fudge
+ // factor to account for the info container - global layout listener below will
+ // use the actual container height once it's known - note that this "fudge factor"
+ // is based on the height of the info container with a two-line description
+ int spacerHeight = mBlogInfoView.getMshotHeight() + DisplayUtils.dpToPx(container.getContext(), 105);
+ mMshotSpacerView = ReaderUtils.addListViewHeader(mListView, spacerHeight);
+
+ // tag the spacer so we can identify it later
+ mMshotSpacerView.setTag(MSHOT_SPACER_TAG);
+
+ // make sure blog info is in front of the listView
+ mBlogInfoView.bringToFront();
+
+ // assign a global layout listener to detect changes to the size of the blogInfo so
+ // we can resize the mshot spacer accordingly
+ mBlogInfoView.getViewTreeObserver().addOnGlobalLayoutListener(
+ new ViewTreeObserver.OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ int currentHeight = mMshotSpacerView.getLayoutParams().height;
+ int newHeight = mBlogInfoView.getInfoContainerHeight()
+ + mBlogInfoView.getMshotHeight();
+ if (currentHeight != newHeight) {
+ mMshotSpacerView.getLayoutParams().height = newHeight;
+ }
+ }
+ }
+ );
+
+ break;
+ }
+
+ // textView that appears when current tag has no posts
+ mEmptyView = rootView.findViewById(R.id.empty_view);
+
+ // set the listView's scroll listener so we can detect flings
+ mListView.setOnScrollListener(this);
+
+ // tapping a post opens the detail view
+ mListView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> adapterView, View view, int position, long id) {
+ // take headers into account
+ position -= mListView.getHeaderViewsCount();
+ if (position >= 0 && mPostSelectedListener != null) {
+ ReaderPost post = (ReaderPost) getPostAdapter().getItem(position);
+ if (post != null) {
+ mPostSelectedListener.onPostSelected(post.blogId, post.postId);
+ }
+ }
+ }
+ });
+
+ // progress bar that appears when loading more posts
+ mProgress = (ProgressBar) rootView.findViewById(R.id.progress_footer);
+ mProgress.setVisibility(View.GONE);
+
+ // pull to refresh setup
+ mPullToRefreshHelper = new PullToRefreshHelper(getActivity(),
+ (PullToRefreshLayout) rootView.findViewById(R.id.ptr_layout),
+ new RefreshListener() {
+ @Override
+ public void onRefreshStarted(View view) {
+ if (getActivity() == null || !NetworkUtils.checkConnection(getActivity())) {
+ mPullToRefreshHelper.setRefreshing(false);
+ return;
+ }
+ switch (getPostListType()) {
+ case TAG_FOLLOWED:
+ case TAG_PREVIEW:
+ updatePostsWithTag(getCurrentTag(), RequestDataAction.LOAD_NEWER, ReaderTypes.RefreshType.MANUAL);
+ break;
+ case BLOG_PREVIEW:
+ updatePostsInCurrentBlog(RequestDataAction.LOAD_NEWER);
+ break;
+ }
+ }
+ }
+ );
+
+ return rootView;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ mPullToRefreshHelper.registerReceiver(getActivity());
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ mPullToRefreshHelper.unregisterReceiver(getActivity());
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ if (activity instanceof OnPostSelectedListener) {
+ mPostSelectedListener = (OnPostSelectedListener) activity;
+ }
+ if (activity instanceof OnTagSelectedListener) {
+ mOnTagSelectedListener = (OnTagSelectedListener) activity;
+ }
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ setHasOptionsMenu(true);
+ checkActionBar();
+
+ // assign the post list adapter
+ boolean adapterAlreadyExists = hasPostAdapter();
+ mListView.setAdapter(getPostAdapter());
+
+ // if adapter didn't already exist, populate it now then update the tag/blog - this
+ // check is important since without it the adapter would be reset and posts would
+ // be updated every time the user moves between fragments
+ if (!adapterAlreadyExists) {
+ boolean isRecreated = (savedInstanceState != null);
+ switch (getPostListType()) {
+ case TAG_FOLLOWED:
+ case TAG_PREVIEW:
+ getPostAdapter().setCurrentTag(mCurrentTag);
+ if (!isRecreated && ReaderTagTable.shouldAutoUpdateTag(mCurrentTag)) {
+ updatePostsWithTag(getCurrentTag(), RequestDataAction.LOAD_NEWER, ReaderTypes.RefreshType.AUTOMATIC);
+ }
+ break;
+ case BLOG_PREVIEW:
+ getPostAdapter().setCurrentBlog(mCurrentBlogId);
+ if (!isRecreated) {
+ updatePostsInCurrentBlog(RequestDataAction.LOAD_NEWER);
+ }
+ break;
+ }
+ }
+
+ switch (getPostListType()) {
+ case BLOG_PREVIEW:
+ loadBlogInfo();
+ // listen for scroll changes so we can scale the mshot and reposition the blogInfo
+ // as the user scrolls
+ mListView.setOnScrollChangedListener(this);
+ break;
+ case TAG_PREVIEW:
+ updateTagPreviewHeader();
+ break;
+ }
+
+ getPostAdapter().setOnTagSelectedListener(mOnTagSelectedListener);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ super.onCreateOptionsMenu(menu, inflater);
+ // only followed tag list has a menu
+ if (getPostListType() == ReaderTypes.ReaderPostListType.TAG_FOLLOWED) {
+ inflater.inflate(R.menu.reader_native, menu);
+ checkActionBar();
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.menu_tags:
+ ReaderActivityLauncher.showReaderSubsForResult(getActivity());
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ /*
+ * show/hide progress bar which appears at the bottom of the activity when loading more posts
+ */
+ private void showLoadingProgress() {
+ if (hasActivity() && mProgress != null) {
+ mProgress.bringToFront();
+ mProgress.setVisibility(View.VISIBLE);
+ }
+ }
+
+ private void hideLoadingProgress() {
+ if (hasActivity() && mProgress != null) {
+ mProgress.setVisibility(View.GONE);
+ }
+ }
+
+ /*
+ * ensures that the ActionBar is correctly configured based on the type of list
+ */
+ private void checkActionBar() {
+ final ActionBar actionBar = getActionBar();
+ if (actionBar == null) {
+ return;
+ }
+
+ if (getPostListType().equals(ReaderTypes.ReaderPostListType.TAG_FOLLOWED)) {
+ // only change if we're not in list navigation mode, since that means the actionBar
+ // is already correctly configured
+ if (actionBar.getNavigationMode() != ActionBar.NAVIGATION_MODE_LIST) {
+ actionBar.setDisplayShowTitleEnabled(false);
+ actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
+ actionBar.setListNavigationCallbacks(getActionBarAdapter(), this);
+ selectTagInActionBar(getCurrentTag());
+ }
+ } else {
+ actionBar.setDisplayShowTitleEnabled(true);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
+ }
+ }
+
+ private void startBoxAndPagesAnimation() {
+ if (!hasActivity()) {
+ return;
+ }
+
+ Animation animPage1 = AnimationUtils.loadAnimation(getActivity(),
+ R.anim.box_with_pages_slide_up_page1);
+ ImageView page1 = (ImageView) getView().findViewById(R.id.empty_tags_box_page1);
+ page1.startAnimation(animPage1);
+
+ Animation animPage2 = AnimationUtils.loadAnimation(getActivity(),
+ R.anim.box_with_pages_slide_up_page2);
+ ImageView page2 = (ImageView) getView().findViewById(R.id.empty_tags_box_page2);
+ page2.startAnimation(animPage2);
+
+ Animation animPage3 = AnimationUtils.loadAnimation(getActivity(),
+ R.anim.box_with_pages_slide_up_page3);
+ ImageView page3 = (ImageView) getView().findViewById(R.id.empty_tags_box_page3);
+ page3.startAnimation(animPage3);
+ }
+
+ private void setEmptyTitleAndDescriptionForCurrentTag() {
+ if (!hasActivity() || getActionBarAdapter() == null) {
+ return;
+ }
+
+ int title;
+ int description = -1;
+ if (isUpdating()) {
+ title = R.string.reader_empty_posts_in_tag_updating;
+ } else {
+ int tagIndex = getActionBarAdapter().getIndexOfTag(mCurrentTag);
+
+ final String tagId;
+ if (tagIndex > -1) {
+ ReaderTag tag = (ReaderTag) getActionBarAdapter().getItem(tagIndex);
+ tagId = tag.getStringIdFromEndpoint();
+ } else {
+ tagId = "";
+ }
+ if (tagId.equals(ReaderTag.TAG_ID_FOLLOWING)) {
+ title = R.string.reader_empty_followed_blogs_title;
+ description = R.string.reader_empty_followed_blogs_description;
+ } else {
+ if (tagId.equals(ReaderTag.TAG_ID_LIKED)) {
+ title = R.string.reader_empty_posts_liked;
+ } else {
+ title = R.string.reader_empty_posts_in_tag;
+ }
+ }
+ }
+
+ TextView titleView = (TextView) getView().findViewById(R.id.title_empty);
+ TextView descriptionView = (TextView) getView().findViewById(R.id.description_empty);
+ titleView.setText(getString(title));
+ if (description == -1) {
+ descriptionView.setVisibility(View.INVISIBLE);
+ } else {
+ descriptionView.setText(getString(description));
+ descriptionView.setVisibility(View.VISIBLE);
+ }
+ }
+
+ /*
+ * called by post adapter when data has been loaded
+ */
+ private final ReaderActions.DataLoadedListener mDataLoadedListener = new ReaderActions.DataLoadedListener() {
+ @Override
+ public void onDataLoaded(boolean isEmpty) {
+ if (!hasActivity())
+ return;
+ // empty text/animation is only show when displaying posts with a specific tag
+ if (isEmpty && getPostListType().isTagType()) {
+ startBoxAndPagesAnimation();
+ setEmptyTitleAndDescriptionForCurrentTag();
+ mEmptyView.setVisibility(View.VISIBLE);
+ } else {
+ mEmptyView.setVisibility(View.GONE);
+ // restore listView state - this returns to the previously scrolled-to item
+ if (mListState != null && mListView != null) {
+ mListView.onRestoreInstanceState(mListState);
+ mListState = null;
+ }
+ }
+ }
+ };
+
+ /*
+ * called by post adapter to load older posts when user scrolls to the last post
+ */
+ private final ReaderActions.DataRequestedListener mDataRequestedListener = new ReaderActions.DataRequestedListener() {
+ @Override
+ public void onRequestData() {
+ // skip if update is already in progress
+ if (isUpdating()) {
+ return;
+ }
+
+ switch (getPostListType()) {
+ case TAG_FOLLOWED:
+ case TAG_PREVIEW:
+ // skip if we already have the max # of posts
+ if (ReaderPostTable.getNumPostsWithTag(mCurrentTag) < ReaderConstants.READER_MAX_POSTS_TO_DISPLAY) {
+ // request older posts
+ updatePostsWithTag(getCurrentTag(), RequestDataAction.LOAD_OLDER, ReaderTypes.RefreshType.MANUAL);
+ AnalyticsTracker.track(AnalyticsTracker.Stat.READER_INFINITE_SCROLL);
+ }
+ break;
+
+ case BLOG_PREVIEW:
+ if (ReaderPostTable.getNumPostsInBlog(mCurrentBlogId) < ReaderConstants.READER_MAX_POSTS_TO_DISPLAY) {
+ updatePostsInCurrentBlog(RequestDataAction.LOAD_OLDER);
+ AnalyticsTracker.track(AnalyticsTracker.Stat.READER_INFINITE_SCROLL);
+ }
+ break;
+ }
+ }
+ };
+
+ /*
+ * called by post adapter when user requests to reblog a post
+ */
+ private final ReaderActions.RequestReblogListener mReblogListener = new ReaderActions.RequestReblogListener() {
+ @Override
+ public void onRequestReblog(ReaderPost post, View view) {
+ if (hasActivity()) {
+ ReaderActivityLauncher.showReaderReblogForResult(getActivity(), post, view);
+ }
+ }
+ };
+
+ private ReaderPostAdapter getPostAdapter() {
+ if (mPostAdapter == null) {
+ AppLog.d(T.READER, "reader post list > creating post adapter");
+
+ mPostAdapter = new ReaderPostAdapter(getActivity(),
+ getPostListType(),
+ mReblogListener,
+ mDataLoadedListener,
+ mDataRequestedListener);
+ }
+ return mPostAdapter;
+ }
+
+ private boolean hasPostAdapter() {
+ return (mPostAdapter != null);
+ }
+
+ boolean isPostAdapterEmpty() {
+ return (mPostAdapter == null || mPostAdapter.isEmpty());
+ }
+
+ ReaderBlogIdPostIdList getBlogIdPostIdList() {
+ if (hasPostAdapter()) {
+ return getPostAdapter().getBlogIdPostIdList();
+ } else {
+ return new ReaderBlogIdPostIdList();
+ }
+ }
+
+ private boolean isCurrentTag(final ReaderTag tag) {
+ return ReaderTag.isSameTag(tag, mCurrentTag);
+ }
+ private boolean isCurrentTagName(String tagName) {
+ return (tagName != null && tagName.equalsIgnoreCase(getCurrentTagName()));
+ }
+
+ ReaderTag getCurrentTag() {
+ return mCurrentTag;
+ }
+
+ String getCurrentTagName() {
+ return (mCurrentTag != null ? mCurrentTag.getTagName() : "");
+ }
+
+ private boolean hasCurrentTag() {
+ return mCurrentTag != null;
+ }
+
+ void setCurrentTagName(String tagName) {
+ setCurrentTagName(tagName, true);
+ }
+ void setCurrentTagName(String tagName, boolean allowAutoUpdate) {
+ if (TextUtils.isEmpty(tagName)) {
+ return;
+ }
+ setCurrentTag(new ReaderTag(tagName, ReaderTagType.FOLLOWED), allowAutoUpdate);
+ }
+ void setCurrentTag(final ReaderTag tag) {
+ setCurrentTag(tag, true);
+ }
+ void setCurrentTag(final ReaderTag tag, boolean allowAutoUpdate) {
+ if (tag == null) {
+ return;
+ }
+
+ // skip if this is already the current tag and the post adapter is already showing it - this
+ // will happen when the list fragment is restored and the current tag is re-selected in the
+ // actionBar dropdown
+ if (isCurrentTag(tag)
+ && hasPostAdapter()
+ && getPostAdapter().isCurrentTag(tag)) {
+ return;
+ }
+
+ mCurrentTag = tag;
+
+ switch (getPostListType()) {
+ case TAG_FOLLOWED:
+ // remember this as the current tag if viewing followed tag
+ UserPrefs.setReaderTag(tag);
+ break;
+ case TAG_PREVIEW:
+ mTagPreviewHistory.push(tag.getTagName());
+ break;
+ }
+
+ getPostAdapter().setCurrentTag(tag);
+ hideNewPostsBar();
+ updateTagPreviewHeader();
+ hideLoadingProgress();
+
+ // update posts in this tag if it's time to do so
+ if (allowAutoUpdate && ReaderTagTable.shouldAutoUpdateTag(tag)) {
+ updatePostsWithTag(tag, RequestDataAction.LOAD_NEWER, ReaderTypes.RefreshType.AUTOMATIC);
+ }
+ }
+
+ /*
+ * when previewing posts with a specific tag, a history of previewed tags is retained so
+ * the user can navigate back through them - this is faster and requires less memory
+ * than creating a new fragment for each previewed tag
+ */
+ boolean goBackInTagHistory() {
+ if (mTagPreviewHistory.empty()) {
+ return false;
+ }
+
+ String tag = mTagPreviewHistory.pop();
+ if (isCurrentTagName(tag)) {
+ if (mTagPreviewHistory.empty()) {
+ return false;
+ }
+ tag = mTagPreviewHistory.pop();
+ }
+
+ setCurrentTagName(tag, false);
+ return true;
+ }
+
+ /*
+ * if we're previewing a tag, show the current tag name in the header and update the
+ * follow button to show the correct follow state for the tag
+ */
+ private void updateTagPreviewHeader() {
+ if (mTagInfoView == null) {
+ return;
+ }
+
+ final TextView txtTagName = (TextView) mTagInfoView.findViewById(R.id.text_tag_name);
+ String color = HtmlUtils.colorResToHtmlColor(getActivity(), R.color.grey_extra_dark);
+ String htmlTag = "<font color=" + color + ">" + getCurrentTagName() + "</font>";
+ String htmlLabel = getString(R.string.reader_label_tag_preview, htmlTag);
+ txtTagName.setText(Html.fromHtml(htmlLabel));
+
+ final TextView txtFollow = (TextView) mTagInfoView.findViewById(R.id.text_follow_blog);
+ ReaderUtils.showFollowStatus(txtFollow, ReaderTagTable.isFollowedTagName(getCurrentTagName()));
+
+ txtFollow.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ ReaderAnim.animateFollowButton(txtFollow);
+ boolean isAskingToFollow = !ReaderTagTable.isFollowedTagName(getCurrentTagName());
+ TagAction action = (isAskingToFollow ? TagAction.ADD : TagAction.DELETE);
+ if (ReaderTagActions.performTagAction(getCurrentTag(), action, null)) {
+ ReaderUtils.showFollowStatus(txtFollow, isAskingToFollow);
+ }
+ }
+ });
+ }
+
+ /*
+ * refresh adapter so latest posts appear
+ */
+ private void refreshPosts() {
+ if (hasPostAdapter()) {
+ getPostAdapter().refresh();
+ }
+ }
+
+ /*
+ * tell the adapter to reload a single post - called when user returns from detail, where the
+ * post may have been changed (either by the user, or because it updated)
+ */
+ void reloadPost(ReaderPost post) {
+ if (post != null && hasPostAdapter()) {
+ getPostAdapter().reloadPost(post);
+ }
+ }
+
+ /*
+ * reload the list of posts
+ */
+ private void reloadPosts() {
+ getPostAdapter().reload();
+ }
+
+ private boolean hasActivity() {
+ return (getActivity() != null && !isRemoving());
+ }
+
+ /*
+ * get posts for the current blog from the server
+ */
+ void updatePostsInCurrentBlog(final RequestDataAction updateAction) {
+ if (!NetworkUtils.isNetworkAvailable(getActivity())) {
+ AppLog.i(T.READER, "reader post list > network unavailable, canceled blog update");
+ return;
+ }
+
+ setIsUpdating(true, updateAction);
+
+ ReaderActions.ActionListener listener = new ReaderActions.ActionListener() {
+ @Override
+ public void onActionResult(boolean succeeded) {
+ if (!hasActivity()) {
+ return;
+ }
+ setIsUpdating(false, updateAction);
+ if (succeeded) {
+ refreshPosts();
+ }
+ }
+ };
+ ReaderPostActions.requestPostsForBlog(mCurrentBlogId, mCurrentBlogUrl, updateAction, listener);
+ }
+
+ /*
+ * get latest posts for this tag from the server
+ */
+ void updatePostsWithTag(final ReaderTag tag,
+ final RequestDataAction updateAction,
+ final ReaderTypes.RefreshType refreshType) {
+ if (tag == null) {
+ return;
+ }
+
+ if (!NetworkUtils.isNetworkAvailable(getActivity())) {
+ AppLog.i(T.READER, "reader post list > network unavailable, canceled update");
+ return;
+ }
+
+ setIsUpdating(true, updateAction);
+ setEmptyTitleAndDescriptionForCurrentTag();
+
+ // go no further if we're viewing a followed tag and the tag table is empty - this will
+ // occur when the Reader is accessed for the first time (ie: fresh install) - note that
+ // this check is purposely done after the "Refreshing" message is shown since we want
+ // that to appear in this situation - ReaderActivity will take of re-issuing this
+ // update request once tag data has been populated
+ if (getPostListType() == ReaderTypes.ReaderPostListType.TAG_FOLLOWED && ReaderTagTable.isEmpty()) {
+ AppLog.d(T.READER, "reader post list > empty followed tags, canceled update");
+ return;
+ }
+
+ // if this is "Posts I Like" or "Blogs I Follow" and it's a manual refresh (user tapped refresh icon),
+ // refresh the posts so posts that were unliked/unfollowed no longer appear
+ if (refreshType == ReaderTypes.RefreshType.MANUAL && isCurrentTag(tag)) {
+ if (tag.getTagName().equals(ReaderTag.TAG_NAME_LIKED) || tag.getTagName().equals(ReaderTag.TAG_NAME_FOLLOWING))
+ refreshPosts();
+ }
+
+ ReaderActions.UpdateResultAndCountListener resultListener = new ReaderActions.UpdateResultAndCountListener() {
+ @Override
+ public void onUpdateResult(ReaderActions.UpdateResult result, int numNewPosts) {
+ if (!hasActivity()) {
+ AppLog.w(T.READER, "reader post list > new posts when fragment has no activity");
+ return;
+ }
+
+ setIsUpdating(false, updateAction);
+
+ // make sure this is still the current tag (user may have switched tags during the update)
+ if (!isCurrentTag(tag)) {
+ AppLog.i(T.READER, "reader post list > new posts in inactive tag");
+ return;
+ }
+
+ if (result == ReaderActions.UpdateResult.CHANGED && numNewPosts > 0) {
+ // show the "new posts" bar rather than immediately update the list
+ // if the user is viewing posts for a followed tag, posts are already
+ // displayed, and the user has scrolled the list
+ if (!isPostAdapterEmpty()
+ && getPostListType().equals(ReaderTypes.ReaderPostListType.TAG_FOLLOWED)
+ && updateAction == RequestDataAction.LOAD_NEWER
+ && !isListScrolledToTop()) {
+ showNewPostsBar();
+ } else {
+ refreshPosts();
+ }
+ } else {
+ // update empty view title and description if the the post list is empty
+ setEmptyTitleAndDescriptionForCurrentTag();
+ }
+ }
+ };
+
+ // if this is a request for newer posts and posts with this tag already exist, assign
+ // a backfill listener to ensure there aren't any gaps between this update and the previous one
+ boolean allowBackfill = (updateAction == RequestDataAction.LOAD_NEWER && !isPostAdapterEmpty());
+ if (allowBackfill) {
+ ReaderActions.PostBackfillListener backfillListener = new ReaderActions.PostBackfillListener() {
+ @Override
+ public void onPostsBackfilled() {
+ if (!hasActivity()) {
+ AppLog.w(T.READER, "reader post list > new posts backfilled when fragment has no activity");
+ return;
+ }
+ if (!isCurrentTag(tag)) {
+ AppLog.i(T.READER, "reader post list > new posts backfilled in inactive tag");
+ } else if (isPostAdapterEmpty()) {
+ // show the new posts right away if this is the current tag and there aren't
+ // any posts showing, otherwise just let them be shown on the next refresh
+ refreshPosts();
+ }
+ }
+ };
+ ReaderPostActions.updatePostsInTagWithBackfill(tag, resultListener, backfillListener);
+ } else {
+ ReaderPostActions.updatePostsInTag(tag, updateAction, resultListener);
+ }
+ }
+
+ boolean isUpdating() {
+ return mIsUpdating;
+ }
+
+ private boolean hasPullToRefresh() {
+ return (mPullToRefreshHelper != null);
+ }
+
+ void setIsUpdating(boolean isUpdating, RequestDataAction updateAction) {
+ if (!hasActivity() || mIsUpdating == isUpdating) {
+ return;
+ }
+ switch (updateAction) {
+ case LOAD_OLDER:
+ // if these are older posts, show/hide message bar at bottom
+ if (isUpdating) {
+ showLoadingProgress();
+ } else {
+ hideLoadingProgress();
+ }
+ break;
+ default:
+ if (hasPullToRefresh()) {
+ mPullToRefreshHelper.setRefreshing(isUpdating);
+ }
+ break;
+ }
+ mIsUpdating = isUpdating;
+ }
+
+ /*
+ * bar that appears at the top when new posts have been retrieved
+ */
+ private boolean isNewPostsBarShowing() {
+ return (mNewPostsBar != null && mNewPostsBar.getVisibility() == View.VISIBLE);
+ }
+
+ private void showNewPostsBar() {
+ if (!hasActivity() || isNewPostsBarShowing()) {
+ return;
+ }
+
+ AniUtils.startAnimation(mNewPostsBar, R.anim.reader_top_bar_in);
+ mNewPostsBar.setVisibility(View.VISIBLE);
+ }
+
+ private void hideNewPostsBar() {
+ if (!hasActivity() || !isNewPostsBarShowing()) {
+ return;
+ }
+
+ Animation.AnimationListener listener = new Animation.AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation animation) {
+ }
+
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ mNewPostsBar.setVisibility(View.GONE);
+ }
+
+ @Override
+ public void onAnimationRepeat(Animation animation) {
+ }
+ };
+ AniUtils.startAnimation(mNewPostsBar, R.anim.reader_top_bar_out, listener);
+ }
+
+ /*
+ * make sure current tag still exists, reset to default if it doesn't
+ */
+ private void checkCurrentTag() {
+ if (hasCurrentTag()
+ && getPostListType().equals(ReaderTypes.ReaderPostListType.TAG_FOLLOWED)
+ && !ReaderTagTable.tagExists(getCurrentTag())) {
+ mCurrentTag = ReaderTag.getDefaultTag();
+ }
+ }
+
+ /*
+ * refresh the list of tags shown in the ActionBar
+ */
+ void refreshTags() {
+ if (!hasActivity()) {
+ return;
+ }
+ checkCurrentTag();
+ if (hasActionBarAdapter()) {
+ getActionBarAdapter().refreshTags();
+ }
+ }
+
+ /*
+ * called from host activity after user adds/removes tags
+ */
+ void doTagsChanged(final String newCurrentTag) {
+ checkCurrentTag();
+ getActionBarAdapter().reloadTags();
+ if (!TextUtils.isEmpty(newCurrentTag)) {
+ setCurrentTagName(newCurrentTag);
+ }
+ }
+
+ /*
+ * are we showing all posts with a specific tag (followed or previewed), or all
+ * posts in a specific blog?
+ */
+ ReaderPostListType getPostListType() {
+ return (mPostListType != null ? mPostListType : ReaderTypes.DEFAULT_POST_LIST_TYPE);
+ }
+
+ private boolean isListScrolledToTop() {
+ return (mListView != null && mListView.isScrolledToTop());
+ }
+
+ /*
+ * let the post adapter know when we're in a fling - this way the adapter can
+ * skip pre-loading images during a fling
+ */
+ @Override
+ public void onScrollStateChanged(AbsListView absListView, int scrollState) {
+ boolean isFlingingNow = (scrollState == SCROLL_STATE_FLING);
+ if (isFlingingNow != mIsFlinging) {
+ mIsFlinging = isFlingingNow;
+ if (hasPostAdapter()) {
+ getPostAdapter().setIsFlinging(mIsFlinging);
+ }
+ }
+ }
+
+ @Override
+ public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
+ // nop
+ }
+
+ /*
+ * ActionBar tag dropdown adapter
+ */
+ private ReaderActionBarTagAdapter getActionBarAdapter() {
+ if (mActionBarAdapter == null) {
+ AppLog.d(T.READER, "reader post list > creating ActionBar adapter");
+ ReaderActions.DataLoadedListener dataListener = new ReaderActions.DataLoadedListener() {
+ @Override
+ public void onDataLoaded(boolean isEmpty) {
+ if (!hasActivity())
+ return;
+ AppLog.d(T.READER, "reader post list > ActionBar adapter loaded");
+ selectTagInActionBar(getCurrentTag());
+ }
+ };
+ mActionBarAdapter = new ReaderActionBarTagAdapter(
+ getActivity(),
+ hasStaticMenuDrawer(),
+ dataListener);
+ }
+
+ return mActionBarAdapter;
+ }
+
+ private boolean hasActionBarAdapter() {
+ return (mActionBarAdapter != null);
+ }
+
+ /*
+ * does the host activity have a static menu drawer?
+ */
+ private boolean hasStaticMenuDrawer() {
+ return (getActivity() instanceof WPActionBarActivity)
+ && ((WPActionBarActivity) getActivity()).isStaticMenuDrawer();
+ }
+
+ private ActionBar getActionBar() {
+ if (hasActivity()) {
+ return getActivity().getActionBar();
+ } else {
+ AppLog.w(T.READER, "reader post list > null ActionBar");
+ return null;
+ }
+ }
+
+ /*
+ * make sure the passed tag is the one selected in the actionbar
+ */
+ private void selectTagInActionBar(final ReaderTag tag) {
+ ActionBar actionBar = getActionBar();
+ if (actionBar == null) {
+ return;
+ }
+
+ int position = getActionBarAdapter().getIndexOfTag(tag);
+ if (position == -1 || position == actionBar.getSelectedNavigationIndex()) {
+ return;
+ }
+
+ if (actionBar.getNavigationMode() != ActionBar.NAVIGATION_MODE_LIST) {
+ AppLog.w(T.READER, "reader post list > unexpected ActionBar navigation mode");
+ return;
+ }
+
+ actionBar.setSelectedNavigationItem(position);
+ }
+
+ /*
+ * called when user selects a tag from the ActionBar dropdown
+ */
+ @Override
+ public boolean onNavigationItemSelected(int itemPosition, long itemId) {
+ final ReaderTag tag = (ReaderTag) getActionBarAdapter().getItem(itemPosition);
+ if (tag == null) {
+ return false;
+ }
+
+ if (!isCurrentTag(tag)) {
+ Map<String, String> properties = new HashMap<String, String>();
+ properties.put("tag", tag.getTagName());
+ AnalyticsTracker.track(AnalyticsTracker.Stat.READER_LOADED_TAG, properties);
+ if (tag.getTagName().equals(ReaderTag.TAG_NAME_FRESHLY_PRESSED)) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.READER_LOADED_FRESHLY_PRESSED);
+ }
+ }
+
+ setCurrentTag(tag);
+ AppLog.d(T.READER, String.format("reader post list > tag %s chosen from actionbar", tag.getTagNameForLog()));
+
+ return true;
+ }
+
+ @Override
+ public void onScrollChanged() {
+ // onScrollChanged is only called when previewing posts in a specific blog so we
+ // can scale & reposition the blogInfo that appears above the listView
+ repositionBlogInfoView();
+ }
+
+ /*
+ * scale & reposition blog info based on the listView's scroll position
+ */
+ private void repositionBlogInfoView() {
+ int scrollPos = mListView.getVerticalScrollOffset();
+ if (mBlogInfoView == null) {
+ return;
+ }
+
+ // scale the mshot based on the scroll position
+ mBlogInfoView.scaleMshotImageBasedOnScrollPos(scrollPos);
+
+ // get the first child of the listView and determine whether it's the mshot spacer
+ // we added in onCreateVew
+ View firstChild = mListView.getChildAt(0);
+ boolean isSpacer = (firstChild != null && MSHOT_SPACER_TAG.equals(firstChild.getTag()));
+
+ // if it is the spacer, the top of the blog info container should move to match the top
+ // of the spacer (which will be negative if list is scrolled) plus the height of the
+ // mshot - and if it's not the spacer, then it means the spacer has been scrolled out
+ // of view which means the blog info container should stick to the top
+ final int infoTop;
+ if (isSpacer) {
+ infoTop = Math.max(0, firstChild.getTop() + mBlogInfoView.getMshotHeight());
+ } else {
+ infoTop = 0;
+ }
+
+ mBlogInfoView.moveInfoContainer(infoTop);
+ }
+
+ /*
+ * tell the blog info view to show the current blog if it's not already loaded
+ */
+ private void loadBlogInfo() {
+ if (mBlogInfoView != null && mBlogInfoView.isEmpty()) {
+ AppLog.d(T.READER, "reader post list > loading blogInfo");
+ mBlogInfoView.loadBlogInfo(mCurrentBlogId, mCurrentBlogUrl, new ReaderBlogInfoView.BlogInfoListener() {
+ @Override
+ public void onBlogInfoLoaded() {
+ // nop
+ }
+ @Override
+ public void onBlogInfoFailed() {
+ if (hasActivity()) {
+ // blog couldn't be shown, alert user then back out after a brief delay
+ ToastUtils.showToast(getActivity(), R.string.reader_toast_err_get_blog_info);
+ new Handler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ if (hasActivity()) {
+ getActivity().onBackPressed();
+ }
+ }
+ }, 1000);
+ }
+ }
+ }
+ );
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostPagerActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostPagerActivity.java
new file mode 100644
index 000000000..a646438d6
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostPagerActivity.java
@@ -0,0 +1,333 @@
+package org.wordpress.android.ui.reader;
+
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.app.ActionBar;
+import android.app.Activity;
+import android.app.Fragment;
+import android.app.FragmentManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.v13.app.FragmentStatePagerAdapter;
+import android.support.v4.view.ViewPager;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.animation.OvershootInterpolator;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.ui.reader.ReaderTypes.ReaderPostListType;
+import org.wordpress.android.ui.reader.models.ReaderBlogIdPostId;
+import org.wordpress.android.ui.reader.models.ReaderBlogIdPostIdList;
+
+import java.io.Serializable;
+import java.lang.ref.WeakReference;
+import java.util.HashMap;
+
+public class ReaderPostPagerActivity extends Activity
+ implements ReaderUtils.FullScreenListener {
+
+ static final String ARG_BLOG_POST_ID_LIST = "blog_post_id_list";
+ static final String ARG_POSITION = "position";
+ static final String ARG_TITLE = "title";
+
+ private ViewPager mViewPager;
+ private PostPagerAdapter mPageAdapter;
+ private boolean mIsFullScreen;
+ private ReaderPostListType mPostListType;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ if (isFullScreenSupported()) {
+ getWindow().requestFeature(Window.FEATURE_ACTION_BAR_OVERLAY);
+ }
+
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.reader_activity_post_pager);
+
+ // remove the window background since each fragment already has a background color
+ getWindow().setBackgroundDrawable(null);
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayShowTitleEnabled(true);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+
+ final int position;
+ final String title;
+ final Serializable serializedList;
+ if (savedInstanceState != null) {
+ position = savedInstanceState.getInt(ARG_POSITION, 0);
+ title = savedInstanceState.getString(ARG_TITLE);
+ serializedList = savedInstanceState.getSerializable(ARG_BLOG_POST_ID_LIST);
+ if (savedInstanceState.containsKey(ReaderConstants.ARG_POST_LIST_TYPE)) {
+ mPostListType = (ReaderPostListType) savedInstanceState.getSerializable(ReaderConstants.ARG_POST_LIST_TYPE);
+ }
+ } else {
+ position = getIntent().getIntExtra(ARG_POSITION, 0);
+ title = getIntent().getStringExtra(ARG_TITLE);
+ serializedList = getIntent().getSerializableExtra(ARG_BLOG_POST_ID_LIST);
+ if (getIntent().hasExtra(ReaderConstants.ARG_POST_LIST_TYPE)) {
+ mPostListType = (ReaderPostListType) getIntent().getSerializableExtra(ReaderConstants.ARG_POST_LIST_TYPE);
+ }
+ }
+
+ if (!TextUtils.isEmpty(title)) {
+ this.setTitle(title);
+ }
+
+ mViewPager = (ViewPager) findViewById(R.id.viewpager);
+ mPageAdapter = new PostPagerAdapter(getFragmentManager(), new ReaderBlogIdPostIdList(serializedList));
+ mViewPager.setAdapter(mPageAdapter);
+ if (mPageAdapter.isValidPosition(position)) {
+ mViewPager.setCurrentItem(position);
+ }
+
+ mViewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
+ @Override
+ public void onPageSelected(int position) {
+ super.onPageSelected(position);
+ onRequestFullScreen(false);
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int state) {
+ super.onPageScrollStateChanged(state);
+ if (state == ViewPager.SCROLL_STATE_DRAGGING) {
+ // return from fullscreen and pause the active web view when the user
+ // starts scrolling - important because otherwise embedded content in
+ // the web view will continue to play
+ onRequestFullScreen(false);
+ ReaderPostDetailFragment fragment = getActiveDetailFragment();
+ if (fragment != null) {
+ fragment.pauseWebView();
+ }
+ }
+ }
+ });
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ outState.putString(ARG_TITLE, (String) this.getTitle());
+ if (mViewPager != null) {
+ outState.putInt(ARG_POSITION, mViewPager.getCurrentItem());
+ }
+ if (mPageAdapter != null) {
+ outState.putSerializable(ARG_BLOG_POST_ID_LIST, mPageAdapter.mIdList);
+ }
+ if (mPostListType != null) {
+ outState.putSerializable(ReaderConstants.ARG_POST_LIST_TYPE, mPostListType);
+ }
+ super.onSaveInstanceState(outState);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ onBackPressed();
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ @Override
+ public void onBackPressed() {
+ // if fullscreen video is showing, hide the custom view rather than navigate back
+ ReaderPostDetailFragment fragment = getActiveDetailFragment();
+ if (fragment != null && fragment.isCustomViewShowing()) {
+ fragment.hideCustomView();
+ } else {
+ super.onBackPressed();
+ }
+ }
+
+ @Override
+ public boolean onRequestFullScreen(boolean enableFullScreen) {
+ if (!isFullScreenSupported() || enableFullScreen == mIsFullScreen) {
+ return false;
+ }
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ if (enableFullScreen) {
+ actionBar.hide();
+ } else {
+ actionBar.show();
+ }
+ }
+
+ mIsFullScreen = enableFullScreen;
+ return true;
+ }
+
+ ReaderPostListType getPostListType() {
+ return mPostListType;
+ }
+
+ @Override
+ public boolean isFullScreen() {
+ return mIsFullScreen;
+ }
+
+ @Override
+ public boolean isFullScreenSupported() {
+ return true;
+ }
+
+ private ReaderPostDetailFragment getActiveDetailFragment() {
+ if (mViewPager == null || mPageAdapter == null) {
+ return null;
+ }
+
+ Fragment fragment = mPageAdapter.getFragmentAtPosition(mViewPager.getCurrentItem());
+ if (fragment instanceof ReaderPostDetailFragment) {
+ return (ReaderPostDetailFragment) fragment;
+ } else {
+ return null;
+ }
+ }
+
+ private class PostPagerAdapter extends FragmentStatePagerAdapter {
+ private final ReaderBlogIdPostIdList mIdList;
+ private final long END_ID = -1;
+
+ // this is used to retain a weak reference to created fragments so we can access them
+ // in getFragmentAtPosition() - necessary because we need to pause the web view in
+ // the active fragment when the user swipes away from it, but the adapter provides
+ // no way to access the active fragment
+ private final HashMap<String, WeakReference<Fragment>> mFragmentMap =
+ new HashMap<String, WeakReference<Fragment>>();
+
+ PostPagerAdapter(FragmentManager fm, ReaderBlogIdPostIdList idList) {
+ super(fm);
+ mIdList = (ReaderBlogIdPostIdList) idList.clone();
+ // add a bogus entry to the end of the list so we can show PostPagerEndFragment
+ // when the user scrolls beyond the last post - note that this is only done
+ // if there's more than one post
+ if (mIdList.size() > 1 && mIdList.indexOf(END_ID, END_ID) == -1) {
+ mIdList.add(new ReaderBlogIdPostId(END_ID, END_ID));
+ }
+ }
+
+ boolean isValidPosition(int position) {
+ return (position >= 0 && position < getCount());
+ }
+
+ @Override
+ public int getCount() {
+ return mIdList.size();
+ }
+
+ @Override
+ public Fragment getItem(int position) {
+ long blogId = mIdList.get(position).getBlogId();
+ long postId = mIdList.get(position).getPostId();
+
+ Fragment fragment;
+ if (blogId == END_ID && postId == END_ID) {
+ fragment = PostPagerEndFragment.newInstance();
+ } else {
+ fragment = ReaderPostDetailFragment.newInstance(blogId, postId, getPostListType());
+ }
+
+ mFragmentMap.put(getItemKey(position), new WeakReference<Fragment>(fragment));
+
+ return fragment;
+ }
+
+ @Override
+ public void destroyItem(ViewGroup container, int position, Object object) {
+ mFragmentMap.remove(getItemKey(position));
+ super.destroyItem(container, position, object);
+ }
+
+ private String getItemKey(int position) {
+ return mIdList.get(position).getBlogId() + ":" + mIdList.get(position).getPostId();
+ }
+
+ private Fragment getFragmentAtPosition(int position) {
+ if (!isValidPosition(position)) {
+ return null;
+ }
+ String key = getItemKey(position);
+ if (!mFragmentMap.containsKey(key)) {
+ return null;
+ }
+ return mFragmentMap.get(key).get();
+ }
+ }
+
+ /*
+ * fragment that appears when user scrolls beyond the last post
+ */
+ public static class PostPagerEndFragment extends Fragment {
+ private TextView mTxtCheckmark;
+
+ private static PostPagerEndFragment newInstance() {
+ return new PostPagerEndFragment();
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.reader_fragment_end, container, false);
+ view.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (getActivity() != null) {
+ getActivity().finish();
+ }
+ }
+ });
+
+ mTxtCheckmark = (TextView) view.findViewById(R.id.text_checkmark);
+
+ return view;
+ }
+
+ private boolean hasActivity() {
+ return (getActivity() != null && getView() != null && !isRemoving());
+ }
+
+ @Override
+ public void setUserVisibleHint(boolean isVisibleToUser) {
+ // setUserVisibleHint wasn't available until API 15 (ICE_CREAM_SANDWICH_MR1)
+ if (Build.VERSION.SDK_INT >= 15) {
+ super.setUserVisibleHint(isVisibleToUser);
+ }
+ if (isVisibleToUser) {
+ showCheckmark();
+ } else {
+ hideCheckmark();
+ }
+ }
+
+ private void showCheckmark() {
+ if (!hasActivity()) {
+ return;
+ }
+
+ mTxtCheckmark.setVisibility(View.VISIBLE);
+
+ AnimatorSet set = new AnimatorSet();
+ set.setDuration(750);
+ set.setInterpolator(new OvershootInterpolator());
+ set.playTogether(ObjectAnimator.ofFloat(mTxtCheckmark, "scaleX", 0.25f, 1f),
+ ObjectAnimator.ofFloat(mTxtCheckmark, "scaleY", 0.25f, 1f));
+ set.start();
+ }
+
+ private void hideCheckmark() {
+ if (hasActivity()) {
+ mTxtCheckmark.setVisibility(View.INVISIBLE);
+ }
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderReblogActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderReblogActivity.java
new file mode 100644
index 000000000..899caeee0
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderReblogActivity.java
@@ -0,0 +1,374 @@
+package org.wordpress.android.ui.reader;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ObjectAnimator;
+import android.app.ActionBar;
+import android.app.Activity;
+import android.content.Intent;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Handler;
+import android.text.Html;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.DecelerateInterpolator;
+import android.widget.EditText;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.ReaderPostTable;
+import org.wordpress.android.models.ReaderPost;
+import org.wordpress.android.ui.prefs.PreferencesActivity;
+import org.wordpress.android.ui.reader.actions.ReaderActions;
+import org.wordpress.android.ui.reader.actions.ReaderPostActions;
+import org.wordpress.android.ui.reader.adapters.ReaderReblogAdapter;
+import org.wordpress.android.util.DisplayUtils;
+import org.wordpress.android.util.EditTextUtils;
+import org.wordpress.android.util.NetworkUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+import org.wordpress.android.widgets.WPNetworkImageView;
+
+/*
+ * displayed when user taps to reblog a post in the Reader
+ */
+public class ReaderReblogActivity extends Activity {
+ private long mBlogId;
+ private long mPostId;
+ private ReaderPost mPost;
+
+ private ReaderReblogAdapter mAdapter;
+ private EditText mEditComment;
+ private ViewGroup mLayoutExcerpt;
+
+ private long mDestinationBlogId;
+ private boolean mIsSubmittingReblog = false;
+
+ private static final int INTENT_SETTINGS = 200;
+ private static final String KEY_DESTINATION_BLOG_ID = "destination_blog_id";
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.reader_activity_reblog);
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayShowTitleEnabled(false);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
+ actionBar.setListNavigationCallbacks(getReblogAdapter(), new ActionBar.OnNavigationListener() {
+ @Override
+ public boolean onNavigationItemSelected(int itemPosition, long itemId) {
+ mDestinationBlogId = itemId;
+ return true;
+ }
+ });
+ }
+
+ mBlogId = getIntent().getLongExtra(ReaderConstants.ARG_BLOG_ID, 0);
+ mPostId = getIntent().getLongExtra(ReaderConstants.ARG_POST_ID, 0);
+
+ mEditComment = (EditText) findViewById(R.id.edit_comment);
+ mLayoutExcerpt = (ViewGroup) findViewById(R.id.layout_post_excerpt);
+
+ if (savedInstanceState == null) {
+ mEditComment.setVisibility(View.INVISIBLE);
+ new Handler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ if (!isFinishing()) {
+ animateCommentView();
+ }
+ }
+ }, 300);
+ }
+
+ loadPost();
+ }
+
+ void animateCommentView() {
+ int duration = getResources().getInteger(android.R.integer.config_mediumAnimTime);
+ int displayHeight = DisplayUtils.getDisplayPixelHeight(this);
+
+ ObjectAnimator commentAnim = ObjectAnimator.ofFloat(mEditComment, View.TRANSLATION_Y, displayHeight, 0f);
+ commentAnim.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animation) {
+ super.onAnimationStart(animation);
+ mEditComment.setVisibility(View.VISIBLE);
+ }
+ });
+ commentAnim.setInterpolator(new DecelerateInterpolator());
+ commentAnim.setDuration(duration);
+ commentAnim.start();
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ if (mDestinationBlogId != 0) {
+ outState.putLong(KEY_DESTINATION_BLOG_ID, mDestinationBlogId);
+ }
+ }
+
+ @Override
+ protected void onRestoreInstanceState(Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ if (savedInstanceState.containsKey(KEY_DESTINATION_BLOG_ID)) {
+ mDestinationBlogId = savedInstanceState.getLong(KEY_DESTINATION_BLOG_ID);
+ }
+ }
+
+ @Override
+ public void onBackPressed() {
+ // don't allow backing out if we're still submitting the reblog
+ if (!mIsSubmittingReblog) {
+ super.onBackPressed();
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.reader_reblog, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ onBackPressed();
+ return true;
+ case R.id.menu_publish:
+ submitReblog();
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ private void loadPost() {
+ new LoadPostTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ // reload adapter if user returned from settings since blog visibility may have changed
+ if (requestCode == INTENT_SETTINGS) {
+ getReblogAdapter().reload();
+ }
+ }
+
+ private boolean hasReblogAdapter() {
+ return (mAdapter != null);
+ }
+
+ private ReaderReblogAdapter getReblogAdapter() {
+ if (mAdapter == null) {
+ mAdapter = new ReaderReblogAdapter(this, mBlogId, new ReaderActions.DataLoadedListener() {
+ @Override
+ public void onDataLoaded(boolean isEmpty) {
+ // show empty message and hide other views if there are no visible blogs to reblog to
+ final TextView txtEmpty = (TextView) findViewById(R.id.text_empty);
+ final View scrollView = findViewById(R.id.scroll_view);
+
+ // empty message includes a link to settings so user can change blog visibility
+ if (isEmpty) {
+ String emptyMsg = getString(R.string.reader_label_reblog_empty);
+ String emptyLink = "<a href='settings'>" + getString(R.string.reader_label_reblog_empty_link) + "</a>";
+ txtEmpty.setText(Html.fromHtml(emptyMsg + "<br /><br />" + emptyLink));
+ txtEmpty.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent i = new Intent(ReaderReblogActivity.this, PreferencesActivity.class);
+ startActivityForResult(i, INTENT_SETTINGS);
+ }
+ });
+ }
+
+ txtEmpty.setVisibility(isEmpty ? View.VISIBLE : View.GONE);
+ scrollView.setVisibility(isEmpty ? View.GONE : View.VISIBLE);
+
+ // restore the previously selected destination blog id
+ if (!isEmpty && mDestinationBlogId != 0) {
+ selectBlogInActionbar(mDestinationBlogId);
+ }
+ }
+ });
+ }
+
+ return mAdapter;
+ }
+
+ private void selectBlogInActionbar(long blogId) {
+ ActionBar actionBar = getActionBar();
+ if (!hasReblogAdapter() || actionBar == null) {
+ return;
+ }
+ int index = getReblogAdapter().indexOfBlogId(blogId);
+ if (index > -1
+ && index < actionBar.getNavigationItemCount()
+ && index != actionBar.getSelectedNavigationIndex()) {
+ actionBar.setSelectedNavigationItem(index);
+ }
+ }
+
+ private void showProgress() {
+ final ViewGroup layoutProgress = (ViewGroup) findViewById(R.id.layout_progress);
+ ObjectAnimator anim = ObjectAnimator.ofFloat(layoutProgress, View.ALPHA, 0f, 1f);
+ anim.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animation) {
+ super.onAnimationStart(animation);
+ layoutProgress.setVisibility(View.VISIBLE);
+ }
+ });
+ anim.setDuration(getResources().getInteger(android.R.integer.config_mediumAnimTime));
+ anim.start();
+ }
+
+ private void hideProgress() {
+ final ViewGroup layoutProgress = (ViewGroup) findViewById(R.id.layout_progress);
+ layoutProgress.clearAnimation();
+
+ ObjectAnimator anim = ObjectAnimator.ofFloat(layoutProgress, View.ALPHA, 1f, 0f);
+ anim.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ super.onAnimationEnd(animation);
+ layoutProgress.setVisibility(View.GONE);
+ }
+ });
+ anim.setDuration(getResources().getInteger(android.R.integer.config_shortAnimTime));
+ anim.start();
+ }
+
+ private void submitReblog() {
+ if (mDestinationBlogId == 0) {
+ ToastUtils.showToast(this, R.string.reader_toast_err_reblog_requires_blog);
+ return;
+ }
+
+ if (!NetworkUtils.checkConnection(this)) {
+ return;
+ }
+
+ if (mIsSubmittingReblog) {
+ return;
+ }
+
+ String commentText = EditTextUtils.getText(mEditComment);
+ mIsSubmittingReblog = true;
+ EditTextUtils.hideSoftInput(mEditComment);
+ showProgress();
+
+ final ReaderActions.ActionListener actionListener = new ReaderActions.ActionListener() {
+ @Override
+ public void onActionResult(boolean succeeded) {
+ mIsSubmittingReblog = false;
+ if (!isFinishing()) {
+ hideProgress();
+ if (succeeded) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.READER_REBLOGGED_ARTICLE);
+ reblogSucceeded();
+ } else {
+ ToastUtils.showToast(ReaderReblogActivity.this, R.string.reader_toast_err_reblog_failed);
+ }
+ }
+ }
+ };
+
+ ReaderPostActions.reblogPost(mPost, mDestinationBlogId, commentText, actionListener);
+ }
+
+ private void reblogSucceeded() {
+ ToastUtils.showToast(this, R.string.reader_toast_reblog_success);
+
+ // wait a second before dismissing activity
+ new Handler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ Intent data = new Intent();
+ data.putExtra(ReaderConstants.ARG_BLOG_ID, mBlogId);
+ data.putExtra(ReaderConstants.ARG_POST_ID, mPostId);
+ setResult(RESULT_OK, data);
+ finish();
+ }
+ }, 1000);
+ }
+
+ /*
+ * AsyncTask to load and display post
+ */
+ private class LoadPostTask extends AsyncTask<Void, Void, Boolean> {
+ ReaderPost tmpPost;
+
+ TextView txtBlogName;
+ TextView txtTitle;
+ TextView txtExcerpt;
+
+ WPNetworkImageView imgAvatar;
+ WPNetworkImageView imgFeatured;
+
+ @Override
+ protected Boolean doInBackground(Void... voids) {
+ txtBlogName = (TextView) mLayoutExcerpt.findViewById(R.id.text_blog_name);
+ txtTitle = (TextView) mLayoutExcerpt.findViewById(R.id.text_title);
+ txtExcerpt = (TextView) mLayoutExcerpt.findViewById(R.id.text_excerpt);
+ imgAvatar = (WPNetworkImageView) mLayoutExcerpt.findViewById(R.id.image_avatar);
+ imgFeatured = (WPNetworkImageView) mLayoutExcerpt.findViewById(R.id.image_featured);
+
+ tmpPost = ReaderPostTable.getPost(mBlogId, mPostId);
+ return (tmpPost != null);
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ if (result) {
+ mPost = tmpPost;
+
+ txtTitle.setText(mPost.getTitle());
+
+ if (mPost.hasBlogName()) {
+ txtBlogName.setText(mPost.getBlogName());
+ } else if (mPost.hasAuthorName()) {
+ txtBlogName.setText(mPost.getAuthorName());
+ }
+
+ if (mPost.hasExcerpt()) {
+ txtExcerpt.setText(mPost.getExcerpt());
+ } else {
+ txtExcerpt.setVisibility(View.GONE);
+ }
+
+ // actual avatar size is avatar_sz_small but use avatar_sz_medium since we know
+ // that will be cached already
+ int avatarSz = getResources().getDimensionPixelSize(R.dimen.avatar_sz_medium);
+ imgAvatar.setImageUrl(mPost.getPostAvatarForDisplay(avatarSz), WPNetworkImageView.ImageType.AVATAR);
+
+ // featured image is hidden in landscape so it doesn't obscure the comment text
+ boolean isLandscape = DisplayUtils.isLandscape(ReaderReblogActivity.this);
+ if (!isLandscape && mPost.hasFeaturedImage()) {
+ int displayWidth = DisplayUtils.getDisplayPixelWidth(ReaderReblogActivity.this);
+ int listMargin = getResources().getDimensionPixelSize(R.dimen.reader_list_margin);
+ int photonWidth = displayWidth - (listMargin * 2);
+ int photonHeight = getResources().getDimensionPixelSize(R.dimen.reader_featured_image_height);
+ final String imageUrl = mPost.getFeaturedImageForDisplay(photonWidth, photonHeight);
+ imgFeatured.setImageUrl(imageUrl, WPNetworkImageView.ImageType.PHOTO);
+ } else if (!isLandscape && mPost.hasFeaturedVideo()) {
+ imgFeatured.setVideoUrl(mPost.postId, mPost.getFeaturedVideo());
+ } else {
+ imgFeatured.setVisibility(View.GONE);
+ }
+ }
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderSubsActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderSubsActivity.java
new file mode 100644
index 000000000..3dc2cfc23
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderSubsActivity.java
@@ -0,0 +1,600 @@
+package org.wordpress.android.ui.reader;
+
+import android.app.ActionBar;
+import android.app.ActionBar.Tab;
+import android.app.Activity;
+import android.app.Fragment;
+import android.app.FragmentManager;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.v13.app.FragmentPagerAdapter;
+import android.support.v4.view.PagerAdapter;
+import android.support.v4.view.PagerTabStrip;
+import android.support.v4.view.ViewPager;
+import android.text.TextUtils;
+import android.view.KeyEvent;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.inputmethod.EditorInfo;
+import android.webkit.URLUtil;
+import android.widget.EditText;
+import android.widget.ImageButton;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.ReaderBlogTable;
+import org.wordpress.android.datasets.ReaderTagTable;
+import org.wordpress.android.models.ReaderTag;
+import org.wordpress.android.models.ReaderTagType;
+import org.wordpress.android.ui.prefs.UserPrefs;
+import org.wordpress.android.ui.reader.actions.ReaderActions;
+import org.wordpress.android.ui.reader.actions.ReaderBlogActions;
+import org.wordpress.android.ui.reader.actions.ReaderTagActions;
+import org.wordpress.android.ui.reader.actions.ReaderTagActions.TagAction;
+import org.wordpress.android.ui.reader.adapters.ReaderBlogAdapter;
+import org.wordpress.android.ui.reader.adapters.ReaderBlogAdapter.ReaderBlogType;
+import org.wordpress.android.ui.reader.adapters.ReaderTagAdapter;
+import org.wordpress.android.util.EditTextUtils;
+import org.wordpress.android.util.MessageBarUtils;
+import org.wordpress.android.util.MessageBarUtils.MessageBarType;
+import org.wordpress.android.util.NetworkUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.UrlUtils;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * activity which shows the user's subscriptions and recommended subscriptions - includes
+ * followed tags, popular tags, followed blogs, and recommended blogs
+ */
+public class ReaderSubsActivity extends Activity
+ implements ReaderTagAdapter.TagActionListener,
+ ReaderBlogAdapter.BlogFollowChangeListener,
+ ActionBar.TabListener {
+
+ private EditText mEditAdd;
+ private ImageButton mBtnAdd;
+ private ViewPager mViewPager;
+ private SubsPageAdapter mPageAdapter;
+
+ private boolean mTagsChanged;
+ private boolean mBlogsChanged;
+ private String mLastAddedTagName;
+ private boolean mHasPerformedUpdate;
+
+ static final String KEY_TAGS_CHANGED = "tags_changed";
+ static final String KEY_BLOGS_CHANGED = "blogs_changed";
+ static final String KEY_LAST_ADDED_TAG_NAME = "last_added_tag_name";
+
+ private static final int TAB_IDX_FOLLOWED_TAGS = 0;
+ private static final int TAB_IDX_SUGGESTED_TAGS = 1;
+ private static final int TAB_IDX_FOLLOWED_BLOGS = 2;
+ private static final int TAB_IDX_RECOMMENDED_BLOGS = 3;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.reader_activity_subs);
+ restoreState(savedInstanceState);
+
+ mViewPager = (ViewPager) findViewById(R.id.viewpager);
+ mViewPager.setAdapter(getPageAdapter());
+
+ getActionBar().setDisplayShowTitleEnabled(true);
+ getActionBar().setDisplayHomeAsUpEnabled(true);
+
+ PagerTabStrip tabStrip = (PagerTabStrip) findViewById(R.id.pager_tabs);
+ tabStrip.setTabIndicatorColorResource(R.color.blue_medium);
+ tabStrip.setBackgroundColor(getResources().getColor(R.color.grey_extra_light));
+
+ mEditAdd = (EditText) findViewById(R.id.edit_add);
+ mEditAdd.setOnEditorActionListener(new TextView.OnEditorActionListener() {
+ @Override
+ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+ if (actionId == EditorInfo.IME_ACTION_DONE) {
+ addCurrentEntry();
+ }
+ return false;
+ }
+ });
+
+ mBtnAdd = (ImageButton) findViewById(R.id.btn_add);
+ mBtnAdd.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ addCurrentEntry();
+ }
+ });
+
+ if (savedInstanceState == null) {
+ // return to the page the user was on the last time they viewed this activity
+ restorePreviousPage();
+ }
+
+ // remember which page the user last viewed - note this listener must be assigned
+ // after we've already called restorePreviousPage()
+ mViewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
+ @Override
+ public void onPageSelected(int position) {
+ String pageTitle = (String) getPageAdapter().getPageTitle(position);
+ UserPrefs.setReaderSubsPageTitle(pageTitle);
+ }
+ });
+
+ // update list of tags and blogs from the server
+ if (!mHasPerformedUpdate) {
+ performUpdate();
+ }
+ }
+
+ private void performUpdate() {
+ if (!NetworkUtils.isNetworkAvailable(this)) {
+ return;
+ }
+ updateTagList();
+ updateFollowedBlogs();
+ updateRecommendedBlogs();
+ mHasPerformedUpdate = true;
+ }
+
+ private void restoreState(Bundle state) {
+ if (state != null) {
+ mTagsChanged = state.getBoolean(KEY_TAGS_CHANGED);
+ mBlogsChanged = state.getBoolean(KEY_BLOGS_CHANGED);
+ mLastAddedTagName = state.getString(KEY_LAST_ADDED_TAG_NAME);
+ mHasPerformedUpdate = state.getBoolean(ReaderConstants.KEY_ALREADY_UPDATED);
+ }
+ }
+
+ private SubsPageAdapter getPageAdapter() {
+ if (mPageAdapter == null) {
+ List<Fragment> fragments = new ArrayList<Fragment>();
+
+ // add tag fragments
+ fragments.add(ReaderTagFragment.newInstance(ReaderTagType.FOLLOWED));
+ fragments.add(ReaderTagFragment.newInstance(ReaderTagType.RECOMMENDED));
+
+ // add blog fragments
+ fragments.add(ReaderBlogFragment.newInstance(ReaderBlogType.FOLLOWED));
+ fragments.add(ReaderBlogFragment.newInstance(ReaderBlogType.RECOMMENDED));
+
+ mPageAdapter = new SubsPageAdapter(getFragmentManager(), fragments);
+ }
+ return mPageAdapter;
+ }
+
+ private boolean hasPageAdapter() {
+ return mPageAdapter != null;
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putBoolean(KEY_TAGS_CHANGED, mTagsChanged);
+ outState.putBoolean(KEY_BLOGS_CHANGED, mBlogsChanged);
+ outState.putBoolean(ReaderConstants.KEY_ALREADY_UPDATED, mHasPerformedUpdate);
+ if (mLastAddedTagName != null) {
+ outState.putString(KEY_LAST_ADDED_TAG_NAME, mLastAddedTagName);
+ }
+ }
+
+ @Override
+ public void onBackPressed() {
+ // let calling activity know if tags/blogs were added/removed
+ if (mTagsChanged || mBlogsChanged) {
+ Bundle bundle = new Bundle();
+ if (mTagsChanged) {
+ bundle.putBoolean(KEY_TAGS_CHANGED, true);
+ if (mLastAddedTagName != null && ReaderTagTable.isFollowedTagName(mLastAddedTagName)) {
+ bundle.putString(KEY_LAST_ADDED_TAG_NAME, mLastAddedTagName);
+ }
+ }
+ if (mBlogsChanged) {
+ bundle.putBoolean(KEY_BLOGS_CHANGED, true);
+ }
+ Intent intent = new Intent();
+ intent.putExtras(bundle);
+ setResult(RESULT_OK, intent);
+ }
+
+ super.onBackPressed();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ onBackPressed();
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ /*
+ * follow the tag or url the user typed into the EditText
+ */
+ private void addCurrentEntry() {
+ String entry = EditTextUtils.getText(mEditAdd);
+ if (TextUtils.isEmpty(entry)) {
+ return;
+ }
+
+ // is it a url or a tag?
+ boolean isUrl = !entry.contains(" ") && (entry.contains(".") || entry.contains("://"));
+ if (isUrl) {
+ addAsUrl(entry);
+ } else {
+ addAsTag(entry);
+ }
+ }
+
+ /*
+ * follow editText entry as a tag
+ */
+ private void addAsTag(final String entry) {
+ if (TextUtils.isEmpty(entry)) {
+ return;
+ }
+
+ if (ReaderTagTable.isFollowedTagName(entry)) {
+ ToastUtils.showToast(this, R.string.reader_toast_err_tag_exists);
+ return;
+ }
+
+ if (!ReaderTag.isValidTagName(entry)) {
+ ToastUtils.showToast(this, R.string.reader_toast_err_tag_invalid);
+ return;
+ }
+
+ // tag is valid, follow it
+ mEditAdd.setText(null);
+ EditTextUtils.hideSoftInput(mEditAdd);
+ performAddTag(entry);
+ }
+
+ /*
+ * follow editText entry as a url
+ */
+ private void addAsUrl(final String entry) {
+ if (TextUtils.isEmpty(entry)) {
+ return;
+ }
+
+ // normalize the url and prepend protocol if not supplied
+ final String normUrl;
+ if (!entry.contains("://")) {
+ normUrl = UrlUtils.normalizeUrl("http://" + entry);
+ } else {
+ normUrl = UrlUtils.normalizeUrl(entry);
+ }
+
+ // if this isn't a valid URL, add original entry as a tag
+ if (!URLUtil.isNetworkUrl(normUrl)) {
+ addAsTag(entry);
+ return;
+ }
+
+ // make sure it isn't already followed
+ if (ReaderBlogTable.isFollowedBlogUrl(normUrl)) {
+ ToastUtils.showToast(this, R.string.reader_toast_err_already_follow_blog);
+ return;
+ }
+
+ // URL is valid, so follow it
+ performAddUrl(normUrl);
+ }
+
+ /*
+ * called when user manually enters a tag - passed tag is assumed to be validated
+ */
+ private void performAddTag(final String tagName) {
+ if (!NetworkUtils.checkConnection(this)) {
+ return;
+ }
+
+ ReaderActions.ActionListener actionListener = new ReaderActions.ActionListener() {
+ @Override
+ public void onActionResult(boolean succeeded) {
+ if (!succeeded && !isFinishing()) {
+ getPageAdapter().refreshTagFragments();
+ ToastUtils.showToast(ReaderSubsActivity.this, R.string.reader_toast_err_add_tag);
+ mLastAddedTagName = null;
+ }
+ }
+ };
+
+ ReaderTag tag = new ReaderTag(tagName, ReaderTagType.FOLLOWED);
+
+ if (ReaderTagActions.performTagAction(tag, TagAction.ADD, actionListener)) {
+ String msgText = getString(R.string.reader_label_added_tag, tagName);
+ MessageBarUtils.showMessageBar(this, msgText, MessageBarType.INFO);
+ getPageAdapter().refreshTagFragments(null, tagName);
+ onTagAction(tag, TagAction.ADD);
+ }
+ }
+
+ /*
+ * start a two-step process to follow a blog by url:
+ * 1. test whether the url is reachable (API will follow any url, even if it doesn't exist)
+ * 2. perform the actual follow
+ * note that the passed URL is assumed to be normalized and validated
+ */
+ private void performAddUrl(final String normUrl) {
+ if (!NetworkUtils.checkConnection(this)) {
+ return;
+ }
+
+ showAddUrlProgress();
+
+ // listener for following the blog
+ final ReaderActions.ActionListener followListener = new ReaderActions.ActionListener() {
+ @Override
+ public void onActionResult(boolean succeeded) {
+ if (!isFinishing()) {
+ hideAddUrlProgress();
+ if (succeeded) {
+ // clear the edit text and hide the soft keyboard
+ mEditAdd.setText(null);
+ EditTextUtils.hideSoftInput(mEditAdd);
+ String msgText = getString(R.string.reader_label_followed_blog);
+ MessageBarUtils.showMessageBar(ReaderSubsActivity.this, msgText, MessageBarType.INFO);
+ onFollowBlogChanged();
+ getPageAdapter().refreshBlogFragments(ReaderBlogType.FOLLOWED);
+ } else {
+ ToastUtils.showToast(ReaderSubsActivity.this, R.string.reader_toast_err_follow_blog);
+ }
+ }
+ }
+ };
+
+ // listener for testing if blog is reachable
+ ReaderActions.ActionListener urlActionListener = new ReaderActions.ActionListener() {
+ @Override
+ public void onActionResult(boolean succeeded) {
+ if (!isFinishing()) {
+ if (succeeded) {
+ // url is reachable, so follow it
+ ReaderBlogActions.performFollowAction(0, normUrl, true, followListener);
+ } else {
+ // url is unreachable
+ hideAddUrlProgress();
+ ToastUtils.showToast(ReaderSubsActivity.this, R.string.reader_toast_err_follow_blog);
+ }
+ }
+ }
+ };
+ ReaderBlogActions.checkBlogUrlReachable(normUrl, urlActionListener);
+ }
+
+ /*
+ * called prior to following a url to show progress and disable controls
+ */
+ private void showAddUrlProgress() {
+ final ProgressBar progress = (ProgressBar) findViewById(R.id.progress_follow);
+ progress.setVisibility(View.VISIBLE);
+ mEditAdd.setEnabled(false);
+ mBtnAdd.setEnabled(false);
+ }
+
+ /*
+ * called after following a url to hide progress and re-enable controls
+ */
+ private void hideAddUrlProgress() {
+ final ProgressBar progress = (ProgressBar) findViewById(R.id.progress_follow);
+ progress.setVisibility(View.GONE);
+ mEditAdd.setEnabled(true);
+ mBtnAdd.setEnabled(true);
+ }
+
+ /*
+ * called from ReaderBlogFragment and this activity when a blog is successfully
+ * followed or unfollowed
+ */
+ @Override
+ public void onFollowBlogChanged() {
+ mBlogsChanged = true;
+ }
+
+ /*
+ * triggered by a tag fragment's adapter after user adds/removes a tag, or from this activity
+ * after user adds a tag - note that network request has been made by the time this is called
+ */
+ @Override
+ public void onTagAction(ReaderTag tag, TagAction action) {
+ mTagsChanged = true;
+
+ final String msgText;
+ final MessageBarType msgType;
+
+ switch (action) {
+ case ADD:
+ AnalyticsTracker.track(AnalyticsTracker.Stat.READER_FOLLOWED_READER_TAG);
+ msgText = getString(R.string.reader_label_added_tag, tag.getTagName());
+ msgType = MessageBarType.INFO;
+ mLastAddedTagName = tag.getTagName();
+ // user added from recommended tags, make sure addition is reflected on followed tags
+ getPageAdapter().refreshTagFragments(ReaderTagType.FOLLOWED);
+ break;
+
+ case DELETE:
+ AnalyticsTracker.track(AnalyticsTracker.Stat.READER_UNFOLLOWED_READER_TAG);
+ msgText = getString(R.string.reader_label_removed_tag, tag.getTagName());
+ msgType = MessageBarType.ALERT;
+ if (mLastAddedTagName != null && mLastAddedTagName.equalsIgnoreCase(tag.getTagName())) {
+ mLastAddedTagName = null;
+ }
+ // user deleted from followed tags, make sure deletion is reflected on recommended tags
+ getPageAdapter().refreshTagFragments(ReaderTagType.RECOMMENDED);
+ break;
+
+ default :
+ return;
+ }
+
+ MessageBarUtils.showMessageBar(this, msgText, msgType);
+ }
+
+ /*
+ * request latest list of tags from the server
+ */
+ void updateTagList() {
+ ReaderActions.UpdateResultListener listener = new ReaderActions.UpdateResultListener() {
+ @Override
+ public void onUpdateResult(ReaderActions.UpdateResult result) {
+ if (!isFinishing() && result == ReaderActions.UpdateResult.CHANGED) {
+ mTagsChanged = true;
+ getPageAdapter().refreshTagFragments();
+ }
+ }
+ };
+ ReaderTagActions.updateTags(listener);
+ }
+
+ /*
+ * request latest recommended blogs
+ */
+ void updateRecommendedBlogs() {
+ ReaderActions.UpdateResultListener listener = new ReaderActions.UpdateResultListener() {
+ @Override
+ public void onUpdateResult(ReaderActions.UpdateResult result) {
+ if (!isFinishing() && result == ReaderActions.UpdateResult.CHANGED) {
+ getPageAdapter().refreshBlogFragments(ReaderBlogType.RECOMMENDED);
+ }
+ }
+ };
+ ReaderBlogActions.updateRecommendedBlogs(listener);
+ }
+
+ /*
+ * request latest followed blogs
+ */
+ void updateFollowedBlogs() {
+ ReaderActions.UpdateResultListener listener = new ReaderActions.UpdateResultListener() {
+ @Override
+ public void onUpdateResult(ReaderActions.UpdateResult result) {
+ if (!isFinishing()) {
+ if (result == ReaderActions.UpdateResult.CHANGED) {
+ getPageAdapter().refreshBlogFragments(ReaderBlogType.FOLLOWED);
+ }
+ }
+ }
+ };
+ ReaderBlogActions.updateFollowedBlogs(listener);
+ }
+
+ /*
+ * return to the previously selected page in the viewPager
+ */
+ private void restorePreviousPage() {
+ if (mViewPager == null || !hasPageAdapter()) {
+ return;
+ }
+
+ String pageTitle = UserPrefs.getReaderSubsPageTitle();
+ if (TextUtils.isEmpty(pageTitle)) {
+ return;
+ }
+
+ PagerAdapter adapter = getPageAdapter();
+ for (int i = 0; i < adapter.getCount(); i++) {
+ if (pageTitle.equals(adapter.getPageTitle(i))) {
+ mViewPager.setCurrentItem(i);
+ return;
+ }
+ }
+ }
+
+ /*
+ * Note: Make sure we don't mix android.app.FragmentTransaction with support Fragment.
+ * As long as the android.app.FragmentTransaction passed to the tab handlers isn't used, we should be fine.
+ * If at some point we do want to make use of the transaction, the solution suggested here
+ * http://stackoverflow.com/a/14685927/1673548 would work.
+ */
+ @Override
+ public void onTabSelected(Tab tab, android.app.FragmentTransaction ft) {
+ mViewPager.setCurrentItem(tab.getPosition());
+ }
+
+ @Override
+ public void onTabUnselected(Tab tab, android.app.FragmentTransaction ft) { }
+
+ @Override
+ public void onTabReselected(Tab tab, android.app.FragmentTransaction ft) { }
+
+
+ private class SubsPageAdapter extends FragmentPagerAdapter {
+ private final List<Fragment> mFragments;
+
+ SubsPageAdapter(FragmentManager fm, List<Fragment> fragments) {
+ super(fm);
+ mFragments = fragments;
+ }
+
+ @Override
+ public CharSequence getPageTitle(int position) {
+ final String title;
+ switch (position) {
+ case TAB_IDX_FOLLOWED_TAGS:
+ title = getString(R.string.reader_page_followed_tags);
+ break;
+ case TAB_IDX_SUGGESTED_TAGS:
+ title = getString(R.string.reader_page_popular_tags);
+ break;
+ case TAB_IDX_RECOMMENDED_BLOGS:
+ title = getString(R.string.reader_page_recommended_blogs);
+ break;
+ case TAB_IDX_FOLLOWED_BLOGS:
+ title = getString(R.string.reader_page_followed_blogs);
+ break;
+ default:
+ return super.getPageTitle(position);
+ }
+
+ // force titles to two lines by replacing the first space with a new line
+ return title.replaceFirst(" ", "\n");
+ }
+
+ @Override
+ public Fragment getItem(int position) {
+ return mFragments.get(position);
+ }
+
+ @Override
+ public int getCount() {
+ return mFragments.size();
+ }
+
+ private void refreshTagFragments() {
+ refreshTagFragments(null, null);
+ }
+ private void refreshTagFragments(ReaderTagType tagType) {
+ refreshTagFragments(tagType, null);
+ }
+ private void refreshTagFragments(ReaderTagType tagType, String scrollToTagName) {
+ for (Fragment fragment: mFragments) {
+ if (fragment instanceof ReaderTagFragment) {
+ ReaderTagFragment tagFragment = (ReaderTagFragment) fragment;
+ if (tagType == null || tagType.equals(tagFragment.getTagType())) {
+ tagFragment.refresh(scrollToTagName);
+ }
+ }
+ }
+ }
+
+ private void refreshBlogFragments(ReaderBlogType blogType) {
+ for (Fragment fragment: mFragments) {
+ if (fragment instanceof ReaderBlogFragment) {
+ ReaderBlogFragment blogFragment = (ReaderBlogFragment) fragment;
+ if (blogType == null || blogType.equals(blogFragment.getBlogType())) {
+ blogFragment.refresh();
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagFragment.java
new file mode 100644
index 000000000..d31c2d753
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagFragment.java
@@ -0,0 +1,186 @@
+package org.wordpress.android.ui.reader;
+
+import android.app.Fragment;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.Animation;
+import android.widget.ListView;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.models.ReaderTag;
+import org.wordpress.android.models.ReaderTagType;
+import org.wordpress.android.ui.reader.actions.ReaderActions;
+import org.wordpress.android.ui.reader.actions.ReaderTagActions.TagAction;
+import org.wordpress.android.ui.reader.adapters.ReaderTagAdapter;
+import org.wordpress.android.ui.reader.adapters.ReaderTagAdapter.TagActionListener;
+import org.wordpress.android.util.AppLog;
+
+/*
+ * fragment hosted by ReaderSubsActivity which shows either followed or popular tags
+ */
+public class ReaderTagFragment extends Fragment implements ReaderTagAdapter.TagActionListener {
+ private ListView mListView;
+ private ReaderTagAdapter mTagAdapter;
+ private ReaderTagType mTagType;
+ private static final String ARG_TAG_TYPE = "tag_type";
+
+ static ReaderTagFragment newInstance(ReaderTagType tagType) {
+ AppLog.d(AppLog.T.READER, "reader tag list > newInstance");
+
+ Bundle args = new Bundle();
+ args.putSerializable(ARG_TAG_TYPE, tagType);
+ ReaderTagFragment fragment = new ReaderTagFragment();
+ fragment.setArguments(args);
+
+ return fragment;
+ }
+
+ @Override
+ public void setArguments(Bundle args) {
+ super.setArguments(args);
+ restoreState(args);
+ }
+
+ private void restoreState(Bundle args) {
+ if (args == null) {
+ return;
+ }
+ if (args.containsKey(ARG_TAG_TYPE)) {
+ mTagType = (ReaderTagType) args.getSerializable(ARG_TAG_TYPE);
+ }
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (savedInstanceState != null) {
+ AppLog.d(AppLog.T.READER, "reader tag fragment > restoring instance state");
+ restoreState(savedInstanceState);
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ final View view = inflater.inflate(R.layout.reader_fragment_list, container, false);
+ mListView = (ListView) view.findViewById(android.R.id.list);
+
+ final TextView emptyView = (TextView)view.findViewById(R.id.text_empty);
+ switch (getTagType()) {
+ case FOLLOWED:
+ emptyView.setText(R.string.reader_empty_followed_tags);
+ break;
+ case RECOMMENDED:
+ emptyView.setText(R.string.reader_empty_popular_tags);
+ break;
+ }
+
+ mListView.setEmptyView(view.findViewById(R.id.text_empty));
+
+ return view;
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ mListView.setAdapter(getTagAdapter());
+ getTagAdapter().refresh();
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putSerializable(ARG_TAG_TYPE, getTagType());
+ }
+
+ private void scrollToTagName(String tagName) {
+ ReaderTag tag = new ReaderTag(tagName, ReaderTagType.FOLLOWED);
+ int index = getTagAdapter().indexOfTag(tag);
+ if (index > -1) {
+ mListView.smoothScrollToPosition(index);
+ }
+ }
+
+ void refresh() {
+ refresh(null);
+ }
+ void refresh(final String scrollToTagName) {
+ if (!hasTagAdapter()) {
+ return;
+ }
+ if (!TextUtils.isEmpty(scrollToTagName)) {
+ ReaderActions.DataLoadedListener dataListener = new ReaderActions.DataLoadedListener() {
+ @Override
+ public void onDataLoaded(boolean isEmpty) {
+ scrollToTagName(scrollToTagName);
+ }
+ };
+ getTagAdapter().refresh(dataListener);
+ } else {
+ getTagAdapter().refresh(null);
+ }
+ }
+
+ ReaderTagType getTagType() {
+ return mTagType;
+ }
+
+ private ReaderTagAdapter getTagAdapter() {
+ if (mTagAdapter == null) {
+ mTagAdapter = new ReaderTagAdapter(getActivity(), getTagType(), this);
+ }
+ return mTagAdapter;
+ }
+
+ private boolean hasTagAdapter() {
+ return (mTagAdapter != null);
+ }
+
+ /*
+ * called from adapter when user adds/removes a tag - note that the network request
+ * has been made by the time this is called
+ */
+ @Override
+ public void onTagAction(ReaderTag tag, TagAction action) {
+ final boolean animateRemoval;
+ switch (action) {
+ case ADD:
+ // animate tag's removal if added from recommended tags
+ animateRemoval = (getTagType() == ReaderTagType.RECOMMENDED);
+ break;
+ case DELETE:
+ // animate tag's removal if deleted from followed tags
+ animateRemoval = (getTagType() == ReaderTagType.FOLLOWED);
+ break;
+ default:
+ animateRemoval = false;
+ break;
+ }
+
+ int index = getTagAdapter().indexOfTag(tag);
+ if (animateRemoval && index > -1) {
+ Animation.AnimationListener aniListener = new Animation.AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation animation) { }
+ @Override
+ public void onAnimationRepeat(Animation animation) { }
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ refresh();
+ }
+ };
+ int aniResId = (action == TagAction.ADD ? R.anim.reader_tag_add : R.anim.reader_tag_delete);
+ ReaderAnim.animateListItem(mListView, index, aniListener, aniResId);
+ } else {
+ refresh();
+ }
+
+ // let the host activity know about the change
+ if (getActivity() instanceof TagActionListener) {
+ ((TagActionListener) getActivity()).onTagAction(tag, action);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTypes.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTypes.java
new file mode 100644
index 000000000..29ea2eb4a
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTypes.java
@@ -0,0 +1,26 @@
+package org.wordpress.android.ui.reader;
+
+
+public class ReaderTypes {
+
+ public static final ReaderPostListType DEFAULT_POST_LIST_TYPE = ReaderPostListType.TAG_FOLLOWED;
+
+ public static enum ReaderPostListType {
+ TAG_FOLLOWED, // list posts in a followed tag
+ TAG_PREVIEW, // list posts in a specific tag
+ BLOG_PREVIEW; // list posts in a specific blog
+
+ public boolean isTagType() {
+ return this.equals(TAG_FOLLOWED) || this.equals(TAG_PREVIEW);
+ }
+
+ public boolean isPreviewType() {
+ return this.equals(TAG_PREVIEW) || this.equals(BLOG_PREVIEW);
+ }
+ }
+
+ protected static enum RefreshType {
+ AUTOMATIC, // refresh was performed by the app without user requesting it
+ MANUAL // refresh was requested by the user
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderUserListActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderUserListActivity.java
new file mode 100644
index 000000000..e224f7288
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderUserListActivity.java
@@ -0,0 +1,138 @@
+package org.wordpress.android.ui.reader;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.view.ViewGroup;
+import android.widget.ListView;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.ReaderPostTable;
+import org.wordpress.android.datasets.ReaderUserTable;
+import org.wordpress.android.models.ReaderUserList;
+import org.wordpress.android.ui.reader.actions.ReaderActions;
+import org.wordpress.android.ui.reader.adapters.ReaderUserAdapter;
+import org.wordpress.android.util.DisplayUtils;
+
+/*
+ * displays a list of users who like a specific reader post
+ */
+public class ReaderUserListActivity extends Activity {
+ private static final String LIST_STATE = "list_state";
+ private Parcelable mListState = null;
+ private ListView mListView;
+
+ private ListView getListView() {
+ if (mListView == null) {
+ mListView = (ListView) findViewById(android.R.id.list);
+ }
+ return mListView;
+ }
+
+ private ReaderUserAdapter mAdapter;
+ private ReaderUserAdapter getAdapter() {
+ if (mAdapter == null) {
+ mAdapter = new ReaderUserAdapter(this, mDataLoadedListener);
+ }
+ return mAdapter;
+ }
+
+ /*
+ * called by adapter when data has been loaded
+ */
+ private final ReaderActions.DataLoadedListener mDataLoadedListener = new ReaderActions.DataLoadedListener() {
+ @Override
+ public void onDataLoaded(boolean isEmpty) {
+ // restore listView state so user returns to the previously scrolled-to item
+ if (!isEmpty && mListState != null) {
+ getListView().onRestoreInstanceState(mListState);
+ mListState = null;
+ }
+ }
+ };
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.reader_activity_userlist);
+
+ long blogId = getIntent().getLongExtra(ReaderConstants.ARG_BLOG_ID, 0);
+ long postId = getIntent().getLongExtra(ReaderConstants.ARG_POST_ID, 0);
+
+ if (savedInstanceState != null) {
+ mListState = savedInstanceState.getParcelable(LIST_STATE);
+ }
+
+ // use a fixed size for the root view so the activity won't change
+ // size as users are loaded
+ final ViewGroup rootView = (ViewGroup) findViewById(R.id.layout_container);
+ int displayHeight = DisplayUtils.getDisplayPixelHeight(this);
+ boolean isLandscape = DisplayUtils.isLandscape(this);
+ int maxHeight = displayHeight - (displayHeight / (isLandscape ? 5 : 3));
+ rootView.getLayoutParams().height = maxHeight;
+
+ getListView().setAdapter(getAdapter());
+ loadUsers(blogId, postId);
+ }
+
+ @Override
+ public void finish() {
+ super.finish();
+ overridePendingTransition(0, 0);
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ if (getListView().getFirstVisiblePosition() > 0) {
+ outState.putParcelable(LIST_STATE, getListView().onSaveInstanceState());
+ }
+ }
+
+ private void loadUsers(final long blogId, final long postId) {
+ new Thread() {
+ @Override
+ public void run() {
+ final String title = getTitleString(blogId, postId);
+ final TextView txtTitle = (TextView) findViewById(R.id.text_title);
+
+ final ReaderUserList users =
+ ReaderUserTable.getUsersWhoLikePost(
+ blogId,
+ postId,
+ ReaderConstants.READER_MAX_USERS_TO_DISPLAY);
+
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (!isFinishing()) {
+ txtTitle.setText(title);
+ getAdapter().setUsers(users);
+ }
+ }
+ });
+ }
+ }.start();
+ }
+
+ private String getTitleString(final long blogId, final long postId) {
+ int numLikes = ReaderPostTable.getNumLikesForPost(blogId, postId);
+ boolean isLikedByCurrentUser = ReaderPostTable.isPostLikedByCurrentUser(blogId, postId);
+
+ if (isLikedByCurrentUser) {
+ switch (numLikes) {
+ case 1 :
+ return getString(R.string.reader_likes_only_you);
+ case 2 :
+ return getString(R.string.reader_likes_you_and_one);
+ default :
+ return getString(R.string.reader_likes_you_and_multi, numLikes-1);
+ }
+ } else {
+ return (numLikes == 1 ? getString(R.string.reader_likes_one) : getString(R.string.reader_likes_multi, numLikes));
+ }
+ }
+
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderUtils.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderUtils.java
new file mode 100644
index 000000000..3eaa8bb63
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderUtils.java
@@ -0,0 +1,152 @@
+package org.wordpress.android.ui.reader;
+
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AbsListView;
+import android.widget.ListView;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.util.UrlUtils;
+
+import java.util.List;
+
+public class ReaderUtils {
+ /*
+ * used by ReaderPostDetailFragment to enter/exit full screen mode
+ */
+ static interface FullScreenListener {
+ boolean onRequestFullScreen(boolean enable);
+ boolean isFullScreen();
+ boolean isFullScreenSupported();
+ }
+
+ /*
+ * used with TextViews that have the ReaderTextView.Follow style to show
+ * the passed follow state
+ */
+ public static void showFollowStatus(final TextView txtFollow, boolean isFollowed) {
+ // selected state is same as followed state, so do nothing if they already match
+ if (txtFollow == null || txtFollow.isSelected() == isFollowed) {
+ return;
+ }
+
+ if (isFollowed) {
+ txtFollow.setText(txtFollow.getContext().getString(R.string.reader_btn_unfollow));
+ } else {
+ txtFollow.setText(txtFollow.getContext().getString(R.string.reader_btn_follow));
+ }
+
+ int drawableId = (isFollowed ? R.drawable.note_icon_following : R.drawable.note_icon_follow);
+ txtFollow.setCompoundDrawablesWithIntrinsicBounds(drawableId, 0, 0, 0);
+
+ txtFollow.setSelected(isFollowed);
+ }
+
+ /*
+ * return the path to use for the /batch/ endpoint from the list of request urls
+ * https://developer.wordpress.com/docs/api/1/get/batch/
+ */
+ public static String getBatchEndpointForRequests(List<String> requestUrls) {
+ StringBuilder sbBatch = new StringBuilder("/batch/");
+ if (requestUrls != null) {
+ boolean isFirst = true;
+ for (String url : requestUrls) {
+ if (!TextUtils.isEmpty(url)) {
+ if (isFirst) {
+ isFirst = false;
+ sbBatch.append("?");
+ } else {
+ sbBatch.append("&");
+ }
+ sbBatch.append("urls%5B%5D=").append(Uri.encode(url));
+ }
+ }
+ }
+ return sbBatch.toString();
+ }
+
+ /*
+ * adds a transparent header to the passed listView
+ */
+ static View addListViewHeader(ListView listView, int height) {
+ if (listView == null) {
+ return null;
+ }
+ RelativeLayout header = new RelativeLayout(listView.getContext());
+ header.setLayoutParams(new AbsListView.LayoutParams(
+ AbsListView.LayoutParams.MATCH_PARENT,
+ height));
+ listView.addHeaderView(header, null, false);
+ return header;
+ }
+
+ /*
+ * adds a rule which tells the view with targetId to be placed below layoutBelowId - only
+ * works if viewParent is a RelativeLayout
+ */
+ static void layoutBelow(ViewGroup viewParent, int targetId, int layoutBelowId) {
+ if (viewParent == null || !(viewParent instanceof RelativeLayout)) {
+ return;
+ }
+
+ View target = viewParent.findViewById(targetId);
+ if (target == null) {
+ return;
+ }
+
+ if (target.getLayoutParams() instanceof RelativeLayout.LayoutParams) {
+ RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) target.getLayoutParams();
+ params.addRule(RelativeLayout.BELOW, layoutBelowId);
+ }
+ }
+
+ /*
+ * returns a bitmap of the passed view - note that the view must have layout for this to work
+ */
+ public static Bitmap createBitmapFromView(View view) {
+ if (view == null) {
+ return null;
+ }
+
+ view.buildDrawingCache();
+ try {
+ Bitmap bmp = view.getDrawingCache();
+ if (bmp == null) {
+ return null;
+ }
+ // return a copy of this bitmap since original will be destroyed when the
+ // cache is destroyed
+ return bmp.copy(Bitmap.Config.ARGB_8888, false);
+ } finally {
+ view.destroyDrawingCache();
+ }
+ }
+
+ /*
+ * use this to request a reduced size image from a private post - images in private posts can't
+ * use photon but these are usually wp images so they support the h= and w= query params
+ */
+ public static String getPrivateImageForDisplay(final String imageUrl, int width, int height) {
+ if (TextUtils.isEmpty(imageUrl)) {
+ return "";
+ }
+
+ final String query;
+ if (width > 0 && height > 0) {
+ query = String.format("?w=%d&h=%d", width, height);
+ } else if (width > 0) {
+ query = String.format("?w=%d", width);
+ } else if (height > 0) {
+ query = String.format("?h=%d", height);
+ } else {
+ query = "";
+ }
+ // remove the existing query string, add the new one, and make sure the url is https:
+ return UrlUtils.removeQuery(UrlUtils.makeHttps(imageUrl)) + query;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderWebView.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderWebView.java
new file mode 100644
index 000000000..b281becca
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderWebView.java
@@ -0,0 +1,264 @@
+package org.wordpress.android.ui.reader;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.webkit.WebChromeClient;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.AppLog;
+
+/*
+ * WebView descendant used by ReaderPostDetailFragment - handles
+ * displaying fullscreen video and detecting url/image clicks
+ */
+class ReaderWebView extends WebView {
+
+ public interface ReaderWebViewUrlClickListener {
+ public boolean onUrlClick(String url);
+ public boolean onImageUrlClick(String imageUrl, View view, int x, int y);
+ }
+
+ public interface ReaderCustomViewListener {
+ public void onCustomViewShown();
+ public void onCustomViewHidden();
+ public ViewGroup onRequestCustomView();
+ public ViewGroup onRequestContentView();
+ }
+
+ private ReaderWebChromeClient mReaderChromeClient;
+ private ReaderCustomViewListener mCustomViewListener;
+ private ReaderWebViewUrlClickListener mUrlClickListener;
+
+ public ReaderWebView(Context context) {
+ super(context);
+ init();
+ }
+
+ public ReaderWebView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public ReaderWebView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ init();
+ }
+
+ private void init() {
+ if (!isInEditMode()) {
+ mReaderChromeClient = new ReaderWebChromeClient(this);
+ this.setWebChromeClient(mReaderChromeClient);
+
+ this.setWebViewClient(new ReaderWebViewClient(this));
+ this.setOnTouchListener(mOnTouchListener);
+ this.getSettings().setUserAgentString(WordPress.getUserAgent());
+ }
+ }
+
+ private ReaderWebViewUrlClickListener getUrlClickListener() {
+ return mUrlClickListener;
+ }
+
+ void setUrlClickListener(ReaderWebViewUrlClickListener listener) {
+ mUrlClickListener = listener;
+ }
+
+ private boolean hasUrlClickListener() {
+ return (mUrlClickListener != null);
+ }
+
+ void setCustomViewListener(ReaderCustomViewListener listener) {
+ mCustomViewListener = listener;
+ }
+
+ private boolean hasCustomViewListener() {
+ return (mCustomViewListener != null);
+ }
+
+ private ReaderCustomViewListener getCustomViewListener() {
+ return mCustomViewListener;
+ }
+
+ private static boolean isValidClickedUrl(String url) {
+ // only return true for http(s) urls so we avoid file: and data: clicks
+ return (url != null && url.startsWith("http"));
+ }
+
+ boolean isCustomViewShowing() {
+ return mReaderChromeClient.isCustomViewShowing();
+ }
+
+ void hideCustomView() {
+ if (isCustomViewShowing()) {
+ mReaderChromeClient.onHideCustomView();
+ }
+ }
+ /*
+ * detect when an image is tapped
+ */
+ private final OnTouchListener mOnTouchListener = new OnTouchListener() {
+ @Override
+ public boolean onTouch(View view, MotionEvent event) {
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_UP:
+ HitTestResult hr = ((WebView) view).getHitTestResult();
+ if (hr != null && (hr.getType() == HitTestResult.IMAGE_TYPE || hr.getType() == HitTestResult.SRC_IMAGE_ANCHOR_TYPE)) {
+ String imageUrl = hr.getExtra();
+ if (isValidClickedUrl(imageUrl) && mUrlClickListener != null) {
+ return mUrlClickListener.onImageUrlClick(imageUrl, view, (int) event.getX(), (int) event.getY());
+ } else {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ default:
+ return false;
+ }
+ }
+ };
+
+ private static class ReaderWebViewClient extends WebViewClient {
+ private final ReaderWebView mReaderWebView;
+
+ ReaderWebViewClient(ReaderWebView readerWebView) {
+ if (readerWebView == null) {
+ throw new IllegalArgumentException("ReaderWebViewClient requires readerWebView");
+ }
+ mReaderWebView = readerWebView;
+ }
+
+ @Override
+ public void onPageFinished(WebView view, String url) {
+ // show the webView now that it has loaded (ReaderPostDetailFragment may have hidden it)
+ if (view.getVisibility() != View.VISIBLE) {
+ view.setVisibility(View.VISIBLE);
+ }
+ }
+
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, String url) {
+ // fire the url click listener, but only do this when webView has
+ // loaded (is visible) - have seen some posts containing iframes
+ // automatically try to open urls (without being clicked)
+ // before the page has loaded
+ if (view.getVisibility() == View.VISIBLE
+ && mReaderWebView.hasUrlClickListener()
+ && isValidClickedUrl(url)) {
+ return mReaderWebView.getUrlClickListener().onUrlClick(url);
+ } else {
+ return false;
+ }
+ }
+ }
+
+ private static class ReaderWebChromeClient extends WebChromeClient {
+ private final ReaderWebView mReaderWebView;
+ private View mCustomView;
+ private CustomViewCallback mCustomViewCallback;
+
+ ReaderWebChromeClient(ReaderWebView readerWebView) {
+ if (readerWebView == null) {
+ throw new IllegalArgumentException("ReaderWebChromeClient requires readerWebView");
+ }
+ mReaderWebView = readerWebView;
+ }
+
+ /*
+ * request the view that will host the fullscreen video
+ */
+ private ViewGroup getTargetView() {
+ if (mReaderWebView.hasCustomViewListener()) {
+ return mReaderWebView.getCustomViewListener().onRequestCustomView();
+ } else {
+ return null;
+ }
+ }
+
+ /*
+ * request the view that should be hidden when showing fullscreen video
+ */
+ private ViewGroup getContentView() {
+ if (mReaderWebView.hasCustomViewListener()) {
+ return mReaderWebView.getCustomViewListener().onRequestContentView();
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ public void onShowCustomView(View view, CustomViewCallback callback) {
+ AppLog.i(AppLog.T.READER, "onShowCustomView");
+
+ if (mCustomView != null) {
+ AppLog.w(AppLog.T.READER, "customView already showing");
+ onHideCustomView();
+ return;
+ }
+
+ // hide the post detail content
+ ViewGroup contentView = getContentView();
+ if (contentView != null) {
+ contentView.setVisibility(View.INVISIBLE);
+ }
+
+ // show the full screen view
+ ViewGroup targetView = getTargetView();
+ if (targetView != null) {
+ targetView.addView(view);
+ targetView.setVisibility(View.VISIBLE);
+ }
+
+ if (mReaderWebView.hasCustomViewListener()) {
+ mReaderWebView.getCustomViewListener().onCustomViewShown();
+ }
+
+ mCustomView = view;
+ mCustomViewCallback = callback;
+ }
+
+ @Override
+ public void onHideCustomView() {
+ AppLog.i(AppLog.T.READER, "onHideCustomView");
+
+ if (mCustomView == null) {
+ AppLog.w(AppLog.T.READER, "customView does not exist");
+ return;
+ }
+
+ // hide the target view
+ ViewGroup targetView = getTargetView();
+ if (targetView != null) {
+ targetView.removeView(mCustomView);
+ targetView.setVisibility(View.GONE);
+ }
+
+ // redisplay the post detail content
+ ViewGroup contentView = getContentView();
+ if (contentView != null) {
+ contentView.setVisibility(View.VISIBLE);
+ }
+
+ if (mCustomViewCallback != null) {
+ mCustomViewCallback.onCustomViewHidden();
+ }
+ if (mReaderWebView.hasCustomViewListener()) {
+ mReaderWebView.getCustomViewListener().onCustomViewHidden();
+ }
+
+ mCustomView = null;
+ mCustomViewCallback = null;
+
+ mReaderWebView.onPause();
+ }
+
+ boolean isCustomViewShowing() {
+ return (mCustomView != null);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderActions.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderActions.java
new file mode 100644
index 000000000..a6e0081c0
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderActions.java
@@ -0,0 +1,98 @@
+package org.wordpress.android.ui.reader.actions;
+
+import android.view.View;
+
+import org.wordpress.android.models.ReaderBlog;
+import org.wordpress.android.models.ReaderComment;
+import org.wordpress.android.models.ReaderPost;
+
+/**
+ * classes in this package serve as a middleman between local data and server data - used by
+ * reader activities/fragments/adapters that wish to perform actions on posts, blogs & topics,
+ * or wish to get the latest data from the server.
+ *
+ * methods in this package which change state (like, follow, etc.) are generally optimistic
+ * and work like this:
+ *
+ * 1. caller asks method to send a network request which changes state
+ * 2. method changes state in local data and returns to caller *before* network request completes
+ * 3. caller can access local state change without waiting for the network request
+ * 4. if the network request fails, the method restores the previous state of the local data
+ * 5. if caller passes a listener, it can be alerted to the actual success/failure of the request
+ *
+ * note that all methods MUST be called from the UI thread in order to guarantee that listeners
+ * are alerted on the UI thread
+ */
+public class ReaderActions {
+
+ private ReaderActions() {
+ throw new AssertionError();
+ }
+
+ /*
+ * result when a specific action is performed (liking a post, etc.)
+ */
+ public interface ActionListener {
+ public void onActionResult(boolean succeeded);
+ }
+
+ /*
+ * result when submitting a comment
+ */
+ public interface CommentActionListener {
+ public void onActionResult(boolean succeeded, ReaderComment newComment);
+ }
+
+ /*
+ * result when updating data (getting latest comments for a post, etc.)
+ */
+ public enum UpdateResult {CHANGED, UNCHANGED, FAILED}
+
+
+ public interface UpdateResultListener {
+ public void onUpdateResult(UpdateResult result);
+ }
+
+ /*
+ * same as UpdateResultListener but includes count
+ */
+ public interface UpdateResultAndCountListener {
+ public void onUpdateResult(UpdateResult result, int numNew);
+ }
+
+ /*
+ * used by adapters to notify when data has been loaded
+ */
+ public interface DataLoadedListener {
+ public void onDataLoaded(boolean isEmpty);
+ }
+
+ /*
+ * used by adapters to notify when more data should be loaded
+ */
+ public static enum RequestDataAction {LOAD_NEWER, LOAD_OLDER}
+ public interface DataRequestedListener {
+ public void onRequestData();
+ }
+
+ /*
+ * used by post list & post list adapter when user asks to reblog a post
+ */
+ public interface RequestReblogListener {
+ public void onRequestReblog(ReaderPost post, View sourceView);
+ }
+
+ /*
+ * used by blog preview when requesting latest info about a blog
+ */
+ public interface UpdateBlogInfoListener {
+ public void onResult(ReaderBlog blogInfo);
+ }
+
+ /*
+ * listener when updating posts and then backfilling them
+ */
+ public interface PostBackfillListener {
+ public void onPostsBackfilled();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderAuthActions.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderAuthActions.java
new file mode 100644
index 000000000..95ac67c15
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderAuthActions.java
@@ -0,0 +1,114 @@
+package org.wordpress.android.ui.reader.actions;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+import android.webkit.CookieManager;
+import android.webkit.CookieSyncManager;
+
+import org.apache.http.HttpResponse;
+import org.apache.http.HttpStatus;
+import org.apache.http.NameValuePair;
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.conn.scheme.Scheme;
+import org.apache.http.conn.ssl.SSLSocketFactory;
+import org.apache.http.cookie.Cookie;
+import org.apache.http.impl.client.BasicCredentialsProvider;
+import org.apache.http.impl.client.DefaultHttpClient;
+import org.apache.http.message.BasicNameValuePair;
+import org.apache.http.protocol.HTTP;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.WordPressDB;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+
+public class ReaderAuthActions {
+ private static final String URI_LOGIN = "https://wordpress.com/wp-login.php";
+ private static final int HTTPS_PORT = 443;
+
+ /*
+ * login to WP using a DefaultHttpClient so we can capture the response cookies and add them to
+ * the CookieSyncManager so they'll be available in our webView on post detail - only needs to
+ * be done once per session
+ */
+ public static void updateCookies(Context context) {
+ // http://developer.android.com/reference/android/webkit/CookieSyncManager.html
+ CookieSyncManager.createInstance(context.getApplicationContext());
+ final CookieManager cookieManager = CookieManager.getInstance();
+ cookieManager.removeAllCookie();
+
+ // nothing more to do if login doesn't exist yet
+ if (!WordPress.hasValidWPComCredentials(context))
+ return;
+
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context);
+ final String username = settings.getString(WordPress.WPCOM_USERNAME_PREFERENCE, "");
+ final String password = WordPressDB.decryptPassword(settings.getString(WordPress.WPCOM_PASSWORD_PREFERENCE, ""));
+
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ URI uri = URI.create(URI_LOGIN);
+ HttpPost postMethod = new HttpPost(uri);
+ postMethod.addHeader("charset", "UTF-8");
+ postMethod.addHeader("User-Agent", WordPress.getUserAgent());
+
+ UsernamePasswordCredentials creds = new UsernamePasswordCredentials(username, password);
+
+ DefaultHttpClient client = getHttpClient(creds);
+
+ List<NameValuePair> nameValuePairs = new ArrayList<NameValuePair>(3);
+ nameValuePairs.add(new BasicNameValuePair("log", username));
+ nameValuePairs.add(new BasicNameValuePair("pwd", password));
+ nameValuePairs.add(new BasicNameValuePair("rememberme", "forever"));
+ nameValuePairs.add(new BasicNameValuePair("wp-submit", "Log In"));
+ nameValuePairs.add(new BasicNameValuePair("redirect_to", "/"));
+ postMethod.setEntity(new UrlEncodedFormEntity(nameValuePairs, HTTP.UTF_8));
+
+ HttpResponse response = client.execute(postMethod);
+
+ int statusCode = response.getStatusLine().getStatusCode();
+ if (statusCode != HttpStatus.SC_OK) {
+ AppLog.w(T.READER, String.format("failed to retrieve cookies, status %d", statusCode));
+ return;
+ }
+
+ List<Cookie> cookies = client.getCookieStore().getCookies();
+ if(!cookies.isEmpty()) {
+ for (Cookie cookie : cookies){
+ String cookieString = cookie.getName() + "=" + cookie.getValue() + "; domain=" + cookie.getDomain();
+ cookieManager.setCookie("wordpress.com", cookieString);
+ }
+ CookieSyncManager.getInstance().sync();
+ }
+
+ } catch (UnsupportedEncodingException e) {
+ AppLog.e(T.READER, e);
+ } catch (IOException e) {
+ AppLog.e(T.READER, e);
+ }
+ }
+ }.start();
+ }
+
+ private static DefaultHttpClient getHttpClient(UsernamePasswordCredentials creds) {
+ DefaultHttpClient client = new DefaultHttpClient();
+
+ BasicCredentialsProvider cP = new BasicCredentialsProvider();
+ cP.setCredentials(AuthScope.ANY, creds);
+ client.setCredentialsProvider(cP);
+ client.getConnectionManager().getSchemeRegistry().register(new Scheme("https", SSLSocketFactory.getSocketFactory(), HTTPS_PORT));
+
+ return client;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderBlogActions.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderBlogActions.java
new file mode 100644
index 000000000..b312e4cef
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderBlogActions.java
@@ -0,0 +1,384 @@
+package org.wordpress.android.ui.reader.actions;
+
+import android.os.Handler;
+import android.text.TextUtils;
+
+import com.android.volley.Request;
+import com.android.volley.Response;
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.StringRequest;
+import com.wordpress.rest.RestRequest;
+
+import org.apache.http.HttpStatus;
+import org.json.JSONObject;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.ReaderBlogTable;
+import org.wordpress.android.datasets.ReaderPostTable;
+import org.wordpress.android.models.ReaderBlog;
+import org.wordpress.android.models.ReaderBlogList;
+import org.wordpress.android.models.ReaderPost;
+import org.wordpress.android.models.ReaderRecommendBlogList;
+import org.wordpress.android.ui.reader.ReaderConstants;
+import org.wordpress.android.ui.reader.actions.ReaderActions.UpdateBlogInfoListener;
+import org.wordpress.android.ui.reader.actions.ReaderActions.UpdateResult;
+import org.wordpress.android.ui.reader.actions.ReaderActions.UpdateResultListener;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.UrlUtils;
+import org.wordpress.android.util.VolleyUtils;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+
+public class ReaderBlogActions {
+
+ /*
+ * follow/unfollow a blog - make sure to pass the blogId when known since following
+ * solely by url may cause the blog to be followed as a feed
+ */
+ public static boolean performFollowAction(final long blogId,
+ final String blogUrl,
+ final boolean isAskingToFollow,
+ final ReaderActions.ActionListener actionListener) {
+ // either blogId or blogUrl are required
+ final boolean hasBlogId = (blogId != 0);
+ final boolean hasBlogUrl = !TextUtils.isEmpty(blogUrl);
+ if (!hasBlogId && !hasBlogUrl) {
+ AppLog.w(T.READER, "follow action performed without blogId or blogUrl");
+ if (actionListener != null) {
+ actionListener.onActionResult(false);
+ }
+ return false;
+ }
+
+ // update local db
+ ReaderBlogTable.setIsFollowedBlog(blogId, blogUrl, isAskingToFollow);
+ ReaderPostTable.setFollowStatusForPostsInBlog(blogId, blogUrl, isAskingToFollow);
+
+ if (isAskingToFollow) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.READER_FOLLOWED_SITE);
+ }
+
+ final String path = getFollowEndpoint(blogId, blogUrl, isAskingToFollow);
+ final String actionName = (isAskingToFollow ? "follow" : "unfollow");
+
+ com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ boolean success = isFollowActionSuccessful(jsonObject, isAskingToFollow);
+ if (success) {
+ AppLog.d(T.READER, "blog " + actionName + " succeeded");
+ } else {
+ AppLog.w(T.READER, "blog " + actionName + " failed");
+ localRevertFollowAction(blogId, blogUrl, isAskingToFollow);
+ }
+ if (actionListener != null) {
+ actionListener.onActionResult(success);
+ }
+ }
+ };
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.w(T.READER, "blog " + actionName + " failed");
+ AppLog.e(T.READER, volleyError);
+ localRevertFollowAction(blogId, blogUrl, isAskingToFollow);
+ if (actionListener != null) {
+ actionListener.onActionResult(false);
+ }
+ }
+ };
+ WordPress.getRestClientUtils().post(path, listener, errorListener);
+
+ // return before API call completes
+ return true;
+ }
+
+ /*
+ * helper routine when following a blog from a post view
+ */
+ public static boolean performFollowAction(ReaderPost post,
+ boolean isAskingToFollow,
+ ReaderActions.ActionListener actionListener) {
+ if (post == null) {
+ return false;
+ }
+ // don't use the blogId if this is an external feed
+ long blogId = (post.isExternal ? 0 : post.blogId);
+ return performFollowAction(blogId, post.getBlogUrl(), isAskingToFollow, actionListener);
+ }
+
+ /*
+ * called when a follow/unfollow fails, restores local data to previous state
+ */
+ private static void localRevertFollowAction(long blogId, String blogUrl, boolean isAskingToFollow) {
+ if (blogId == 0 && TextUtils.isEmpty(blogUrl)) {
+ return;
+ }
+ ReaderBlogTable.setIsFollowedBlog(blogId, blogUrl, !isAskingToFollow);
+ ReaderPostTable.setFollowStatusForPostsInBlog(blogId, blogUrl, !isAskingToFollow);
+ }
+
+ /*
+ * returns whether a follow/unfollow was successful based on the response to:
+ * read/follows/new
+ * read/follows/delete
+ * site/$site/follows/new
+ * site/$site/follows/mine/delete
+ */
+ private static boolean isFollowActionSuccessful(JSONObject json, boolean isAskingToFollow) {
+ if (json == null) {
+ return false;
+ }
+
+ final boolean isSubscribed;
+ if (json.has("subscribed")) {
+ // read/follows/
+ isSubscribed = json.optBoolean("subscribed", false);
+ } else if (json.has("is_following")) {
+ // site/$site/follows/
+ isSubscribed = json.optBoolean("is_following", false);
+ } else {
+ isSubscribed = false;
+ }
+
+ return (isSubscribed == isAskingToFollow);
+ }
+
+ /*
+ * returns the endpoint path to use when following/unfollowing a blog
+ */
+ private static String getFollowEndpoint(long blogId, String blogUrl, boolean isAskingToFollow) {
+ if (isAskingToFollow) {
+ // if we have a blogId, use /sites/$siteId/follows/new - this is important
+ // because /read/following/mine/new follows it as a feed rather than a blog,
+ // so its posts show up without support for likes, comments, etc.
+ if (blogId != 0) {
+ return "/sites/" + blogId + "/follows/new";
+ } else {
+ AppLog.w(T.READER, "following blog by url rather than id");
+ return "/read/following/mine/new?url=" + UrlUtils.urlEncode(blogUrl);
+ }
+ } else {
+ if (blogId != 0) {
+ return "/sites/" + blogId + "/follows/mine/delete";
+ } else {
+ AppLog.w(T.READER, "unfollowing blog by url rather than id");
+ return "/read/following/mine/delete?url=" + UrlUtils.urlEncode(blogUrl);
+ }
+ }
+ }
+
+ /*
+ * request the list of blogs the current user is following
+ */
+ public static void updateFollowedBlogs(final UpdateResultListener resultListener) {
+ RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ handleFollowedBlogsResponse(jsonObject, resultListener);
+ }
+ };
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.e(T.READER, volleyError);
+ if (resultListener != null) {
+ resultListener.onUpdateResult(UpdateResult.FAILED);
+ }
+ }
+ };
+ // request using ?meta=site,feed to get extra info
+ WordPress.getRestClientUtils().get("/read/following/mine?meta=site%2Cfeed", listener, errorListener);
+ }
+ private static void handleFollowedBlogsResponse(final JSONObject jsonObject, final UpdateResultListener resultListener) {
+ if (jsonObject == null) {
+ if (resultListener != null) {
+ resultListener.onUpdateResult(UpdateResult.FAILED);
+ }
+ return;
+ }
+
+ final Handler handler = new Handler();
+ new Thread() {
+ @Override
+ public void run() {
+ ReaderBlogList serverBlogs = ReaderBlogList.fromJson(jsonObject);
+ ReaderBlogList localBlogs = ReaderBlogTable.getFollowedBlogs();
+
+ final boolean hasChanges = !localBlogs.isSameList(serverBlogs);
+ if (hasChanges) {
+ ReaderBlogTable.setFollowedBlogs(serverBlogs);
+ }
+
+ if (resultListener != null) {
+ handler.post(new Runnable() {
+ public void run() {
+ ReaderActions.UpdateResult result = (hasChanges ? UpdateResult.CHANGED : UpdateResult.UNCHANGED);
+ resultListener.onUpdateResult(result);
+ }
+ });
+ }
+ }
+ }.start();
+ }
+
+ /*
+ * request info about a specific blog
+ */
+ public static void updateBlogInfo(long blogId,
+ final String blogUrl,
+ final UpdateBlogInfoListener infoListener) {
+ // must pass either a valid id or url
+ final boolean hasBlogId = (blogId != 0);
+ final boolean hasBlogUrl = !TextUtils.isEmpty(blogUrl);
+ if (!hasBlogId && !hasBlogUrl) {
+ AppLog.w(T.READER, "cannot get blog info without either id or url");
+ if (infoListener != null) {
+ infoListener.onResult(null);
+ }
+ return;
+ }
+
+ RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ handleUpdateBlogInfoResponse(jsonObject, infoListener);
+ }
+ };
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ // authentication error may indicate that API access has been disabled for this blog
+ int statusCode = VolleyUtils.statusCodeFromVolleyError(volleyError);
+ boolean isAuthErr = (statusCode == HttpStatus.SC_FORBIDDEN);
+ // if we failed to get the blog info using the id and this isn't an authentication
+ // error, try again using just the domain
+ if (!isAuthErr && hasBlogId && hasBlogUrl) {
+ AppLog.w(T.READER, "failed to get blog info by id, retrying with url");
+ updateBlogInfo(0, blogUrl, infoListener);
+ } else {
+ AppLog.e(T.READER, volleyError);
+ if (infoListener != null) {
+ infoListener.onResult(null);
+ }
+ }
+ }
+ };
+
+ if (hasBlogId) {
+ WordPress.getRestClientUtils().get("/sites/" + blogId, listener, errorListener);
+ } else {
+ WordPress.getRestClientUtils().get("/sites/" + UrlUtils.urlEncode(UrlUtils.getDomainFromUrl(blogUrl)), listener, errorListener);
+ }
+ }
+ private static void handleUpdateBlogInfoResponse(JSONObject jsonObject, UpdateBlogInfoListener infoListener) {
+ if (jsonObject == null) {
+ if (infoListener != null) {
+ infoListener.onResult(null);
+ }
+ return;
+ }
+
+ ReaderBlog blogInfo = ReaderBlog.fromJson(jsonObject);
+ ReaderBlogTable.addOrUpdateBlog(blogInfo);
+
+ if (infoListener != null) {
+ infoListener.onResult(blogInfo);
+ }
+ }
+
+ /*
+ * request the latest recommended blogs, replaces all local ones
+ */
+ public static void updateRecommendedBlogs(final UpdateResultListener resultListener) {
+ RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ handleRecommendedBlogsResponse(jsonObject, resultListener);
+ }
+ };
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.e(T.READER, volleyError);
+ if (resultListener != null) {
+ resultListener.onUpdateResult(UpdateResult.FAILED);
+ }
+ }
+ };
+
+ String path = "/read/recommendations/mine/"
+ + "?source=mobile"
+ + "&number=" + Integer.toString(ReaderConstants.READER_MAX_RECOMMENDED_TO_REQUEST);
+ WordPress.getRestClientUtils().get(path, listener, errorListener);
+ }
+ private static void handleRecommendedBlogsResponse(final JSONObject jsonObject,
+ final UpdateResultListener resultListener) {
+ if (jsonObject == null) {
+ if (resultListener != null) {
+ resultListener.onUpdateResult(UpdateResult.FAILED);
+ }
+ return;
+ }
+
+ final Handler handler = new Handler();
+
+ new Thread() {
+ @Override
+ public void run() {
+ ReaderRecommendBlogList serverBlogs = ReaderRecommendBlogList.fromJson(jsonObject);
+ ReaderRecommendBlogList localBlogs = ReaderBlogTable.getAllRecommendedBlogs();
+
+ final boolean hasChanges = !localBlogs.isSameList(serverBlogs);
+ if (hasChanges) {
+ ReaderBlogTable.setRecommendedBlogs(serverBlogs);
+ }
+
+ if (resultListener != null) {
+ handler.post(new Runnable() {
+ public void run() {
+ ReaderActions.UpdateResult result = (hasChanges ? UpdateResult.CHANGED : UpdateResult.UNCHANGED);
+ resultListener.onUpdateResult(result);
+ }
+ });
+ }
+ }
+ }.start();
+ }
+
+ /*
+ * tests whether the passed url can be reached - does NOT use authentication, and does not
+ * account for 404 replacement pages used by ISPs such as Charter
+ */
+ public static void checkBlogUrlReachable(final String blogUrl, final ReaderActions.ActionListener actionListener) {
+ // ActionListener is required
+ if (actionListener == null) {
+ return;
+ }
+ if (TextUtils.isEmpty(blogUrl)) {
+ actionListener.onActionResult(false);
+ return;
+ }
+
+ Response.Listener<String> listener = new Response.Listener<String>() {
+ @Override
+ public void onResponse(String response) {
+ actionListener.onActionResult(true);
+ }
+ };
+ Response.ErrorListener errorListener = new Response.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.e(T.READER, volleyError);
+ actionListener.onActionResult(false);
+ }
+ };
+
+ // TODO: this should be a HEAD rather than GET request, but Volley doesn't support HEAD
+ StringRequest request = new StringRequest(
+ Request.Method.GET,
+ blogUrl,
+ listener,
+ errorListener);
+ WordPress.requestQueue.add(request);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderCommentActions.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderCommentActions.java
new file mode 100644
index 000000000..857d243bd
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderCommentActions.java
@@ -0,0 +1,165 @@
+package org.wordpress.android.ui.reader.actions;
+
+import android.os.Handler;
+import android.text.TextUtils;
+
+import com.android.volley.VolleyError;
+import com.wordpress.rest.RestRequest;
+
+import org.json.JSONObject;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.ReaderCommentTable;
+import org.wordpress.android.datasets.ReaderUserTable;
+import org.wordpress.android.models.ReaderComment;
+import org.wordpress.android.models.ReaderCommentList;
+import org.wordpress.android.models.ReaderPost;
+import org.wordpress.android.models.ReaderUser;
+import org.wordpress.android.ui.reader.ReaderConstants;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.DateTimeUtils;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class ReaderCommentActions {
+ /**
+ * get the latest comments for this post
+ **/
+ public static void updateCommentsForPost(final ReaderPost post, final ReaderActions.UpdateResultListener resultListener) {
+ String path = "sites/" + post.blogId + "/posts/" + post.postId + "/replies/?number=" + Integer.toString(ReaderConstants.READER_MAX_COMMENTS_TO_REQUEST);
+
+ // get older comments first - subsequent calls to this routine will get newer ones if they exist
+ path += "&order=ASC";
+
+ // offset by the number of comments already stored locally (so we only get new comments)
+ int numLocalComments = ReaderCommentTable.getNumCommentsForPost(post);
+ if (numLocalComments > 0)
+ path += "&offset=" + Integer.toString(numLocalComments);
+
+ com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ handleUpdateCommentsResponse(jsonObject, post.blogId, resultListener);
+ }
+ };
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.e(T.READER, volleyError);
+ if (resultListener!=null)
+ resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED);
+
+ }
+ };
+ AppLog.d(T.READER, "updating comments");
+ WordPress.getRestClientUtils().get(path, null, null, listener, errorListener);
+ }
+ private static void handleUpdateCommentsResponse(final JSONObject jsonObject, final long blogId, final ReaderActions.UpdateResultListener resultListener) {
+ if (jsonObject==null) {
+ if (resultListener!=null)
+ resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED);
+ return;
+ }
+
+ final Handler handler = new Handler();
+
+ new Thread() {
+ @Override
+ public void run() {
+ // request asks for only newer comments, so if it returns any comments then they are all new
+ ReaderCommentList serverComments = ReaderCommentList.fromJson(jsonObject, blogId);
+ final int numNew = serverComments.size();
+ if (numNew > 0) {
+ AppLog.d(T.READER, "new comments found");
+ ReaderCommentTable.addOrUpdateComments(serverComments);
+ }
+
+ if (resultListener!=null) {
+ handler.post(new Runnable() {
+ public void run() {
+ resultListener.onUpdateResult(numNew > 0 ? ReaderActions.UpdateResult.CHANGED : ReaderActions.UpdateResult.UNCHANGED);
+ }
+ });
+ }
+ }
+ }.start();
+ }
+
+ /*
+ * used by post detail to generate a temporary "fake" comment id (see below)
+ */
+ public static long generateFakeCommentId() {
+ return System.currentTimeMillis();
+ }
+
+ /*
+ * add the passed comment text to the passed post - caller must pass a unique "fake" comment id
+ * to give the comment that's generated locally
+ */
+ public static ReaderComment submitPostComment(final ReaderPost post,
+ final long fakeCommentId,
+ final String commentText,
+ final long replyToCommentId,
+ final ReaderActions.CommentActionListener actionListener) {
+ if (post==null || TextUtils.isEmpty(commentText))
+ return null;
+
+ // create a "fake" comment that's added to the db so it can be shown right away - will be
+ // replaced with actual comment if it succeeds to be posted, or deleted if comment fails
+ // to be posted
+ ReaderComment newComment = new ReaderComment();
+ newComment.commentId = fakeCommentId;
+ newComment.postId = post.postId;
+ newComment.blogId = post.blogId;
+ newComment.parentId = replyToCommentId;
+ newComment.setText(commentText);
+ String published = DateTimeUtils.nowUTC().toString();
+ newComment.setPublished(published);
+ newComment.timestamp = DateTimeUtils.iso8601ToTimestamp(published);
+ ReaderUser currentUser = ReaderUserTable.getCurrentUser();
+ if (currentUser!=null) {
+ newComment.setAuthorAvatar(currentUser.getAvatarUrl());
+ newComment.setAuthorName(currentUser.getDisplayName());
+ }
+ ReaderCommentTable.addOrUpdateComment(newComment);
+
+ // different endpoint depending on whether the new comment is a reply to another comment
+ final String path;
+ if (replyToCommentId==0) {
+ path = "sites/" + post.blogId + "/posts/" + post.postId + "/replies/new";
+ } else {
+ path = "sites/" + post.blogId + "/comments/" + Long.toString(replyToCommentId) + "/replies/new";
+ }
+
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("content", commentText);
+
+ com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ ReaderCommentTable.deleteComment(post, fakeCommentId);
+ AppLog.i(T.READER, "comment succeeded");
+ ReaderComment newComment = ReaderComment.fromJson(jsonObject, post.blogId);
+ ReaderCommentTable.addOrUpdateComment(newComment);
+ if (actionListener!=null)
+ actionListener.onActionResult(true, newComment);
+ }
+ };
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ ReaderCommentTable.deleteComment(post, fakeCommentId);
+ AppLog.w(T.READER, "comment failed");
+ AppLog.e(T.READER, volleyError);
+ if (actionListener!=null)
+ actionListener.onActionResult(false, null);
+ }
+ };
+
+ AppLog.i(T.READER, "submitting comment");
+ WordPress.getRestClientUtils().post(path, params, null, listener, errorListener);
+
+ return newComment;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderPostActions.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderPostActions.java
new file mode 100644
index 000000000..19c3e0f08
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderPostActions.java
@@ -0,0 +1,634 @@
+package org.wordpress.android.ui.reader.actions;
+
+import android.os.Handler;
+import android.text.TextUtils;
+
+import com.android.volley.VolleyError;
+import com.wordpress.rest.RestRequest;
+
+import org.json.JSONObject;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.ReaderLikeTable;
+import org.wordpress.android.datasets.ReaderPostTable;
+import org.wordpress.android.datasets.ReaderTagTable;
+import org.wordpress.android.datasets.ReaderUserTable;
+import org.wordpress.android.models.ReaderPost;
+import org.wordpress.android.models.ReaderPostList;
+import org.wordpress.android.models.ReaderTag;
+import org.wordpress.android.models.ReaderTagType;
+import org.wordpress.android.models.ReaderUserList;
+import org.wordpress.android.ui.reader.ReaderConstants;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.DateTimeUtils;
+import org.wordpress.android.util.JSONUtil;
+import org.wordpress.android.util.UrlUtils;
+import org.wordpress.android.util.VolleyUtils;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+public class ReaderPostActions {
+
+ private ReaderPostActions() {
+ throw new AssertionError();
+ }
+
+ /**
+ * like/unlike the passed post
+ */
+ public static boolean performLikeAction(final ReaderPost post,
+ final boolean isAskingToLike) {
+ // get post BEFORE we make changes so we can revert on error
+ final ReaderPost originalPost = ReaderPostTable.getPost(post.blogId, post.postId);
+
+ // do nothing and return true if post's like state is same as passed
+ if (originalPost != null && originalPost.isLikedByCurrentUser == isAskingToLike) {
+ return true;
+ }
+
+ // update post in local db
+ post.isLikedByCurrentUser = isAskingToLike;
+ if (isAskingToLike) {
+ post.numLikes++;
+ } else if (!isAskingToLike && post.numLikes > 0) {
+ post.numLikes--;
+ }
+ ReaderPostTable.addOrUpdatePost(post);
+ ReaderLikeTable.setCurrentUserLikesPost(post, isAskingToLike);
+
+ final String actionName = isAskingToLike ? "like" : "unlike";
+ String path = "sites/" + post.blogId + "/posts/" + post.postId + "/likes/";
+ if (isAskingToLike) {
+ path += "new";
+ } else {
+ path += "mine/delete";
+ }
+
+ com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ AppLog.d(T.READER, String.format("post %s succeeded", actionName));
+ }
+ };
+
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ String error = VolleyUtils.errStringFromVolleyError(volleyError);
+ if (TextUtils.isEmpty(error)) {
+ AppLog.w(T.READER, String.format("post %s failed", actionName));
+ } else {
+ AppLog.w(T.READER, String.format("post %s failed (%s)", actionName, error));
+ }
+ AppLog.e(T.READER, volleyError);
+
+ // revert to original post
+ if (originalPost != null) {
+ ReaderPostTable.addOrUpdatePost(originalPost);
+ ReaderLikeTable.setCurrentUserLikesPost(post, originalPost.isLikedByCurrentUser);
+ }
+ }
+ };
+
+ WordPress.getRestClientUtils().post(path, listener, errorListener);
+
+ return true;
+ }
+
+ /*
+ * reblogs the passed post to the passed destination with optional comment
+ * https://developer.wordpress.com/docs/api/1/post/sites/%24site/posts/%24post_ID/reblogs/new/
+ */
+ public static void reblogPost(final ReaderPost post,
+ long destinationBlogId,
+ final String optionalComment,
+ final ReaderActions.ActionListener actionListener) {
+ if (post == null) {
+ if (actionListener != null) {
+ actionListener.onActionResult(false);
+ }
+ return;
+ }
+
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("destination_site_id", Long.toString(destinationBlogId));
+ if (!TextUtils.isEmpty(optionalComment)) {
+ params.put("note", optionalComment);
+ }
+
+ com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ boolean isReblogged = (jsonObject != null && JSONUtil.getBool(jsonObject, "is_reblogged"));
+ if (isReblogged) {
+ ReaderPostTable.setPostReblogged(post, true);
+ }
+ if (actionListener != null) {
+ actionListener.onActionResult(isReblogged);
+ }
+ }
+ };
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.e(T.READER, volleyError);
+ if (actionListener != null) {
+ actionListener.onActionResult(false);
+ }
+
+ }
+ };
+
+ String path = "/sites/" + post.blogId
+ + "/posts/" + post.postId
+ + "/reblogs/new";
+ WordPress.getRestClientUtils().post(path, params, null, listener, errorListener);
+ }
+
+ /*
+ * get the latest version of this post - note that the post is only considered changed if the
+ * like/comment count has changed, or if the current user's like/follow status has changed
+ */
+ public static void updatePost(final ReaderPost post, final ReaderActions.UpdateResultListener resultListener) {
+ String path = "sites/" + post.blogId + "/posts/" + post.postId + "/?meta=site,likes";
+
+ com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ handleUpdatePostResponse(post, jsonObject, resultListener);
+ }
+ };
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.e(T.READER, volleyError);
+ if (resultListener != null) {
+ resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED);
+ }
+ }
+ };
+ AppLog.d(T.READER, "updating post");
+ WordPress.getRestClientUtils().get(path, null, null, listener, errorListener);
+ }
+
+ private static void handleUpdatePostResponse(final ReaderPost post,
+ final JSONObject jsonObject,
+ final ReaderActions.UpdateResultListener resultListener) {
+ if (jsonObject == null) {
+ if (resultListener != null) {
+ resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED);
+ }
+ return;
+ }
+
+ final Handler handler = new Handler();
+
+ new Thread() {
+ @Override
+ public void run() {
+ ReaderPost updatedPost = ReaderPost.fromJson(jsonObject);
+ final boolean hasChanges = (updatedPost.numReplies != post.numReplies
+ || updatedPost.numLikes != post.numLikes
+ || updatedPost.isCommentsOpen != post.isCommentsOpen
+ || updatedPost.isLikedByCurrentUser != post.isLikedByCurrentUser
+ || updatedPost.isFollowedByCurrentUser != post.isFollowedByCurrentUser);
+
+ if (hasChanges) {
+ AppLog.d(T.READER, "post updated");
+ // the endpoint for requesting a single post doesn't support featured images,
+ // so if the original post had a featured image, set the featured image for
+ // the updated post to that of the original post - this should be done even
+ // if the updated post has a featured image since that was most likely
+ // assigned by ReaderPost.findFeaturedImage()
+ if (post.hasFeaturedImage()) {
+ updatedPost.setFeaturedImage(post.getFeaturedImage());
+ }
+ // likewise for featured video
+ if (post.hasFeaturedVideo()) {
+ updatedPost.setFeaturedVideo(post.getFeaturedVideo());
+ updatedPost.isVideoPress = post.isVideoPress;
+ }
+ ReaderPostTable.addOrUpdatePost(updatedPost);
+ }
+
+ // always update liking users regardless of whether changes were detected - this
+ // ensures that the liking avatars are immediately available to post detail
+ handlePostLikes(updatedPost, jsonObject);
+
+ if (resultListener != null) {
+ handler.post(new Runnable() {
+ public void run() {
+ resultListener.onUpdateResult(hasChanges ? ReaderActions.UpdateResult.CHANGED : ReaderActions.UpdateResult.UNCHANGED);
+ }
+ });
+ }
+ }
+ }.start();
+ }
+
+ /*
+ * updates local liking users based on the "likes" meta section of the post's json - requires
+ * using the /sites/ endpoint with ?meta=likes
+ */
+ private static void handlePostLikes(final ReaderPost post, JSONObject jsonPost) {
+ if (post == null || jsonPost == null) {
+ return;
+ }
+
+ JSONObject jsonLikes = JSONUtil.getJSONChild(jsonPost, "meta/data/likes");
+ if (jsonLikes == null) {
+ return;
+ }
+
+ ReaderUserList likingUsers = ReaderUserList.fromJsonLikes(jsonLikes);
+ ReaderUserTable.addOrUpdateUsers(likingUsers);
+ ReaderLikeTable.setLikesForPost(post, likingUsers.getUserIds());
+ }
+
+ /**
+ * similar to updatePost, but used when post doesn't already exist in local db
+ **/
+ public static void requestPost(final long blogId, final long postId, final ReaderActions.ActionListener actionListener) {
+ String path = "sites/" + blogId + "/posts/" + postId + "/?meta=site,likes";
+
+ com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ ReaderPost post = ReaderPost.fromJson(jsonObject);
+ // make sure the post has the passed blogId so it's saved correctly - necessary
+ // since the /sites/ endpoints return site_id="1" for Jetpack-powered blogs
+ post.blogId = blogId;
+ ReaderPostTable.addOrUpdatePost(post);
+ handlePostLikes(post, jsonObject);
+ if (actionListener != null) {
+ actionListener.onActionResult(true);
+ }
+ }
+ };
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.e(T.READER, volleyError);
+ if (actionListener != null) {
+ actionListener.onActionResult(false);
+ }
+ }
+ };
+ AppLog.d(T.READER, "requesting post");
+ WordPress.getRestClientUtils().get(path, null, null, listener, errorListener);
+ }
+
+ /*
+ * get the latest posts in the passed topic - note that this uses an UpdateResultAndCountListener
+ * so the caller can be told how many new posts were added - use the second method which accepts
+ * a backfillListener to request new posts and backfill missing posts - note that a backfill
+ * will NOT occur unless a backfillListener is passed
+ */
+ public static void updatePostsInTag(final ReaderTag tag,
+ final ReaderActions.RequestDataAction updateAction,
+ final ReaderActions.UpdateResultAndCountListener resultListener) {
+ updatePostsInTag(tag, updateAction, resultListener, null);
+ }
+ public static void updatePostsInTagWithBackfill(final ReaderTag tag,
+ final ReaderActions.UpdateResultAndCountListener resultListener,
+ final ReaderActions.PostBackfillListener backfillListener) {
+ updatePostsInTag(tag, ReaderActions.RequestDataAction.LOAD_NEWER, resultListener, backfillListener);
+ }
+ private static void updatePostsInTag(final ReaderTag tag,
+ final ReaderActions.RequestDataAction updateAction,
+ final ReaderActions.UpdateResultAndCountListener resultListener,
+ final ReaderActions.PostBackfillListener backfillListener) {
+
+ String endpoint = getEndpointForTag(tag);
+ if (TextUtils.isEmpty(endpoint)) {
+ if (resultListener != null) {
+ resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED, -1);
+ }
+ return;
+ }
+
+ StringBuilder sb = new StringBuilder(endpoint);
+
+ // append #posts to retrieve
+ sb.append("?number=").append(ReaderConstants.READER_MAX_POSTS_TO_REQUEST);
+
+ // return newest posts first (this is the default, but make it explicit since it's important)
+ sb.append("&order=DESC");
+
+ // apply the after/before to limit results based on previous update, but only if there are
+ // existing posts in this topic
+ if (ReaderPostTable.hasPostsWithTag(tag)) {
+ switch (updateAction) {
+ case LOAD_NEWER:
+ String dateNewest = ReaderTagTable.getTagNewestDate(tag);
+ if (!TextUtils.isEmpty(dateNewest)) {
+ sb.append("&after=").append(UrlUtils.urlEncode(dateNewest));
+ AppLog.d(T.READER, String.format("requesting newer posts in tag %s (%s)", tag.getTagNameForLog(), dateNewest));
+ }
+ break;
+
+ case LOAD_OLDER:
+ String dateOldest = ReaderTagTable.getTagOldestDate(tag);
+ // if oldest date isn't stored, it means we haven't requested older posts until
+ // now, so use the date of the oldest stored post
+ if (TextUtils.isEmpty(dateOldest)) {
+ dateOldest = ReaderPostTable.getOldestPubDateWithTag(tag);
+ }
+ if (!TextUtils.isEmpty(dateOldest)) {
+ sb.append("&before=").append(UrlUtils.urlEncode(dateOldest));
+ AppLog.d(T.READER, String.format("requesting older posts in tag %s (%s)", tag.getTagNameForLog(), dateOldest));
+ }
+ break;
+ }
+ } else {
+ AppLog.d(T.READER, "requesting posts in empty tag " + tag.getTagNameForLog());
+ }
+
+ com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ handleUpdatePostsWithTagResponse(tag, updateAction, jsonObject, resultListener, backfillListener);
+ }
+ };
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.e(T.READER, volleyError);
+ if (resultListener != null) {
+ resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED, -1);
+ }
+ }
+ };
+
+ WordPress.getRestClientUtils().get(sb.toString(), null, null, listener, errorListener);
+ }
+
+ private static void handleUpdatePostsWithTagResponse(final ReaderTag tag,
+ final ReaderActions.RequestDataAction updateAction,
+ final JSONObject jsonObject,
+ final ReaderActions.UpdateResultAndCountListener resultListener,
+ final ReaderActions.PostBackfillListener backfillListener) {
+ if (jsonObject == null) {
+ if (resultListener != null) {
+ resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED, -1);
+ }
+ return;
+ }
+ final Handler handler = new Handler();
+
+ new Thread() {
+ @Override
+ public void run() {
+ final ReaderPostList serverPosts = ReaderPostList.fromJson(jsonObject);
+
+ // remember when this topic was updated if newer posts were requested, regardless of
+ // whether the response contained any posts
+ if (updateAction == ReaderActions.RequestDataAction.LOAD_NEWER) {
+ ReaderTagTable.setTagLastUpdated(tag, DateTimeUtils.javaDateToIso8601(new Date()));
+ }
+
+ // go no further if the response didn't contain any posts
+ if (serverPosts.size() == 0) {
+ AppLog.d(T.READER, "no new posts in tag " + tag.getTagNameForLog());
+ if (resultListener != null) {
+ handler.post(new Runnable() {
+ public void run() {
+ resultListener.onUpdateResult(ReaderActions.UpdateResult.UNCHANGED, 0);
+ }
+ });
+ }
+ return;
+ }
+
+ // json "date_range" tells the the range of dates in the response, which we want to
+ // store for use the next time we request newer/older if this response contained any
+ // posts - note that freshly-pressed uses "newest" and "oldest" but other endpoints
+ // use "after" and "before"
+ JSONObject jsonDateRange = jsonObject.optJSONObject("date_range");
+ if (jsonDateRange != null) {
+ switch (updateAction) {
+ case LOAD_NEWER:
+ String newest = jsonDateRange.has("before") ? JSONUtil.getString(jsonDateRange, "before") : JSONUtil.getString(jsonDateRange, "newest");
+ if (!TextUtils.isEmpty(newest)) {
+ ReaderTagTable.setTagNewestDate(tag, newest);
+ }
+ break;
+ case LOAD_OLDER:
+ String oldest = jsonDateRange.has("after") ? JSONUtil.getString(jsonDateRange, "after") : JSONUtil.getString(jsonDateRange, "oldest");
+ if (!TextUtils.isEmpty(oldest)) {
+ ReaderTagTable.setTagOldestDate(tag, oldest);
+ }
+ break;
+ }
+ }
+
+ // remember whether there were existing posts with this tag before adding
+ // the ones we just retrieved
+ final boolean hasExistingPostsWithTag = ReaderPostTable.hasPostsWithTag(tag);
+
+ // determine how many of the downloaded posts are new (response may contain both
+ // new posts and posts updated since the last call), then save the posts even if
+ // none are new in order to update comment counts, likes, etc., on existing posts
+ final int numNewPosts;
+ if (hasExistingPostsWithTag) {
+ numNewPosts = ReaderPostTable.getNumNewPostsWithTag(tag, serverPosts);
+ } else {
+ numNewPosts = serverPosts.size();
+ }
+ ReaderPostTable.addOrUpdatePosts(tag, serverPosts);
+
+ AppLog.d(T.READER, String.format("retrieved %d posts (%d new) in tag %s",
+ serverPosts.size(), numNewPosts, tag.getTagNameForLog()));
+
+ handler.post(new Runnable() {
+ public void run() {
+ if (resultListener != null) {
+ // always pass CHANGED as the result even if there are no new posts (since if
+ // get this far, it means there are changed - updated - posts)
+ resultListener.onUpdateResult(ReaderActions.UpdateResult.CHANGED, numNewPosts);
+ }
+
+ // if a backfill listener was passed, there were existing posts with this tag,
+ // and all posts retrieved are new, then backfill the posts to fill in gaps
+ // between posts just retrieved and posts previously retrieved
+ if (backfillListener != null && hasExistingPostsWithTag) {
+ boolean areAllPostsNew = (numNewPosts == ReaderConstants.READER_MAX_POSTS_TO_REQUEST);
+ if (areAllPostsNew) {
+ Date dtOldestServerPost = serverPosts.getOldestPubDate();
+ backfillPostsWithTag(tag, dtOldestServerPost, 0, backfillListener);
+ }
+ }
+ }
+ });
+ }
+ }.start();
+ }
+
+ /*
+ * get the latest posts in the passed blog
+ */
+ public static void requestPostsForBlog(final long blogId,
+ final String blogUrl,
+ final ReaderActions.RequestDataAction updateAction,
+ final ReaderActions.ActionListener actionListener) {
+ String path;
+ if (blogId == 0) {
+ path = "sites/" + UrlUtils.getDomainFromUrl(blogUrl);
+ } else {
+ path = "sites/" + blogId;
+ }
+ path += "/posts/?meta=site,likes";
+
+ // append the date of the oldest cached post in this blog when requesting older posts
+ if (updateAction == ReaderActions.RequestDataAction.LOAD_OLDER) {
+ String dateOldest = ReaderPostTable.getOldestPubDateInBlog(blogId);
+ if (!TextUtils.isEmpty(dateOldest)) {
+ path += "&before=" + UrlUtils.urlEncode(dateOldest);
+ }
+ }
+ com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ handleGetPostsResponse(jsonObject, actionListener);
+ }
+ };
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.e(T.READER, volleyError);
+ if (actionListener != null) {
+ actionListener.onActionResult(false);
+ }
+ }
+ };
+ AppLog.d(T.READER, "updating posts in blog " + blogId);
+ WordPress.getRestClientUtils().get(path, null, null, listener, errorListener);
+ }
+
+ private static void handleGetPostsResponse(JSONObject jsonObject, final ReaderActions.ActionListener actionListener) {
+ if (jsonObject==null) {
+ if (actionListener != null) {
+ actionListener.onActionResult(false);
+ }
+ return;
+ }
+
+ ReaderPostList posts = ReaderPostList.fromJson(jsonObject);
+ ReaderPostTable.addOrUpdatePosts(null, posts);
+
+ if (actionListener != null) {
+ actionListener.onActionResult(posts.size() > 0);
+ }
+ }
+
+ /*
+ * returns the endpoint to use for the passed tag - first gets it from local db, if not
+ * there it generates it "by hand"
+ */
+ private static String getEndpointForTag(ReaderTag tag) {
+ if (tag == null) {
+ return null;
+ }
+
+ // if passed tag has an assigned endpoint, return it and be done
+ if (!TextUtils.isEmpty(tag.getEndpoint())) {
+ return tag.getEndpoint();
+ }
+
+ // check the db for the endpoint
+ String endpoint = ReaderTagTable.getEndpointForTag(tag);
+ if (!TextUtils.isEmpty(endpoint)) {
+ return endpoint;
+ }
+
+ // never hand craft the endpoint for default tags, since these MUST be updated
+ // using their stored endpoints
+ if (tag.tagType == ReaderTagType.DEFAULT) {
+ return null;
+ }
+
+ return String.format("/read/tags/%s/posts", ReaderTagActions.sanitizeTitle(tag.getTagName()));
+ }
+
+ /*
+ * "backfill" posts with a specific tag - used to fill in gaps between syncs, ex: sync the
+ * reader, come back the next day and sync again, with a popular tag there may be posts
+ * missing between the posts retrieved the previous day and the posts just retrieved
+ */
+ private static final int BACKFILL_MAX_RECURSION = 3;
+ private static void backfillPostsWithTag(final ReaderTag tag,
+ final Date dateBefore,
+ final int recursionCounter,
+ final ReaderActions.PostBackfillListener backfillListener) {
+ String endpoint = getEndpointForTag(tag);
+ if (TextUtils.isEmpty(endpoint)) {
+ return;
+ }
+
+ com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ handleBackfillResponse(jsonObject, tag, recursionCounter, backfillListener);
+ }
+ };
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.e(T.READER, volleyError);
+ }
+ };
+
+ String strDateBefore = DateTimeUtils.javaDateToIso8601(dateBefore);
+ String path = endpoint
+ + "?number=" + ReaderConstants.READER_MAX_POSTS_TO_REQUEST
+ + "&order=DESC"
+ + "&before=" + UrlUtils.urlEncode(strDateBefore);
+ AppLog.i(T.READER, String.format("backfilling tag %s, recursion %d", tag.getTagNameForLog(), recursionCounter));
+ WordPress.getRestClientUtils().get(path, null, null, listener, errorListener);
+ }
+ private static void handleBackfillResponse(final JSONObject jsonObject,
+ final ReaderTag tag,
+ final int recursionCounter,
+ final ReaderActions.PostBackfillListener backfillListener) {
+ if (jsonObject == null) {
+ return;
+ }
+
+ final Handler handler = new Handler();
+
+ new Thread() {
+ @Override
+ public void run() {
+ final ReaderPostList serverPosts = ReaderPostList.fromJson(jsonObject);
+ final int numNewPosts = ReaderPostTable.getNumNewPostsWithTag(tag, serverPosts);
+ if (numNewPosts == 0) {
+ return;
+ }
+
+ AppLog.i(T.READER, String.format("backfilling tag %s found %d new posts", tag.getTagNameForLog(), numNewPosts));
+ ReaderPostTable.addOrUpdatePosts(tag, serverPosts);
+
+ handler.post(new Runnable() {
+ public void run() {
+ if (backfillListener != null) {
+ backfillListener.onPostsBackfilled();
+ }
+
+ // backfill again if all posts were new, but enforce a max on recursion
+ // so we don't backfill forever
+ boolean areAllPostsNew = (numNewPosts == ReaderConstants.READER_MAX_POSTS_TO_REQUEST);
+ if (areAllPostsNew && recursionCounter < BACKFILL_MAX_RECURSION) {
+ backfillPostsWithTag(tag,
+ serverPosts.getOldestPubDate(),
+ recursionCounter + 1,
+ backfillListener);
+ }
+ }
+ });
+ }
+ }.start();
+ }
+
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderTagActions.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderTagActions.java
new file mode 100644
index 000000000..51273dce1
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderTagActions.java
@@ -0,0 +1,272 @@
+package org.wordpress.android.ui.reader.actions;
+
+import android.database.sqlite.SQLiteDatabase;
+import android.os.Handler;
+
+import com.android.volley.VolleyError;
+import com.wordpress.rest.RestRequest;
+
+import org.json.JSONObject;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.ReaderDatabase;
+import org.wordpress.android.datasets.ReaderPostTable;
+import org.wordpress.android.datasets.ReaderTagTable;
+import org.wordpress.android.models.ReaderTag;
+import org.wordpress.android.models.ReaderTagList;
+import org.wordpress.android.models.ReaderTagType;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.JSONUtil;
+import org.wordpress.android.util.VolleyUtils;
+
+import java.util.Iterator;
+
+public class ReaderTagActions {
+ public enum TagAction {ADD, DELETE}
+
+ private ReaderTagActions() {
+ throw new AssertionError();
+ }
+
+ /**
+ * perform the passed action on the passed tag - this is optimistic (returns before API call completes)
+ **/
+ public static boolean performTagAction(final ReaderTag tag,
+ final TagAction action,
+ final ReaderActions.ActionListener actionListener) {
+ if (tag == null) {
+ if (actionListener != null) {
+ actionListener.onActionResult(false);
+ }
+ return false;
+ }
+
+ // don't allow actions on default tags
+ if (tag.tagType == ReaderTagType.DEFAULT) {
+ AppLog.w(T.READER, "cannot add or delete default tag");
+ if (actionListener != null) {
+ actionListener.onActionResult(false);
+ }
+ return false;
+ }
+
+ final String path;
+ final String tagNameForApi = sanitizeTitle(tag.getTagName());
+
+ switch (action) {
+ case DELETE:
+ // delete tag & all related posts
+ ReaderTagTable.deleteTag(tag);
+ ReaderPostTable.deletePostsWithTag(tag);
+ path = "read/tags/" + tagNameForApi + "/mine/delete";
+ break;
+
+ case ADD :
+ String endpoint = "/read/tags/" + tagNameForApi + "/posts";
+ ReaderTag newTopic = new ReaderTag(tag.getTagName(), endpoint, ReaderTagType.FOLLOWED);
+ ReaderTagTable.addOrUpdateTag(newTopic);
+ path = "read/tags/" + tagNameForApi + "/mine/new";
+ break;
+
+ default :
+ return false;
+ }
+
+ com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ AppLog.i(T.READER, "tag action " + action.name() + " succeeded");
+ if (actionListener != null) {
+ actionListener.onActionResult(true);
+ }
+ }
+ };
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ // if we're adding a topic and the error says the user is already following
+ // this topic, or we're removing a topic and the error says the user isn't
+ // following it, treat it as a success - this can happen if the user edits
+ // topics in the web reader while this app is running
+ String error = VolleyUtils.errStringFromVolleyError(volleyError);
+ boolean isSuccess = (action== TagAction.ADD && error.equals("already_subscribed"))
+ || (action== TagAction.DELETE && error.equals("not_subscribed"));
+ if (isSuccess) {
+ AppLog.w(T.READER, "tag action " + action.name() + " succeeded with error " + error);
+ if (actionListener != null) {
+ actionListener.onActionResult(true);
+ }
+ return;
+ }
+
+ AppLog.w(T.READER, "tag action " + action.name() + " failed");
+ AppLog.e(T.READER, volleyError);
+
+ // revert on failure
+ switch (action) {
+ case DELETE:
+ // add back original tag
+ ReaderTagTable.addOrUpdateTag(tag);
+ break;
+ case ADD:
+ // remove new topic
+ ReaderTagTable.deleteTag(tag);
+ break;
+ }
+
+ if (actionListener != null) {
+ actionListener.onActionResult(false);
+ }
+ }
+ };
+ WordPress.getRestClientUtils().post(path, listener, errorListener);
+
+ return true;
+ }
+
+ /*
+ * returns the passed tagName formatted for use with our API
+ * see sanitize_title_with_dashes in http://core.trac.wordpress.org/browser/tags/3.6/wp-includes/formatting.php#L0
+ */
+ static String sanitizeTitle(final String tagName) {
+ if (tagName == null) {
+ return "";
+ }
+
+ // remove ampersands and number signs, replace spaces & periods with dashes
+ String sanitized = tagName.replace("&", "")
+ .replace("#", "")
+ .replace(" ", "-")
+ .replace(".", "-");
+
+ // replace double dashes with single dash (may have been added above)
+ while (sanitized.contains("--")) {
+ sanitized = sanitized.replace("--", "-");
+ }
+
+ return sanitized.trim();
+ }
+
+ /**
+ * update list of reader tags from the server
+ **/
+ public static void updateTags(final ReaderActions.UpdateResultListener resultListener) {
+ com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ handleUpdateTagsResponse(jsonObject, resultListener);
+ }
+ };
+
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.e(T.READER, volleyError);
+ if (resultListener!=null)
+ resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED);
+ }
+ };
+ AppLog.d(T.READER, "updating reader tags");
+ WordPress.getRestClientUtils().get("read/menu", null, null, listener, errorListener);
+ }
+ private static void handleUpdateTagsResponse(final JSONObject jsonObject, final ReaderActions.UpdateResultListener resultListener) {
+ if (jsonObject==null) {
+ if (resultListener!=null)
+ resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED);
+ return;
+ }
+
+ // create handler on main thread, process & store response in separate thread
+ final Handler handler = new Handler();
+
+ new Thread() {
+ @Override
+ public void run() {
+ // get server topics, both default & followed
+ ReaderTagList serverTopics = new ReaderTagList();
+ serverTopics.addAll(parseTags(jsonObject, "default", ReaderTagType.DEFAULT));
+ serverTopics.addAll(parseTags(jsonObject, "subscribed", ReaderTagType.FOLLOWED));
+
+ // parse topics from the response, detect whether they're different from local
+ ReaderTagList localTopics = new ReaderTagList();
+ localTopics.addAll(ReaderTagTable.getDefaultTags());
+ localTopics.addAll(ReaderTagTable.getFollowedTags());
+ final boolean hasChanges = !localTopics.isSameList(serverTopics);
+
+ if (hasChanges) {
+ // if any local topics have been removed from the server, make sure to delete
+ // them locally (including their posts)
+ deleteTags(localTopics.getDeletions(serverTopics));
+ // now replace local topics with the server topics
+ ReaderTagTable.replaceTags(serverTopics);
+ }
+
+ // save changes to recommended topics
+ ReaderTagList serverRecommended = parseTags(jsonObject, "recommended", ReaderTagType.RECOMMENDED);
+ ReaderTagList localRecommended = ReaderTagTable.getRecommendedTags(false);
+ if (!serverRecommended.isSameList(localRecommended)) {
+ AppLog.d(T.READER, "recommended topics changed");
+ ReaderTagTable.setRecommendedTags(serverRecommended);
+ }
+
+ // listener must run on the main thread
+ if (resultListener!=null) {
+ handler.post(new Runnable() {
+ public void run() {
+ resultListener.onUpdateResult(hasChanges ? ReaderActions.UpdateResult.CHANGED : ReaderActions.UpdateResult.UNCHANGED);
+ }
+ });
+ }
+ }
+ }.start();
+ }
+
+ /*
+ * parse a specific topic section from the topic response
+ */
+ private static ReaderTagList parseTags(JSONObject jsonObject, String name, ReaderTagType topicType) {
+ ReaderTagList topics = new ReaderTagList();
+
+ if (jsonObject == null) {
+ return topics;
+ }
+
+ JSONObject jsonTopics = jsonObject.optJSONObject(name);
+ if (jsonTopics == null) {
+ return topics;
+ }
+
+ Iterator<String> it = jsonTopics.keys();
+ while (it.hasNext()) {
+ String internalName = it.next();
+ JSONObject jsonTopic = jsonTopics.optJSONObject(internalName);
+ if (jsonTopic!=null) {
+ String tagName = JSONUtil.getStringDecoded(jsonTopic, "title");
+ String endpoint = JSONUtil.getString(jsonTopic, "URL");
+ topics.add(new ReaderTag(tagName, endpoint, topicType));
+ }
+ }
+
+ return topics;
+ }
+
+ private static void deleteTags(ReaderTagList tagList) {
+ if (tagList == null || tagList.size() == 0) {
+ return;
+ }
+
+ SQLiteDatabase db = ReaderDatabase.getWritableDb();
+ db.beginTransaction();
+ try {
+ for (ReaderTag tag: tagList) {
+ ReaderTagTable.deleteTag(tag);
+ ReaderPostTable.deletePostsWithTag(tag);
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderUserActions.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderUserActions.java
new file mode 100644
index 000000000..277a73557
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/actions/ReaderUserActions.java
@@ -0,0 +1,69 @@
+package org.wordpress.android.ui.reader.actions;
+
+import com.android.volley.VolleyError;
+import com.wordpress.rest.RestRequest;
+
+import org.json.JSONObject;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.ReaderUserTable;
+import org.wordpress.android.models.ReaderUser;
+import org.wordpress.android.ui.prefs.UserPrefs;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+
+public class ReaderUserActions {
+ /*
+ * request the current user's info, update locally if different than existing local
+ */
+ public static void updateCurrentUser(final ReaderActions.UpdateResultListener resultListener) {
+ com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
+ @Override
+ public void onResponse(JSONObject jsonObject) {
+ final ReaderActions.UpdateResult result;
+ if (jsonObject == null) {
+ result = ReaderActions.UpdateResult.FAILED;
+ } else {
+ final ReaderUser serverUser = ReaderUser.fromJson(jsonObject);
+ final ReaderUser localUser = ReaderUserTable.getCurrentUser();
+ if (serverUser == null) {
+ result = ReaderActions.UpdateResult.FAILED;
+ } else if (serverUser.isSameUser(localUser)) {
+ result = ReaderActions.UpdateResult.UNCHANGED;
+ } else {
+ setCurrentUser(serverUser);
+ result = ReaderActions.UpdateResult.CHANGED;
+ }
+ }
+
+ if (resultListener != null)
+ resultListener.onUpdateResult(result);
+ }
+ };
+
+ RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.e(T.READER, volleyError);
+ if (resultListener != null)
+ resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED);
+ }
+ };
+
+ WordPress.getRestClientUtils().get("me", listener, errorListener);
+ }
+
+ /*
+ * set the passed user as the current user in both the local db and prefs
+ */
+ public static void setCurrentUser(JSONObject jsonUser) {
+ if (jsonUser == null)
+ return;
+ setCurrentUser(ReaderUser.fromJson(jsonUser));
+ }
+ private static void setCurrentUser(ReaderUser user) {
+ if (user == null)
+ return;
+ ReaderUserTable.addOrUpdateUser(user);
+ UserPrefs.setCurrentUserId(user.userId);
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderActionBarTagAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderActionBarTagAdapter.java
new file mode 100644
index 000000000..112e0a1ae
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderActionBarTagAdapter.java
@@ -0,0 +1,171 @@
+package org.wordpress.android.ui.reader.adapters;
+
+import android.content.Context;
+import android.os.AsyncTask;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.ReaderTagTable;
+import org.wordpress.android.models.ReaderTag;
+import org.wordpress.android.models.ReaderTagList;
+import org.wordpress.android.ui.reader.actions.ReaderActions;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.DisplayUtils;
+
+/**
+ * populates ActionBar dropdown with reader tags
+ */
+public class ReaderActionBarTagAdapter extends BaseAdapter {
+ private ReaderTagList mTags = new ReaderTagList();
+ private final LayoutInflater mInflater;
+ private final ReaderActions.DataLoadedListener mDataListener;
+ private final int mPaddingForStaticDrawer;
+ private final boolean mIsStaticMenuDrawer;
+
+ public ReaderActionBarTagAdapter(Context context, boolean isStaticMenuDrawer, ReaderActions.DataLoadedListener dataListener) {
+ mDataListener = dataListener;
+ mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+
+ // if the menu drawer is static (which it is for landscape tablets) add extra left padding
+ // so that the list of tags appears flush left with the fragment (ie: to the right of the
+ // menu drawer). without this, the tag list will be all the way to the left where it's not
+ // obvious to the user that it changes the reader's view
+ mIsStaticMenuDrawer = isStaticMenuDrawer;
+ mPaddingForStaticDrawer = context.getResources().getDimensionPixelOffset(R.dimen.menu_drawer_width)
+ - DisplayUtils.dpToPx(context, 32); // 32dp is the size of the ActionBar home icon
+
+ refreshTags();
+ }
+
+ public int getIndexOfTag(ReaderTag tag) {
+ if (tag == null) {
+ return -1;
+ }
+ for (int i = 0; i < mTags.size(); i++) {
+ if (ReaderTag.isSameTag(tag, mTags.get(i))) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ public void refreshTags() {
+ if (mIsTaskRunning) {
+ AppLog.w(T.READER, "reader tag adapter > Load tags task already running");
+ } else {
+ new LoadTagsTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+ }
+
+ public void reloadTags() {
+ mTags.clear();
+ refreshTags();
+ }
+
+ @Override
+ public int getCount() {
+ return (mTags !=null ? mTags.size() : 0);
+ }
+
+ private boolean isValidPosition(int position) {
+ return (position >= 0 && position < getCount());
+ }
+
+ @Override
+ public Object getItem(int index) {
+ if (isValidPosition(index)) {
+ return mTags.get(index);
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ final ReaderTag tag = mTags.get(position);
+ final TagHolder holder;
+
+ if (convertView == null) {
+ convertView = mInflater.inflate(R.layout.reader_actionbar_item, parent, false);
+ holder = new TagHolder(convertView, false);
+ convertView.setTag(holder);
+ } else {
+ holder = (TagHolder) convertView.getTag();
+ }
+
+ holder.textView.setText(tag.getCapitalizedTagName());
+ return convertView;
+ }
+
+ @Override
+ public View getDropDownView(int position, View convertView, ViewGroup parent) {
+ final ReaderTag tag = mTags.get(position);
+ final TagHolder holder;
+
+ if (convertView == null) {
+ convertView = mInflater.inflate(R.layout.reader_actionbar_dropdown_item, parent, false);
+ holder = new TagHolder(convertView, true);
+ convertView.setTag(holder);
+ } else {
+ holder = (TagHolder) convertView.getTag();
+ }
+
+ holder.textView.setText(tag.getCapitalizedTagName());
+ return convertView;
+ }
+
+ private class TagHolder {
+ private final TextView textView;
+ TagHolder(View view, boolean isDropDownView) {
+ textView = (TextView) view.findViewById(R.id.text);
+ if (mIsStaticMenuDrawer) {
+ if (isDropDownView) {
+ textView.setGravity(Gravity.RIGHT);
+ } else {
+ textView.setPadding(mPaddingForStaticDrawer, 0, 0, 0);
+ }
+ }
+ }
+ }
+
+ private boolean mIsTaskRunning = false;
+ private class LoadTagsTask extends AsyncTask<Void, Void, Boolean> {
+ private final ReaderTagList tmpTags = new ReaderTagList();
+ @Override
+ protected void onPreExecute() {
+ mIsTaskRunning = true;
+ }
+ @Override
+ protected void onCancelled() {
+ mIsTaskRunning = false;
+ }
+ @Override
+ protected Boolean doInBackground(Void... voids) {
+ tmpTags.addAll(ReaderTagTable.getDefaultTags());
+ tmpTags.addAll(ReaderTagTable.getFollowedTags());
+ return !mTags.isSameList(tmpTags);
+ }
+ @Override
+ protected void onPostExecute(Boolean result) {
+ if (result) {
+ mTags = (ReaderTagList) tmpTags.clone();
+ notifyDataSetChanged();
+ if (mDataListener != null) {
+ mDataListener.onDataLoaded(mTags.isEmpty());
+ }
+ }
+ mIsTaskRunning = false;
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderBlogAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderBlogAdapter.java
new file mode 100644
index 000000000..d8e22aa05
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderBlogAdapter.java
@@ -0,0 +1,343 @@
+package org.wordpress.android.ui.reader.adapters;
+
+import android.content.Context;
+import android.os.AsyncTask;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.ReaderBlogTable;
+import org.wordpress.android.models.ReaderBlog;
+import org.wordpress.android.models.ReaderBlogList;
+import org.wordpress.android.models.ReaderRecommendBlogList;
+import org.wordpress.android.models.ReaderRecommendedBlog;
+import org.wordpress.android.ui.prefs.UserPrefs;
+import org.wordpress.android.ui.reader.ReaderActivityLauncher;
+import org.wordpress.android.ui.reader.ReaderAnim;
+import org.wordpress.android.ui.reader.ReaderConstants;
+import org.wordpress.android.ui.reader.ReaderUtils;
+import org.wordpress.android.ui.reader.actions.ReaderActions;
+import org.wordpress.android.ui.reader.actions.ReaderBlogActions;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.UrlUtils;
+import org.wordpress.android.widgets.WPNetworkImageView;
+
+import java.lang.ref.WeakReference;
+
+/*
+ * adapter which shows either recommended or followed blogs - used by ReaderBlogFragment
+ */
+public class ReaderBlogAdapter extends BaseAdapter {
+ public enum ReaderBlogType {RECOMMENDED, FOLLOWED}
+
+ public interface BlogFollowChangeListener {
+ public void onFollowBlogChanged();
+ }
+
+ private final LayoutInflater mInflater;
+ private final ReaderBlogType mBlogType;
+ private final boolean mCanUseStableIds;
+ private final BlogFollowChangeListener mFollowListener;
+ private final WeakReference<Context> mWeakContext;
+
+ private ReaderRecommendBlogList mRecommendedBlogs = new ReaderRecommendBlogList();
+ private ReaderBlogList mFollowedBlogs = new ReaderBlogList();
+
+ public ReaderBlogAdapter(Context context,
+ ReaderBlogType blogType,
+ BlogFollowChangeListener followListener) {
+ super();
+ mWeakContext = new WeakReference<Context>(context);
+ mInflater = LayoutInflater.from(context);
+ mBlogType = blogType;
+ mFollowListener = followListener;
+
+ // recommended blogs all have a unique blogId, but followed blogs may have multiple
+ // blogs with a blogId of zero
+ mCanUseStableIds = (getBlogType() == ReaderBlogType.RECOMMENDED);
+ }
+
+ private Context getContext() {
+ return mWeakContext.get();
+ }
+
+ public void refresh() {
+ if (mIsTaskRunning) {
+ AppLog.w(T.READER, "load blogs task is already running");
+ } else {
+ new LoadBlogsTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+ }
+
+ /*
+ * make sure the follow status of all blogs is accurate
+ */
+ public void checkFollowStatus() {
+ switch (getBlogType()) {
+ case FOLLOWED:
+ // followed blogs store their follow status in the local db, so refreshing from
+ // the local db will ensure the correct follow status is shown
+ refresh();
+ break;
+ case RECOMMENDED:
+ // recommended blogs check their follow status in getView(), so notifyDataSetChanged()
+ // will ensure the correct follow status is shown
+ notifyDataSetChanged();
+ break;
+ }
+ }
+
+ private ReaderBlogType getBlogType() {
+ return mBlogType;
+ }
+
+ @Override
+ public int getCount() {
+ switch (getBlogType()) {
+ case RECOMMENDED:
+ return mRecommendedBlogs.size();
+ case FOLLOWED:
+ return mFollowedBlogs.size();
+ default:
+ return 0;
+ }
+ }
+
+ @Override
+ public Object getItem(int position) {
+ switch (getBlogType()) {
+ case RECOMMENDED:
+ return mRecommendedBlogs.get(position);
+ case FOLLOWED:
+ return mFollowedBlogs.get(position);
+ default:
+ return null;
+ }
+ }
+
+ private boolean isPositionValid(int position) {
+ return (position >= 0 && position < getCount());
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return mCanUseStableIds;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ if (mCanUseStableIds && getBlogType() == ReaderBlogType.RECOMMENDED) {
+ return mRecommendedBlogs.get(position).blogId;
+ } else {
+ return position;
+ }
+ }
+
+ @Override
+ public View getView(final int position, View convertView, final ViewGroup parent) {
+ final BlogViewHolder holder;
+ if (convertView == null || !(convertView.getTag() instanceof BlogViewHolder)) {
+ convertView = mInflater.inflate(R.layout.reader_listitem_blog, parent, false);
+ holder = new BlogViewHolder(convertView);
+ convertView.setTag(holder);
+ } else {
+ holder = (BlogViewHolder) convertView.getTag();
+ }
+
+ final long blogId;
+ final String blogUrl;
+ final boolean isFollowing;
+ switch (getBlogType()) {
+ case RECOMMENDED:
+ final ReaderRecommendedBlog blog = (ReaderRecommendedBlog) getItem(position);
+ blogId = blog.blogId;
+ blogUrl = blog.getBlogUrl();
+ isFollowing = ReaderBlogTable.isFollowedBlog(blogId, blogUrl);
+ holder.txtTitle.setText(blog.getTitle());
+ holder.txtDescription.setText(blog.getReason());
+ holder.txtUrl.setText(UrlUtils.getDomainFromUrl(blogUrl));
+ holder.imgBlog.setImageUrl(blog.getImageUrl(), WPNetworkImageView.ImageType.AVATAR);
+ break;
+
+ case FOLLOWED:
+ final ReaderBlog blogInfo = (ReaderBlog) getItem(position);
+ blogId = blogInfo.blogId;
+ blogUrl = blogInfo.getUrl();
+ isFollowing = blogInfo.isFollowing;
+ String domain = UrlUtils.getDomainFromUrl(blogUrl);
+ if (blogInfo.hasName()) {
+ holder.txtTitle.setText(blogInfo.getName());
+ } else {
+ holder.txtTitle.setText(domain);
+ }
+ holder.txtUrl.setText(domain);
+ break;
+
+ default:
+ blogId = 0;
+ blogUrl = null;
+ isFollowing = false;
+ break;
+ }
+
+ // show the correct following status
+ ReaderUtils.showFollowStatus(holder.txtFollow, isFollowing);
+ holder.txtFollow.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ ReaderAnim.animateFollowButton(holder.txtFollow);
+ changeFollowStatus(holder.txtFollow, position, !isFollowing);
+ }
+ });
+
+ // show blog preview when view is clicked
+ convertView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ // make sure we have either the blog id or url
+ if (blogId != 0 || !TextUtils.isEmpty(blogUrl)) {
+ ReaderActivityLauncher.showReaderBlogPreview(getContext(), blogId, blogUrl);
+ }
+ }
+ });
+
+ return convertView;
+ }
+
+ private class BlogViewHolder {
+ private final TextView txtTitle;
+ private final TextView txtDescription;
+ private final TextView txtUrl;
+ private final TextView txtFollow;
+ private final WPNetworkImageView imgBlog;
+
+ BlogViewHolder(View view) {
+ txtTitle = (TextView) view.findViewById(R.id.text_title);
+ txtDescription = (TextView) view.findViewById(R.id.text_description);
+ txtUrl = (TextView) view.findViewById(R.id.text_url);
+ txtFollow = (TextView) view.findViewById(R.id.text_follow);
+ imgBlog = (WPNetworkImageView) view.findViewById(R.id.image_blog);
+
+ switch (getBlogType()) {
+ case FOLLOWED:
+ txtDescription.setVisibility(View.GONE);
+ imgBlog.setVisibility(View.GONE);
+ break;
+ case RECOMMENDED:
+ txtDescription.setVisibility(View.VISIBLE);
+ imgBlog.setVisibility(View.VISIBLE);
+ break;
+ }
+ }
+ }
+
+ private void changeFollowStatus(final TextView txtFollow,
+ final int position,
+ final boolean isAskingToFollow) {
+ if (!isPositionValid(position)) {
+ return;
+ }
+
+ final long blogId;
+ final String blogUrl;
+ switch (getBlogType()) {
+ case RECOMMENDED:
+ ReaderRecommendedBlog blog = mRecommendedBlogs.get(position);
+ blogId = blog.blogId;
+ blogUrl = blog.getBlogUrl();
+ break;
+ case FOLLOWED:
+ ReaderBlog info = mFollowedBlogs.get(position);
+ blogId = info.blogId;
+ blogUrl = info.getUrl();
+ break;
+ default:
+ return;
+ }
+
+ ReaderActions.ActionListener actionListener = new ReaderActions.ActionListener() {
+ @Override
+ public void onActionResult(boolean succeeded) {
+ if (!succeeded && getContext() != null) {
+ int resId = (isAskingToFollow ? R.string.reader_toast_err_follow_blog : R.string.reader_toast_err_unfollow_blog);
+ ToastUtils.showToast(getContext(), resId);
+ ReaderUtils.showFollowStatus(txtFollow, !isAskingToFollow);
+ checkFollowStatus();
+ }
+ }
+ };
+ if (ReaderBlogActions.performFollowAction(blogId, blogUrl, isAskingToFollow, actionListener)) {
+ if (getBlogType() == ReaderBlogType.FOLLOWED) {
+ mFollowedBlogs.get(position).isFollowing = isAskingToFollow;
+ }
+ ReaderUtils.showFollowStatus(txtFollow, isAskingToFollow);
+ notifyDataSetChanged(); // <-- required for getView() to know correct follow status
+ if (mFollowListener != null) {
+ mFollowListener.onFollowBlogChanged();
+ }
+ }
+ }
+
+ private boolean mIsTaskRunning = false;
+ private class LoadBlogsTask extends AsyncTask<Void, Void, Boolean> {
+ ReaderRecommendBlogList tmpRecommendedBlogs;
+ ReaderBlogList tmpFollowedBlogs;
+
+ @Override
+ protected void onPreExecute() {
+ mIsTaskRunning = true;
+ }
+
+ @Override
+ protected void onCancelled() {
+ mIsTaskRunning = false;
+ }
+
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ switch (getBlogType()) {
+ case RECOMMENDED:
+ // get recommended blogs using this offset, then start over with no offset
+ // if there aren't any with this offset,
+ int limit = ReaderConstants.READER_MAX_RECOMMENDED_TO_DISPLAY;
+ int offset = UserPrefs.getReaderRecommendedBlogOffset();
+ tmpRecommendedBlogs = ReaderBlogTable.getRecommendedBlogs(limit, offset);
+ if (tmpRecommendedBlogs.size() == 0 && offset > 0) {
+ UserPrefs.setReaderRecommendedBlogOffset(0);
+ tmpRecommendedBlogs = ReaderBlogTable.getRecommendedBlogs(limit, 0);
+ }
+ return !mRecommendedBlogs.isSameList(tmpRecommendedBlogs);
+
+ case FOLLOWED:
+ tmpFollowedBlogs = ReaderBlogTable.getFollowedBlogs();
+ return !mFollowedBlogs.isSameList(tmpFollowedBlogs);
+
+ default:
+ return false;
+ }
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ if (result) {
+ switch (getBlogType()) {
+ case RECOMMENDED:
+ mRecommendedBlogs = (ReaderRecommendBlogList) (tmpRecommendedBlogs.clone());
+ break;
+ case FOLLOWED:
+ mFollowedBlogs = (ReaderBlogList) (tmpFollowedBlogs.clone());
+ break;
+ }
+ notifyDataSetChanged();
+ }
+
+ mIsTaskRunning = false;
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderCommentAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderCommentAdapter.java
new file mode 100644
index 000000000..3c167fe88
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderCommentAdapter.java
@@ -0,0 +1,320 @@
+package org.wordpress.android.ui.reader.adapters;
+
+import android.content.Context;
+import android.os.AsyncTask;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ProgressBar;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.ReaderCommentTable;
+import org.wordpress.android.datasets.ReaderPostTable;
+import org.wordpress.android.models.ReaderComment;
+import org.wordpress.android.models.ReaderCommentList;
+import org.wordpress.android.models.ReaderPost;
+import org.wordpress.android.ui.comments.CommentUtils;
+import org.wordpress.android.ui.reader.ReaderActivityLauncher;
+import org.wordpress.android.ui.reader.actions.ReaderActions;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.DateTimeUtils;
+import org.wordpress.android.util.PhotonUtils;
+import org.wordpress.android.util.WPLinkMovementMethod;
+import org.wordpress.android.widgets.WPNetworkImageView;
+
+public class ReaderCommentAdapter extends BaseAdapter {
+ private final LayoutInflater mInflater;
+ private final ReaderPost mPost;
+ private boolean mMoreCommentsExist;
+
+ private static final int MAX_INDENT_LEVEL = 2;
+ private final int mIndentPerLevel;
+ private final int mAvatarSz;
+ private final int mMaxImageSz;
+
+ private long mHighlightCommentId = 0;
+ private boolean mShowProgressForHighlightedComment = false;
+
+ private final int mBgColorNormal;
+ private final int mBgColorHighlight;
+ private final int mLinkColor;
+ private final int mNoLinkColor;
+
+ public interface RequestReplyListener {
+ void onRequestReply(long commentId);
+ }
+
+ private ReaderCommentList mComments = new ReaderCommentList();
+ private final RequestReplyListener mReplyListener;
+ private final ReaderActions.DataLoadedListener mDataLoadedListener;
+ private final ReaderActions.DataRequestedListener mDataRequestedListener;
+
+ public ReaderCommentAdapter(Context context,
+ ReaderPost post,
+ RequestReplyListener replyListener,
+ ReaderActions.DataLoadedListener dataLoadedListener,
+ ReaderActions.DataRequestedListener dataRequestedListener) {
+ mPost = post;
+ mReplyListener = replyListener;
+ mDataLoadedListener = dataLoadedListener;
+ mDataRequestedListener = dataRequestedListener;
+
+ mInflater = LayoutInflater.from(context);
+ mIndentPerLevel = (context.getResources().getDimensionPixelSize(R.dimen.reader_comment_indent_per_level) / 2);
+ mAvatarSz = context.getResources().getDimensionPixelSize(R.dimen.avatar_sz_small);
+ mMaxImageSz = context.getResources().getDimensionPixelSize(R.dimen.reader_comment_max_image_size);
+
+ mBgColorNormal = context.getResources().getColor(R.color.grey_extra_light);
+ mBgColorHighlight = context.getResources().getColor(R.color.grey_light);
+ mLinkColor = context.getResources().getColor(R.color.reader_hyperlink);
+ mNoLinkColor = context.getResources().getColor(R.color.grey_medium_dark);
+ }
+
+ public void refreshComments() {
+ if (mIsTaskRunning) {
+ AppLog.w(T.READER, "reader comment adapter > Load comments task already running");
+ }
+ new LoadCommentsTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ @Override
+ public int getCount() {
+ return mComments.size();
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return mComments.get(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ // this MUST return the comment id in order for ReaderPostDetailActivity to enable replying
+ // to an individual comment when clicked - note that while the commentId isn't unique in our
+ // database, it will be unique here since we're only showing comments on a specific post
+ return mComments.get(position).commentId;
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return true;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ final ReaderComment comment = mComments.get(position);
+ final CommentHolder holder;
+
+ if (convertView == null) {
+ convertView = mInflater.inflate(R.layout.reader_listitem_comment, parent, false);
+ holder = new CommentHolder(convertView);
+ convertView.setTag(holder);
+ } else {
+ holder = (CommentHolder) convertView.getTag();
+ }
+
+ holder.txtAuthor.setText(comment.getAuthorName());
+ holder.imgAvatar.setImageUrl(PhotonUtils.fixAvatar(comment.getAuthorAvatar(), mAvatarSz), WPNetworkImageView.ImageType.AVATAR);
+ CommentUtils.displayHtmlComment(holder.txtText, comment.getText(), mMaxImageSz);
+
+ java.util.Date dtPublished = DateTimeUtils.iso8601ToJavaDate(comment.getPublished());
+ holder.txtDate.setText(DateTimeUtils.javaDateToTimeSpan(dtPublished));
+
+ // tapping avatar or author name opens blog preview
+ if (comment.hasAuthorBlogId()) {
+ View.OnClickListener authorListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ ReaderActivityLauncher.showReaderBlogPreview(view.getContext(), comment.authorBlogId, comment.getAuthorUrl());
+ }
+ };
+ holder.imgAvatar.setOnClickListener(authorListener);
+ holder.txtAuthor.setOnClickListener(authorListener);
+ holder.txtAuthor.setTextColor(mLinkColor);
+ } else {
+ holder.txtAuthor.setTextColor(mNoLinkColor);
+ }
+
+ // show top spacer for first comment (adds extra space between comments and post)
+ holder.spacerTop.setVisibility(position == 0 ? View.VISIBLE : View.GONE);
+
+ // show indentation spacer and indent it based on comment level
+ holder.spacerIndent.setVisibility(comment.parentId==0 ? View.GONE : View.VISIBLE);
+ if (comment.level > 0) {
+ int indent = Math.min(MAX_INDENT_LEVEL, comment.level) * mIndentPerLevel;
+ RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) holder.spacerIndent.getLayoutParams();
+ if (params.width!=indent)
+ params.width = indent;
+ holder.spacerIndent.setVisibility(View.VISIBLE);
+ }
+
+ // different background for highlighted comment, with optional progress bar
+ if (mHighlightCommentId==comment.commentId) {
+ convertView.setBackgroundColor(mBgColorHighlight);
+ holder.progress.setVisibility(mShowProgressForHighlightedComment ? View.VISIBLE : View.GONE);
+ } else {
+ convertView.setBackgroundColor(mBgColorNormal);
+ holder.progress.setVisibility(View.GONE);
+ }
+
+ // tapping reply icon tells activity to show reply box
+ if (mReplyListener != null) {
+ holder.txtReply.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ mReplyListener.onRequestReply(comment.commentId);
+ }
+ });
+ }
+
+ // if we're nearing the end of the comments and we know more exist on the server,
+ // fire request to load more
+ if (mMoreCommentsExist && mDataRequestedListener!=null && (position >= getCount()-1)) {
+ mDataRequestedListener.onRequestData();
+ }
+
+ // hide divider if this is the last comment
+ holder.divider.setVisibility(position < getCount()-1 ? View.VISIBLE : View.INVISIBLE);
+
+ return convertView;
+ }
+
+ private static class CommentHolder {
+ private final TextView txtAuthor;
+ private final TextView txtText;
+ private final TextView txtDate;
+ private final TextView txtReply;
+ private final WPNetworkImageView imgAvatar;
+ private final View spacerIndent;
+ private final View spacerTop;
+ private final ProgressBar progress;
+ private final View divider;
+
+ CommentHolder(View view) {
+ txtAuthor = (TextView) view.findViewById(R.id.text_comment_author);
+ txtText = (TextView) view.findViewById(R.id.text_comment_text);
+ txtDate = (TextView) view.findViewById(R.id.text_comment_date);
+ txtReply = (TextView) view.findViewById(R.id.text_reply);
+ imgAvatar = (WPNetworkImageView) view.findViewById(R.id.image_avatar);
+ spacerIndent = view.findViewById(R.id.spacer_indent);
+ spacerTop = view.findViewById(R.id.spacer_top);
+ progress = (ProgressBar) view.findViewById(R.id.progress);
+ divider = view.findViewById(R.id.divider_comment);
+
+ // this is necessary in order for anchor tags in the comment text to be clickable
+ txtText.setLinksClickable(true);
+ txtText.setMovementMethod(WPLinkMovementMethod.getInstance());
+ }
+ }
+
+ /*
+ * called from post detail activity when user submits a comment
+ */
+ public void addComment(ReaderComment comment) {
+ if (comment == null) {
+ return;
+ }
+
+ // if the comment doesn't have a parent we can just add it to the list of existing
+ // comments - but if it does have a parent, we need to reload the list so that it
+ // appears under its parent and is correctly indented
+ if (comment.parentId == 0) {
+ mComments.add(comment);
+ notifyDataSetChanged();
+ } else {
+ refreshComments();
+ }
+ }
+
+ /*
+ * called from post detail when submitted a comment fails - this removes the "fake" comment
+ * that was inserted while the API call was still being processed
+ */
+ public void removeComment(long commentId) {
+ if (commentId == mHighlightCommentId) {
+ setHighlightCommentId(0, false);
+ }
+
+ int position = indexOfCommentId(commentId);
+ if (position == -1) {
+ return;
+ }
+
+ mComments.remove(position);
+ notifyDataSetChanged();
+ }
+
+ /*
+ * replace the comment that has the passed commentId with another comment - used
+ * after a comment is submitted to replace the "fake" comment with the real one
+ */
+ public void replaceComment(long commentId, ReaderComment comment) {
+ mComments.replaceComment(commentId, comment);
+ }
+
+ /*
+ * sets the passed comment as highlighted with a different background color and an optional
+ * progress bar (used when posting new comments) - note that we don't call notifyDataSetChanged()
+ * here since in most cases it's unnecessary, so we leave it up to the caller to do that
+ */
+ public void setHighlightCommentId(long commentId, boolean showProgress) {
+ mHighlightCommentId = commentId;
+ mShowProgressForHighlightedComment = showProgress;
+ }
+
+ public int indexOfCommentId(long commentId) {
+ return mComments.indexOfCommentId(commentId);
+ }
+
+ /*
+ * AsyncTask to load comments for this post
+ */
+ private boolean mIsTaskRunning = false;
+ private class LoadCommentsTask extends AsyncTask<Void, Void, Boolean> {
+ private ReaderCommentList tmpComments;
+ private boolean tmpMoreCommentsExist;
+
+ @Override
+ protected void onPreExecute() {
+ mIsTaskRunning = true;
+ }
+ @Override
+ protected void onCancelled() {
+ mIsTaskRunning = false;
+ }
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ if (mPost == null) {
+ return false;
+ }
+
+ // determine whether more comments can be downloaded by comparing the number of
+ // comments the post says it has with the number of comments actually stored
+ // locally for this post
+ int numServerComments = ReaderPostTable.getNumCommentsForPost(mPost);
+ int numLocalComments = ReaderCommentTable.getNumCommentsForPost(mPost);
+ tmpMoreCommentsExist = (numServerComments > numLocalComments);
+
+ tmpComments = ReaderCommentTable.getCommentsForPost(mPost);
+ return !mComments.isSameList(tmpComments);
+ }
+ @Override
+ protected void onPostExecute(Boolean result) {
+ mMoreCommentsExist = tmpMoreCommentsExist;
+
+ if (result) {
+ // assign the comments with children sorted under their parents and indent levels applied
+ mComments = ReaderCommentList.getLevelList(tmpComments);
+ notifyDataSetChanged();
+ }
+ if (mDataLoadedListener != null) {
+ mDataLoadedListener.onDataLoaded(isEmpty());
+ }
+ mIsTaskRunning = false;
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderPostAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderPostAdapter.java
new file mode 100644
index 000000000..8084f2d35
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderPostAdapter.java
@@ -0,0 +1,692 @@
+package org.wordpress.android.ui.reader.adapters;
+
+import android.animation.LayoutTransition;
+import android.content.Context;
+import android.os.AsyncTask;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ImageView;
+import android.widget.ListView;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.ImageLoader;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.ReaderPostTable;
+import org.wordpress.android.models.ReaderPost;
+import org.wordpress.android.models.ReaderPostList;
+import org.wordpress.android.models.ReaderTag;
+import org.wordpress.android.ui.reader.ReaderActivityLauncher;
+import org.wordpress.android.ui.reader.ReaderAnim;
+import org.wordpress.android.ui.reader.ReaderConstants;
+import org.wordpress.android.ui.reader.ReaderPostListFragment.OnTagSelectedListener;
+import org.wordpress.android.ui.reader.ReaderTypes;
+import org.wordpress.android.ui.reader.ReaderTypes.ReaderPostListType;
+import org.wordpress.android.ui.reader.ReaderUtils;
+import org.wordpress.android.ui.reader.actions.ReaderActions;
+import org.wordpress.android.ui.reader.actions.ReaderBlogActions;
+import org.wordpress.android.ui.reader.actions.ReaderPostActions;
+import org.wordpress.android.ui.reader.models.ReaderBlogIdPostId;
+import org.wordpress.android.ui.reader.models.ReaderBlogIdPostIdList;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.DateTimeUtils;
+import org.wordpress.android.util.DisplayUtils;
+import org.wordpress.android.util.FormatUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+import org.wordpress.android.widgets.WPNetworkImageView;
+
+import java.lang.ref.WeakReference;
+
+/**
+ * adapter for list of posts in a specific tag
+ */
+public class ReaderPostAdapter extends BaseAdapter {
+ private ReaderTag mCurrentTag;
+ private long mCurrentBlogId;
+
+ private final int mPhotonWidth;
+ private final int mPhotonHeight;
+ private final int mAvatarSz;
+ private final int mMarginLarge;
+
+ private boolean mCanRequestMorePosts = false;
+ private boolean mIsFlinging = false;
+
+ private final LayoutInflater mInflater;
+ private final WeakReference<Context> mWeakContext;
+ private final ReaderPostListType mPostListType;
+ private ReaderPostList mPosts = new ReaderPostList();
+
+ private OnTagSelectedListener mOnTagSelectedListener;
+ private final ReaderActions.RequestReblogListener mReblogListener;
+ private final ReaderActions.DataLoadedListener mDataLoadedListener;
+ private final ReaderActions.DataRequestedListener mDataRequestedListener;
+
+ private final boolean mEnableImagePreload;
+ private int mLastPreloadPos = -1;
+ private static final int PRELOAD_OFFSET = 2;
+
+ public ReaderPostAdapter(Context context,
+ ReaderPostListType postListType,
+ ReaderActions.RequestReblogListener reblogListener,
+ ReaderActions.DataLoadedListener dataLoadedListener,
+ ReaderActions.DataRequestedListener dataRequestedListener) {
+ super();
+
+ mWeakContext = new WeakReference<Context>(context);
+ mInflater = LayoutInflater.from(context);
+
+ mPostListType = postListType;
+ mReblogListener = reblogListener;
+ mDataLoadedListener = dataLoadedListener;
+ mDataRequestedListener = dataRequestedListener;
+
+ mAvatarSz = context.getResources().getDimensionPixelSize(R.dimen.avatar_sz_medium);
+ mMarginLarge = context.getResources().getDimensionPixelSize(R.dimen.margin_large);
+
+ int displayWidth = DisplayUtils.getDisplayPixelWidth(context);
+ int listMargin = context.getResources().getDimensionPixelSize(R.dimen.reader_list_margin);
+ mPhotonWidth = displayWidth - (listMargin * 2);
+ mPhotonHeight = context.getResources().getDimensionPixelSize(R.dimen.reader_featured_image_height);
+
+ // enable preloading of images
+ mEnableImagePreload = true;
+ }
+
+ private Context getContext() {
+ return mWeakContext.get();
+ }
+
+ public void setOnTagSelectedListener(OnTagSelectedListener listener) {
+ mOnTagSelectedListener = listener;
+ }
+
+ ReaderPostListType getPostListType() {
+ return (mPostListType != null ? mPostListType : ReaderTypes.DEFAULT_POST_LIST_TYPE);
+ }
+
+ // used when the viewing tagged posts
+ public void setCurrentTag(ReaderTag tag) {
+ if (!ReaderTag.isSameTag(tag, mCurrentTag)) {
+ mCurrentTag = tag;
+ reload();
+ }
+ }
+
+ public boolean isCurrentTag(ReaderTag tag) {
+ return ReaderTag.isSameTag(tag, mCurrentTag);
+ }
+
+ // used when the list type is ReaderPostListType.BLOG_PREVIEW
+ public void setCurrentBlog(long blogId) {
+ if (blogId != mCurrentBlogId) {
+ mCurrentBlogId = blogId;
+ reload();
+ }
+ }
+
+ private void clear() {
+ mLastPreloadPos = -1;
+ if (!mPosts.isEmpty()) {
+ mPosts.clear();
+ notifyDataSetChanged();
+ }
+ }
+
+ public void refresh() {
+ loadPosts();
+ }
+
+ /*
+ * same as refresh() above but first clears the existing posts
+ */
+ public void reload() {
+ clear();
+ loadPosts();
+ }
+
+ /*
+ * reload a single post
+ */
+ public void reloadPost(ReaderPost post) {
+ int index = mPosts.indexOfPost(post);
+ if (index == -1) {
+ return;
+ }
+
+ final ReaderPost updatedPost = ReaderPostTable.getPost(post.blogId, post.postId);
+ if (updatedPost != null) {
+ mPosts.set(index, updatedPost);
+ notifyDataSetChanged();
+ }
+ }
+
+ /*
+ * ensures that the follow status of each post in the list reflects what is currently
+ * stored in the reader post table
+ */
+ public void checkFollowStatusForAllPosts() {
+ if (ReaderPostTable.checkFollowStatusOnPosts(mPosts)) {
+ notifyDataSetChanged();
+ }
+ }
+
+ /*
+ * sets the follow status of each post in the passed blog
+ */
+ void updateFollowStatusOnPostsForBlog(long blogId, String blogUrl, boolean followStatus) {
+ if (isEmpty()) {
+ return;
+ }
+
+ boolean hasBlogId = (blogId != 0);
+ boolean hasBlogUrl = !TextUtils.isEmpty(blogUrl);
+ if (!hasBlogId && !hasBlogUrl) {
+ return;
+ }
+
+ boolean isChanged = false;
+ for (ReaderPost post: mPosts) {
+ boolean isMatched = (hasBlogId ? (blogId == post.blogId) : blogUrl.equals(post.getBlogUrl()));
+ if (isMatched) {
+ post.isFollowedByCurrentUser = followStatus;
+ isChanged = true;
+ }
+ }
+ if (isChanged) {
+ notifyDataSetChanged();
+ }
+ }
+
+ private void loadPosts() {
+ if (mIsTaskRunning) {
+ AppLog.w(T.READER, "reader posts task already running");
+ }
+ new LoadPostsTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ public ReaderBlogIdPostIdList getBlogIdPostIdList() {
+ ReaderBlogIdPostIdList ids = new ReaderBlogIdPostIdList();
+ for (ReaderPost post: mPosts) {
+ ids.add(new ReaderBlogIdPostId(post.blogId, post.postId));
+ }
+ return ids;
+ }
+
+ @Override
+ public int getCount() {
+ return mPosts.size();
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return mPosts.get(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return mPosts.get(position).getStableId();
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return true;
+ }
+
+ @Override
+ public View getView(final int position, View convertView, final ViewGroup parent) {
+ final ReaderPost post = (ReaderPost) getItem(position);
+ final PostViewHolder holder;
+
+ if (convertView == null) {
+ convertView = mInflater.inflate(R.layout.reader_listitem_post_excerpt, parent, false);
+ holder = new PostViewHolder(convertView, getPostListType());
+ convertView.setTag(holder);
+ } else {
+ holder = (PostViewHolder) convertView.getTag();
+ }
+
+ holder.txtTitle.setText(post.getTitle());
+ holder.txtDate.setText(DateTimeUtils.javaDateToTimeSpan(post.getDatePublished()));
+
+ // post header (avatar, blog name and follow button) only appears when showing tagged posts
+ if (getPostListType().isTagType()) {
+ holder.imgAvatar.setImageUrl(post.getPostAvatarForDisplay(mAvatarSz), WPNetworkImageView.ImageType.AVATAR);
+ if (post.hasBlogName()) {
+ holder.txtBlogName.setText(post.getBlogName());
+ } else if (post.hasAuthorName()) {
+ holder.txtBlogName.setText(post.getAuthorName());
+ } else {
+ holder.txtBlogName.setText(null);
+ }
+
+ // follow/following
+ ReaderUtils.showFollowStatus(holder.txtFollow, post.isFollowedByCurrentUser);
+ holder.txtFollow.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ toggleFollow(holder, position, post);
+ }
+ });
+
+ // tapping header shows blog preview unless this post is from an external feed
+ if (!post.isExternal) {
+ holder.layoutPostHeader.setEnabled(true);
+ holder.layoutPostHeader.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ ReaderActivityLauncher.showReaderBlogPreview(getContext(), post.blogId, post.getBlogUrl());
+ }
+ });
+ } else {
+ holder.layoutPostHeader.setOnClickListener(null);
+ holder.layoutPostHeader.setEnabled(false);
+ }
+ }
+
+ if (post.hasExcerpt()) {
+ holder.txtText.setVisibility(View.VISIBLE);
+ holder.txtText.setText(post.getExcerpt());
+ } else {
+ holder.txtText.setVisibility(View.GONE);
+ }
+
+ final int titleMargin;
+ if (post.hasFeaturedImage()) {
+ final String imageUrl = post.getFeaturedImageForDisplay(mPhotonWidth, mPhotonHeight);
+ holder.imgFeatured.setImageUrl(imageUrl, WPNetworkImageView.ImageType.PHOTO);
+ holder.imgFeatured.setVisibility(View.VISIBLE);
+ titleMargin = mMarginLarge;
+ } else if (post.hasFeaturedVideo()) {
+ holder.imgFeatured.setVideoUrl(post.postId, post.getFeaturedVideo());
+ holder.imgFeatured.setVisibility(View.VISIBLE);
+ titleMargin = mMarginLarge;
+ } else {
+ holder.imgFeatured.setVisibility(View.GONE);
+ titleMargin = (holder.layoutPostHeader.getVisibility() == View.VISIBLE ? 0 : mMarginLarge);
+ }
+
+ // set the top margin of the title based on whether there's a featured image and post header
+ RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) holder.txtTitle.getLayoutParams();
+ params.topMargin = titleMargin;
+
+ // show the best tag for this post
+ final String tagToDisplay = (mCurrentTag != null ? post.getTagForDisplay(mCurrentTag.getTagName()) : null);
+ if (!TextUtils.isEmpty(tagToDisplay)) {
+ holder.txtTag.setText(tagToDisplay);
+ holder.txtTag.setVisibility(View.VISIBLE);
+ holder.txtTag.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (mOnTagSelectedListener != null) {
+ mOnTagSelectedListener.onTagSelected(tagToDisplay);
+ }
+ }
+ });
+ } else {
+ holder.txtTag.setVisibility(View.GONE);
+ }
+
+ // likes, comments & reblogging - supported by wp posts only
+ if (post.isWP()) {
+ showLikeStatus(holder.imgBtnLike, post.isLikedByCurrentUser);
+ holder.imgBtnComment.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (parent instanceof ListView) {
+ ListView listView = (ListView) parent;
+ // the base listView onItemClick includes the header count in the position,
+ // so do the same here
+ int index = position + listView.getHeaderViewsCount();
+ listView.performItemClick(holder.imgBtnComment, index, getItemId(position));
+ }
+ }
+ });
+
+ holder.imgBtnLike.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ toggleLike(holder, position, post);
+ }
+ });
+
+ holder.imgBtnLike.setVisibility(View.VISIBLE);
+ holder.imgBtnComment.setVisibility(View.VISIBLE);
+ showCounts(holder, post, false);
+ } else {
+ holder.imgBtnLike.setVisibility(View.INVISIBLE);
+ holder.imgBtnComment.setVisibility(View.INVISIBLE);
+ holder.txtLikeCount.setVisibility(View.GONE);
+ holder.txtCommentCount.setVisibility(View.GONE);
+ }
+
+ if (post.canReblog()) {
+ showReblogStatus(holder.imgBtnReblog, post.isRebloggedByCurrentUser);
+ holder.imgBtnReblog.setVisibility(View.VISIBLE);
+ if (!post.isRebloggedByCurrentUser) {
+ holder.imgBtnReblog.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ ReaderAnim.animateReblogButton(holder.imgBtnReblog);
+ if (mReblogListener != null) {
+ mReblogListener.onRequestReblog(post, v);
+ }
+ }
+ });
+ }
+ } else {
+ holder.imgBtnReblog.setVisibility(View.INVISIBLE);
+ }
+
+ // if we're nearing the end of the posts, fire request to load more
+ if (mCanRequestMorePosts && mDataRequestedListener != null && (position >= getCount()-1)) {
+ mDataRequestedListener.onRequestData();
+ }
+
+ // if image preload is enabled, preload images in the post PRELOAD_OFFSET positions ahead of this one
+ if (mEnableImagePreload && position > (mLastPreloadPos - PRELOAD_OFFSET)) {
+ preloadPostImages(position + PRELOAD_OFFSET);
+ }
+
+ return convertView;
+ }
+
+ /*
+ * shows like & comment count
+ */
+ private void showCounts(final PostViewHolder holder,
+ final ReaderPost post,
+ boolean animateChanges) {
+ if (animateChanges) {
+ holder.layoutBottom.setLayoutTransition(new LayoutTransition());
+ }
+
+ if (post.numLikes > 0) {
+ holder.txtLikeCount.setText(FormatUtils.formatInt(post.numLikes));
+ holder.txtLikeCount.setVisibility(View.VISIBLE);
+ } else {
+ holder.txtLikeCount.setVisibility(View.GONE);
+ }
+
+ if (post.numReplies > 0) {
+ holder.txtCommentCount.setText(FormatUtils.formatInt(post.numReplies));
+ holder.txtCommentCount.setVisibility(View.VISIBLE);
+ // note that the comment icon is shown here even if comments are now closed since
+ // the post has existing comments
+ holder.imgBtnComment.setVisibility(View.VISIBLE);
+ } else {
+ holder.txtCommentCount.setVisibility(View.GONE);
+ holder.imgBtnComment.setVisibility(post.isCommentsOpen ? View.VISIBLE : View.GONE);
+ }
+
+ if (animateChanges) {
+ holder.layoutBottom.setLayoutTransition(null);
+ }
+ }
+
+ private static class PostViewHolder {
+ private final TextView txtTitle;
+ private final TextView txtText;
+ private final TextView txtBlogName;
+ private final TextView txtDate;
+ private final TextView txtFollow;
+ private final TextView txtTag;
+
+ private final TextView txtLikeCount;
+ private final TextView txtCommentCount;
+
+ private final ImageView imgBtnLike;
+ private final ImageView imgBtnComment;
+ private final ImageView imgBtnReblog;
+
+ private final WPNetworkImageView imgFeatured;
+ private final WPNetworkImageView imgAvatar;
+
+ private final ViewGroup layoutBottom;
+ private final ViewGroup layoutPostHeader;
+
+ PostViewHolder(View view, ReaderPostListType postListType) {
+ txtTitle = (TextView) view.findViewById(R.id.text_title);
+ txtText = (TextView) view.findViewById(R.id.text_excerpt);
+ txtBlogName = (TextView) view.findViewById(R.id.text_blog_name);
+ txtDate = (TextView) view.findViewById(R.id.text_date);
+ txtFollow = (TextView) view.findViewById(R.id.text_follow);
+ txtTag = (TextView) view.findViewById(R.id.text_tag);
+
+ txtCommentCount = (TextView) view.findViewById(R.id.text_comment_count);
+ txtLikeCount = (TextView) view.findViewById(R.id.text_like_count);
+
+ imgFeatured = (WPNetworkImageView) view.findViewById(R.id.image_featured);
+ imgAvatar = (WPNetworkImageView) view.findViewById(R.id.image_avatar);
+
+ imgBtnLike = (ImageView) view.findViewById(R.id.image_like_btn);
+ imgBtnComment = (ImageView) view.findViewById(R.id.image_comment_btn);
+ imgBtnReblog = (ImageView) view.findViewById(R.id.image_reblog_btn);
+
+ layoutBottom = (ViewGroup) view.findViewById(R.id.layout_bottom);
+ layoutPostHeader = (ViewGroup) view.findViewById(R.id.layout_post_header);
+
+ // hide the post header (avatar, blog name & follow button) if we're showing posts
+ // in a specific blog
+ if (postListType.equals(ReaderTypes.ReaderPostListType.BLOG_PREVIEW)) {
+ layoutPostHeader.setVisibility(View.GONE);
+ }
+ }
+ }
+
+ /*
+ * triggered when user taps the like button (textView)
+ */
+ private void toggleLike(PostViewHolder holder, int position, ReaderPost post) {
+ // start animation immediately so user knows they did something
+ ReaderAnim.animateLikeButton(holder.imgBtnLike);
+
+ boolean isAskingToLike = !post.isLikedByCurrentUser;
+ if (!ReaderPostActions.performLikeAction(post, isAskingToLike)) {
+ return;
+ }
+
+ if (isAskingToLike) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.READER_LIKED_ARTICLE);
+ }
+
+ // update post in array and on screen
+ ReaderPost updatedPost = ReaderPostTable.getPost(post.blogId, post.postId);
+ mPosts.set(position, updatedPost);
+ showLikeStatus(holder.imgBtnLike, updatedPost.isLikedByCurrentUser);
+ showCounts(holder, post, true);
+ }
+
+ private void showLikeStatus(ImageView imgBtnLike, boolean isLikedByCurrentUser) {
+ if (isLikedByCurrentUser != imgBtnLike.isSelected())
+ imgBtnLike.setSelected(isLikedByCurrentUser);
+ }
+
+ /*
+ * triggered when user taps the follow button
+ */
+ private void toggleFollow(final PostViewHolder holder, int position, ReaderPost post) {
+ ReaderAnim.animateFollowButton(holder.txtFollow);
+ final boolean isAskingToFollow = !post.isFollowedByCurrentUser;
+
+ ReaderActions.ActionListener actionListener = new ReaderActions.ActionListener() {
+ @Override
+ public void onActionResult(boolean succeeded) {
+ if (!succeeded && getContext() != null) {
+ int resId = (isAskingToFollow ? R.string.reader_toast_err_follow_blog : R.string.reader_toast_err_unfollow_blog);
+ ToastUtils.showToast(getContext(), resId);
+ ReaderUtils.showFollowStatus(holder.txtFollow, !isAskingToFollow);
+ }
+ }
+ };
+
+ if (!ReaderBlogActions.performFollowAction(post, isAskingToFollow, actionListener)) {
+ return;
+ }
+
+ ReaderPost updatedPost = ReaderPostTable.getPost(post.blogId, post.postId);
+ if (updatedPost != null) {
+ mPosts.set(position, updatedPost);
+ }
+
+ ReaderUtils.showFollowStatus(holder.txtFollow, isAskingToFollow);
+ updateFollowStatusOnPostsForBlog(post.blogId, post.getBlogUrl(), isAskingToFollow);
+ }
+
+ private void showReblogStatus(ImageView imgBtnReblog, boolean isRebloggedByCurrentUser) {
+ if (isRebloggedByCurrentUser != imgBtnReblog.isSelected()) {
+ imgBtnReblog.setSelected(isRebloggedByCurrentUser);
+ }
+ if (isRebloggedByCurrentUser) {
+ imgBtnReblog.setOnClickListener(null);
+ }
+ }
+
+ /*
+ * AsyncTask to load posts in the current tag
+ */
+ private boolean mIsTaskRunning = false;
+ private class LoadPostsTask extends AsyncTask<Void, Void, Boolean> {
+ ReaderPostList tmpPosts;
+ @Override
+ protected void onPreExecute() {
+ mIsTaskRunning = true;
+ }
+ @Override
+ protected void onCancelled() {
+ mIsTaskRunning = false;
+ }
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ final int numExisting;
+ switch (getPostListType()) {
+ case TAG_PREVIEW: case TAG_FOLLOWED:
+ tmpPosts = ReaderPostTable.getPostsWithTag(mCurrentTag, ReaderConstants.READER_MAX_POSTS_TO_DISPLAY);
+ numExisting = ReaderPostTable.getNumPostsWithTag(mCurrentTag);
+ break;
+ case BLOG_PREVIEW:
+ tmpPosts = ReaderPostTable.getPostsInBlog(mCurrentBlogId, ReaderConstants.READER_MAX_POSTS_TO_DISPLAY);
+ numExisting = ReaderPostTable.getNumPostsInBlog(mCurrentBlogId);
+ break;
+ default:
+ return false;
+ }
+
+ if (mPosts.isSameList(tmpPosts)) {
+ return false;
+ }
+
+ // if we're not already displaying the max # posts, enable requesting more when
+ // the user scrolls to the end of the list
+ mCanRequestMorePosts = (numExisting < ReaderConstants.READER_MAX_POSTS_TO_DISPLAY);
+
+ // pre-calc avatar URLs, featured image URLs, display tag, and pubDates in each
+ // post - these values are all cached by the post after the first time they're
+ // computed, so calling these getters ensures the values are immediately available
+ // when accessed from getView
+ String currentTagName = (mCurrentTag != null ? mCurrentTag.getTagName() : "");
+ for (ReaderPost post: tmpPosts) {
+ post.getPostAvatarForDisplay(mAvatarSz);
+ post.getFeaturedImageForDisplay(mPhotonWidth, mPhotonHeight);
+ post.getDatePublished();
+ post.getTagForDisplay(currentTagName);
+ }
+
+ return true;
+ }
+ @Override
+ protected void onPostExecute(Boolean result) {
+ if (result) {
+ mPosts = (ReaderPostList)(tmpPosts.clone());
+
+ // preload images in the first few posts, skipping the first two since they'll
+ // likely already be on screen and loading images before preload completes
+ if (mEnableImagePreload) {
+ int preloadStart = 2;
+ int preloadEnd = preloadStart + PRELOAD_OFFSET;
+ if (mPosts.size() > preloadEnd) {
+ for (int i = preloadStart; i <= preloadEnd; i++) {
+ preloadPostImages(i);
+ }
+ }
+ }
+
+ notifyDataSetChanged();
+ }
+
+ if (mDataLoadedListener != null) {
+ mDataLoadedListener.onDataLoaded(isEmpty());
+ }
+
+ mIsTaskRunning = false;
+ }
+ }
+
+ /*
+ * called from ReaderPostListFragment when user starts/ends listview fling
+ */
+ public void setIsFlinging(boolean isFlinging) {
+ mIsFlinging = isFlinging;
+ }
+
+ /**
+ * preload images for the post at the passed position
+ */
+ private void preloadPostImages(final int position) {
+ if (position >= mPosts.size() || position < 0) {
+ return;
+ }
+
+ mLastPreloadPos = position;
+
+ // skip if listview is in a fling (note that we still set mLastPreloadPos above)
+ if (mIsFlinging) {
+ return;
+ }
+
+ final ReaderPost post = mPosts.get(position);
+ if (post.hasFeaturedImage()) {
+ preloadImage(post.getFeaturedImageForDisplay(mPhotonWidth, mPhotonHeight));
+ }
+ if (post.hasPostAvatar()) {
+ preloadImage(post.getPostAvatarForDisplay(mAvatarSz));
+ }
+ }
+
+ /*
+ * preload the passed image if it's not already cached
+ */
+ private void preloadImage(final String imageUrl) {
+ // skip if image is already in the LRU memory cache
+ if (WordPress.imageLoader.isCached(imageUrl, 0, 0)) {
+ return;
+ }
+
+ // skip if image is already in the disk cache
+ if (WordPress.requestQueue.getCache().get(imageUrl) != null) {
+ return;
+ }
+
+ // note that mImagePreloadListener doesn't do anything, but it's required by volley
+ WordPress.imageLoader.get(imageUrl, mImagePreloadListener);
+ }
+
+ private final ImageLoader.ImageListener mImagePreloadListener = new ImageLoader.ImageListener() {
+ @Override
+ public void onResponse(ImageLoader.ImageContainer imageContainer, boolean isImmediate) {
+ // nop
+ }
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.e(T.READER, volleyError);
+ }
+ };
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderReblogAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderReblogAdapter.java
new file mode 100644
index 000000000..1746c2343
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderReblogAdapter.java
@@ -0,0 +1,192 @@
+package org.wordpress.android.ui.reader.adapters;
+
+import android.content.Context;
+import android.os.AsyncTask;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.ui.reader.actions.ReaderActions.DataLoadedListener;
+import org.wordpress.android.util.DisplayUtils;
+import org.wordpress.android.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * adapter which displays list of blogs (accounts) for user to choose from when reblogging
+ */
+public class ReaderReblogAdapter extends BaseAdapter {
+ private final LayoutInflater mInflater;
+ private final DataLoadedListener mDataLoadedListener;
+ private final long mExcludeBlogId;
+ private final boolean mIsLandscape;
+ private SimpleAccountList mAccounts = new SimpleAccountList();
+
+ public ReaderReblogAdapter(Context context,
+ long excludeBlogId,
+ DataLoadedListener dataLoadedListener) {
+ mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ mExcludeBlogId = excludeBlogId;
+ mDataLoadedListener = dataLoadedListener;
+ mIsLandscape = DisplayUtils.isLandscape(context);
+ loadAccounts();
+ }
+
+ private void loadAccounts() {
+ new LoadAccountsTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ public int indexOfBlogId(long blogId) {
+ for (int i = 0; i < mAccounts.size(); i++) {
+ if (mAccounts.get(i).remoteBlogId == blogId) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ public void reload() {
+ clear();
+ loadAccounts();
+ }
+
+ private void clear() {
+ if (mAccounts.size() > 0) {
+ mAccounts.clear();
+ notifyDataSetChanged();
+ }
+ }
+
+ @Override
+ public int getCount() {
+ return mAccounts.size();
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return mAccounts.get(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ if (position == -1) {
+ return position;
+ }
+ return mAccounts.get(position).remoteBlogId;
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return true;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ final ReblogHolder holder;
+ if (convertView == null) {
+ convertView = mInflater.inflate(R.layout.reader_actionbar_reblog_item, parent, false);
+ holder = new ReblogHolder(convertView, mIsLandscape);
+ convertView.setTag(holder);
+ } else {
+ holder = (ReblogHolder)convertView.getTag();
+ }
+ holder.text.setText(mAccounts.get(position).blogName);
+ return convertView;
+ }
+
+ @Override
+ public View getDropDownView(int position, View convertView, ViewGroup parent) {
+ final ReblogHolder holder;
+ if (convertView == null) {
+ convertView = mInflater.inflate(R.layout.reader_actionbar_dropdown_item, parent, false);
+ holder = new ReblogHolder(convertView, mIsLandscape);
+ convertView.setTag(holder);
+ } else {
+ holder = (ReblogHolder)convertView.getTag();
+ }
+ holder.text.setText(mAccounts.get(position).blogName);
+ return convertView;
+ }
+
+ static class ReblogHolder {
+ final TextView text;
+ ReblogHolder(View view, boolean isLandscape) {
+ text = (TextView) view.findViewById(R.id.text);
+ if (isLandscape) {
+ ((LinearLayout) view).setOrientation(LinearLayout.HORIZONTAL);
+ }
+ }
+ }
+
+ private class SimpleAccountItem {
+ final int remoteBlogId;
+ final String blogName;
+
+ private SimpleAccountItem(int blogId, String blogName) {
+ this.remoteBlogId = blogId;
+ this.blogName = blogName;
+ }
+ }
+
+ private class SimpleAccountList extends ArrayList<SimpleAccountItem> {}
+
+ /*
+ * AsyncTask to retrieve list of blogs (accounts) from db
+ */
+ private class LoadAccountsTask extends AsyncTask<Void, Void, Boolean> {
+ final SimpleAccountList tmpAccounts = new SimpleAccountList();
+
+ @Override
+ protected Boolean doInBackground(Void... voids) {
+ // only .com blogs support reblogging
+ List<Map<String, Object>> accounts = WordPress.wpDB.getVisibleDotComAccounts();
+ if (accounts == null || accounts.size() == 0) {
+ return false;
+ }
+
+ int currentRemoteBlogId = WordPress.getCurrentRemoteBlogId();
+
+ for (Map<String, Object> curHash : accounts) {
+ int blogId = (Integer) curHash.get("blogId");
+ // don't add if this is the blog we're excluding (prevents reblogging to
+ // the same blog the post is from)
+ if (blogId != mExcludeBlogId) {
+ String blogName = StringUtils.unescapeHTML(curHash.get("blogName").toString());
+ if (TextUtils.isEmpty(blogName)) {
+ blogName = curHash.get("url").toString();
+ }
+
+ SimpleAccountItem item = new SimpleAccountItem(blogId, blogName);
+
+ // if this is the current blog, insert it at the top so it's automatically selected
+ if (tmpAccounts.size() > 0 && blogId == currentRemoteBlogId) {
+ tmpAccounts.add(0, item);
+ } else {
+ tmpAccounts.add(item);
+ }
+ }
+ }
+ return true;
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ if (result) {
+ mAccounts = (SimpleAccountList) tmpAccounts.clone();
+ notifyDataSetChanged();
+ }
+
+ if (mDataLoadedListener != null) {
+ mDataLoadedListener.onDataLoaded(isEmpty());
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderTagAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderTagAdapter.java
new file mode 100644
index 000000000..02ef60ac4
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderTagAdapter.java
@@ -0,0 +1,237 @@
+package org.wordpress.android.ui.reader.adapters;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.os.AsyncTask;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ImageButton;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.ReaderTagTable;
+import org.wordpress.android.models.ReaderTag;
+import org.wordpress.android.models.ReaderTagList;
+import org.wordpress.android.models.ReaderTagType;
+import org.wordpress.android.ui.reader.actions.ReaderActions;
+import org.wordpress.android.ui.reader.actions.ReaderTagActions;
+import org.wordpress.android.ui.reader.actions.ReaderTagActions.TagAction;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.NetworkUtils;
+import org.wordpress.android.util.ToastUtils;
+
+import java.lang.ref.WeakReference;
+
+public class ReaderTagAdapter extends BaseAdapter {
+ public interface TagActionListener {
+ public void onTagAction(ReaderTag tag, TagAction action);
+ }
+
+ private final WeakReference<Context> mWeakContext;
+ private final LayoutInflater mInflater;
+ private ReaderTagList mTags = new ReaderTagList();
+ private final TagActionListener mTagListener;
+ private final ReaderTagType mTagType;
+ private ReaderActions.DataLoadedListener mDataLoadedListener;
+ private final Drawable mDrawableAdd;
+ private final Drawable mDrawableRemove;
+
+ public ReaderTagAdapter(Context context, ReaderTagType tagType, TagActionListener tagListener) {
+ super();
+ mInflater = LayoutInflater.from(context);
+ mTagListener = tagListener;
+ mTagType = tagType;
+ mDrawableAdd = context.getResources().getDrawable(R.drawable.ic_content_new);
+ mDrawableRemove = context.getResources().getDrawable(R.drawable.ic_content_remove);
+ mWeakContext = new WeakReference<Context>(context);
+ }
+
+ private boolean hasContext() {
+ return (getContext() != null);
+ }
+
+ private Context getContext() {
+ return mWeakContext.get();
+ }
+
+ public void refresh(ReaderActions.DataLoadedListener dataListener) {
+ if (mIsTaskRunning) {
+ AppLog.w(T.READER, "tag task is already running");
+ }
+
+ mDataLoadedListener = dataListener;
+ new LoadTagsTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ public void refresh() {
+ refresh(null);
+ }
+
+ public int indexOfTag(ReaderTag tag) {
+ return mTags.indexOfTag(tag);
+ }
+
+ @Override
+ public int getCount() {
+ return mTags.size();
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return mTags.get(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return mTags.get(position).getTagName().hashCode();
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return true;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ final ReaderTag tag = (ReaderTag) getItem(position);
+ TagViewHolder holder;
+ if (convertView==null) {
+ convertView = mInflater.inflate(R.layout.reader_listitem_tag, parent, false);
+ holder = new TagViewHolder();
+ holder.txtTagName = (TextView) convertView.findViewById(R.id.text_topic);
+ holder.btnAddRemove = (ImageButton) convertView.findViewById(R.id.btn_add_remove);
+ convertView.setTag(holder);
+ } else {
+ holder = (TagViewHolder) convertView.getTag();
+ }
+
+ holder.txtTagName.setText(tag.getCapitalizedTagName());
+
+ switch (tag.tagType) {
+ case FOLLOWED:
+ // only followed tags can be deleted
+ holder.btnAddRemove.setImageDrawable(mDrawableRemove);
+ holder.btnAddRemove.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ performTagAction(TagAction.DELETE, tag.getTagName());
+
+ }
+ });
+ holder.btnAddRemove.setVisibility(View.VISIBLE);
+ break;
+
+ case RECOMMENDED:
+ // only recommended tags can be added
+ holder.btnAddRemove.setImageDrawable(mDrawableAdd);
+ holder.btnAddRemove.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ performTagAction(TagAction.ADD, tag.getTagName());
+ }
+ });
+ holder.btnAddRemove.setVisibility(View.VISIBLE);
+ break;
+
+ default :
+ holder.btnAddRemove.setVisibility(View.GONE);
+ break;
+
+ }
+
+ return convertView;
+ }
+
+ private void performTagAction(final TagAction action, String tagName) {
+ if (!NetworkUtils.checkConnection(getContext())) {
+ return;
+ }
+
+ ReaderActions.ActionListener actionListener = new ReaderActions.ActionListener() {
+ @Override
+ public void onActionResult(boolean succeeded) {
+ if (!succeeded && hasContext()) {
+ switch (action) {
+ case ADD:
+ ToastUtils.showToast(getContext(), R.string.reader_toast_err_add_tag);
+ break;
+ case DELETE:
+ ToastUtils.showToast(getContext(), R.string.reader_toast_err_remove_tag);
+ break;
+ }
+ refresh();
+ }
+ }
+ };
+
+ final boolean success;
+ ReaderTag tag = new ReaderTag(tagName, ReaderTagType.FOLLOWED);
+ switch (action) {
+ case ADD:
+ success = ReaderTagActions.performTagAction(tag, TagAction.ADD, actionListener);
+ break;
+ case DELETE:
+ success = ReaderTagActions.performTagAction(tag, TagAction.DELETE, actionListener);
+ break;
+ default:
+ success = false;
+ break;
+ }
+
+ if (success && mTagListener != null) {
+ mTagListener.onTagAction(tag, action);
+ }
+ }
+
+ private static class TagViewHolder {
+ private TextView txtTagName;
+ private ImageButton btnAddRemove;
+ }
+
+ /*
+ * AsyncTask to load tags
+ */
+ private boolean mIsTaskRunning = false;
+ private class LoadTagsTask extends AsyncTask<Void, Void, Boolean> {
+ ReaderTagList tmpTags;
+ @Override
+ protected void onPreExecute() {
+ mIsTaskRunning = true;
+ }
+ @Override
+ protected void onCancelled() {
+ mIsTaskRunning = false;
+ }
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ switch (mTagType) {
+ case RECOMMENDED:
+ tmpTags = ReaderTagTable.getRecommendedTags(true);
+ break;
+ case FOLLOWED:
+ tmpTags = ReaderTagTable.getFollowedTags();
+ break;
+ default :
+ tmpTags = ReaderTagTable.getDefaultTags();
+ break;
+ }
+
+ return !mTags.isSameList(tmpTags);
+ }
+ @Override
+ protected void onPostExecute(Boolean result) {
+ if (result) {
+ mTags = (ReaderTagList)(tmpTags.clone());
+ notifyDataSetChanged();
+ }
+ mIsTaskRunning = false;
+ if (mDataLoadedListener != null) {
+ mDataLoadedListener.onDataLoaded(isEmpty());
+ }
+ }
+ }
+
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderUserAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderUserAdapter.java
new file mode 100644
index 000000000..b830f7135
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderUserAdapter.java
@@ -0,0 +1,182 @@
+package org.wordpress.android.ui.reader.adapters;
+
+import android.content.Context;
+import android.os.Handler;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.ReaderBlogTable;
+import org.wordpress.android.models.ReaderUrlList;
+import org.wordpress.android.models.ReaderUser;
+import org.wordpress.android.models.ReaderUserList;
+import org.wordpress.android.ui.reader.ReaderActivityLauncher;
+import org.wordpress.android.ui.reader.ReaderAnim;
+import org.wordpress.android.ui.reader.ReaderUtils;
+import org.wordpress.android.ui.reader.actions.ReaderActions.DataLoadedListener;
+import org.wordpress.android.ui.reader.actions.ReaderBlogActions;
+import org.wordpress.android.util.PhotonUtils;
+import org.wordpress.android.widgets.WPNetworkImageView;
+
+/**
+ * owner must call setUsers() with the list of
+ * users to display
+ */
+public class ReaderUserAdapter extends BaseAdapter {
+ private final LayoutInflater mInflater;
+ private ReaderUserList mUsers = new ReaderUserList();
+ private final DataLoadedListener mDataLoadedListener;
+ private final int mAvatarSz;
+
+ public ReaderUserAdapter(Context context, DataLoadedListener dataLoadedListener) {
+ super();
+ mInflater = LayoutInflater.from(context);
+ mDataLoadedListener = dataLoadedListener;
+ mAvatarSz = context.getResources().getDimensionPixelSize(R.dimen.avatar_sz_small);
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return true;
+ }
+
+ @Override
+ public int getCount() {
+ return mUsers.size();
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return mUsers.get(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return mUsers.get(position).userId;
+ }
+
+
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ final ReaderUser user = mUsers.get(position);
+ final UserViewHolder holder;
+
+ if (convertView == null) {
+ convertView = mInflater.inflate(R.layout.reader_listitem_user, parent, false);
+ holder = new UserViewHolder(convertView);
+ convertView.setTag(holder);
+ } else {
+ holder = (UserViewHolder) convertView.getTag();
+ }
+
+ holder.txtName.setText(user.getDisplayName());
+ if (user.hasUrl()) {
+ holder.txtUrl.setVisibility(View.VISIBLE);
+ holder.txtUrl.setText(user.getUrlDomain());
+
+ // tapping anywhere in the view shows the user's blog (requires knowing the blog id)
+ convertView.setEnabled(true);
+ convertView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (user.hasBlogId()) {
+ ReaderActivityLauncher.showReaderBlogPreview(v.getContext(), user.blogId, user.getUrl());
+ }
+ }
+ });
+
+ // enable following/unfollowing the user's blog
+ ReaderUtils.showFollowStatus(holder.txtFollow, user.isFollowed);
+ holder.txtFollow.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ ReaderAnim.animateFollowButton(holder.txtFollow);
+ toggleFollowUser(user, holder.txtFollow);
+ }
+ });
+ holder.txtFollow.setVisibility(View.VISIBLE);
+ } else {
+ // no blog url, so can't view blog or follow
+ holder.txtUrl.setVisibility(View.GONE);
+ holder.txtFollow.setVisibility(View.GONE);
+ convertView.setOnClickListener(null);
+ convertView.setEnabled(false);
+ }
+
+ holder.imgAvatar.setImageUrl(user.getAvatarUrl(), WPNetworkImageView.ImageType.AVATAR);
+
+ return convertView;
+ }
+
+ private void toggleFollowUser(ReaderUser user, TextView txtFollow) {
+ if (user == null) {
+ return;
+ }
+
+ boolean isAskingToFollow = !user.isFollowed;
+ if (ReaderBlogActions.performFollowAction(user.blogId, user.getUrl(), isAskingToFollow, null)) {
+ user.isFollowed = isAskingToFollow;
+ ReaderUtils.showFollowStatus(txtFollow, isAskingToFollow);
+ }
+ }
+
+ private static class UserViewHolder {
+ private final TextView txtName;
+ private final TextView txtUrl;
+ private final TextView txtFollow;
+ private final WPNetworkImageView imgAvatar;
+
+ UserViewHolder(View view) {
+ txtName = (TextView) view.findViewById(R.id.text_name);
+ txtUrl = (TextView) view.findViewById(R.id.text_url);
+ txtFollow = (TextView) view.findViewById(R.id.text_follow);
+ imgAvatar = (WPNetworkImageView) view.findViewById(R.id.image_avatar);
+ }
+ }
+
+ private void clear() {
+ if (mUsers.size() > 0) {
+ mUsers.clear();
+ notifyDataSetChanged();
+ }
+ }
+
+ public void setUsers(final ReaderUserList users) {
+ if (users == null || users.size() == 0) {
+ clear();
+ return;
+ }
+
+ mUsers = (ReaderUserList) users.clone();
+ final Handler handler = new Handler();
+
+ new Thread() {
+ @Override
+ public void run() {
+ // flag followed users, set avatar urls for use with photon, and pre-load
+ // user domains so we can avoid having to do this for each user when getView()
+ // is called
+ ReaderUrlList followedBlogUrls = ReaderBlogTable.getFollowedBlogUrls();
+ for (ReaderUser user: mUsers) {
+ user.isFollowed = user.hasUrl() && followedBlogUrls.contains(user.getUrl());
+ user.setAvatarUrl(PhotonUtils.fixAvatar(user.getAvatarUrl(), mAvatarSz));
+ user.getUrlDomain();
+ }
+
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ notifyDataSetChanged();
+ if (mDataLoadedListener != null) {
+ mDataLoadedListener.onDataLoaded(isEmpty());
+ }
+ }
+ });
+ }
+ }.start();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/models/ReaderBlogIdPostId.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/models/ReaderBlogIdPostId.java
new file mode 100644
index 000000000..a058ff20a
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/models/ReaderBlogIdPostId.java
@@ -0,0 +1,23 @@
+package org.wordpress.android.ui.reader.models;
+
+import java.io.Serializable;
+
+public class ReaderBlogIdPostId implements Serializable {
+ private static final long serialVersionUID = 0L;
+
+ private final long blogId;
+ private final long postId;
+
+ public ReaderBlogIdPostId(long blogId, long postId) {
+ this.blogId = blogId;
+ this.postId = postId;
+ }
+
+ public long getBlogId() {
+ return blogId;
+ }
+
+ public long getPostId() {
+ return postId;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/models/ReaderBlogIdPostIdList.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/models/ReaderBlogIdPostIdList.java
new file mode 100644
index 000000000..42202e780
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/models/ReaderBlogIdPostIdList.java
@@ -0,0 +1,38 @@
+package org.wordpress.android.ui.reader.models;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+
+public class ReaderBlogIdPostIdList extends ArrayList<ReaderBlogIdPostId>
+ implements Serializable {
+
+ private static final long serialVersionUID = 0L;
+
+ public ReaderBlogIdPostIdList() {
+ super();
+ }
+
+ /*
+ * when Android serializes any ArrayList descendant, it does so as an ArrayList
+ * rather than its actual class - use this to convert the serialized list back
+ * into a ReaderBlogIdPostIdList
+ */
+ public ReaderBlogIdPostIdList(Serializable serializedList) {
+ super();
+ if (serializedList != null && serializedList instanceof ArrayList) {
+ ArrayList<ReaderBlogIdPostId> list = (ArrayList<ReaderBlogIdPostId>) serializedList;
+ for (ReaderBlogIdPostId idPair: list) {
+ this.add(idPair);
+ }
+ }
+ }
+
+ public int indexOf(long blogId, long postId) {
+ for (int i = 0; i < this.size(); i++) {
+ if (this.get(i).getBlogId() == blogId && this.get(i).getPostId() == postId) {
+ return i;
+ }
+ }
+ return -1;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAbsPagedViewFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAbsPagedViewFragment.java
new file mode 100644
index 000000000..47667b23d
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAbsPagedViewFragment.java
@@ -0,0 +1,207 @@
+package org.wordpress.android.ui.stats;
+
+import android.app.Fragment;
+import android.app.FragmentTransaction;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+import android.widget.RadioGroup.OnCheckedChangeListener;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.StatUtils;
+import org.wordpress.android.util.Utils;
+
+import java.util.Locale;
+
+/**
+ * For stats that have multiple pages (e.g. Today, Yesterday).
+ * <p>
+ * This fragment appears as a frame layout with buttons.
+ * Each page is a child fragment.
+ * </p>
+ * <p>
+ * Fragments are provided by subclasses implementing {@code getFragment(int)}
+ * </p>
+ */
+public abstract class StatsAbsPagedViewFragment extends StatsAbsViewFragment
+ implements OnCheckedChangeListener,
+ StatsCursorInterface {
+ private static final int ONE_DAY = 24 * 60 * 60 * 1000;
+
+ private static final String SELECTED_BUTTON_INDEX = "SELECTED_BUTTON_INDEX";
+ private int mSelectedButtonIndex = 0;
+
+ // the active fragment has the CHILD_TAG:class.getSimpleName():<mChildIndex>
+ private static final String CHILD_TAG = "CHILD_TAG";
+
+ private RadioGroup mRadioGroup;
+ private FrameLayout mFragmentContainer;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.stats_pager_fragment, container, false);
+
+ // Create the frame layout that will be used to add/replace the inner fragment
+ FrameLayout frameLayoutForInnerFragment = new FrameLayout(container.getContext());
+ FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.WRAP_CONTENT,
+ Gravity.CENTER_HORIZONTAL|Gravity.CENTER_VERTICAL);
+ frameLayoutForInnerFragment.setLayoutParams(layoutParams);
+ frameLayoutForInnerFragment.setId(getInnerFragmentID());
+
+ LinearLayout statsPagerInnerContainer = (LinearLayout) view.findViewById(R.id.stats_pager_inner_container);
+ statsPagerInnerContainer.addView(frameLayoutForInnerFragment);
+
+ setRetainInstance(true);
+ initLayout(view);
+ restoreState(savedInstanceState);
+
+ return view;
+ }
+
+ private void restoreState(Bundle savedInstanceState) {
+ if (savedInstanceState == null)
+ return;
+ mSelectedButtonIndex = savedInstanceState.getInt(SELECTED_BUTTON_INDEX);
+ }
+
+ private void initLayout(View view) {
+ final TextView titleView = (TextView) view.findViewById(R.id.stats_pager_title);
+ titleView.setText(getTitle().toUpperCase(Locale.getDefault()));
+
+ mFragmentContainer = (FrameLayout) view.findViewById(getInnerFragmentID());
+ mRadioGroup = (RadioGroup) view.findViewById(R.id.stats_pager_tabs);
+
+ int dp8 = (int) Utils.dpToPx(8);
+ int dp80 = (int) Utils.dpToPx(80);
+
+ LayoutInflater inflater = LayoutInflater.from(getActivity());
+
+ String[] titles = getTabTitles();
+ for (int i = 0; i < titles.length; i++) {
+ RadioButton rb = (RadioButton) inflater.inflate(R.layout.stats_radio_button, null, false);
+ RadioGroup.LayoutParams params = new RadioGroup.LayoutParams(RadioGroup.LayoutParams.WRAP_CONTENT,
+ RadioGroup.LayoutParams.WRAP_CONTENT);
+ params.setMargins(0, 0, dp8, 0);
+ rb.setMinimumWidth(dp80);
+ rb.setGravity(Gravity.CENTER);
+ rb.setLayoutParams(params);
+ rb.setText(titles[i]);
+ mRadioGroup.addView(rb);
+
+ if (i == mSelectedButtonIndex)
+ rb.setChecked(true);
+ }
+
+ loadFragmentIndex(mSelectedButtonIndex);
+
+ mRadioGroup.setVisibility(View.VISIBLE);
+ mRadioGroup.setOnCheckedChangeListener(this);
+ }
+
+ @Override
+ public void onCheckedChanged(RadioGroup group, int checkedId) {
+ // checkedId will be -1 when the selection is cleared
+ if (checkedId == -1)
+ return;
+
+ int index = group.indexOfChild(group.findViewById(checkedId));
+ if (index == -1)
+ return;
+
+ mSelectedButtonIndex = index;
+ loadFragmentIndex(mSelectedButtonIndex);
+ }
+
+ private void loadFragmentIndex(int index) {
+ if (index == -1) {
+ AppLog.w(AppLog.T.STATS, "invalid fragment index");
+ return;
+ }
+
+ String childTag = CHILD_TAG + ":" + this.getClass().getSimpleName() + ":" + index;
+ //set minimum height for container, so we don't get a janky fragment transaction
+ mFragmentContainer.setMinimumHeight(mFragmentContainer.getHeight());
+ Fragment fragment = getFragment(index);
+ FragmentTransaction ft = getFragmentManager().beginTransaction();
+ ft.setCustomAnimations(R.anim.stats_fade_in, R.anim.stats_fade_out);
+ ft.replace(getInnerFragmentID(), fragment, childTag);
+ ft.commit();
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putInt(SELECTED_BUTTON_INDEX, mSelectedButtonIndex);
+ }
+
+ protected abstract int getInnerFragmentID();
+
+ protected abstract String[] getTabTitles();
+
+ protected abstract Fragment getFragment(int position);
+
+ @Override
+ public void onCursorLoaded(final Uri uri, Cursor cursor) {
+ if (getActivity() == null)
+ return;
+ if (!cursor.moveToFirst())
+ return;
+
+ int colDate = cursor.getColumnIndex("date");
+ if (colDate == -1)
+ return;
+
+ String timeframe = uri.getQueryParameter("timeframe");
+ if (timeframe == null)
+ return;
+
+ long date = cursor.getLong(colDate);
+ long currentDate = StatUtils.getCurrentDateMs();
+
+ boolean isToday = timeframe.equals(StatsTimeframe.TODAY.name());
+ boolean isYesterday = timeframe.equals(StatsTimeframe.YESTERDAY.name());
+
+ final String label0;
+ final String label1;
+ if (isToday) {
+ if (date < currentDate) { // old stats
+ label0 = StatUtils.msToString(date, "MMM d");
+ label1 = StatUtils.msToString(date - ONE_DAY, "MMM d"); // assume the second set of stats is also old, and one day behind
+ } else {
+ label0 = StatsTimeframe.TODAY.getLabel();
+ label1 = StatsTimeframe.YESTERDAY.getLabel();
+ }
+ } else if (isYesterday) {
+ label0 = null;
+ currentDate -= ONE_DAY;
+ if (date < currentDate) {// old stats
+ label1 = StatUtils.msToString(date, "MMM d");
+ } else {
+ label1 = StatsTimeframe.YESTERDAY.getLabel();
+ }
+ } else {
+ return;
+ }
+
+ if (mRadioGroup == null)
+ return;
+ final RadioButton radio0 = (RadioButton) mRadioGroup.getChildAt(0);
+ final RadioButton radio1 = (RadioButton) mRadioGroup.getChildAt(1);
+
+ if (label0 != null && radio0 != null)
+ radio0.setText(label0);
+ if (label1 != null && radio1 != null)
+ radio1.setText(label1);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAbsViewFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAbsViewFragment.java
new file mode 100644
index 000000000..27ec4a50a
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAbsViewFragment.java
@@ -0,0 +1,67 @@
+package org.wordpress.android.ui.stats;
+
+import android.app.Fragment;
+import android.os.Bundle;
+
+/**
+ * A generic view for all the different stats views.
+ */
+public abstract class StatsAbsViewFragment extends Fragment {
+ public static final String TAG = StatsAbsViewFragment.class.getSimpleName();
+
+ public static StatsAbsViewFragment newInstance(StatsViewType viewType) {
+ StatsAbsViewFragment fragment = null;
+
+ switch (viewType) {
+ case CLICKS:
+ fragment = new StatsClicksFragment();
+ break;
+ case COMMENTS:
+ fragment = new StatsCommentsFragment();
+ break;
+ case REFERRERS:
+ fragment = new StatsReferrersFragment();
+ break;
+ case SEARCH_ENGINE_TERMS:
+ fragment = new StatsSearchEngineTermsFragment();
+ break;
+ case TAGS_AND_CATEGORIES:
+ fragment = new StatsTagsAndCategoriesFragment();
+ break;
+ case TOP_AUTHORS:
+ fragment = new StatsTopAuthorsFragment();
+ break;
+ case TOP_POSTS_AND_PAGES:
+ fragment = new StatsTopPostsAndPagesFragment();
+ break;
+ case TOTALS_FOLLOWERS_AND_SHARES:
+ fragment = new StatsTotalsFollowersAndSharesFragment();
+ break;
+ case VIDEO_PLAYS:
+ fragment = new StatsVideoFragment();
+ break;
+ case VIEWS_BY_COUNTRY:
+ fragment = new StatsGeoviewsFragment();
+ break;
+ case VISITORS_AND_VIEWS:
+ fragment = new StatsVisitorsAndViewsFragment();
+ break;
+
+ }
+
+ Bundle args = new Bundle();
+ args.putInt(ARGS_VIEW_TYPE, viewType.ordinal());
+ fragment.setArguments(args);
+
+ return fragment;
+ }
+
+ private static final String ARGS_VIEW_TYPE = "ARGS_VIEW_TYPE";
+
+ protected StatsViewType getViewType() {
+ int ordinal = getArguments().getInt(ARGS_VIEW_TYPE);
+ return StatsViewType.values()[ordinal];
+ }
+
+ protected abstract String getTitle();
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsActivity.java
new file mode 100644
index 000000000..4613e8ddf
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsActivity.java
@@ -0,0 +1,718 @@
+package org.wordpress.android.ui.stats;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.FragmentManager;
+import android.app.FragmentTransaction;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.graphics.Point;
+import android.os.Bundle;
+import android.os.Handler;
+import android.preference.PreferenceManager;
+import android.support.v4.content.LocalBroadcastManager;
+import android.view.Display;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.ScrollView;
+import android.widget.Toast;
+
+import com.android.volley.VolleyError;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.WordPressDB;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.ui.AuthenticatedWebViewActivity;
+import org.wordpress.android.ui.PullToRefreshHelper;
+import org.wordpress.android.ui.PullToRefreshHelper.RefreshListener;
+import org.wordpress.android.ui.WPActionBarActivity;
+import org.wordpress.android.ui.accounts.WPComLoginActivity;
+import org.wordpress.android.ui.stats.service.StatsService;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.NetworkUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.ToastUtils.Duration;
+import org.wordpress.android.util.Utils;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+import org.xmlrpc.android.ApiHelper;
+import org.xmlrpc.android.XMLRPCCallback;
+import org.xmlrpc.android.XMLRPCClientInterface;
+import org.xmlrpc.android.XMLRPCFactory;
+
+import java.io.Serializable;
+import java.lang.ref.WeakReference;
+import java.util.HashMap;
+import java.util.Map;
+
+import uk.co.senab.actionbarpulltorefresh.library.PullToRefreshLayout;
+
+/**
+ * The native stats activity, accessible via the menu drawer.
+ * <p>
+ * By pressing a spinner on the action bar, the user can select which stats view they wish to see.
+ * </p>
+ */
+public class StatsActivity extends WPActionBarActivity {
+ // Max number of rows to show in a stats fragment
+ public static final int STATS_GROUP_MAX_ITEMS = 10;
+ public static final int STATS_CHILD_MAX_ITEMS = 25;
+
+ private static final String SAVED_NAV_POSITION = "SAVED_NAV_POSITION";
+ private static final String SAVED_WP_LOGIN_STATE = "SAVED_WP_LOGIN_STATE";
+ private static final int REQUEST_JETPACK = 7000;
+ public static final String ARG_NO_MENU_DRAWER = "no_menu_drawer";
+
+ private Dialog mSignInDialog;
+ private int mNavPosition = 0;
+
+ private int mResultCode = -1;
+ private boolean mIsRestoredFromState = false;
+ private boolean mIsInFront;
+ private boolean mNoMenuDrawer = false;
+ private boolean mIsUpdatingStats;
+ private PullToRefreshHelper mPullToRefreshHelper;
+
+ // Used for tablet UI
+ private static final int TABLET_720DP = 720;
+ private static final int TABLET_600DP = 600;
+ private LinearLayout mFragmentContainer;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (WordPress.wpDB == null) {
+ Toast.makeText(this, R.string.fatal_db_error, Toast.LENGTH_LONG).show();
+ finish();
+ return;
+ }
+
+ if (savedInstanceState == null) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.STATS_ACCESSED);
+ }
+
+ mNoMenuDrawer = getIntent().getBooleanExtra(ARG_NO_MENU_DRAWER, false);
+ if (mNoMenuDrawer) {
+ setContentView(R.layout.stats_activity);
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+ } else {
+ createMenuDrawer(R.layout.stats_activity);
+ }
+
+ mFragmentContainer = (LinearLayout) findViewById(R.id.stats_fragment_container);
+
+ // pull to refresh setup
+ mPullToRefreshHelper = new PullToRefreshHelper(this, (PullToRefreshLayout) findViewById(R.id.ptr_layout),
+ new RefreshListener() {
+ @Override
+ public void onRefreshStarted(View view) {
+ if (!NetworkUtils.checkConnection(getBaseContext())) {
+ mPullToRefreshHelper.setRefreshing(false);
+ return;
+ }
+ refreshStats();
+ }
+ });
+
+ loadStatsFragments();
+ setTitle(R.string.stats);
+
+ restoreState(savedInstanceState);
+ }
+
+ @Override
+ protected void onDestroy() {
+ stopStatsService();
+ super.onDestroy();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ mPullToRefreshHelper.registerReceiver(this);
+ mIsInFront = true;
+ // register to receive broadcasts when StatsService starts/stops updating
+ LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(this);
+ lbm.registerReceiver(mReceiver, new IntentFilter(StatsService.ACTION_STATS_UPDATING));
+
+ if (!mIsRestoredFromState) {
+ mPullToRefreshHelper.setRefreshing(true);
+ refreshStats();
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+
+ mIsInFront = false;
+ LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(this);
+ lbm.unregisterReceiver(mReceiver);
+ mPullToRefreshHelper.unregisterReceiver(this);
+ }
+
+ private void restoreState(Bundle savedInstanceState) {
+ if (savedInstanceState == null) {
+ return;
+ }
+
+ mNavPosition = savedInstanceState.getInt(SAVED_NAV_POSITION);
+ mResultCode = savedInstanceState.getInt(SAVED_WP_LOGIN_STATE);
+ mIsRestoredFromState = true;
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+
+ outState.putInt(SAVED_NAV_POSITION, mNavPosition);
+ outState.putInt(SAVED_WP_LOGIN_STATE, mResultCode);
+ }
+
+ private void startWPComLoginActivity() {
+ mResultCode = RESULT_CANCELED;
+ Intent loginIntent = new Intent(this, WPComLoginActivity.class);
+ loginIntent.putExtra(WPComLoginActivity.JETPACK_AUTH_REQUEST, true);
+ startActivityForResult(loginIntent, WPComLoginActivity.REQUEST_CODE);
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (requestCode == WPComLoginActivity.REQUEST_CODE) {
+ mResultCode = resultCode;
+ if (resultCode == RESULT_OK && !WordPress.getCurrentBlog().isDotcomFlag()) {
+ if (getBlogId() == null) {
+ final Handler handler = new Handler();
+ final Blog currentBlog = WordPress.getCurrentBlog();
+ // Attempt to get the Jetpack blog ID
+ XMLRPCClientInterface xmlrpcClient = XMLRPCFactory.instantiate(currentBlog.getUri(), "", "");
+ Map<String, String> args = ApiHelper.blogOptionsXMLRPCParameters;
+ Object[] params = {
+ currentBlog.getRemoteBlogId(), currentBlog.getUsername(), currentBlog.getPassword(), args
+ };
+ xmlrpcClient.callAsync(new XMLRPCCallback() {
+ @Override
+ public void onSuccess(long id, Object result) {
+ if (result != null && (result instanceof HashMap)) {
+ Map<?, ?> blogOptions = (HashMap<?, ?>) result;
+ ApiHelper.updateBlogOptions(currentBlog, blogOptions);
+ AnalyticsTracker.track(AnalyticsTracker.Stat.SIGNED_INTO_JETPACK);
+ AnalyticsTracker.track(
+ AnalyticsTracker.Stat.PERFORMED_JETPACK_SIGN_IN_FROM_STATS_SCREEN);
+ if (!isFinishing()) {
+ mPullToRefreshHelper.setRefreshing(true);
+ refreshStats();
+ }
+ }
+ }
+ @Override
+ public void onFailure(long id, Exception error) {
+ AppLog.e(T.STATS,
+ "Cannot load blog options (wp.getOptions failed) "
+ + "and no jetpack_client_id is then available",
+ error);
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ mPullToRefreshHelper.setRefreshing(false);
+ ToastUtils.showToast(StatsActivity.this,
+ StatsActivity.this.getString(R.string.error_refresh_stats),
+ Duration.LONG);
+ }
+ });
+ }
+ }, "wp.getOptions", params);
+ } else {
+ refreshStats();
+ }
+ mPullToRefreshHelper.setRefreshing(true);
+ }
+ }
+ }
+
+ private void loadStatsFragments() {
+ FragmentManager fm = getFragmentManager();
+ FragmentTransaction ft = fm.beginTransaction();
+
+ StatsAbsViewFragment fragment;
+
+ if (fm.findFragmentByTag(StatsVisitorsAndViewsFragment.TAG) == null) {
+ fragment = StatsAbsViewFragment.newInstance(StatsViewType.VISITORS_AND_VIEWS);
+ ft.replace(R.id.stats_visitors_and_views_container, fragment, StatsVisitorsAndViewsFragment.TAG);
+ }
+
+ if (fm.findFragmentByTag(StatsReferrersFragment.TAG) == null) {
+ fragment = StatsReferrersFragment.newInstance(StatsViewType.REFERRERS);
+ ft.replace(R.id.stats_referrers_container, fragment, StatsReferrersFragment.TAG);
+ }
+
+ if (fm.findFragmentByTag(StatsClicksFragment.TAG) == null) {
+ fragment = StatsAbsViewFragment.newInstance(StatsViewType.CLICKS);
+ ft.replace(R.id.stats_clicks_container, fragment, StatsClicksFragment.TAG);
+ }
+
+ if (fm.findFragmentByTag(StatsGeoviewsFragment.TAG) == null) {
+ fragment = StatsAbsViewFragment.newInstance(StatsViewType.VIEWS_BY_COUNTRY);
+ ft.replace(R.id.stats_geoviews_container, fragment, StatsGeoviewsFragment.TAG);
+ }
+
+ if (fm.findFragmentByTag(StatsSearchEngineTermsFragment.TAG) == null) {
+ fragment = StatsAbsViewFragment.newInstance(StatsViewType.SEARCH_ENGINE_TERMS);
+ ft.replace(R.id.stats_searchengine_container, fragment, StatsSearchEngineTermsFragment.TAG);
+ }
+
+ if (fm.findFragmentByTag(StatsTotalsFollowersAndSharesFragment.TAG) == null) {
+ fragment = StatsAbsViewFragment.newInstance(StatsViewType.TOTALS_FOLLOWERS_AND_SHARES);
+ ft.replace(R.id.stats_totals_followers_shares_container,
+ fragment, StatsTotalsFollowersAndSharesFragment.TAG);
+ }
+
+ if (fm.findFragmentByTag(StatsTopPostsAndPagesFragment.TAG) == null) {
+ fragment = StatsAbsViewFragment.newInstance(StatsViewType.TOP_POSTS_AND_PAGES);
+ ft.replace(R.id.stats_top_posts_container, fragment, StatsTopPostsAndPagesFragment.TAG);
+ }
+
+ // TODO: awaiting stats APIs
+ /*if (fm.findFragmentByTag(StatsVideoFragment.TAG) == null) {
+ fragment = StatsAbsViewFragment.newInstance(StatsViewType.VIDEO_PLAYS);
+ ft.replace(R.id.stats_video_container, fragment, StatsVideoFragment.TAG);
+ }
+ if (fm.findFragmentByTag(StatsTagsAndCategoriesFragment.TAG) == null) {
+ fragment = StatsAbsViewFragment.newInstance(StatsViewType.TAGS_AND_CATEGORIES);
+ ft.replace(R.id.stats_tags_and_categories_container, fragment, StatsTagsAndCategoriesFragment.TAG);
+ }
+ if (fm.findFragmentByTag(StatsTopAuthorsFragment.TAG) == null) {
+ fragment = StatsAbsViewFragment.newInstance(StatsViewType.TOP_AUTHORS);
+ ft.replace(R.id.stats_top_authors_container, fragment, StatsTopAuthorsFragment.TAG);
+ }
+ if (fm.findFragmentByTag(StatsCommentsFragment.TAG) == null) {
+ fragment = StatsAbsViewFragment.newInstance(StatsViewType.COMMENTS);
+ ft.replace(R.id.stats_comments_container, fragment, StatsCommentsFragment.TAG);
+ }*/
+
+ ft.commit();
+
+ // split layout into two for 720DP tablets and 600DP tablets in landscape
+ if (Utils.getSmallestWidthDP() >= TABLET_720DP
+ || (Utils.getSmallestWidthDP() == TABLET_600DP && isInLandscape())) {
+ loadSplitLayout();
+ }
+ }
+
+ private void loadSplitLayout() {
+ LinearLayout columnLeft = (LinearLayout) findViewById(R.id.stats_tablet_col_left);
+ LinearLayout columnRight = (LinearLayout) findViewById(R.id.stats_tablet_col_right);
+ FrameLayout frameView;
+
+ /*
+ * left column
+ */
+ frameView = (FrameLayout) findViewById(R.id.stats_top_posts_container);
+ mFragmentContainer.removeView(frameView);
+ columnLeft.addView(frameView);
+
+ frameView = (FrameLayout) findViewById(R.id.stats_referrers_container);
+ mFragmentContainer.removeView(frameView);
+ columnLeft.addView(frameView);
+
+ frameView = (FrameLayout) findViewById(R.id.stats_clicks_container);
+ mFragmentContainer.removeView(frameView);
+ columnLeft.addView(frameView);
+
+ /*
+ * right column
+ */
+ frameView = (FrameLayout) findViewById(R.id.stats_geoviews_container);
+ mFragmentContainer.removeView(frameView);
+ columnRight.addView(frameView);
+
+ frameView = (FrameLayout) findViewById(R.id.stats_searchengine_container);
+ mFragmentContainer.removeView(frameView);
+ columnRight.addView(frameView);
+
+ frameView = (FrameLayout) findViewById(R.id.stats_totals_followers_shares_container);
+ mFragmentContainer.removeView(frameView);
+ columnRight.addView(frameView);
+
+ // TODO: awaiting stats APIs
+ /*frameView = (FrameLayout) findViewById(R.id.stats_top_authors_container);
+ mFragmentContainer.removeView(frameView);
+ columnLeft.addView(frameView);
+
+ frameView = (FrameLayout) findViewById(R.id.stats_video_container);
+ mFragmentContainer.removeView(frameView);
+ columnLeft.addView(frameView);
+
+ frameView = (FrameLayout) findViewById(R.id.stats_comments_container);
+ mFragmentContainer.removeView(frameView);
+ columnRight.addView(frameView);
+
+ frameView = (FrameLayout) findViewById(R.id.stats_tags_and_categories_container);
+ mFragmentContainer.removeView(frameView);
+ columnRight.addView(frameView);*/
+ }
+
+ private boolean isInLandscape() {
+ Display display = getWindowManager().getDefaultDisplay();
+ Point point = new Point();
+ display.getSize(point);
+ return (point.y < point.x);
+ }
+
+ private class VerifyJetpackSettingsCallback implements ApiHelper.GenericCallback {
+ private final WeakReference<StatsActivity> mStatsActivityWeakRef;
+
+ public VerifyJetpackSettingsCallback(StatsActivity refActivity) {
+ this.mStatsActivityWeakRef = new WeakReference<StatsActivity>(refActivity);
+ }
+
+ @Override
+ public void onSuccess() {
+ if (mStatsActivityWeakRef.get() == null || mStatsActivityWeakRef.get().isFinishing()
+ || !mStatsActivityWeakRef.get().mIsInFront) {
+ return;
+ }
+
+ if (getBlogId() == null) {
+ // Blog has not returned a jetpack_client_id
+ stopStatsService();
+ mPullToRefreshHelper.setRefreshing(false);
+ showJetpackMissingAlert(this.mStatsActivityWeakRef.get());
+ }
+ }
+
+ @Override
+ public void onFailure(ApiHelper.ErrorType errorType, String errorMessage, Throwable throwable) {
+ mPullToRefreshHelper.setRefreshing(false);
+ if (mStatsActivityWeakRef.get() == null || mStatsActivityWeakRef.get().isFinishing()
+ || !mStatsActivityWeakRef.get().mIsInFront) {
+ return;
+ }
+ if (mSignInDialog != null && mSignInDialog.isShowing()) {
+ return;
+ }
+ stopStatsService();
+ Toast.makeText(mStatsActivityWeakRef.get(), R.string.error_refresh_stats, Toast.LENGTH_LONG).show();
+ }
+ }
+
+ private void showJetpackMissingAlert(final Activity currentActivity) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(currentActivity);
+ if (WordPress.getCurrentBlog().isAdmin()) {
+ builder.setMessage(getString(R.string.jetpack_message))
+ .setTitle(getString(R.string.jetpack_not_found));
+ builder.setPositiveButton(R.string.yes, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int id) {
+ Intent jetpackIntent = new Intent(
+ currentActivity,
+ AuthenticatedWebViewActivity.class);
+ jetpackIntent.putExtra(AuthenticatedWebViewActivity.LOAD_AUTHENTICATED_URL,
+ WordPress.getCurrentBlog().getAdminUrl()
+ + "plugin-install.php?tab=search&s=jetpack+by+wordpress.com"
+ + "&plugin-search-input=Search+Plugins");
+ startActivityForResult(jetpackIntent, REQUEST_JETPACK);
+ AnalyticsTracker.track(AnalyticsTracker.Stat.STATS_SELECTED_INSTALL_JETPACK);
+ }
+ });
+ builder.setNegativeButton(R.string.no, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int id) {
+ // User cancelled the dialog
+ }
+ });
+ } else {
+ builder.setMessage(getString(R.string.jetpack_message_not_admin))
+ .setTitle(getString(R.string.jetpack_not_found));
+ builder.setPositiveButton(R.string.yes, null);
+ }
+ builder.create().show();
+ }
+
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.stats, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == R.id.menu_view_stats_full_site) {
+ final String blogId = getBlogId();
+ if (blogId == null) {
+ showJetpackMissingAlert(this);
+ return true;
+ }
+ Intent statsWebViewIntent = new Intent(this, StatsWebViewActivity.class);
+ String addressToLoad = "https://wordpress.com/my-stats/?no-chrome&blog=" + blogId + "&unit=1";
+
+ // 1. Read the credentials at blog level (Jetpack connected with a wpcom account != main account)
+ // 2. If credentials are empty read the global wpcom credentials
+ // 3. Check that credentials are not empty before launching the activity
+ String statsAuthenticatedUser = WordPress.getCurrentBlog().getDotcom_username();
+ String statsAuthenticatedPassword = WordPress.getCurrentBlog().getDotcom_password();
+
+ if (org.apache.commons.lang.StringUtils.isEmpty(statsAuthenticatedPassword)
+ || org.apache.commons.lang.StringUtils.isEmpty(statsAuthenticatedUser)) {
+ // Let's try the global wpcom credentials
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this);
+ statsAuthenticatedUser = settings.getString(WordPress.WPCOM_USERNAME_PREFERENCE, null);
+ statsAuthenticatedPassword = WordPressDB.decryptPassword(
+ settings.getString(WordPress.WPCOM_PASSWORD_PREFERENCE, null)
+ );
+ }
+ if (org.apache.commons.lang.StringUtils.isEmpty(statsAuthenticatedPassword)
+ || org.apache.commons.lang.StringUtils.isEmpty(statsAuthenticatedUser)) {
+ // Still empty. Show Toast.
+ Toast.makeText(this, R.string.jetpack_message_not_admin, Toast.LENGTH_LONG).show();
+ return true;
+ }
+
+ statsWebViewIntent.putExtra(StatsWebViewActivity.STATS_AUTHENTICATED_USER, statsAuthenticatedUser);
+ statsWebViewIntent.putExtra(StatsWebViewActivity.STATS_AUTHENTICATED_PASSWD, statsAuthenticatedPassword);
+ statsWebViewIntent.putExtra(StatsWebViewActivity.STATS_AUTHENTICATED_URL, addressToLoad);
+ startActivityWithDelay(statsWebViewIntent);
+ return true;
+ } else if (mNoMenuDrawer && item.getItemId() == android.R.id.home) {
+ onBackPressed();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void scrollToTop() {
+ ScrollView scrollView = (ScrollView) findViewById(R.id.scroll_view_stats);
+ if (scrollView != null) {
+ scrollView.fullScroll(ScrollView.FOCUS_UP);
+ }
+ }
+
+ @Override
+ public void onBlogChanged() {
+ super.onBlogChanged();
+
+ stopStatsService();
+ scrollToTop();
+
+ FragmentManager fm = getFragmentManager();
+ FragmentTransaction ft = fm.beginTransaction();
+
+ StatsAbsViewFragment fragment;
+
+ fragment = StatsAbsViewFragment.newInstance(StatsViewType.VISITORS_AND_VIEWS);
+ ft.replace(R.id.stats_visitors_and_views_container, fragment, StatsVisitorsAndViewsFragment.TAG);
+
+ fragment = StatsAbsViewFragment.newInstance(StatsViewType.TOP_POSTS_AND_PAGES);
+ ft.replace(R.id.stats_top_posts_container, fragment, StatsTopPostsAndPagesFragment.TAG);
+
+ fragment = StatsAbsViewFragment.newInstance(StatsViewType.VIEWS_BY_COUNTRY);
+ ft.replace(R.id.stats_geoviews_container, fragment, StatsGeoviewsFragment.TAG);
+
+ fragment = StatsAbsViewFragment.newInstance(StatsViewType.CLICKS);
+ ft.replace(R.id.stats_clicks_container, fragment, StatsClicksFragment.TAG);
+
+ fragment = StatsAbsViewFragment.newInstance(StatsViewType.SEARCH_ENGINE_TERMS);
+ ft.replace(R.id.stats_searchengine_container, fragment, StatsSearchEngineTermsFragment.TAG);
+
+ fragment = StatsAbsViewFragment.newInstance(StatsViewType.TOTALS_FOLLOWERS_AND_SHARES);
+ ft.replace(R.id.stats_totals_followers_shares_container, fragment, StatsTotalsFollowersAndSharesFragment.TAG);
+
+ fragment = StatsReferrersFragment.newInstance(StatsViewType.REFERRERS);
+ ft.replace(R.id.stats_referrers_container, fragment, StatsReferrersFragment.TAG);
+
+ ft.commit();
+
+ mPullToRefreshHelper.setRefreshing(true);
+ refreshStats();
+ }
+
+ /**
+ * Do not refresh Stats in BG when user switch between blogs since the refresh
+ * is already started in foreground at this point.
+ */
+ @Override
+ protected boolean shouldUpdateCurrentBlogStatsInBackground() {
+ return false;
+ }
+
+ boolean dotComCredentialsMatch() {
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this);
+ String username = settings.getString(WordPress.WPCOM_USERNAME_PREFERENCE, "");
+ return username.equals(WordPress.getCurrentBlog().getUsername());
+ }
+
+ private void refreshStats() {
+ if (WordPress.getCurrentBlog() == null) {
+ mPullToRefreshHelper.setRefreshing(false);
+ return;
+ }
+ if (!NetworkUtils.isNetworkAvailable(this)) {
+ mPullToRefreshHelper.setRefreshing(false);
+ return;
+ }
+
+ if (mIsUpdatingStats) {
+ mPullToRefreshHelper.setRefreshing(false);
+ AppLog.w(T.STATS, "stats are already updating, refresh cancelled");
+ return;
+ }
+
+ final Blog currentBlog = WordPress.getCurrentBlog();
+ if (currentBlog == null) {
+ mPullToRefreshHelper.setRefreshing(false);
+ AppLog.w(T.STATS, "Current blog is null. This should never happen here.");
+ return;
+ }
+
+ final String blogId = getBlogId();
+
+ // Make sure the blogId is available.
+ if (blogId != null) {
+ // for self-hosted sites; launch the user into an activity where they can provide their credentials
+ if (!WordPress.getCurrentBlog().isDotcomFlag()
+ && !WordPress.getCurrentBlog().hasValidJetpackCredentials() && mResultCode != RESULT_CANCELED) {
+ if (WordPress.hasValidWPComCredentials(this)) {
+ // Let's try the global wpcom credentials them first
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this);
+ String username = settings.getString(WordPress.WPCOM_USERNAME_PREFERENCE, null);
+ String password = WordPressDB.decryptPassword(
+ settings.getString(WordPress.WPCOM_PASSWORD_PREFERENCE, null)
+ );
+ WordPress.getCurrentBlog().setDotcom_username(username);
+ WordPress.getCurrentBlog().setDotcom_password(password);
+ WordPress.wpDB.saveBlog(WordPress.getCurrentBlog());
+ mPullToRefreshHelper.setRefreshing(true);
+ } else {
+ startWPComLoginActivity();
+ return;
+ }
+ }
+ } else {
+ // blogId is null at this point.
+ if (!currentBlog.isDotcomFlag()) {
+ // Refresh blog settings/options that includes 'jetpack_client_id'needed here
+ new ApiHelper.RefreshBlogContentTask(this, currentBlog,
+ new VerifyJetpackSettingsCallback(StatsActivity.this)).execute(false);
+ } else {
+ // blodID cannot be null on dotcom blogs.
+ Toast.makeText(this, R.string.error_refresh_stats, Toast.LENGTH_LONG).show();
+ AppLog.e(T.STATS, "blogID is null for a wpcom blog!! " + currentBlog.getHomeURL());
+ }
+ return;
+ }
+
+ // check again that we've valid credentials for a Jetpack site
+ if (!currentBlog.isDotcomFlag()
+ && !currentBlog.hasValidJetpackCredentials()
+ && !WordPress.hasValidWPComCredentials(this)) {
+ mPullToRefreshHelper.setRefreshing(false);
+ AppLog.w(T.STATS, "Jetpack blog with no wpcom credentials");
+ return;
+ }
+
+ // start service to get stats
+ Intent intent = new Intent(this, StatsService.class);
+ intent.putExtra(StatsService.ARG_BLOG_ID, blogId);
+ startService(intent);
+ }
+
+ /**
+ * Return the remote blogId as stored on the wpcom backend.
+ * <p>
+ * blogId is always available for dotcom blogs. It could be null on Jetpack blogs
+ * with blogOptions still empty or when the option 'jetpack_client_id' is not available in blogOptions.
+ * </p>
+ * @return String blogId or null
+ */
+ String getBlogId() {
+ Blog currentBlog = WordPress.getCurrentBlog();
+ if (currentBlog.isDotcomFlag()) {
+ return String.valueOf(currentBlog.getRemoteBlogId());
+ } else {
+ return currentBlog.getApi_blogid();
+ }
+ }
+
+ private void stopStatsService() {
+ stopService(new Intent(this, StatsService.class));
+ if (mIsUpdatingStats) {
+ mIsUpdatingStats = false;
+ mPullToRefreshHelper.setRefreshing(false);
+ }
+ }
+
+ /*
+ * receiver for broadcast from StatsService which alerts when stats update has started/ended
+ */
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = StringUtils.notNullStr(intent.getAction());
+ if (action.equals(StatsService.ACTION_STATS_UPDATING)) {
+ mIsUpdatingStats = intent.getBooleanExtra(StatsService.EXTRA_IS_UPDATING, false);
+ if (!mIsUpdatingStats) {
+ mPullToRefreshHelper.setRefreshing(false);
+ }
+
+ // Check if there were errors
+ if (intent.getBooleanExtra(StatsService.EXTRA_IS_ERROR, false) && !isFinishing()
+ && (mSignInDialog == null || !mSignInDialog.isShowing())) {
+ Serializable errorObject = intent.getSerializableExtra(StatsService.EXTRA_ERROR_OBJECT);
+ if (errorObject instanceof String && errorObject.toString().contains("unauthorized")
+ && errorObject.toString().contains("403")) {
+ // This site has the wrong WP.com credentials
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(
+ StatsActivity.this);
+ // Read the current wpcom username from blog settings, then read it from
+ // the app wpcom account.
+ String username = StringUtils.notNullStr(WordPress.getCurrentBlog().getDotcom_username());
+ if (username.equals("")) {
+ username = settings.getString(WordPress.WPCOM_USERNAME_PREFERENCE, "");
+ }
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(StatsActivity.this);
+ builder.setTitle(getString(R.string.jetpack_stats_unauthorized))
+ .setMessage(getString(R.string.jetpack_stats_switch_user, username));
+ builder.setPositiveButton(R.string.yes, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int id) {
+ startWPComLoginActivity();
+ }
+ });
+ builder.setNegativeButton(R.string.no, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int id) {
+ // User cancelled the dialog
+ }
+ });
+ mSignInDialog = builder.create();
+ mSignInDialog.show();
+ } else if (errorObject instanceof VolleyError) {
+ ToastUtils.showToastOrAuthAlert(StatsActivity.this, (VolleyError) errorObject,
+ StatsActivity.this.getString(R.string.error_refresh_stats));
+ } else {
+ ToastUtils.showToast(StatsActivity.this,
+ StatsActivity.this.getString(R.string.error_refresh_stats),
+ Duration.LONG);
+ }
+ } // End error check
+ }
+ }
+ };
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsBarChartUnit.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsBarChartUnit.java
new file mode 100644
index 000000000..498a24fc5
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsBarChartUnit.java
@@ -0,0 +1,24 @@
+package org.wordpress.android.ui.stats;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+
+/**
+ * A enum of the different bar chart time frames.
+ */
+public enum StatsBarChartUnit {
+ DAY(R.string.stats_timeframe_days),
+ WEEK(R.string.stats_timeframe_weeks),
+ MONTH(R.string.stats_timeframe_months),
+ ;
+
+ private final int mLabelResId;
+
+ private StatsBarChartUnit(int labelResId) {
+ mLabelResId = labelResId;
+ }
+
+ public String getLabel() {
+ return WordPress.getContext().getString(mLabelResId);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsBarGraph.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsBarGraph.java
new file mode 100644
index 000000000..1ccca8a79
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsBarGraph.java
@@ -0,0 +1,112 @@
+package org.wordpress.android.ui.stats;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+
+import com.jjoe64.graphview.CustomLabelFormatter;
+import com.jjoe64.graphview.GraphView;
+import com.jjoe64.graphview.GraphViewDataInterface;
+import com.jjoe64.graphview.GraphViewSeries.GraphViewSeriesStyle;
+
+import org.wordpress.android.R;
+
+/**
+ * A Bar graph depicting the view and visitors.
+ * Based on BarGraph from the GraphView library.
+ */
+class StatsBarGraph extends GraphView {
+ public StatsBarGraph(Context context) {
+ super(context, "");
+
+ setProperties();
+ }
+
+ private void setProperties() {
+ getGraphViewStyle().setHorizontalLabelsColor(Color.BLACK);
+ getGraphViewStyle().setVerticalLabelsColor(Color.BLACK);
+ getGraphViewStyle().setTextSize(getResources().getDimensionPixelSize(R.dimen.graph_font_size));
+ getGraphViewStyle().setGridXColor(Color.TRANSPARENT);
+ getGraphViewStyle().setGridYColor(getResources().getColor(R.color.stats_bar_graph_grid));
+ getGraphViewStyle().setNumVerticalLabels(6);
+
+ setCustomLabelFormatter(new CustomLabelFormatter() {
+ @Override
+ public String formatLabel(double value, boolean isValueX) {
+ if(isValueX)
+ return null;
+
+ if (value < 1000) {
+ return null;
+ } else if (value < 1000000) { // thousands
+ return Math.round(value / 1000) + "K";
+ } else if (value < 1000000000) { // millions
+ return Math.round(value / 1000000) + "M";
+ } else {
+ return null;
+ }
+ }
+ });
+ }
+
+ @Override
+ public void drawSeries(Canvas canvas, GraphViewDataInterface[] values,
+ float graphwidth, float graphheight, float border, double minX,
+ double minY, double diffX, double diffY, float horstart,
+ GraphViewSeriesStyle style) {
+ float colwidth = graphwidth / values.length;
+
+ paint.setStrokeWidth(style.thickness);
+ paint.setColor(style.color);
+
+ // draw data
+ for (int i = 0; i < values.length; i++) {
+ float valY = (float) (values[i].getY() - minY);
+ float ratY = (float) (valY / diffY);
+ float y = graphheight * ratY;
+
+ // hook for value dependent color
+ if (style.getValueDependentColor() != null) {
+ paint.setColor(style.getValueDependentColor().get(values[i]));
+ }
+
+
+ float pad = style.padding;
+
+ float left = (i * colwidth) + horstart;
+ float top = (border - y) + graphheight;
+ float right = left + colwidth;
+ float bottom = graphheight + border - 1;
+
+ canvas.drawRect(left + pad, top, right - pad, bottom, paint);
+ }
+ }
+
+ @Override
+ protected double getMinY() {
+ return 0;
+ }
+
+ @Override
+ protected double getMaxY() {
+ double maxY = super.getMaxY();
+
+ final int divideBy;
+ if (maxY < 100)
+ divideBy = 10;
+ else if (maxY < 1000)
+ divideBy = 100;
+ else if (maxY < 10000)
+ divideBy = 1000;
+ else if (maxY < 100000)
+ divideBy = 10000;
+ else if (maxY < 1000000)
+ divideBy = 100000;
+ else
+ divideBy = 1000000;
+
+ maxY = Math.rint((maxY / divideBy) + 1) * divideBy;
+ return maxY;
+
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsBarGraphFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsBarGraphFragment.java
new file mode 100644
index 000000000..808db3a77
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsBarGraphFragment.java
@@ -0,0 +1,218 @@
+package org.wordpress.android.ui.stats;
+
+import android.app.Fragment;
+import android.app.LoaderManager;
+import android.content.Context;
+import android.content.CursorLoader;
+import android.content.Loader;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.os.Handler;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+
+import com.jjoe64.graphview.GraphView;
+import com.jjoe64.graphview.GraphViewSeries;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.StatsBarChartDataTable;
+import org.wordpress.android.providers.StatsContentProvider;
+import org.wordpress.android.util.StatUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.Utils;
+
+/**
+ * A fragment that shows stats bar chart data.
+ */
+public class StatsBarGraphFragment extends Fragment implements LoaderManager.LoaderCallbacks<Cursor> {
+ private static final String ARGS_BAR_CHART_UNIT = "ARGS_TIMEFRAME";
+
+ private LinearLayout mGraphContainer;
+ private final ContentObserver mContentObserver = new BarGraphContentObserver(new Handler());
+
+ public static StatsBarGraphFragment newInstance(StatsBarChartUnit unit) {
+ StatsBarGraphFragment fragment = new StatsBarGraphFragment();
+
+ Bundle args = new Bundle();
+ args.putInt(ARGS_BAR_CHART_UNIT, unit.ordinal());
+ fragment.setArguments(args);
+
+ return fragment;
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ mGraphContainer = (LinearLayout)inflater.inflate(R.layout.stats_bar_graph_fragment, container, false);
+ mGraphContainer.setTag(getArguments().getInt(ARGS_BAR_CHART_UNIT, -1));
+ return mGraphContainer;
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ getLoaderManager().restartLoader(getBarChartUnit().ordinal(), null, this);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ getActivity().getContentResolver().registerContentObserver(StatsContentProvider.STATS_BAR_CHART_DATA_URI, true, mContentObserver);
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ getActivity().getContentResolver().unregisterContentObserver(mContentObserver);
+ }
+
+ private StatsBarChartUnit getBarChartUnit() {
+ int ordinal = getArguments().getInt(ARGS_BAR_CHART_UNIT);
+ return StatsBarChartUnit.values()[ordinal];
+ }
+
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ if (WordPress.getCurrentBlog() == null)
+ return null;
+
+ String blogId = WordPress.getCurrentBlog().getDotComBlogId();
+ if (TextUtils.isEmpty(blogId))
+ blogId = "0";
+ StatsBarChartUnit unit = getBarChartUnit();
+ return new CursorLoader(getActivity(),
+ StatsContentProvider.STATS_BAR_CHART_DATA_URI,
+ null,
+ "blogId=? AND unit=?",
+ new String[] { blogId, unit.name() },
+ null);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
+ if (getActivity() == null)
+ return;
+
+ if (!cursor.moveToFirst()) {
+ Context context = mGraphContainer.getContext();
+ if (context != null) {
+ LayoutInflater inflater = LayoutInflater.from(context);
+ View emptyBarGraphView = inflater.inflate(R.layout.stats_bar_graph_empty, mGraphContainer, false);
+ if (emptyBarGraphView != null)
+ mGraphContainer.addView(emptyBarGraphView);
+ }
+ return;
+ }
+
+ int numPoints = Math.min(getNumOfPoints(), cursor.getCount());
+ final String[] horLabels = new String[numPoints];
+ GraphView.GraphViewData[] views = new GraphView.GraphViewData[numPoints];
+ GraphView.GraphViewData[] visitors = new GraphView.GraphViewData[numPoints];
+
+ StatsBarChartUnit unit = getBarChartUnit();
+ for (int i = numPoints - 1; i >= 0; i--) {
+ views[i] = new GraphView.GraphViewData(i, getViews(cursor));
+ visitors[i] = new GraphView.GraphViewData(i, getVisitors(cursor));
+ horLabels[i] = getDateLabel(cursor, unit);
+ cursor.moveToNext();
+ }
+
+ GraphViewSeries viewsSeries = new GraphViewSeries(views);
+ GraphViewSeries visitorsSeries = new GraphViewSeries(visitors);
+
+ viewsSeries.getStyle().color = getResources().getColor(R.color.stats_bar_graph_views);
+ viewsSeries.getStyle().padding = Utils.dpToPx(1);
+ visitorsSeries.getStyle().color = getResources().getColor(R.color.stats_bar_graph_visitors);
+ visitorsSeries.getStyle().padding = Utils.dpToPx(3);
+
+ // Update or create a new GraphView
+ GraphView graphView;
+ if (mGraphContainer.getChildCount() >= 1 && mGraphContainer.getChildAt(0) instanceof GraphView) {
+ graphView = (GraphView) mGraphContainer.getChildAt(0);
+ } else {
+ mGraphContainer.removeAllViews();
+ graphView = new StatsBarGraph(getActivity());
+ mGraphContainer.addView(graphView);
+ }
+
+ if (graphView != null) {
+ graphView.removeAllSeries();
+ graphView.addSeries(viewsSeries);
+ graphView.addSeries(visitorsSeries);
+ graphView.getGraphViewStyle().setNumHorizontalLabels(getNumOfHorizontalLabels(numPoints));
+ graphView.setHorizontalLabels(horLabels);
+ }
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> cursorLoader) {
+ //noop
+ }
+
+ private int getNumOfPoints() {
+ if (Utils.isTablet()) {
+ return 30;
+ }
+
+ if (getBarChartUnit() == StatsBarChartUnit.DAY)
+ return 7;
+ else
+ return 12;
+ }
+
+ private int getNumOfHorizontalLabels(int numPoints) {
+ if (Utils.isTablet()) {
+ return numPoints / 5;
+ }
+
+ if (getBarChartUnit() == StatsBarChartUnit.DAY)
+ return numPoints / 2;
+ else
+ return numPoints / 3;
+ }
+
+ private int getViews(Cursor cursor) {
+ return cursor.getInt(cursor.getColumnIndex(StatsBarChartDataTable.Columns.VIEWS));
+ }
+
+ private int getVisitors(Cursor cursor) {
+ return cursor.getInt(cursor.getColumnIndex(StatsBarChartDataTable.Columns.VISITORS));
+ }
+
+ private String getDateLabel(Cursor cursor, StatsBarChartUnit unit) {
+ String cursorDate = StringUtils.notNullStr(cursor.getString(cursor.getColumnIndex(StatsBarChartDataTable.Columns.DATE)));
+
+ switch (unit) {
+ case DAY:
+ return StatUtils.parseDate(cursorDate, "yyyy-MM-dd", "MMM d");
+ case WEEK:
+ // first four digits are the year
+ // followed by Wxx where xx is the month
+ // followed by Wxx where xx is the day of the month
+ // ex: 2013W07W22 = July 22, 2013
+ return StatUtils.parseDate(cursorDate, "yyyy'W'MM'W'dd", "MMM d");
+ case MONTH:
+ return StatUtils.parseDate(cursorDate, "yyyy-MM", "MMM yyyy");
+ default:
+ return cursorDate;
+ }
+ }
+
+ class BarGraphContentObserver extends ContentObserver {
+ public BarGraphContentObserver(Handler handler) {
+ super(handler);
+ }
+
+ @Override
+ public void onChange(boolean selfChange) {
+ if (isAdded()) {
+ getLoaderManager().restartLoader(0, null, StatsBarGraphFragment.this);
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsClicksFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsClicksFragment.java
new file mode 100644
index 000000000..45d838501
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsClicksFragment.java
@@ -0,0 +1,137 @@
+package org.wordpress.android.ui.stats;
+
+import android.app.Fragment;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CursorTreeAdapter;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.StatsReferrerGroupsTable;
+import org.wordpress.android.datasets.StatsReferrersTable;
+import org.wordpress.android.providers.StatsContentProvider;
+import org.wordpress.android.util.FormatUtils;
+
+/**
+ * Fragment for click stats. Has two pages, for Today's and Yesterday's stats.
+ * Clicks contain expandable lists.
+ */
+public class StatsClicksFragment extends StatsAbsPagedViewFragment {
+ private static final Uri STATS_CLICK_GROUP_URI = StatsContentProvider.STATS_CLICK_GROUP_URI;
+ private static final Uri STATS_CLICKS_URI = StatsContentProvider.STATS_CLICKS_URI;
+
+ private static final StatsTimeframe[] TIMEFRAMES = new StatsTimeframe[] { StatsTimeframe.TODAY, StatsTimeframe.YESTERDAY };
+
+ public static final String TAG = StatsClicksFragment.class.getSimpleName();
+
+ @Override
+ protected String[] getTabTitles() {
+ return StatsTimeframe.toStringArray(TIMEFRAMES);
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.stats_view_clicks);
+ }
+
+ @Override
+ protected int getInnerFragmentID() {
+ return R.id.stats_clicks;
+ }
+
+ @Override
+ protected Fragment getFragment(int position) {
+ Uri groupUri = Uri.parse(STATS_CLICK_GROUP_URI.toString() + "?timeframe=" + TIMEFRAMES[position].name());
+ Uri childrenUri = STATS_CLICKS_URI;
+
+ StatsCursorTreeFragment fragment = StatsCursorTreeFragment.newInstance(groupUri, childrenUri,
+ R.string.stats_entry_clicks_url, R.string.stats_totals_clicks, R.string.stats_empty_clicks_title,
+ R.string.stats_empty_clicks_desc);
+ CustomAdapter adapter = new CustomAdapter(null, getActivity());
+ adapter.setCursorLoaderCallback(fragment);
+ fragment.setListAdapter(adapter);
+ fragment.setCallback(this);
+ return fragment;
+ }
+
+ public class CustomAdapter extends CursorTreeAdapter {
+ private StatsCursorLoaderCallback mCallback;
+ private final LayoutInflater inflater;
+
+ public CustomAdapter(Cursor cursor, Context context) {
+ super(cursor, context, true);
+ inflater = LayoutInflater.from(context);
+ }
+
+ public void setCursorLoaderCallback(StatsCursorLoaderCallback callback) {
+ mCallback = callback;
+ }
+
+ @Override
+ protected View newChildView(Context context, Cursor cursor, boolean isLastChild, ViewGroup parent) {
+ View view = inflater.inflate(R.layout.stats_list_cell, parent, false);
+ view.setTag(new StatsViewHolder(view));
+ return view;
+ }
+
+ @Override
+ protected void bindChildView(View view, Context context, Cursor cursor, boolean isLastChild) {
+ final StatsViewHolder holder = (StatsViewHolder)view.getTag();
+
+ String name = cursor.getString(cursor.getColumnIndex(StatsReferrersTable.Columns.NAME));
+ int total = cursor.getInt(cursor.getColumnIndex(StatsReferrersTable.Columns.TOTAL));
+
+ // name, url
+ holder.setEntryTextOrLink(name, name);
+
+ // totals
+ holder.totalsTextView.setText(FormatUtils.formatDecimal(total));
+
+ // no icon, make it invisible so children are indented
+ holder.networkImageView.setVisibility(View.INVISIBLE);
+ }
+
+ @Override
+ protected View newGroupView(Context context, Cursor cursor, boolean isExpanded, ViewGroup parent) {
+ View view = inflater.inflate(R.layout.stats_list_cell, parent, false);
+ view.setTag(new StatsViewHolder(view));
+ return view;
+ }
+
+ @Override
+ protected void bindGroupView(View view, Context context, Cursor cursor, boolean isExpanded) {
+ final StatsViewHolder holder = (StatsViewHolder) view.getTag();
+
+ String name = cursor.getString(cursor.getColumnIndex(StatsReferrerGroupsTable.Columns.NAME));
+ int total = cursor.getInt(cursor.getColumnIndex(StatsReferrerGroupsTable.Columns.TOTAL));
+ String url = cursor.getString(cursor.getColumnIndex(StatsReferrerGroupsTable.Columns.URL));
+ String icon = cursor.getString(cursor.getColumnIndex(StatsReferrerGroupsTable.Columns.ICON));
+ int children = cursor.getInt(cursor.getColumnIndex(StatsReferrerGroupsTable.Columns.CHILDREN));
+
+ // name, url
+ holder.setEntryTextOrLink(url, name);
+
+ // totals
+ holder.totalsTextView.setText(FormatUtils.formatDecimal(total));
+
+ // icon
+ holder.showNetworkImage(icon);
+
+ // expand/collapse chevron
+ holder.chevronImageView.setVisibility(children > 0 ? View.VISIBLE : View.GONE);
+ }
+
+ @Override
+ protected Cursor getChildrenCursor(Cursor groupCursor) {
+ Bundle bundle = new Bundle();
+ bundle.putLong(StatsCursorLoaderCallback.BUNDLE_DATE, groupCursor.getLong(groupCursor.getColumnIndex("date")));
+ bundle.putString(StatsCursorLoaderCallback.BUNDLE_GROUP_ID, groupCursor.getString(groupCursor.getColumnIndex("groupId")));
+ mCallback.onUriRequested(groupCursor.getPosition(), STATS_CLICKS_URI, bundle);
+ return null;
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCommentsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCommentsFragment.java
new file mode 100644
index 000000000..5f7bd4839
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCommentsFragment.java
@@ -0,0 +1,199 @@
+package org.wordpress.android.ui.stats;
+
+import android.app.Fragment;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CursorAdapter;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.StatsMostCommentedTable;
+import org.wordpress.android.datasets.StatsTopCommentersTable;
+import org.wordpress.android.models.StatsSummary;
+import org.wordpress.android.providers.StatsContentProvider;
+import org.wordpress.android.util.FormatUtils;
+import org.wordpress.android.util.StatUtils;
+
+/**
+ * Fragment for comments stats. Has three pages, for Most Commented, for Top Commenters, and for Comments Summary
+ */
+public class StatsCommentsFragment extends StatsAbsPagedViewFragment {
+ private static final Uri STATS_MOST_COMMENTED_URI = StatsContentProvider.STATS_MOST_COMMENTED_URI;
+ private static final Uri STATS_TOP_COMMENTERS_URI = StatsContentProvider.STATS_TOP_COMMENTERS_URI;
+
+ public static final String TAG = StatsCommentsFragment.class.getSimpleName();
+
+ private static final String[] TITLES = new String[] { "Top Recent Commenters", "Most Commented", "Summary" };
+
+ private static final int TOP_COMMENTERS = 0;
+ private static final int MOST_COMMENTED = 1;
+
+ @Override
+ protected Fragment getFragment(int position) {
+ if (position == 0) {
+ StatsCursorFragment fragment = StatsCursorFragment.newInstance(STATS_TOP_COMMENTERS_URI,
+ R.string.stats_entry_top_commenter, R.string.stats_totals_comments, R.string.stats_empty_comments);
+ fragment.setListAdapter(new CustomCursorAdapter(getActivity(), null, TOP_COMMENTERS));
+ fragment.setCallback(this);
+ return fragment;
+ } else if (position == 1) {
+ int entryLabelResId = R.string.stats_entry_most_commented;
+ int totalsLabelResId = R.string.stats_totals_comments;
+ StatsCursorFragment fragment = StatsCursorFragment.newInstance(STATS_MOST_COMMENTED_URI,
+ R.string.stats_entry_most_commented, R.string.stats_totals_comments, R.string.stats_empty_comments);
+ fragment.setListAdapter(new CustomCursorAdapter(getActivity(), null, MOST_COMMENTED));
+ fragment.setCallback(this);
+ return fragment;
+ } else {
+ return new CommentsSummaryFragment();
+ }
+ }
+
+ public class CustomCursorAdapter extends CursorAdapter {
+ private final LayoutInflater inflater;
+ private final int mType;
+
+ public CustomCursorAdapter(Context context, Cursor c, int type) {
+ super(context, c, true);
+ mType = type;
+ inflater = LayoutInflater.from(context);
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup root) {
+ View view = inflater.inflate(R.layout.stats_list_cell, root, false);
+ view.setTag(new StatsViewHolder(view));
+ return view;
+ }
+
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ final StatsViewHolder holder = (StatsViewHolder) view.getTag();
+
+ final String entry;
+ final int total;
+ if (mType == TOP_COMMENTERS) {
+ entry = cursor.getString(cursor.getColumnIndex(StatsTopCommentersTable.Columns.NAME));
+ total = cursor.getInt(cursor.getColumnIndex(StatsTopCommentersTable.Columns.COMMENTS));
+ } else {
+ entry = cursor.getString(cursor.getColumnIndex(StatsMostCommentedTable.Columns.POST));
+ total = cursor.getInt(cursor.getColumnIndex(StatsMostCommentedTable.Columns.COMMENTS));
+ }
+
+ holder.entryTextView.setText(entry);
+ holder.totalsTextView.setText(FormatUtils.formatDecimal(total));
+
+ // image
+ if (mType == TOP_COMMENTERS) {
+ String imageUrl = cursor.getString(cursor.getColumnIndex(StatsTopCommentersTable.Columns.IMAGE_URL));
+ holder.networkImageView.setVisibility(View.VISIBLE);
+ holder.showNetworkImage(imageUrl);
+ } else {
+ holder.networkImageView.setVisibility(View.GONE);
+ }
+ }
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.stats_view_comments);
+ }
+
+ @Override
+ protected String[] getTabTitles() {
+ return TITLES;
+ }
+
+ @Override
+ protected int getInnerFragmentID() {
+ return R.id.stats_comments;
+ }
+
+ /** Fragment used for summary view **/
+ public static class CommentsSummaryFragment extends Fragment {
+ private TextView mPerMonthText;
+ private TextView mTotalText;
+ private TextView mActiveDayText;
+ private TextView mActiveTimeText;
+ private TextView mMostCommentedText;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.stats_comments_summary, container, false);
+
+ mPerMonthText = (TextView) view.findViewById(R.id.stats_comments_summary_per_month_count);
+ mTotalText = (TextView) view.findViewById(R.id.stats_comments_summary_total_count);
+ mActiveDayText = (TextView) view.findViewById(R.id.stats_comments_summary_most_active_day_text);
+ mActiveTimeText = (TextView) view.findViewById(R.id.stats_comments_summary_most_active_time_text);
+ mMostCommentedText = (TextView) view.findViewById(R.id.stats_comments_summary_most_commented_text);
+
+ return view;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ refreshStatsFromFile();
+ }
+
+ private void refreshStatsFromFile() {
+ if (WordPress.getCurrentBlog() == null)
+ return;
+
+ final String blogId = String.valueOf(WordPress.getCurrentBlog().getRemoteBlogId());
+ new AsyncTask<Void, Void, StatsSummary>() {
+ @Override
+ protected StatsSummary doInBackground(Void... params) {
+ //StatsRestHelper.getStatsSummary(blogId);
+ return StatUtils.getSummary(blogId);
+ }
+
+ protected void onPostExecute(final StatsSummary result) {
+ if (getActivity() == null)
+ return;
+ getActivity().runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ refreshStats(result);
+ }
+ });
+ }
+ }.execute();
+ }
+
+ private void refreshStats(StatsSummary stats) {
+ int perMonth = 0;
+ int total = 0;
+ String activeDay = "";
+ String activeTime = "";
+ String activePost = "";
+ String activePostUrl = "";
+
+ if (stats != null) {
+ perMonth = stats.getCommentsPerMonth();
+ total = stats.getCommentsAllTime();
+ activeDay = stats.getCommentsMostActiveRecentDay();
+ activeTime = stats.getCommentsMostActiveTime();
+// activePost = result.getRecentMostActivePost(); // TODO
+// activePostUrl = result.getRecentMostActivePostUrl(); // TODO
+ }
+
+
+ mPerMonthText.setText(FormatUtils.formatDecimal(perMonth));
+ mTotalText.setText(FormatUtils.formatDecimal(total));
+ mActiveDayText.setText(activeDay);
+ mActiveTimeText.setText(activeTime);
+
+ // StatUtils.setEntryTextOrLink(mMostCommentedText, activePostUrl, activePost);
+ }
+
+
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCursorFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCursorFragment.java
new file mode 100644
index 000000000..20d5b0d27
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCursorFragment.java
@@ -0,0 +1,239 @@
+package org.wordpress.android.ui.stats;
+
+import android.app.Fragment;
+import android.app.LoaderManager;
+import android.content.CursorLoader;
+import android.content.Loader;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.graphics.Color;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.text.Html;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CursorAdapter;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.AppLog;
+
+/**
+ * A fragment that appears as a 'page' in the {@link StatsAbsPagedViewFragment}.
+ * The fragment has a {@link ContentObserver} to listen for changes in the supplied URIs.
+ * By implementing {@link LoaderCallbacks}, it asynchronously fetches new data to update itself.
+ * <p>
+ * This fragment appears as a linearlayout, with a maximum of 10 entries.
+ * A linearlayout is necessary because a listview cannot be placed inside the scrollview of the root layout.
+ * The linearlayout also gets its views from the CursorAdapter.
+ * </p>
+ */
+public class StatsCursorFragment extends Fragment implements LoaderManager.LoaderCallbacks<Cursor> {
+ private static final String ARGS_URI = "ARGS_URI";
+ private static final String ARGS_ENTRY_LABEL = "ARGS_ENTRY_LABEL";
+ private static final String ARGS_TOTALS_LABEL = "ARGS_TOTALS_LABEL";
+ private static final String ARGS_EMPTY_LABEL_TITLE = "ARGS_EMPTY_LABEL_TITLE";
+ private static final String ARGS_EMPTY_LABEL_DESC = "ARGS_EMPTY_LABEL_DESC";
+ private static final int NO_STRING_ID = -1;
+
+ public static final String TAG = StatsCursorFragment.class.getSimpleName();
+
+ private TextView mEmptyLabel;
+ private LinearLayout mLinearLayout;
+
+ private CursorAdapter mAdapter;
+ private final ContentObserver mContentObserver = new MyObserver(new Handler());
+
+ private StatsCursorInterface mCallback;
+
+ public static StatsCursorFragment newInstance(Uri uri, int entryLabelResId, int totalsLabelResId,
+ int emptyLabelTitleResId) {
+ return newInstance(uri, entryLabelResId, totalsLabelResId, emptyLabelTitleResId, NO_STRING_ID);
+ }
+
+ public static StatsCursorFragment newInstance(Uri uri, int entryLabelResId, int totalsLabelResId,
+ int emptyLabelTitleResId, int emptyLabelDescResId) {
+ StatsCursorFragment fragment = new StatsCursorFragment();
+
+ Bundle args = new Bundle();
+ args.putString(ARGS_URI, uri.toString());
+ args.putInt(ARGS_ENTRY_LABEL, entryLabelResId);
+ args.putInt(ARGS_TOTALS_LABEL, totalsLabelResId);
+ args.putInt(ARGS_EMPTY_LABEL_TITLE, emptyLabelTitleResId);
+ args.putInt(ARGS_EMPTY_LABEL_DESC, emptyLabelDescResId);
+ fragment.setArguments(args);
+
+ return fragment;
+ }
+
+ private Uri getUri() {
+ return Uri.parse(getArguments().getString(ARGS_URI));
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.stats_list_fragment, container, false);
+
+ TextView entryLabel = (TextView) view.findViewById(R.id.stats_list_entry_label);
+ entryLabel.setText(getEntryLabelResId());
+ TextView totalsLabel = (TextView) view.findViewById(R.id.stats_list_totals_label);
+ totalsLabel.setText(getTotalsLabelResId());
+ mEmptyLabel = (TextView) view.findViewById(R.id.stats_list_empty_text);
+
+ String label;
+ if (getEmptyLabelDescResId() == NO_STRING_ID) {
+ label = "<b>" + getString(getEmptyLabelTitleResId()) + "</b>";
+ } else {
+ label = "<b>" + getString(getEmptyLabelTitleResId()) + "</b> " + getString(getEmptyLabelDescResId());
+ }
+ if (label.contains("<")) {
+ mEmptyLabel.setText(Html.fromHtml(label));
+ } else {
+ mEmptyLabel.setText(label);
+ }
+ configureEmptyLabel();
+
+ mLinearLayout = (LinearLayout) view.findViewById(R.id.stats_list_linearlayout);
+ mLinearLayout.setVisibility(View.VISIBLE);
+
+ return view;
+ }
+
+ private int getEntryLabelResId() {
+ return getArguments().getInt(ARGS_ENTRY_LABEL);
+ }
+
+ private int getTotalsLabelResId() {
+ return getArguments().getInt(ARGS_TOTALS_LABEL);
+ }
+
+ private int getEmptyLabelTitleResId() {
+ return getArguments().getInt(ARGS_EMPTY_LABEL_TITLE);
+ }
+
+ private int getEmptyLabelDescResId() {
+ return getArguments().getInt(ARGS_EMPTY_LABEL_DESC);
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ getLoaderManager().restartLoader(0, null, this);
+ }
+
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ if (WordPress.getCurrentBlog() == null)
+ return null;
+
+ String blogId = WordPress.getCurrentBlog().getDotComBlogId();
+ if (TextUtils.isEmpty(blogId)) blogId = "0";
+ return new CursorLoader(getActivity(), getUri(), null, "blogId=?", new String[] { blogId }, null);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
+ if (mCallback != null) {
+ mCallback.onCursorLoaded(getUri(), data);
+ } else {
+ AppLog.e(AppLog.T.STATS, "mCallback is null");
+ }
+
+ if (mAdapter != null)
+ mAdapter.changeCursor(data);
+ configureEmptyLabel();
+ reloadLinearLayout();
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ if (mAdapter != null)
+ mAdapter.changeCursor(null);
+ configureEmptyLabel();
+ reloadLinearLayout();
+ }
+
+ public void setCallback(StatsCursorInterface callback) {
+ mCallback = callback;
+ }
+
+ public void setListAdapter(CursorAdapter adapter) {
+ mAdapter = adapter;
+ reloadLinearLayout();
+ }
+
+ private void reloadLinearLayout() {
+ if (getActivity() == null || mLinearLayout == null || mAdapter == null)
+ return;
+
+ // limit number of items to show otherwise it would cause performance issues on the LinearLayout
+ int count = Math.min(mAdapter.getCount(), StatsActivity.STATS_GROUP_MAX_ITEMS);
+
+ if (count == 0) {
+ mLinearLayout.removeAllViews();
+ return;
+ }
+
+ int numExistingViews = mLinearLayout.getChildCount();
+ int altRowColor = getResources().getColor(R.color.stats_alt_row);
+
+ // remove excess views
+ if (count < numExistingViews) {
+ int numToRemove = numExistingViews - count;
+ mLinearLayout.removeViews(count, numToRemove);
+ numExistingViews = count;
+ }
+
+ for (int i = 0; i < count; i++) {
+ int bgColor = (i % 2 == 1 ? altRowColor : Color.TRANSPARENT);
+ final View view;
+ // reuse existing view when possible
+ if (i < numExistingViews) {
+ View convertView = mLinearLayout.getChildAt(i);
+ view = mAdapter.getView(i, convertView, mLinearLayout);
+ view.setBackgroundColor(bgColor);
+ } else {
+ view = mAdapter.getView(i, null, mLinearLayout);
+ view.setBackgroundColor(bgColor);
+ mLinearLayout.addView(view);
+ }
+ }
+ mLinearLayout.invalidate();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ getActivity().getContentResolver().registerContentObserver(getUri(), true, mContentObserver);
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ getActivity().getContentResolver().unregisterContentObserver(mContentObserver);
+ }
+
+ class MyObserver extends ContentObserver {
+ public MyObserver(Handler handler) {
+ super(handler);
+ }
+
+ @Override
+ public void onChange(boolean selfChange) {
+ if (isAdded())
+ getLoaderManager().restartLoader(0, null, StatsCursorFragment.this);
+ }
+ }
+
+ private void configureEmptyLabel() {
+ if (mAdapter == null || mAdapter.getCount() == 0)
+ mEmptyLabel.setVisibility(View.VISIBLE);
+ else
+ mEmptyLabel.setVisibility(View.GONE);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCursorInterface.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCursorInterface.java
new file mode 100644
index 000000000..297a74936
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCursorInterface.java
@@ -0,0 +1,12 @@
+package org.wordpress.android.ui.stats;
+
+import android.database.Cursor;
+import android.net.Uri;
+
+/**
+ * An interface to call when the cursor has been loaded.
+ * Used so that the {@link StatsAbsPagedViewFragment} can update its titles
+ */
+interface StatsCursorInterface {
+ public void onCursorLoaded(Uri uri, Cursor cursor);
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCursorLoaderCallback.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCursorLoaderCallback.java
new file mode 100644
index 000000000..765360741
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCursorLoaderCallback.java
@@ -0,0 +1,16 @@
+package org.wordpress.android.ui.stats;
+
+import android.net.Uri;
+import android.os.Bundle;
+import android.widget.CursorTreeAdapter;
+
+/**
+ * An interface used by {@link CursorTreeAdapter} subclasses
+ * to communicate with the {@link StatsCursorTreeFragment}
+ */
+interface StatsCursorLoaderCallback {
+ static final String BUNDLE_DATE = "BUNDLE_DATE";
+ static final String BUNDLE_GROUP_ID = "BUNDLE_GROUP_ID";
+
+ public void onUriRequested(int id, Uri uri, Bundle bundle);
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCursorTreeFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCursorTreeFragment.java
new file mode 100644
index 000000000..33ccfca59
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCursorTreeFragment.java
@@ -0,0 +1,442 @@
+package org.wordpress.android.ui.stats;
+
+import android.app.Fragment;
+import android.app.LoaderManager;
+import android.content.CursorLoader;
+import android.content.Loader;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.graphics.Color;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.text.Html;
+import android.text.TextUtils;
+import android.util.SparseBooleanArray;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.view.animation.AccelerateInterpolator;
+import android.view.animation.Animation;
+import android.view.animation.Interpolator;
+import android.view.animation.RotateAnimation;
+import android.view.animation.ScaleAnimation;
+import android.widget.CursorTreeAdapter;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.AppLog;
+
+/**
+ * A fragment that appears as a 'page' in the {@link StatsAbsPagedViewFragment}. Similar to {@link StatsCursorFragment},
+ * except it is used for stats that have expandable groups, such as Referrers or Clicks.
+ * <p>
+ * The fragment has a {@link ContentObserver} to listen for changes in the supplied group URIs.
+ * By implementing {@link LoaderCallbacks}, it asynchronously fetches new data to update itself.
+ * It then restarts loaders on the children URI for each group id, which results in the children views being updated.
+ * </p>
+ * <p>
+ * This fragment appears as a linearlayout, with a maximum of 10 entries.
+ * A linearlayout is necessary because a listview cannot be placed inside the scrollview of the root layout.
+ * The linearlayout also gets its group and children views from the CursorTreeAdapter.
+ * </p>
+ */
+public class StatsCursorTreeFragment extends Fragment
+ implements LoaderManager.LoaderCallbacks<Cursor>, StatsCursorLoaderCallback {
+ private static final int LOADER_URI_GROUP_INDEX = -1;
+ private static final String ARGS_GROUP_URI = "ARGS_GROUP_URI";
+ private static final String ARGS_CHILDREN_URI = "ARGS_CHILDREN_URI";
+ private static final String ARGS_ENTRY_LABEL = "ARGS_ENTRY_LABEL";
+ private static final String ARGS_TOTALS_LABEL = "ARGS_TOTALS_LABEL";
+ private static final String ARGS_EMPTY_LABEL_TITLE = "ARGS_EMPTY_LABEL_TITLE";
+ private static final String ARGS_EMPTY_LABEL_DESC = "ARGS_EMPTY_LABEL_DESC";
+
+ public static final String TAG = StatsCursorTreeFragment.class.getSimpleName();
+
+ private TextView mEmptyLabel;
+ private LinearLayout mLinearLayout;
+
+ private SparseBooleanArray mGroupIdToExpandedMap;
+
+ private CursorTreeAdapter mAdapter;
+ private final ContentObserver mContentObserver = new MyObserver(new Handler());
+
+ private StatsCursorInterface mCallback;
+
+ private static final int ANIM_DURATION = 150;
+
+ public static StatsCursorTreeFragment newInstance(Uri groupUri, Uri childrenUri, int entryLabelResId,
+ int totalsLabelResId, int emptyLabelTitleResId,
+ int emptyLabelDescResId) {
+ StatsCursorTreeFragment fragment = new StatsCursorTreeFragment();
+
+ Bundle args = new Bundle();
+ args.putString(ARGS_GROUP_URI, groupUri.toString());
+ args.putString(ARGS_CHILDREN_URI, childrenUri.toString());
+ args.putInt(ARGS_ENTRY_LABEL, entryLabelResId);
+ args.putInt(ARGS_TOTALS_LABEL, totalsLabelResId);
+ args.putInt(ARGS_EMPTY_LABEL_TITLE, emptyLabelTitleResId);
+ args.putInt(ARGS_EMPTY_LABEL_DESC, emptyLabelDescResId);
+ fragment.setArguments(args);
+
+ return fragment;
+ }
+
+ private Uri getGroupUri() {
+ return Uri.parse(getArguments().getString(ARGS_GROUP_URI));
+ }
+
+ private Uri getChildrenUri() {
+ return Uri.parse(getArguments().getString(ARGS_CHILDREN_URI));
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ mGroupIdToExpandedMap = new SparseBooleanArray();
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.stats_expandable_list_fragment, container, false);
+
+ TextView entryLabel = (TextView) view.findViewById(R.id.stats_list_entry_label);
+ entryLabel.setText(getEntryLabelResId());
+ TextView totalsLabel = (TextView) view.findViewById(R.id.stats_list_totals_label);
+ totalsLabel.setText(getTotalsLabelResId());
+
+ mEmptyLabel = (TextView) view.findViewById(R.id.stats_list_empty_text);
+ String label = "<b>" + getString(getEmptyLabelTitleResId()) + "</b> " + getString(getEmptyLabelDescResId());
+ if (label.contains("<")) {
+ mEmptyLabel.setText(Html.fromHtml(label));
+ } else {
+ mEmptyLabel.setText(label);
+ }
+ configureEmptyLabel();
+
+ mLinearLayout = (LinearLayout) view.findViewById(R.id.stats_list_linearlayout);
+ mLinearLayout.setVisibility(View.VISIBLE);
+
+ return view;
+ }
+
+ private int getEntryLabelResId() {
+ return getArguments().getInt(ARGS_ENTRY_LABEL);
+ }
+
+ private int getTotalsLabelResId() {
+ return getArguments().getInt(ARGS_TOTALS_LABEL);
+ }
+
+ private int getEmptyLabelTitleResId() {
+ return getArguments().getInt(ARGS_EMPTY_LABEL_TITLE);
+ }
+
+ private int getEmptyLabelDescResId() {
+ return getArguments().getInt(ARGS_EMPTY_LABEL_DESC);
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ getLoaderManager().restartLoader(LOADER_URI_GROUP_INDEX, null, this);
+ }
+
+ private int mNumChildLoaders = 0;
+
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ if (WordPress.getCurrentBlog() == null)
+ return null;
+
+ String blogId = WordPress.getCurrentBlog().getDotComBlogId();
+ if (TextUtils.isEmpty(blogId))
+ blogId = "0";
+
+ Uri uri = getGroupUri();
+
+ if (id == LOADER_URI_GROUP_INDEX) {
+ return new CursorLoader(getActivity(), uri, null, "blogId=?", new String[] { blogId }, null);
+ } else {
+ mNumChildLoaders++;
+ uri = getChildrenUri();
+ String groupId = args.getString(StatsCursorLoaderCallback.BUNDLE_GROUP_ID);
+ long date = args.getLong(StatsCursorLoaderCallback.BUNDLE_DATE);
+ return new CursorLoader(getActivity(), uri, null, "blogId=? AND groupId=? AND date=?", new String[] { blogId, groupId, date + "" }, null);
+ }
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
+ // cursor is for groups
+ boolean isGroupLoader = (loader.getId() == LOADER_URI_GROUP_INDEX);
+ if (isGroupLoader) {
+ // start loaders on children
+ while (data.moveToNext()) {
+ String groupId = data.getString(data.getColumnIndex("groupId"));
+ long date = data.getLong(data.getColumnIndex("date"));
+
+ Bundle bundle = new Bundle();
+ bundle.putString(StatsCursorLoaderCallback.BUNDLE_GROUP_ID, groupId);
+ bundle.putLong(StatsCursorLoaderCallback.BUNDLE_DATE, date);
+
+ getLoaderManager().restartLoader(data.getPosition(), bundle, StatsCursorTreeFragment.this);
+ }
+
+ if (mCallback != null) {
+ mCallback.onCursorLoaded(getGroupUri(), data);
+ } else {
+ AppLog.e(AppLog.T.STATS, "mCallback is null");
+ }
+
+ if (mAdapter != null)
+ mAdapter.changeCursor(data);
+ } else {
+ // cursor is for children
+ if (mNumChildLoaders > 0)
+ mNumChildLoaders--;
+ if (mAdapter != null) {
+ // due to a race condition that occurs when stats are refreshed,
+ // it is possible to have more rows in the listview initially than when done refreshing,
+ // causing null pointer exceptions to occur.
+ try {
+ mAdapter.setChildrenCursor(loader.getId(), data);
+ } catch (NullPointerException e) {
+ // do nothing
+ }
+ }
+ }
+
+ // refresh views if this was a group loader, or if all child loaders have completed
+ if (isGroupLoader || mNumChildLoaders == 0) {
+ configureEmptyLabel();
+ reloadGroupViews();
+ }
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ mGroupIdToExpandedMap.clear();
+ mNumChildLoaders = 0;
+
+ if (mAdapter != null)
+ mAdapter.changeCursor(null);
+ configureEmptyLabel();
+ reloadGroupViews();
+ }
+
+ public void setCallback(StatsCursorInterface callback) {
+ mCallback = callback;
+ }
+
+ public void setListAdapter(CursorTreeAdapter adapter) {
+ mAdapter = adapter;
+ reloadGroupViews();
+ }
+
+ /*
+ * interpolator for all expand/collapse animations
+ */
+ private Interpolator getInterpolator() {
+ return new AccelerateInterpolator();
+ }
+
+ private void reloadGroupViews() {
+ if (getActivity() == null || mLinearLayout == null || mAdapter == null)
+ return;
+
+ int groupCount = Math.min(mAdapter.getGroupCount(), StatsActivity.STATS_GROUP_MAX_ITEMS);
+ if (groupCount == 0) {
+ mLinearLayout.removeAllViews();
+ return;
+ }
+
+ int numExistingGroupViews = mLinearLayout.getChildCount();
+ int altRowColor = getResources().getColor(R.color.stats_alt_row);
+
+ // remove excess views
+ if (groupCount < numExistingGroupViews) {
+ int numToRemove = numExistingGroupViews - groupCount;
+ mLinearLayout.removeViews(groupCount, numToRemove);
+ numExistingGroupViews = groupCount;
+ }
+
+ // add each group
+ for (int i = 0; i < groupCount; i++) {
+ boolean isExpanded = mGroupIdToExpandedMap.get(i);
+ int bgColor = (i % 2 == 1 ? altRowColor : Color.TRANSPARENT);
+
+ // reuse existing view when possible
+ final View groupView;
+ if (i < numExistingGroupViews) {
+ View convertView = mLinearLayout.getChildAt(i);
+ groupView = mAdapter.getGroupView(i, isExpanded, convertView, mLinearLayout);
+ groupView.setBackgroundColor(bgColor);
+ } else {
+ groupView = mAdapter.getGroupView(i, isExpanded, null, mLinearLayout);
+ groupView.setBackgroundColor(bgColor);
+ mLinearLayout.addView(groupView);
+ }
+
+ // add children if this group is expanded
+ if (isExpanded) {
+ showChildViews(i, groupView, false);
+ }
+
+ // toggle expand/collapse when group view is tapped
+ final int groupPosition = i;
+ groupView.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (mAdapter.getChildrenCount(groupPosition) == 0)
+ return;
+ boolean shouldExpand = !mGroupIdToExpandedMap.get(groupPosition);
+ mGroupIdToExpandedMap.put(groupPosition, shouldExpand);
+ if (shouldExpand) {
+ showChildViews(groupPosition, groupView, true);
+ } else {
+ hideChildViews(groupView, true);
+ }
+ }
+ });
+ }
+ }
+
+ private void showChildViews(int groupPosition, View groupView, boolean animate) {
+ int childCount = Math.min(mAdapter.getChildrenCount(groupPosition), StatsActivity.STATS_CHILD_MAX_ITEMS);
+ if (childCount == 0)
+ return;
+
+ final ViewGroup childContainer = (ViewGroup) groupView.findViewById(R.id.layout_child_container);
+ if (childContainer == null)
+ return;
+
+ int numExistingViews = childContainer.getChildCount();
+ if (childCount < numExistingViews) {
+ int numToRemove = numExistingViews - childCount;
+ childContainer.removeViews(childCount, numToRemove);
+ numExistingViews = childCount;
+ }
+
+ for (int i = 0; i < childCount; i++) {
+ boolean isLastChild = (i == childCount - 1);
+ if (i < numExistingViews) {
+ View convertView = childContainer.getChildAt(i);
+ mAdapter.getChildView(groupPosition, i, isLastChild, convertView, mLinearLayout);
+ } else {
+ View childView = mAdapter.getChildView(groupPosition, i, isLastChild, null, mLinearLayout);
+ // remove the right padding so the child total aligns with the group total
+ childView.setPadding(childView.getPaddingLeft(),
+ childView.getPaddingTop(),
+ 0,
+ childView.getPaddingBottom());
+ childContainer.addView(childView);
+ }
+ }
+
+ if (childContainer.getVisibility() != View.VISIBLE) {
+ if (animate) {
+ Animation expand = new ScaleAnimation(1.0f, 1.0f, 0.0f, 1.0f);
+ expand.setDuration(ANIM_DURATION);
+ expand.setInterpolator(getInterpolator());
+ childContainer.startAnimation(expand);
+ }
+ childContainer.setVisibility(View.VISIBLE);
+ }
+
+ setGroupChevron(true, groupView, animate);
+ }
+
+ private void hideChildViews(View groupView, boolean animate) {
+ final ViewGroup childContainer = (ViewGroup) groupView.findViewById(R.id.layout_child_container);
+ if (childContainer == null)
+ return;
+ if (childContainer.getVisibility() != View.GONE) {
+ if (animate) {
+ Animation expand = new ScaleAnimation(1.0f, 1.0f, 1.0f, 0.0f);
+ expand.setDuration(ANIM_DURATION);
+ expand.setInterpolator(getInterpolator());
+ expand.setAnimationListener(new Animation.AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation animation) { }
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ childContainer.setVisibility(View.GONE);
+ }
+ @Override
+ public void onAnimationRepeat(Animation animation) { }
+ });
+ childContainer.startAnimation(expand);
+ } else {
+ childContainer.setVisibility(View.GONE);
+ }
+ }
+ setGroupChevron(false, groupView, animate);
+ }
+
+ /*
+ * shows the correct up/down chevron for the passed group
+ */
+ private void setGroupChevron(final boolean isGroupExpanded, View groupView, boolean animate) {
+ final ImageView chevron = (ImageView) groupView.findViewById(R.id.stats_list_cell_chevron);
+ if (chevron == null)
+ return;
+
+ if (animate) {
+ // make sure we start with the correct chevron for the prior state before animating it
+ chevron.setImageResource(isGroupExpanded ? R.drawable.stats_chevron_right : R.drawable.stats_chevron_down);
+ float start = (isGroupExpanded ? 0.0f : 0.0f);
+ float end = (isGroupExpanded ? 90.0f : -90.0f);
+ Animation rotate = new RotateAnimation(start, end, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f);
+ rotate.setDuration(ANIM_DURATION);
+ rotate.setInterpolator(getInterpolator());
+ rotate.setFillAfter(true);
+ chevron.startAnimation(rotate);
+ } else {
+ chevron.setImageResource(isGroupExpanded ? R.drawable.stats_chevron_down : R.drawable.stats_chevron_right);
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ getActivity().getContentResolver().registerContentObserver(getGroupUri(), true, mContentObserver);
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ getActivity().getContentResolver().unregisterContentObserver(mContentObserver);
+ }
+
+ class MyObserver extends ContentObserver {
+ public MyObserver(Handler handler) {
+ super(handler);
+ }
+
+ @Override
+ public void onChange(boolean selfChange) {
+ if (isAdded())
+ getLoaderManager().restartLoader(LOADER_URI_GROUP_INDEX, null, StatsCursorTreeFragment.this);
+ }
+ }
+
+ private void configureEmptyLabel() {
+ if (mAdapter == null || mAdapter.getGroupCount() == 0)
+ mEmptyLabel.setVisibility(View.VISIBLE);
+ else
+ mEmptyLabel.setVisibility(View.GONE);
+ }
+
+ @Override
+ public void onUriRequested(int id, Uri uri, Bundle bundle) {
+ if (isAdded() && uri.equals(getChildrenUri())) {
+ getLoaderManager().restartLoader(id, bundle, StatsCursorTreeFragment.this);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsGeoviewsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsGeoviewsFragment.java
new file mode 100644
index 000000000..354625409
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsGeoviewsFragment.java
@@ -0,0 +1,86 @@
+package org.wordpress.android.ui.stats;
+
+import android.app.Fragment;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CursorAdapter;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.StatsGeoviewsTable;
+import org.wordpress.android.providers.StatsContentProvider;
+import org.wordpress.android.util.FormatUtils;
+
+/**
+ * Fragment for geoview (views by country) stats. Has two pages, for Today's and Yesterday's stats.
+ */
+public class StatsGeoviewsFragment extends StatsAbsPagedViewFragment {
+ private static final Uri STATS_GEOVIEWS_URI = StatsContentProvider.STATS_GEOVIEWS_URI;
+
+ private static final StatsTimeframe[] TIMEFRAMES = new StatsTimeframe[] { StatsTimeframe.TODAY, StatsTimeframe.YESTERDAY };
+
+ public static final String TAG = StatsGeoviewsFragment.class.getSimpleName();
+
+ @Override
+ protected Fragment getFragment(int position) {
+ int entryLabelResId = R.string.stats_entry_country;
+ int totalsLabelResId = R.string.stats_totals_views;
+ int emptyLabelResId = R.string.stats_empty_geoviews;
+
+ Uri uri = Uri.parse(STATS_GEOVIEWS_URI.toString() + "?timeframe=" + TIMEFRAMES[position].name());
+
+ StatsCursorFragment fragment = StatsCursorFragment.newInstance(uri, entryLabelResId, totalsLabelResId, emptyLabelResId);
+ fragment.setListAdapter(new CustomCursorAdapter(getActivity(), null));
+ fragment.setCallback(this);
+ return fragment;
+ }
+
+ public static class CustomCursorAdapter extends CursorAdapter {
+ private final LayoutInflater inflater;
+
+ public CustomCursorAdapter(Context context, Cursor c) {
+ super(context, c, true);
+ inflater = LayoutInflater.from(context);
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup root) {
+ View view = inflater.inflate(R.layout.stats_list_cell, root, false);
+ view.setTag(new StatsViewHolder(view));
+ return view;
+ }
+
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ final StatsViewHolder holder = (StatsViewHolder) view.getTag();
+
+ String entry = cursor.getString(cursor.getColumnIndex(StatsGeoviewsTable.Columns.COUNTRY));
+ String imageUrl = cursor.getString(cursor.getColumnIndex(StatsGeoviewsTable.Columns.IMAGE_URL));
+ int total = cursor.getInt(cursor.getColumnIndex(StatsGeoviewsTable.Columns.VIEWS));
+
+ holder.entryTextView.setText(entry);
+ holder.totalsTextView.setText(FormatUtils.formatDecimal(total));
+
+ // image (country flag)
+ holder.showNetworkImage(imageUrl);
+ }
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.stats_view_views_by_country);
+ }
+
+ @Override
+ protected String[] getTabTitles() {
+ return StatsTimeframe.toStringArray(TIMEFRAMES);
+ }
+
+ @Override
+ protected int getInnerFragmentID() {
+ return R.id.stats_geoviews;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsReferrersFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsReferrersFragment.java
new file mode 100644
index 000000000..ef6ef2803
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsReferrersFragment.java
@@ -0,0 +1,136 @@
+package org.wordpress.android.ui.stats;
+
+import android.app.Fragment;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CursorTreeAdapter;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.StatsReferrerGroupsTable;
+import org.wordpress.android.datasets.StatsReferrersTable;
+import org.wordpress.android.providers.StatsContentProvider;
+import org.wordpress.android.util.FormatUtils;
+
+/**
+ * Fragment for referrer stats. Has two pages, for Today's and Yesterday's stats.
+ * Referrers contain expandable lists.
+ */
+public class StatsReferrersFragment extends StatsAbsPagedViewFragment {
+ private static final Uri STATS_REFERRER_GROUP_URI = StatsContentProvider.STATS_REFERRER_GROUP_URI;
+ private static final Uri STATS_REFERRERS_URI = StatsContentProvider.STATS_REFERRERS_URI;
+ private static final StatsTimeframe[] TIMEFRAMES = new StatsTimeframe[] { StatsTimeframe.TODAY, StatsTimeframe.YESTERDAY };
+
+ public static final String TAG = StatsReferrersFragment.class.getSimpleName();
+
+ @Override
+ protected Fragment getFragment(int position) {
+ Uri groupUri = Uri.parse(STATS_REFERRER_GROUP_URI.toString() + "?timeframe=" + TIMEFRAMES[position].name());
+ Uri childrenUri = STATS_REFERRERS_URI;
+
+ StatsCursorTreeFragment fragment = StatsCursorTreeFragment.newInstance(groupUri, childrenUri,
+ R.string.stats_entry_referrers, R.string.stats_totals_views, R.string.stats_empty_referrers_title,
+ R.string.stats_empty_referrers_desc);
+ CustomAdapter adapter = new CustomAdapter(null, getActivity());
+ adapter.setCursorLoaderCallback(fragment);
+ fragment.setListAdapter(adapter);
+ fragment.setCallback(this);
+ return fragment;
+ }
+
+
+ public class CustomAdapter extends CursorTreeAdapter {
+ private final LayoutInflater inflater;
+ private StatsCursorLoaderCallback mCallback;
+
+ public CustomAdapter(Cursor cursor, Context context) {
+ super(cursor, context, true);
+ inflater = LayoutInflater.from(context);
+ }
+
+ public void setCursorLoaderCallback(StatsCursorLoaderCallback callback) {
+ mCallback = callback;
+ }
+
+ @Override
+ protected View newChildView(Context context, Cursor cursor, boolean isLastChild, ViewGroup parent) {
+ View view = inflater.inflate(R.layout.stats_list_cell, parent, false);
+ view.setTag(new StatsViewHolder(view));
+ return view;
+ }
+
+ @Override
+ protected void bindChildView(View view, Context context, Cursor cursor, boolean isLastChild) {
+ final StatsViewHolder holder = (StatsViewHolder) view.getTag();
+
+ String name = cursor.getString(cursor.getColumnIndex(StatsReferrersTable.Columns.NAME));
+ int total = cursor.getInt(cursor.getColumnIndex(StatsReferrersTable.Columns.TOTAL));
+
+ // name, url
+ holder.setEntryTextOrLink(name, name);
+
+ // totals
+ holder.totalsTextView.setText(FormatUtils.formatDecimal(total));
+
+ // no icon, make it invisible so children are indented
+ holder.networkImageView.setVisibility(View.INVISIBLE);
+ }
+
+ @Override
+ protected View newGroupView(Context context, Cursor cursor, boolean isExpanded, ViewGroup parent) {
+ View view = inflater.inflate(R.layout.stats_list_cell, parent, false);
+ view.setTag(new StatsViewHolder(view));
+ return view;
+ }
+
+ @Override
+ protected void bindGroupView(View view, Context context, Cursor cursor, boolean isExpanded) {
+ final StatsViewHolder holder = (StatsViewHolder) view.getTag();
+
+ String name = cursor.getString(cursor.getColumnIndex(StatsReferrerGroupsTable.Columns.NAME));
+ int total = cursor.getInt(cursor.getColumnIndex(StatsReferrerGroupsTable.Columns.TOTAL));
+ String url = cursor.getString(cursor.getColumnIndex(StatsReferrerGroupsTable.Columns.URL));
+ String icon = cursor.getString(cursor.getColumnIndex(StatsReferrerGroupsTable.Columns.ICON));
+ int children = cursor.getInt(cursor.getColumnIndex(StatsReferrerGroupsTable.Columns.CHILDREN));
+
+ holder.setEntryTextOrLink(url, name);
+
+ // totals
+ holder.totalsTextView.setText(FormatUtils.formatDecimal(total));
+
+ // icon
+ holder.showNetworkImage(icon);
+
+ // expand/collapse chevron
+ holder.chevronImageView.setVisibility(children > 0 ? View.VISIBLE : View.GONE);
+ }
+
+ @Override
+ protected Cursor getChildrenCursor(Cursor groupCursor) {
+ Bundle bundle = new Bundle();
+ bundle.putLong(StatsCursorLoaderCallback.BUNDLE_DATE, groupCursor.getLong(groupCursor.getColumnIndex("date")));
+ bundle.putString(StatsCursorLoaderCallback.BUNDLE_GROUP_ID, groupCursor.getString(groupCursor.getColumnIndex("groupId")));
+ mCallback.onUriRequested(groupCursor.getPosition(), STATS_REFERRERS_URI, bundle);
+ return null;
+ }
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.stats_view_referrers);
+ }
+
+ @Override
+ protected String[] getTabTitles() {
+ return StatsTimeframe.toStringArray(TIMEFRAMES);
+ }
+
+ @Override
+ protected int getInnerFragmentID() {
+ return R.id.stats_referrers;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsSearchEngineTermsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsSearchEngineTermsFragment.java
new file mode 100644
index 000000000..75b5ff513
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsSearchEngineTermsFragment.java
@@ -0,0 +1,80 @@
+package org.wordpress.android.ui.stats;
+
+import android.app.Fragment;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CursorAdapter;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.StatsSearchEngineTermsTable;
+import org.wordpress.android.providers.StatsContentProvider;
+import org.wordpress.android.util.FormatUtils;
+
+/**
+ * Fragment for search engine term stats. Has two pages, for Today's and Yesterday's stats.
+ */
+public class StatsSearchEngineTermsFragment extends StatsAbsPagedViewFragment {
+ private static final Uri STATS_SEARCH_ENGINE_TERMS_URI = StatsContentProvider.STATS_SEARCH_ENGINE_TERMS_URI;
+ private static final StatsTimeframe[] TIMEFRAMES = new StatsTimeframe[] { StatsTimeframe.TODAY, StatsTimeframe.YESTERDAY };
+
+ public static final String TAG = StatsSearchEngineTermsFragment.class.getSimpleName();
+
+ @Override
+ protected Fragment getFragment(int position) {
+ Uri uri = Uri.parse(STATS_SEARCH_ENGINE_TERMS_URI.toString() + "?timeframe=" + TIMEFRAMES[position].name());
+
+ StatsCursorFragment fragment = StatsCursorFragment.newInstance(uri, R.string.stats_entry_search_engine_terms,
+ R.string.stats_totals_views, R.string.stats_empty_search_engine_terms_title,
+ R.string.stats_empty_search_engine_terms_desc);
+ fragment.setListAdapter(new CustomCursorAdapter(getActivity(), null));
+ fragment.setCallback(this);
+ return fragment;
+ }
+
+ public class CustomCursorAdapter extends CursorAdapter {
+ private final LayoutInflater inflater;
+
+ public CustomCursorAdapter(Context context, Cursor c) {
+ super(context, c, true);
+ inflater = LayoutInflater.from(context);
+ }
+
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ final StatsViewHolder holder = (StatsViewHolder) view.getTag();
+
+ String entry = cursor.getString(cursor.getColumnIndex(StatsSearchEngineTermsTable.Columns.SEARCH));
+ int total = cursor.getInt(cursor.getColumnIndex(StatsSearchEngineTermsTable.Columns.VIEWS));
+
+ holder.entryTextView.setText(entry);
+ holder.totalsTextView.setText(FormatUtils.formatDecimal(total));
+ holder.networkImageView.setVisibility(View.GONE);
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup root) {
+ View view = inflater.inflate(R.layout.stats_list_cell, root, false);
+ view.setTag(new StatsViewHolder(view));
+ return view;
+ }
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.stats_view_search_engine_terms);
+ }
+
+ @Override
+ protected String[] getTabTitles() {
+ return StatsTimeframe.toStringArray(TIMEFRAMES);
+ }
+
+ @Override
+ protected int getInnerFragmentID() {
+ return R.id.stats_search;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTagsAndCategoriesFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTagsAndCategoriesFragment.java
new file mode 100644
index 000000000..dfcb9f362
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTagsAndCategoriesFragment.java
@@ -0,0 +1,104 @@
+package org.wordpress.android.ui.stats;
+
+import android.app.FragmentManager;
+import android.app.FragmentTransaction;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CursorAdapter;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.StatsTagsAndCategoriesTable;
+import org.wordpress.android.models.StatsTagsandCategories.Type;
+import org.wordpress.android.providers.StatsContentProvider;
+import org.wordpress.android.util.FormatUtils;
+import org.wordpress.android.util.Utils;
+
+import java.util.Locale;
+
+/**
+ * Fragment for tags and categories stats. Only a single page.
+ */
+public class StatsTagsAndCategoriesFragment extends StatsAbsViewFragment implements StatsCursorInterface {
+ private static final Uri STATS_TAGS_AND_CATEGORIES_URI = StatsContentProvider.STATS_TAGS_AND_CATEGORIES_URI;
+
+ public static final String TAG = StatsTagsAndCategoriesFragment.class.getSimpleName();
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.stats_pager_fragment, container, false);
+
+ if (Utils.isTablet()) {
+ TextView tv = (TextView) view.findViewById(R.id.stats_pager_title);
+ tv.setText(getTitle().toUpperCase(Locale.getDefault()));
+ }
+
+ FragmentManager fm = getFragmentManager();
+
+ int entryLabelResId = R.string.stats_entry_tags_and_categories;
+ int totalsLabelResId = R.string.stats_totals_views;
+ int emptyLabelResId = R.string.stats_empty_tags_and_categories;
+ StatsCursorFragment fragment = StatsCursorFragment.newInstance(STATS_TAGS_AND_CATEGORIES_URI, entryLabelResId, totalsLabelResId, emptyLabelResId);
+ fragment.setListAdapter(new CustomCursorAdapter(getActivity(), null));
+ fragment.setCallback(this);
+
+ FragmentTransaction ft = fm.beginTransaction();
+ ft.replace(R.id.stats_pager_container, fragment, StatsCursorFragment.TAG);
+ ft.commit();
+
+ return view;
+ }
+
+ public class CustomCursorAdapter extends CursorAdapter {
+ public CustomCursorAdapter(Context context, Cursor c) {
+ super(context, c, true);
+ }
+
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ String entry = cursor.getString(cursor.getColumnIndex(StatsTagsAndCategoriesTable.Columns.TOPIC));
+ int total = cursor.getInt(cursor.getColumnIndex(StatsTagsAndCategoriesTable.Columns.VIEWS));
+ String type = cursor.getString(cursor.getColumnIndex(StatsTagsAndCategoriesTable.Columns.TYPE));
+
+ // entries
+ TextView entryTextView = (TextView) view.findViewById(R.id.stats_list_cell_entry);
+ entryTextView.setText(entry);
+
+ // tag and category icons
+ if (type.equals(Type.CATEGORY.getLabel())) {
+ entryTextView.setCompoundDrawablesWithIntrinsicBounds(R.drawable.stats_icon_categories, 0, 0, 0);
+ } else if (type.equals(Type.TAG.getLabel())) {
+ entryTextView.setCompoundDrawablesWithIntrinsicBounds(R.drawable.stats_icon_tags, 0, 0, 0);
+ } else {
+ entryTextView.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0);
+ }
+
+ // totals
+ TextView totalsTextView = (TextView) view.findViewById(R.id.stats_list_cell_total);
+ totalsTextView.setText(FormatUtils.formatDecimal(total));
+
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup root) {
+ LayoutInflater inflater = LayoutInflater.from(context);
+ return inflater.inflate(R.layout.stats_list_cell, root, false);
+ }
+
+ }
+
+ @Override
+ protected String getTitle() {
+ return getString(R.string.stats_view_tags_and_categories);
+ }
+
+ @Override
+ public void onCursorLoaded(Uri uri, Cursor cursor) {
+ // StatsCursorInterface callback: do nothing
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTimeframe.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTimeframe.java
new file mode 100644
index 000000000..c8c918656
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTimeframe.java
@@ -0,0 +1,34 @@
+package org.wordpress.android.ui.stats;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+
+/**
+ * Timeframes for the stats pages.
+ */
+public enum StatsTimeframe {
+ TODAY(R.string.stats_timeframe_today),
+ YESTERDAY(R.string.stats_timeframe_yesterday),
+ SUMMARY(R.string.stats_summary),
+ ;
+
+ private final int mLabelResId;
+
+ private StatsTimeframe(int labelResId) {
+ mLabelResId = labelResId;
+ }
+
+ public String getLabel() {
+ return WordPress.getContext().getString(mLabelResId);
+ }
+
+ public static String[] toStringArray(StatsTimeframe[] timeframes) {
+ String[] titles = new String[timeframes.length];
+
+ for (int i = 0; i < timeframes.length; i++) {
+ titles[i] = timeframes[i].getLabel();
+ }
+
+ return titles;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTopAuthorsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTopAuthorsFragment.java
new file mode 100644
index 000000000..1e98af433
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTopAuthorsFragment.java
@@ -0,0 +1,85 @@
+package org.wordpress.android.ui.stats;
+
+import android.app.Fragment;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CursorAdapter;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.StatsTopAuthorsTable;
+import org.wordpress.android.providers.StatsContentProvider;
+import org.wordpress.android.util.FormatUtils;
+
+/**
+ * Fragment for top author stats. Has two pages, for Today's and Yesterday's stats.
+ */
+public class StatsTopAuthorsFragment extends StatsAbsPagedViewFragment {
+ private static final Uri STATS_TOP_AUTHORS_URI = StatsContentProvider.STATS_TOP_AUTHORS_URI;
+ private static final StatsTimeframe[] TIMEFRAMES = new StatsTimeframe[] { StatsTimeframe.TODAY, StatsTimeframe.YESTERDAY };
+
+ public static final String TAG = StatsTopAuthorsFragment.class.getSimpleName();
+
+ @Override
+ protected Fragment getFragment(int position) {
+ int entryLabelResId = R.string.stats_entry_authors;
+ int totalsLabelResId = R.string.stats_totals_views;
+ int emptyLabelResId = R.string.stats_empty_top_authors;
+
+ Uri uri = Uri.parse(STATS_TOP_AUTHORS_URI.toString() + "?timeframe=" + TIMEFRAMES[position].name());
+
+ StatsCursorFragment fragment = StatsCursorFragment.newInstance(uri, entryLabelResId, totalsLabelResId, emptyLabelResId);
+ fragment.setListAdapter(new CustomCursorAdapter(getActivity(), null));
+ fragment.setCallback(this);
+ return fragment;
+ }
+
+ public class CustomCursorAdapter extends CursorAdapter {
+ private final LayoutInflater inflater;
+
+ public CustomCursorAdapter(Context context, Cursor c) {
+ super(context, c, true);
+ inflater = LayoutInflater.from(context);
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup root) {
+ View view = inflater.inflate(R.layout.stats_list_cell, root, false);
+ view.setTag(new StatsViewHolder(view));
+ return view;
+ }
+
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ final StatsViewHolder holder = (StatsViewHolder) view.getTag();
+
+ String entry = cursor.getString(cursor.getColumnIndex(StatsTopAuthorsTable.Columns.NAME));
+ String imageUrl = cursor.getString(cursor.getColumnIndex(StatsTopAuthorsTable.Columns.IMAGE_URL));
+ int total = cursor.getInt(cursor.getColumnIndex(StatsTopAuthorsTable.Columns.VIEWS));
+
+ holder.entryTextView.setText(entry);
+ holder.totalsTextView.setText(FormatUtils.formatDecimal(total));
+
+ // image
+ holder.showNetworkImage(imageUrl);
+ }
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.stats_view_top_authors);
+ }
+
+ @Override
+ protected String[] getTabTitles() {
+ return StatsTimeframe.toStringArray(TIMEFRAMES);
+ }
+
+ @Override
+ protected int getInnerFragmentID() {
+ return R.id.stats_top_authors;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTopPostsAndPagesFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTopPostsAndPagesFragment.java
new file mode 100644
index 000000000..51ba30bb0
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTopPostsAndPagesFragment.java
@@ -0,0 +1,85 @@
+package org.wordpress.android.ui.stats;
+
+import android.app.Fragment;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CursorAdapter;
+
+import org.wordpress.android.R;
+import org.wordpress.android.datasets.StatsTopPostsAndPagesTable;
+import org.wordpress.android.providers.StatsContentProvider;
+import org.wordpress.android.util.FormatUtils;
+
+/**
+ * Fragment for top posts and pages stats. Has two pages, for Today's and Yesterday's stats.
+ */
+public class StatsTopPostsAndPagesFragment extends StatsAbsPagedViewFragment {
+ private static final Uri STATS_TOP_POSTS_AND_PAGES_URI = StatsContentProvider.STATS_TOP_POSTS_AND_PAGES_URI;
+ private static final StatsTimeframe[] TIMEFRAMES = new StatsTimeframe[] { StatsTimeframe.TODAY, StatsTimeframe.YESTERDAY };
+
+ public static final String TAG = StatsTopPostsAndPagesFragment.class.getSimpleName();
+
+ @Override
+ protected Fragment getFragment(int position) {
+ Uri uri = Uri.parse(STATS_TOP_POSTS_AND_PAGES_URI.toString() + "?timeframe=" + TIMEFRAMES[position].name());
+
+ StatsCursorFragment fragment = StatsCursorFragment.newInstance(uri, R.string.stats_entry_posts_and_pages,
+ R.string.stats_totals_views, R.string.stats_empty_top_posts_title, R.string.stats_empty_top_posts_desc);
+ fragment.setListAdapter(new CustomCursorAdapter(getActivity(), null));
+ fragment.setCallback(this);
+ return fragment;
+ }
+
+ public class CustomCursorAdapter extends CursorAdapter {
+ private final LayoutInflater inflater;
+
+ public CustomCursorAdapter(Context context, Cursor c) {
+ super(context, c, true);
+ inflater = LayoutInflater.from(context);
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup root) {
+ View view = inflater.inflate(R.layout.stats_list_cell, root, false);
+ view.setTag(new StatsViewHolder(view));
+ return view;
+ }
+
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ final StatsViewHolder holder = (StatsViewHolder) view.getTag();
+
+ final String entry = cursor.getString(cursor.getColumnIndex(StatsTopPostsAndPagesTable.Columns.TITLE));
+ final String url = cursor.getString(cursor.getColumnIndex(StatsTopPostsAndPagesTable.Columns.URL));
+ int total = cursor.getInt(cursor.getColumnIndex(StatsTopPostsAndPagesTable.Columns.VIEWS));
+
+ // entries
+ holder.setEntryTextOrLink(url, entry);
+
+ // totals
+ holder.totalsTextView.setText(FormatUtils.formatDecimal(total));
+
+ // no icon
+ holder.networkImageView.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.stats_view_top_posts_and_pages);
+ }
+
+ @Override
+ protected String[] getTabTitles() {
+ return StatsTimeframe.toStringArray(TIMEFRAMES);
+ }
+
+ @Override
+ protected int getInnerFragmentID() {
+ return R.id.stats_top_posts;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTotalsFollowersAndSharesFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTotalsFollowersAndSharesFragment.java
new file mode 100644
index 000000000..0c238f725
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTotalsFollowersAndSharesFragment.java
@@ -0,0 +1,151 @@
+package org.wordpress.android.ui.stats;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.os.Handler;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.Html;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.StatsSummary;
+import org.wordpress.android.ui.stats.service.StatsService;
+import org.wordpress.android.util.FormatUtils;
+import org.wordpress.android.util.StatUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.WPLinkMovementMethod;
+
+import java.io.Serializable;
+import java.util.Locale;
+
+/**
+ * Fragment for summary stats. Only a single page.
+ */
+public class StatsTotalsFollowersAndSharesFragment extends StatsAbsViewFragment {
+ public static final String TAG = StatsTotalsFollowersAndSharesFragment.class.getSimpleName();
+
+ private TextView mPostsCountView;
+ private TextView mCategoriesCountView;
+ private TextView mTagsCountView;
+ private TextView mFollowersCountView;
+ private TextView mCommentsCountView;
+ private TextView mSharesCountView;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.stats_totals_followers_shares, container, false);
+
+ final TextView titleView = (TextView) view.findViewById(R.id.stats_pager_title);
+ titleView.setText(getTitle().toUpperCase(Locale.getDefault()));
+
+ mPostsCountView = (TextView) view.findViewById(R.id.stats_totals_followers_shares_posts_count);
+ mCategoriesCountView = (TextView) view.findViewById(R.id.stats_totals_followers_shares_categories_count);
+ mTagsCountView = (TextView) view.findViewById(R.id.stats_totals_followers_shares_tags_count);
+ mFollowersCountView = (TextView) view.findViewById(R.id.stats_totals_followers_shares_followers_count);
+ mCommentsCountView = (TextView) view.findViewById(R.id.stats_totals_followers_shares_comments_count);
+ mSharesCountView = (TextView) view.findViewById(R.id.stats_totals_followers_shares_shares_count);
+
+ TextView followersHeader = (TextView) view.findViewById(R.id.stats_totals_followers_shares_header_followers);
+ if (followersHeader != null) {
+ String headerText = getString(R.string.stats_totals_followers_shares_header_followers) +
+ " <font color=\"#9E9E9E\">(" +
+ String.format(getString(R.string.stats_totals_followers_shares_header_includes_publicize),
+ "</font><font color=\"#21759B\"><a href=\"http://support.wordpress.com/publicize/\">") +
+ "</font><font color=\"#9E9E9E\">)</font>";
+ followersHeader.setText(Html.fromHtml(headerText));
+ followersHeader.setMovementMethod(WPLinkMovementMethod.getInstance());
+ }
+
+ return view;
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(getActivity());
+ lbm.unregisterReceiver(mReceiver);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(getActivity());
+ lbm.registerReceiver(mReceiver, new IntentFilter(StatsService.ACTION_STATS_SUMMARY_UPDATED));
+
+ refreshSummary();
+ }
+
+ private void refreshSummary() {
+ final Handler handler = new Handler();
+ new Thread() {
+ @Override
+ public void run() {
+ if (WordPress.getCurrentBlog() == null)
+ return;
+
+ String blogId = WordPress.getCurrentBlog().getDotComBlogId();
+ if (TextUtils.isEmpty(blogId))
+ blogId = "0";
+
+ final StatsSummary stats = StatUtils.getSummary(blogId);
+
+ handler.post(new Runnable() {
+ public void run() {
+ refreshSummary(stats);
+ }
+ });
+ }
+ }.start();
+ }
+
+ private void refreshSummary(final StatsSummary stats) {
+ if (getActivity() == null)
+ return;
+
+ if (stats == null){
+ mPostsCountView.setText("0");
+ mCategoriesCountView.setText("0");
+ mTagsCountView.setText("0");
+ mFollowersCountView.setText("0");
+ mCommentsCountView.setText("0");
+ mSharesCountView.setText("0");
+ } else {
+ mPostsCountView.setText(FormatUtils.formatDecimal(stats.getPosts()));
+ mCategoriesCountView.setText(FormatUtils.formatDecimal(stats.getCategories()));
+ mTagsCountView.setText(FormatUtils.formatDecimal(stats.getTags()));
+ mFollowersCountView.setText(FormatUtils.formatDecimal(stats.getFollowersBlog()));
+ mCommentsCountView.setText(FormatUtils.formatDecimal(stats.getFollowersComments()));
+ mSharesCountView.setText(FormatUtils.formatDecimal(stats.getShares()));
+ }
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.stats_view_totals_followers_and_shares);
+ }
+
+ /*
+ * receives broadcast when summary data has been updated
+ */
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = StringUtils.notNullStr(intent.getAction());
+ if (action.equals(StatsService.ACTION_STATS_SUMMARY_UPDATED)) {
+ Serializable serial = intent.getSerializableExtra(StatsService.STATS_SUMMARY_UPDATED_EXTRA);
+ if (serial instanceof StatsSummary) {
+ refreshSummary((StatsSummary) serial);
+ }
+ }
+ }
+ };
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsVideoFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsVideoFragment.java
new file mode 100644
index 000000000..169f34fd0
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsVideoFragment.java
@@ -0,0 +1,215 @@
+package org.wordpress.android.ui.stats;
+
+import android.app.Fragment;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CursorAdapter;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.StatsVideosTable;
+import org.wordpress.android.models.StatsVideoSummary;
+import org.wordpress.android.providers.StatsContentProvider;
+import org.wordpress.android.util.FormatUtils;
+import org.wordpress.android.util.StatUtils;
+
+/**
+ * Fragment for video stats. Has three pages, for Today's and Yesterday's stats as well as a summary page.
+ */
+public class StatsVideoFragment extends StatsAbsPagedViewFragment {
+ private static final Uri STATS_VIDEOS_URI = StatsContentProvider.STATS_VIDEOS_URI;
+ private static final StatsTimeframe[] TIMEFRAMES = new StatsTimeframe[] { StatsTimeframe.TODAY, StatsTimeframe.YESTERDAY, StatsTimeframe.SUMMARY };
+
+ public static final String TAG = StatsVideoFragment.class.getSimpleName();
+
+ @Override
+ protected Fragment getFragment(int position) {
+ if (position < 2) {
+ int entryLabelResId = R.string.stats_entry_video_plays;
+ int totalsLabelResId = R.string.stats_totals_plays;
+ int emptyLabelResId = R.string.stats_empty_video;
+
+ Uri uri = Uri.parse(STATS_VIDEOS_URI.toString() + "?timeframe=" + TIMEFRAMES[position].name());
+
+ StatsCursorFragment fragment = StatsCursorFragment.newInstance(uri, entryLabelResId, totalsLabelResId, emptyLabelResId);
+ fragment.setListAdapter(new CustomCursorAdapter(getActivity(), null));
+ fragment.setCallback(this);
+ return fragment;
+ } else {
+ return new VideoSummaryFragment();
+ }
+ }
+
+ public class CustomCursorAdapter extends CursorAdapter {
+ private final LayoutInflater inflater;
+
+ public CustomCursorAdapter(Context context, Cursor c) {
+ super(context, c, true);
+ inflater = LayoutInflater.from(context);
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup root) {
+ View view = inflater.inflate(R.layout.stats_list_cell, root, false);
+ view.setTag(new StatsViewHolder(view));
+ return view;
+ }
+
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ final StatsViewHolder holder = (StatsViewHolder) view.getTag();
+
+ String entry = cursor.getString(cursor.getColumnIndex(StatsVideosTable.Columns.NAME));
+ String url = cursor.getString(cursor.getColumnIndex(StatsVideosTable.Columns.URL));
+ int total = cursor.getInt(cursor.getColumnIndex(StatsVideosTable.Columns.PLAYS));
+
+ // entries
+ holder.setEntryTextOrLink(url, entry);
+
+ // totals
+ holder.totalsTextView.setText(FormatUtils.formatDecimal(total));
+ }
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.stats_view_video_plays);
+ }
+
+ @Override
+ protected String[] getTabTitles() {
+ return StatsTimeframe.toStringArray(TIMEFRAMES);
+ }
+
+ @Override
+ protected int getInnerFragmentID() {
+ return R.id.stats_pager_container;
+ }
+
+ /**
+ * Fragment used for video summary
+ */
+ public static class VideoSummaryFragment extends Fragment {
+ private TextView mHeader;
+ private TextView mPlays;
+ private TextView mImpressions;
+ private TextView mPlaybackTotals;
+ private TextView mPlaybackUnit;
+ private TextView mBandwidth;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.stats_video_summary, container, false);
+
+ mHeader = (TextView) view.findViewById(R.id.stats_video_summary_header);
+ mHeader.setText("");
+ mPlays = (TextView) view.findViewById(R.id.stats_video_summary_plays_total);
+ mImpressions = (TextView) view.findViewById(R.id.stats_video_summary_impressions_total);
+ mPlaybackTotals = (TextView) view.findViewById(R.id.stats_video_summary_playback_length_total);
+ mPlaybackUnit = (TextView) view.findViewById(R.id.stats_video_summary_playback_length_unit);
+ mBandwidth = (TextView) view.findViewById(R.id.stats_video_summary_bandwidth_total);
+
+ return view;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ refreshSummary();
+ refreshStatsFromServer();
+ }
+
+ private void refreshSummary() {
+ if (WordPress.getCurrentBlog() == null)
+ return;
+
+ String blogId = String.valueOf(WordPress.getCurrentBlog());
+
+ new AsyncTask<String, Void, StatsVideoSummary>() {
+ @Override
+ protected StatsVideoSummary doInBackground(String... params) {
+ final String blogId = params[0];
+ return StatUtils.getVideoSummary(blogId);
+ }
+
+ protected void onPostExecute(StatsVideoSummary result) {
+ refreshSummaryViews(result);
+ }
+ }.execute(blogId);
+ }
+
+
+ private void refreshStatsFromServer() {
+ if (WordPress.getCurrentBlog() == null)
+ return;
+
+ final String blogId = String.valueOf(WordPress.getCurrentBlog());
+
+ /* WordPress.getRestClientUtils().getStatsVideoSummary(blogId,
+ new Listener() {
+ @Override
+ public void onResponse(final JSONObject response) {
+ new AsyncTask<Void, Void, Void> () {
+ @Override
+ protected Void doInBackground(Void... params) {
+ StatUtils.saveVideoSummary(blogId, response);
+ return null;
+ }
+
+ protected void onPostExecute(Void result) {
+ if (getActivity() == null)
+ return;
+
+ getActivity().runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ refreshSummary();
+ }
+ });
+ }
+
+ }.execute();
+
+ }
+ },
+ new ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError error) {
+ // TODO Auto-generated method stub
+
+ }
+ });*/
+ }
+
+ void refreshSummaryViews(StatsVideoSummary result) {
+ String header = "";
+ int plays = 0;
+ int impressions = 0;
+ int playbackTotals = 0;
+ String bandwidth = "0 MB";
+
+ if (result != null) {
+ plays = result.getPlays();
+ impressions = result.getImpressions();
+ playbackTotals = result.getMinutes();
+ bandwidth = result.getBandwidth();
+ header = String.format(getString(R.string.stats_video_summary_header), result.getTimeframe());
+ }
+
+ mHeader.setText(header);
+ mPlays.setText(FormatUtils.formatDecimal(plays));
+ mImpressions.setText(FormatUtils.formatDecimal(impressions));
+ mPlaybackTotals.setText(playbackTotals + "");
+ mBandwidth.setText(bandwidth);
+ }
+
+
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsViewHolder.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsViewHolder.java
new file mode 100644
index 000000000..558b7b3d2
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsViewHolder.java
@@ -0,0 +1,71 @@
+package org.wordpress.android.ui.stats;
+
+import android.text.Html;
+import android.text.TextUtils;
+import android.text.util.Linkify;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.android.volley.toolbox.NetworkImageView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+
+/**
+ * View holder for stats_list_cell layout
+ */
+class StatsViewHolder {
+ public final TextView entryTextView;
+ public final TextView totalsTextView;
+ public final NetworkImageView networkImageView;
+ public final ImageView chevronImageView;
+
+ public StatsViewHolder(View view) {
+ entryTextView = (TextView) view.findViewById(R.id.stats_list_cell_entry);
+ entryTextView.setMovementMethod(StatsWPLinkMovementMethod.getInstance());
+ totalsTextView = (TextView) view.findViewById(R.id.stats_list_cell_total);
+ chevronImageView = (ImageView) view.findViewById(R.id.stats_list_cell_chevron);
+
+ networkImageView = (NetworkImageView) view.findViewById(R.id.stats_list_cell_image);
+ networkImageView.setErrorImageResId(R.drawable.stats_blank_image);
+ networkImageView.setDefaultImageResId(R.drawable.stats_blank_image);
+ }
+
+ /*
+ * used by stats fragments to set the entry text, making it a clickable link if a url is passed
+ */
+ public void setEntryTextOrLink(String linkUrl, String linkName) {
+ if (entryTextView == null) {
+ return;
+ }
+
+ if (TextUtils.isEmpty(linkUrl)) {
+ entryTextView.setText(linkName);
+ if (linkName != null && linkName.startsWith("http")) {
+ Linkify.addLinks(entryTextView, Linkify.WEB_URLS);
+ }
+ } else if (TextUtils.isEmpty(linkName)) {
+ entryTextView.setText(linkUrl);
+ if (linkUrl != null && linkUrl.startsWith("http")) {
+ Linkify.addLinks(entryTextView, Linkify.WEB_URLS);
+ }
+ } else {
+ entryTextView.setText(Html.fromHtml("<a href=\"" + linkUrl + "\">" + linkName + "</a>"));
+ }
+ }
+
+ /*
+ * used by stats fragments to show a downloadable icon or default image
+ */
+ public void showNetworkImage(String imageUrl) {
+ if (networkImageView == null) {
+ return;
+ }
+ if (imageUrl != null && imageUrl.startsWith("http")) {
+ networkImageView.setImageUrl(imageUrl, WordPress.imageLoader);
+ } else {
+ networkImageView.setImageResource(R.drawable.stats_blank_image);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsViewType.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsViewType.java
new file mode 100644
index 000000000..d8b33aadb
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsViewType.java
@@ -0,0 +1,19 @@
+package org.wordpress.android.ui.stats;
+
+/**
+ * An enum of the different view types to appear on the stats view.
+ */
+
+enum StatsViewType {
+ VISITORS_AND_VIEWS,
+ VIEWS_BY_COUNTRY,
+ TOP_POSTS_AND_PAGES,
+ TOTALS_FOLLOWERS_AND_SHARES,
+ CLICKS,
+ TAGS_AND_CATEGORIES,
+ TOP_AUTHORS,
+ REFERRERS,
+ VIDEO_PLAYS,
+ COMMENTS,
+ SEARCH_ENGINE_TERMS
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsVisitorsAndViewsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsVisitorsAndViewsFragment.java
new file mode 100644
index 000000000..0eb31534e
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsVisitorsAndViewsFragment.java
@@ -0,0 +1,192 @@
+package org.wordpress.android.ui.stats;
+
+import android.app.FragmentTransaction;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.os.Handler;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.TextUtils;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.StatsSummary;
+import org.wordpress.android.ui.stats.service.StatsService;
+import org.wordpress.android.util.FormatUtils;
+import org.wordpress.android.util.StatUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.Utils;
+
+import java.io.Serializable;
+import java.util.Locale;
+
+/**
+ * Fragment for visitors and views stats. Has three pages, for DAY, WEEK and MONTH stats.
+ * A summary of the blog's stats are also shown on each page.
+ */
+public class StatsVisitorsAndViewsFragment extends StatsAbsViewFragment implements RadioGroup.OnCheckedChangeListener {
+ private static final String[] TITLES = new String [] { StatsBarChartUnit.DAY.getLabel(),
+ StatsBarChartUnit.WEEK.getLabel(),
+ StatsBarChartUnit.MONTH.getLabel() };
+
+ private TextView mVisitorsToday;
+ private TextView mViewsToday;
+ private TextView mViewsBestEver;
+ private TextView mViewsAllTime;
+ private TextView mCommentsAllTime;
+ private RadioGroup mRadioGroup;
+
+ private static final String CHILD_TAG = "CHILD_TAG";
+
+ private int mSelectedButtonIndex = 0;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.stats_visitors_and_views_fragment, container, false);
+ setRetainInstance(true);
+
+ TextView titleTextView = (TextView) view.findViewById(R.id.stats_pager_title);
+ titleTextView.setText(getTitle().toUpperCase(Locale.getDefault()));
+
+ mVisitorsToday = (TextView) view.findViewById(R.id.stats_visitors_and_views_today_visitors_count);
+ mViewsToday = (TextView) view.findViewById(R.id.stats_visitors_and_views_today_views_count);
+ mViewsBestEver = (TextView) view.findViewById(R.id.stats_visitors_and_views_best_ever_views_count);
+ mViewsAllTime = (TextView) view.findViewById(R.id.stats_visitors_and_views_all_time_view_count);
+ mCommentsAllTime = (TextView) view.findViewById(R.id.stats_visitors_and_views_all_time_comment_count);
+
+ mRadioGroup = (RadioGroup) view.findViewById(R.id.stats_pager_tabs);
+ mRadioGroup.setVisibility(View.VISIBLE);
+ mRadioGroup.setOnCheckedChangeListener(this);
+
+ for (int i = 0; i < TITLES.length; i++) {
+ RadioButton rb = (RadioButton) LayoutInflater.from(getActivity()).inflate(R.layout.stats_radio_button, null, false);
+ RadioGroup.LayoutParams params = new RadioGroup.LayoutParams(RadioGroup.LayoutParams.WRAP_CONTENT, RadioGroup.LayoutParams.WRAP_CONTENT);
+ int dp8 = (int) Utils.dpToPx(8);
+ params.setMargins(0, 0, dp8, 0);
+ rb.setMinimumWidth((int) Utils.dpToPx(80));
+ rb.setGravity(Gravity.CENTER);
+ rb.setLayoutParams(params);
+ rb.setText(TITLES[i]);
+ mRadioGroup.addView(rb);
+
+ if (i == mSelectedButtonIndex)
+ rb.setChecked(true);
+ }
+ return view;
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(getActivity());
+ lbm.unregisterReceiver(mReceiver); }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ mRadioGroup.setOnCheckedChangeListener(this);
+
+ LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(getActivity());
+ lbm.registerReceiver(mReceiver, new IntentFilter(StatsService.ACTION_STATS_SUMMARY_UPDATED));
+
+ refreshSummary();
+ }
+
+ @Override
+ public void onCheckedChanged(RadioGroup group, int checkedId) {
+ mSelectedButtonIndex = group.indexOfChild(group.findViewById(checkedId));
+ loadBarChartFragmentForIndex(mSelectedButtonIndex);
+ }
+
+ private void loadBarChartFragmentForIndex(int index) {
+ String childTag = CHILD_TAG + ":" + this.getClass().getSimpleName() + ":" + index;
+ final StatsBarChartUnit unit;
+ switch (index) {
+ case 1:
+ unit = StatsBarChartUnit.WEEK;
+ break;
+ case 2:
+ unit = StatsBarChartUnit.MONTH;
+ break;
+ default:
+ unit = StatsBarChartUnit.DAY;
+ }
+
+ StatsBarGraphFragment statsBarGraphFragment = StatsBarGraphFragment.newInstance(unit);
+ FragmentTransaction ft = getFragmentManager().beginTransaction();
+ ft.setCustomAnimations(R.anim.stats_fade_in, R.anim.stats_fade_out);
+ ft.replace(R.id.stats_bar_chart_fragment_container, statsBarGraphFragment, childTag);
+ ft.commit();
+ }
+
+ private void refreshSummary() {
+ if (WordPress.getCurrentBlog() == null)
+ return;
+
+ final Handler handler = new Handler();
+ new Thread() {
+ @Override
+ public void run() {
+ String blogId = WordPress.getCurrentBlog().getDotComBlogId();
+ if (TextUtils.isEmpty(blogId))
+ blogId = "0";
+ final StatsSummary summary = StatUtils.getSummary(blogId);
+ handler.post(new Runnable() {
+ public void run() {
+ refreshSummary(summary);
+ }
+ });
+ }
+ }.start();
+ }
+
+ private void refreshSummary(final StatsSummary stats) {
+ if (getActivity() == null)
+ return;
+
+ if (stats == null) {
+ mVisitorsToday.setText("0");
+ mViewsToday.setText("0");
+ mViewsBestEver.setText("0");
+ mViewsAllTime.setText("0");
+ mCommentsAllTime.setText("0");
+ } else {
+ mVisitorsToday.setText(FormatUtils.formatDecimal(stats.getVisitorsToday()));
+ mViewsToday.setText(FormatUtils.formatDecimal(stats.getViewsToday()));
+ mViewsBestEver.setText(FormatUtils.formatDecimal(stats.getViewsBestDayTotal()));
+ mViewsAllTime.setText(FormatUtils.formatDecimal(stats.getViewsAllTime()));
+ mCommentsAllTime.setText(FormatUtils.formatDecimal(stats.getCommentsAllTime()));
+ }
+ }
+
+ @Override
+ protected String getTitle() {
+ return getString(R.string.stats_view_visitors_and_views);
+ }
+
+ /*
+ * receives broadcast when summary data has been updated
+ */
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = StringUtils.notNullStr(intent.getAction());
+ if (action.equals(StatsService.ACTION_STATS_SUMMARY_UPDATED)) {
+ Serializable serial = intent.getSerializableExtra(StatsService.STATS_SUMMARY_UPDATED_EXTRA);
+ if (serial instanceof StatsSummary) {
+ refreshSummary((StatsSummary) serial);
+ }
+ }
+ }
+ };
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWPLinkMovementMethod.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWPLinkMovementMethod.java
new file mode 100644
index 000000000..f0b013137
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWPLinkMovementMethod.java
@@ -0,0 +1,93 @@
+package org.wordpress.android.ui.stats;
+
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+import android.text.Layout;
+import android.text.Spannable;
+import android.text.style.URLSpan;
+import android.view.MotionEvent;
+import android.widget.TextView;
+
+import org.wordpress.android.WordPress;
+import org.wordpress.android.WordPressDB;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.UrlUtils;
+import org.wordpress.android.util.WPLinkMovementMethod;
+
+public class StatsWPLinkMovementMethod extends WPLinkMovementMethod {
+ public static WPLinkMovementMethod getInstance() {
+ if (mMovementMethod == null) {
+ mMovementMethod = new StatsWPLinkMovementMethod();
+ }
+ return mMovementMethod;
+ }
+
+ @Override
+ public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) {
+ int action = event.getAction();
+
+ if (action == MotionEvent.ACTION_UP) {
+ int x = (int) event.getX();
+ int y = (int) event.getY();
+
+ x -= widget.getTotalPaddingLeft();
+ y -= widget.getTotalPaddingTop();
+
+ x += widget.getScrollX();
+ y += widget.getScrollY();
+
+ Layout layout = widget.getLayout();
+ if ( layout == null ) {
+ return super.onTouchEvent(widget, buffer, event);
+ }
+ int line = layout.getLineForVertical(y);
+ int off = layout.getOffsetForHorizontal(line, x);
+
+ URLSpan[] link = buffer.getSpans(off, off, URLSpan.class);
+ if (link.length != 0) {
+ String url = link[0].getURL();
+ AppLog.d(AppLog.T.UTILS, "Tapped on the Link: " + url);
+ if (url.startsWith("https://wordpress.com/my-stats")
+ || url.startsWith("http://wordpress.com/my-stats")) {
+ // make sure to load the no-chrome version of Stats over https
+ url = UrlUtils.makeHttps(url);
+ if (url.contains("?")) {
+ // add the no chrome parameters if not available
+ if (!url.contains("?no-chrome") && !url.contains("&no-chrome")) {
+ url += "&no-chrome";
+ }
+ } else {
+ url += "?no-chrome";
+ }
+ AppLog.d(AppLog.T.UTILS, "Opening the Authenticated in-app browser : " + url);
+ // Let's try the global wpcom credentials
+ SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(widget.getContext());
+ String statsAuthenticatedUser = settings.getString(WordPress.WPCOM_USERNAME_PREFERENCE, null);
+ String statsAuthenticatedPassword = WordPressDB.decryptPassword(
+ settings.getString(WordPress.WPCOM_PASSWORD_PREFERENCE, null)
+ );
+ if (org.apache.commons.lang.StringUtils.isEmpty(statsAuthenticatedPassword)
+ || org.apache.commons.lang.StringUtils.isEmpty(statsAuthenticatedUser)) {
+ // Still empty. Do not eat the event, but let's open the default Web Browser.
+ return super.onTouchEvent(widget, buffer, event);
+ }
+ Intent statsWebViewIntent = new Intent(widget.getContext(), StatsWebViewActivity.class);
+ statsWebViewIntent.putExtra(StatsWebViewActivity.STATS_AUTHENTICATED_USER, statsAuthenticatedUser);
+ statsWebViewIntent.putExtra(StatsWebViewActivity.STATS_AUTHENTICATED_PASSWD,
+ statsAuthenticatedPassword);
+ statsWebViewIntent.putExtra(StatsWebViewActivity.STATS_AUTHENTICATED_URL, url);
+ widget.getContext().startActivity(statsWebViewIntent);
+ return true;
+ } else if (url.startsWith("https") || url.startsWith("http")) {
+ AppLog.d(AppLog.T.UTILS, "Opening the in-app browser: " + url);
+ Intent statsWebViewIntent = new Intent(widget.getContext(), StatsWebViewActivity.class);
+ statsWebViewIntent.putExtra(StatsWebViewActivity.STATS_URL, url);
+ widget.getContext().startActivity(statsWebViewIntent);
+ return true;
+ }
+ }
+ }
+ return super.onTouchEvent(widget, buffer, event);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWebViewActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWebViewActivity.java
new file mode 100644
index 000000000..93b33e7fd
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWebViewActivity.java
@@ -0,0 +1,128 @@
+package org.wordpress.android.ui.stats;
+
+import android.annotation.SuppressLint;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.webkit.WebSettings;
+import android.webkit.WebViewClient;
+import android.widget.ProgressBar;
+import android.widget.Toast;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.ui.WebViewActivity;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.WPWebChromeClient;
+import org.wordpress.android.util.WPWebViewClient;
+import org.wordpress.passcodelock.AppLockManager;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+
+/**
+ * Activity for opening stats external links in a webview.
+ */
+public class StatsWebViewActivity extends WebViewActivity {
+ public static final String STATS_AUTHENTICATED_URL = "stats_authenticated_url";
+ public static final String STATS_AUTHENTICATED_USER = "stats_authenticated_user";
+ public static final String STATS_AUTHENTICATED_PASSWD = "stats_authenticated_passwd";
+ public static final String STATS_URL = "stats_url";
+
+ @SuppressLint("SetJavaScriptEnabled")
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ Bundle extras = getIntent().getExtras();
+
+ mWebView.setWebViewClient(new WebViewClient());
+ mWebView.setWebChromeClient(new WPWebChromeClient(this, (ProgressBar) findViewById(R.id.progress_bar)));
+ mWebView.getSettings().setJavaScriptEnabled(true);
+ mWebView.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE);
+ mWebView.getSettings().setUserAgentString(WordPress.getUserAgent());
+ mWebView.getSettings().setDomStorageEnabled(true);
+
+ if (extras != null) {
+ if (extras.containsKey(STATS_AUTHENTICATED_URL)) {
+ String addressToLoad = extras.getString(STATS_AUTHENTICATED_URL);
+ String username = extras.getString(STATS_AUTHENTICATED_USER, "");
+ String password = extras.getString(STATS_AUTHENTICATED_PASSWD, "");
+ this.loadAuthenticatedStatsUrl(addressToLoad, username, password);
+ } else if (extras.containsKey(STATS_URL)) {
+ String addressToLoad = extras.getString(STATS_URL);
+ this.loadUrl(addressToLoad);
+ }
+ } else {
+ AppLog.e(AppLog.T.STATS, "No valid URL passed to StatsWebViewActivity!!");
+ }
+ }
+
+ /**
+ * Login to the WordPress.com and load the specified URL.
+ *
+ * @param url URL to be loaded in the webview.
+ */
+ protected void loadAuthenticatedStatsUrl(String url, String username, String passwd) {
+ try {
+ String postData = String.format("log=%s&pwd=%s&redirect_to=%s",
+ URLEncoder.encode(username, "UTF-8"), URLEncoder.encode(passwd, "UTF-8"),
+ URLEncoder.encode(url, "UTF-8"));
+ mWebView.postUrl("https://wordpress.com/wp-login.php", postData.getBytes());
+ } catch (UnsupportedEncodingException e) {
+ AppLog.e(AppLog.T.STATS, e);
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ // The 2 lines below fix an issue where this activity has leaked window
+ // android.widget.ZoomButtonsController$Container
+ mWebView.getSettings().setBuiltInZoomControls(false);
+ mWebView.setVisibility(View.GONE);
+ super.onDestroy();
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.webview, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ if (mWebView == null)
+ return false;
+
+ int itemID = item.getItemId();
+ if (itemID == R.id.menu_refresh) {
+ mWebView.reload();
+ return true;
+ } else if (itemID == R.id.menu_share) {
+ Intent share = new Intent(Intent.ACTION_SEND);
+ share.setType("text/plain");
+ share.putExtra(Intent.EXTRA_TEXT, mWebView.getUrl());
+ startActivity(Intent.createChooser(share, getResources().getText(R.string.share_link)));
+ return true;
+ } else if (itemID == R.id.menu_browser) {
+ String url = mWebView.getUrl();
+ if (url != null) {
+ Uri uri = Uri.parse(url);
+ if (uri != null) {
+ Intent i = new Intent(Intent.ACTION_VIEW);
+ i.setData(uri);
+ startActivity(i);
+ AppLockManager.getInstance().setExtendedTimeout();
+ }
+ }
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/service/StatsService.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/service/StatsService.java
new file mode 100644
index 000000000..ff2558741
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/service/StatsService.java
@@ -0,0 +1,764 @@
+package org.wordpress.android.ui.stats.service;
+
+import android.app.Service;
+import android.content.ContentProviderOperation;
+import android.content.ContentValues;
+import android.content.Intent;
+import android.content.OperationApplicationException;
+import android.net.Uri;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.support.v4.content.LocalBroadcastManager;
+
+import com.android.volley.Request;
+import com.android.volley.VolleyError;
+import com.wordpress.rest.RestRequest;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.wordpress.android.BuildConfig;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.StatsBarChartDataTable;
+import org.wordpress.android.datasets.StatsClickGroupsTable;
+import org.wordpress.android.datasets.StatsClicksTable;
+import org.wordpress.android.datasets.StatsGeoviewsTable;
+import org.wordpress.android.datasets.StatsReferrerGroupsTable;
+import org.wordpress.android.datasets.StatsReferrersTable;
+import org.wordpress.android.datasets.StatsSearchEngineTermsTable;
+import org.wordpress.android.datasets.StatsTopPostsAndPagesTable;
+import org.wordpress.android.models.StatsBarChartData;
+import org.wordpress.android.models.StatsClick;
+import org.wordpress.android.models.StatsClickGroup;
+import org.wordpress.android.models.StatsGeoview;
+import org.wordpress.android.models.StatsReferrer;
+import org.wordpress.android.models.StatsReferrerGroup;
+import org.wordpress.android.models.StatsSearchEngineTerm;
+import org.wordpress.android.models.StatsSummary;
+import org.wordpress.android.models.StatsTopPostsAndPages;
+import org.wordpress.android.networking.RestClientUtils;
+import org.wordpress.android.providers.StatsContentProvider;
+import org.wordpress.android.ui.stats.StatsActivity;
+import org.wordpress.android.ui.stats.StatsBarChartUnit;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.StatUtils;
+import org.wordpress.android.util.StringUtils;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Locale;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadPoolExecutor;
+
+/**
+ * Background service to retrieve latest stats - Uses a Thread to enqueue the network batch call in Volley;
+ * Parsing of response data is done by using a ThreadPoolExecutor with a single thread.
+ */
+
+public class StatsService extends Service {
+ public static final String ARG_BLOG_ID = "blog_id";
+
+ // broadcast action to notify clients of update start/end
+ public static final String ACTION_STATS_UPDATING = "wp-stats-updating";
+ public static final String EXTRA_IS_UPDATING = "is-updating";
+ public static final String EXTRA_IS_ERROR = "is-error";
+ public static final String EXTRA_ERROR_OBJECT = "error-object";
+
+ // broadcast action to notify clients when summary data has changed
+ public static final String ACTION_STATS_SUMMARY_UPDATED = "STATS_SUMMARY_UPDATED";
+ public static final String STATS_SUMMARY_UPDATED_EXTRA = "STATS_SUMMARY_UPDATED_EXTRA";
+
+ private static final long TWO_DAYS = 2 * 24 * 60 * 60 * 1000;
+
+ private String mServiceBlogId;
+ private Object mServiceBlogIdMonitor = new Object();
+ private int mServiceStartId;
+ private Serializable mErrorObject = null;
+ private Request<JSONObject> mCurrentStatsNetworkRequest = null;
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ AppLog.i(T.STATS, "service created");
+ }
+
+ @Override
+ public void onDestroy() {
+ stopRefresh();
+ AppLog.i(T.STATS, "service destroyed");
+ super.onDestroy();
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ final String blogId = StringUtils.notNullStr(intent.getStringExtra(ARG_BLOG_ID));
+ final String currentServiceBlogId = getServiceBlogId();
+
+ if (currentServiceBlogId == null) {
+ startTasks(blogId, startId);
+ } else if (blogId.equals(currentServiceBlogId)) {
+ // already running on the same blogID
+ // Do nothing
+ AppLog.i(T.STATS, "StatsService is already running on this blogID - " + currentServiceBlogId);
+ } else {
+ // stats is running on a different blogID
+ stopRefresh();
+ startTasks(blogId, startId);
+ }
+ // Always update the startId. Always.
+ this.mServiceStartId = startId;
+
+ return START_NOT_STICKY;
+ }
+
+ /**
+ * Returns a copy of the current mServiceBlogId value, or null.
+ */
+ private String getServiceBlogId() {
+ synchronized (mServiceBlogIdMonitor) {
+ if (mServiceBlogId == null) {
+ return null;
+ }
+ return new String(mServiceBlogId);
+ }
+ }
+
+ private void setServiceBlogId(String value) {
+ synchronized (mServiceBlogIdMonitor) {
+ mServiceBlogId = value;
+ }
+ }
+
+ private void stopRefresh() {
+ if (mCurrentStatsNetworkRequest != null && !mCurrentStatsNetworkRequest.hasHadResponseDelivered()
+ && !mCurrentStatsNetworkRequest.isCanceled()) {
+ mCurrentStatsNetworkRequest.cancel();
+ }
+ setServiceBlogId(null);
+ this.mErrorObject = null;
+ this.mServiceStartId = 0;
+ }
+
+ private void startTasks(final String blogId, final int startId) {
+ setServiceBlogId(blogId);
+ this.mServiceStartId = startId;
+ this.mErrorObject = null;
+
+ new Thread() {
+ @Override
+ public void run() {
+ final RestClientUtils restClientUtils = WordPress.getRestClientUtils();
+ final String today = StatUtils.getCurrentDate();
+ final String yesterday = StatUtils.getYesterdaysDate();
+
+ AppLog.i(T.STATS, "Update started for blogID - " + blogId);
+ broadcastUpdate(true);
+
+ // visitors and views
+ final String summaryPath = String.format("/sites/%s/stats", blogId);
+ // bar charts
+ final String barChartWeekPath = getBarChartPath(blogId, StatsBarChartUnit.WEEK, 30);
+ final String barChartMonthPath = getBarChartPath(blogId, StatsBarChartUnit.MONTH, 30);
+ // top posts and pages
+ final String topPostsAndPagesTodayPath = String.format(
+ "/sites/%s/stats/top-posts?date=%s", blogId, today);
+ final String topPostsAndPagesYesterdayPath = String.format(
+ "/sites/%s/stats/top-posts?date=%s", blogId, yesterday);
+ // referrers
+ final String referrersTodayPath = String.format(
+ "/sites/%s/stats/referrers?date=%s", blogId, today);
+ final String referrersYesterdayPath = String.format(
+ "/sites/%s/stats/referrers?date=%s", blogId, yesterday);
+ // clicks
+ final String clicksTodayPath = String.format(
+ "/sites/%s/stats/clicks?date=%s", blogId, today);
+ final String clicksYesterdayPath = String.format(
+ "/sites/%s/stats/clicks?date=%s", blogId, yesterday);
+ // search engine terms
+ final String searchEngineTermsTodayPath = String.format(
+ "/sites/%s/stats/search-terms?date=%s", blogId, today);
+ final String searchEngineTermsYesterdayPath = String.format(
+ "/sites/%s/stats/search-terms?date=%s", blogId, yesterday);
+ // Views by country
+ final String viewByCountryTodayPath = String.format(
+ "/sites/%s/stats/country-views?date=%s", blogId, today);
+ final String viewByCountryYesterdayPath = String.format(
+ "/sites/%s/stats/country-views?date=%s", blogId, yesterday);
+
+ final String parametersSepator = "&urls%5B%5D=";
+
+ String path = new StringBuilder("batch/?urls%5B%5D=")
+ .append(Uri.encode(summaryPath))
+ .append(parametersSepator)
+ .append(Uri.encode(barChartWeekPath))
+ .append(parametersSepator)
+ .append(Uri.encode(barChartMonthPath))
+ .append(parametersSepator)
+ .append(Uri.encode(topPostsAndPagesTodayPath))
+ .append(parametersSepator)
+ .append(Uri.encode(topPostsAndPagesYesterdayPath))
+ .append(parametersSepator)
+ .append(Uri.encode(referrersTodayPath))
+ .append(parametersSepator)
+ .append(Uri.encode(referrersYesterdayPath))
+ .append(parametersSepator)
+ .append(Uri.encode(clicksTodayPath))
+ .append(parametersSepator)
+ .append(Uri.encode(clicksYesterdayPath))
+ .append(parametersSepator)
+ .append(Uri.encode(searchEngineTermsTodayPath))
+ .append(parametersSepator)
+ .append(Uri.encode(searchEngineTermsYesterdayPath))
+ .append(parametersSepator)
+ .append(Uri.encode(viewByCountryTodayPath))
+ .append(parametersSepator)
+ .append(Uri.encode(viewByCountryYesterdayPath))
+ .toString();
+
+ RestBatchCallListener restAPIListener = new RestBatchCallListener(blogId,
+ summaryPath,
+ barChartWeekPath, barChartMonthPath,
+ topPostsAndPagesTodayPath, topPostsAndPagesYesterdayPath,
+ referrersTodayPath, referrersYesterdayPath,
+ clicksTodayPath, clicksYesterdayPath,
+ searchEngineTermsTodayPath, searchEngineTermsYesterdayPath,
+ viewByCountryTodayPath, viewByCountryYesterdayPath);
+
+ AppLog.i(T.STATS, "Enqueuing the following Stats request " + path);
+ restClientUtils.get(path, restAPIListener, restAPIListener);
+ } // end run
+ } .start();
+ }
+
+ private String getBarChartPath(String blogID, StatsBarChartUnit mBarChartUnit, int quantity) {
+ String path = String.format("/sites/%s/stats/visits", blogID);
+ String unit = mBarChartUnit.name().toLowerCase(Locale.ENGLISH);
+ path += String.format("?unit=%s", unit);
+ if (quantity > 0) {
+ path += String.format("&quantity=%d", quantity);
+ }
+ return path;
+ }
+
+ private class RestBatchCallListener implements RestRequest.Listener, RestRequest.ErrorListener {
+ final String mRequestBlogId, mSummaryAPICallPath, mBarChartWeekPath, mBarChartMonthPath,
+ mTopPostsAndPagesTodayPath, mTopPostsAndPagesYesterdayPath, mReferrersTodayPath,
+ mReferrersYesterdayPath, mClicksTodayPath, mClicksYesterdayPath,
+ mSearchEngineTermsTodayPath, mSearchEngineTermsYesterdayPath,
+ mViewByCountryTodayPath, mViewByCountryYesterdayPath;
+
+ RestBatchCallListener(String blogId, String summaryAPICallPath,
+ String barChartWeekPath, String barChartMonthPath,
+ String topPostsAndPagesTodayPath, String topPostsAndPagesYesterdayPath,
+ String referrersTodayPath, String referrersYesterdayPath,
+ String clicksTodayPath, String clicksYesterdayPath,
+ String searchEngineTermsTodayPath, String searchEngineTermsYesterdayPath,
+ String viewByCountryTodayPath, String viewByCountryYesterdayPath) {
+ this.mRequestBlogId = blogId;
+ this.mSummaryAPICallPath = summaryAPICallPath;
+ this.mBarChartWeekPath = barChartWeekPath;
+ this.mBarChartMonthPath = barChartMonthPath;
+ this.mTopPostsAndPagesTodayPath = topPostsAndPagesTodayPath;
+ this.mTopPostsAndPagesYesterdayPath = topPostsAndPagesYesterdayPath;
+ this.mReferrersTodayPath = referrersTodayPath;
+ this.mReferrersYesterdayPath = referrersYesterdayPath;
+ this.mClicksTodayPath = clicksTodayPath;
+ this.mClicksYesterdayPath = clicksYesterdayPath;
+ this.mSearchEngineTermsTodayPath = searchEngineTermsTodayPath;
+ this.mSearchEngineTermsYesterdayPath = searchEngineTermsYesterdayPath;
+ this.mViewByCountryTodayPath = viewByCountryTodayPath;
+ this.mViewByCountryYesterdayPath = viewByCountryYesterdayPath;
+ }
+
+ @Override
+ public void onResponse(final JSONObject response) {
+ // single thread
+ ThreadPoolExecutor parseResponseExecutor = (ThreadPoolExecutor) Executors.newFixedThreadPool(1);
+ parseResponseExecutor.submit(new Thread() {
+ @Override
+ public void run() {
+ parseSummaryResponse(response);
+ parseBarChartResponse(response);
+ parseTopPostsAndPagesResponse(response);
+ parseReferrersResponse(response);
+ parseClicksResponse(response);
+ parseSearchEngineTermsResponse(response);
+ parseViewsByCountryResponse(response);
+
+ // Stop the service if this is the current response, or mServiceBlogId is null
+ String currentServiceBlogId = getServiceBlogId();
+ if (currentServiceBlogId == null || currentServiceBlogId.equals(mRequestBlogId)) {
+ stopService();
+ }
+ }
+ });
+ }
+
+ private void parseViewsByCountryResponse(final JSONObject response) {
+ String currentServiceBlogId = getServiceBlogId();
+ if (currentServiceBlogId == null || !currentServiceBlogId.equals(mRequestBlogId)) {
+ return;
+ }
+ String[] viewsByCountryPaths = {mViewByCountryTodayPath, mViewByCountryYesterdayPath};
+ for (String currentViewsByCountryPath : viewsByCountryPaths) {
+ if (response.has(currentViewsByCountryPath)) {
+ try {
+ final JSONObject currentViewsByCountryJsonObject =
+ response.getJSONObject(currentViewsByCountryPath);
+ if (!isSingleCallResponseError(currentViewsByCountryPath, currentViewsByCountryJsonObject)) {
+ if (!currentViewsByCountryJsonObject.has("country-views")) {
+ return;
+ }
+
+ JSONArray results = currentViewsByCountryJsonObject.getJSONArray("country-views");
+ int count = Math.min(results.length(), StatsActivity.STATS_GROUP_MAX_ITEMS);
+ String date = currentViewsByCountryJsonObject.getString("date");
+ long dateMs = StatUtils.toMs(date);
+ ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
+
+ if (count > 0) {
+ // delete data with the same date, and data older than two days ago (keep
+ // yesterday's data)
+ ContentProviderOperation deleteOp = ContentProviderOperation
+ .newDelete(StatsContentProvider.STATS_GEOVIEWS_URI)
+ .withSelection("blogId=? AND (date=? OR date<=?)", new String[] {
+ mRequestBlogId, dateMs + "", (dateMs - TWO_DAYS) + ""
+ }).build();
+ operations.add(deleteOp);
+ }
+
+ for (int i = 0; i < count; i++) {
+ JSONObject result = results.getJSONObject(i);
+ StatsGeoview stat = new StatsGeoview(mRequestBlogId, result);
+ ContentValues values = StatsGeoviewsTable.getContentValues(stat);
+ ContentProviderOperation op = ContentProviderOperation
+ .newInsert(StatsContentProvider.STATS_GEOVIEWS_URI).withValues(values).build();
+ operations.add(op);
+ }
+
+ getContentResolver().applyBatch(BuildConfig.STATS_PROVIDER_AUTHORITY, operations);
+ }
+ } catch (RemoteException e) {
+ logSingleCallError(currentViewsByCountryPath, e);
+ } catch (OperationApplicationException e) {
+ logSingleCallError(currentViewsByCountryPath, e);
+ } catch (JSONException e) {
+ logSingleCallError(currentViewsByCountryPath, e);
+ }
+ }
+ }
+ getContentResolver().notifyChange(StatsContentProvider.STATS_GEOVIEWS_URI, null);
+ }
+
+ private void parseSearchEngineTermsResponse(final JSONObject response) {
+ String currentServiceBlogId = getServiceBlogId();
+ if (currentServiceBlogId == null || !currentServiceBlogId.equals(mRequestBlogId)) {
+ return;
+ }
+ String[] searchEngineTermsPaths = {mSearchEngineTermsTodayPath, mSearchEngineTermsYesterdayPath};
+ for (String currentSearchEngineTermsPath : searchEngineTermsPaths) {
+ if (response.has(currentSearchEngineTermsPath)) {
+ try {
+ final JSONObject currentSearchEngineTermsJsonObject =
+ response.getJSONObject(currentSearchEngineTermsPath);
+ if (!isSingleCallResponseError(currentSearchEngineTermsPath,
+ currentSearchEngineTermsJsonObject)) {
+ String date = currentSearchEngineTermsJsonObject.getString("date");
+ long dateMs = StatUtils.toMs(date);
+
+ ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
+
+ ContentProviderOperation deleteOp = ContentProviderOperation
+ .newDelete(StatsContentProvider.STATS_SEARCH_ENGINE_TERMS_URI)
+ .withSelection("blogId=? AND (date=? OR date<=?)",
+ new String[] {
+ mRequestBlogId, dateMs + "", (dateMs - TWO_DAYS) + ""
+ }).build();
+
+ operations.add(deleteOp);
+
+ JSONArray results = currentSearchEngineTermsJsonObject.getJSONArray("search-terms");
+
+ int count = Math.min(results.length(), StatsActivity.STATS_GROUP_MAX_ITEMS);
+ for (int i = 0; i < count; i++) {
+ JSONArray result = results.getJSONArray(i);
+ StatsSearchEngineTerm stat = new StatsSearchEngineTerm(mRequestBlogId, date, result);
+ ContentValues values = StatsSearchEngineTermsTable.getContentValues(stat);
+ getContentResolver()
+ .insert(StatsContentProvider.STATS_SEARCH_ENGINE_TERMS_URI, values);
+
+ ContentProviderOperation insertOp = ContentProviderOperation
+ .newInsert(StatsContentProvider.STATS_SEARCH_ENGINE_TERMS_URI)
+ .withValues(values)
+ .build();
+ operations.add(insertOp);
+ }
+
+ getContentResolver().applyBatch(BuildConfig.STATS_PROVIDER_AUTHORITY, operations);
+ }
+ } catch (RemoteException e) {
+ logSingleCallError(currentSearchEngineTermsPath, e);
+ } catch (OperationApplicationException e) {
+ logSingleCallError(currentSearchEngineTermsPath, e);
+ } catch (JSONException e) {
+ logSingleCallError(currentSearchEngineTermsPath, e);
+ }
+ }
+ }
+ getContentResolver().notifyChange(StatsContentProvider.STATS_SEARCH_ENGINE_TERMS_URI, null);
+ }
+
+ private void parseClicksResponse(final JSONObject response) {
+ String currentServiceBlogId = getServiceBlogId();
+ if (currentServiceBlogId == null || !currentServiceBlogId.equals(mRequestBlogId)) {
+ return;
+ }
+ String[] clicksPaths = {mClicksTodayPath, mClicksYesterdayPath};
+ for (String currentClickPath : clicksPaths) {
+ if (response.has(currentClickPath)) {
+ try {
+ final JSONObject currentClicksJsonObject =
+ response.getJSONObject(currentClickPath);
+ if (!isSingleCallResponseError(currentClickPath, currentClicksJsonObject)) {
+ String date = currentClicksJsonObject.getString("date");
+ long dateMs = StatUtils.toMs(date);
+
+ ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
+
+ // delete data with the same date, and data older than two days ago (keep yesterday's
+ // data)
+ ContentProviderOperation deleteGroup = ContentProviderOperation
+ .newDelete(StatsContentProvider.STATS_CLICK_GROUP_URI)
+ .withSelection("blogId=? AND (date=? OR date<=?)",
+ new String[] {
+ mRequestBlogId, dateMs + "", (dateMs - TWO_DAYS) + ""
+ }).build();
+ ContentProviderOperation deleteChildOp = ContentProviderOperation
+ .newDelete(StatsContentProvider.STATS_CLICKS_URI)
+ .withSelection("blogId=? AND (date=? OR date<=?)",
+ new String[] {mRequestBlogId, dateMs + "", (dateMs - TWO_DAYS) + ""}
+ )
+ .build();
+
+ operations.add(deleteGroup);
+ operations.add(deleteChildOp);
+
+ JSONArray groups = currentClicksJsonObject.getJSONArray("clicks");
+
+ // insert groups, limited to the number that can actually be displayed
+ int groupsCount = Math.min(groups.length(), StatsActivity.STATS_GROUP_MAX_ITEMS);
+ for (int i = 0; i < groupsCount; i++) {
+ JSONObject group = groups.getJSONObject(i);
+ StatsClickGroup statGroup = new StatsClickGroup(mRequestBlogId, date, group);
+ ContentValues values = StatsClickGroupsTable.getContentValues(statGroup);
+
+ ContentProviderOperation insertGroupOp = ContentProviderOperation
+ .newInsert(StatsContentProvider.STATS_CLICK_GROUP_URI).withValues(values)
+ .build();
+ operations.add(insertGroupOp);
+
+ // insert children if there are any, limited to the number that can be displayed
+ JSONArray clicks = group.getJSONArray("results");
+ int childCount = Math.min(clicks.length(), StatsActivity.STATS_CHILD_MAX_ITEMS);
+ if (childCount > 1) {
+ for (int j = 0; j < childCount; j++) {
+ StatsClick stat = new StatsClick(mRequestBlogId, date,
+ statGroup.getGroupId(), clicks.getJSONArray(j));
+ ContentValues v = StatsClicksTable.getContentValues(stat);
+ ContentProviderOperation insertChildOp = ContentProviderOperation
+ .newInsert(StatsContentProvider.STATS_CLICKS_URI).withValues(v).build();
+ operations.add(insertChildOp);
+ }
+ }
+ }
+
+ getContentResolver().applyBatch(BuildConfig.STATS_PROVIDER_AUTHORITY, operations);
+ }
+ } catch (RemoteException e) {
+ logSingleCallError(currentClickPath, e);
+ } catch (OperationApplicationException e) {
+ logSingleCallError(currentClickPath, e);
+ } catch (JSONException e) {
+ logSingleCallError(currentClickPath, e);
+ }
+ }
+ }
+ getContentResolver().notifyChange(StatsContentProvider.STATS_CLICK_GROUP_URI, null);
+ getContentResolver().notifyChange(StatsContentProvider.STATS_CLICKS_URI, null);
+ }
+
+ private void parseReferrersResponse(final JSONObject response) {
+ String currentServiceBlogId = getServiceBlogId();
+ if (currentServiceBlogId == null || !currentServiceBlogId.equals(mRequestBlogId)) {
+ return;
+ }
+ String[] referrersPaths = {mReferrersTodayPath, mReferrersYesterdayPath};
+ for (String currentReferrerPath : referrersPaths) {
+ if (response.has(currentReferrerPath)) {
+ try {
+ final JSONObject currentReferrersJsonObject =
+ response.getJSONObject(currentReferrerPath);
+ if (!isSingleCallResponseError(currentReferrerPath, currentReferrersJsonObject)) {
+ String date = currentReferrersJsonObject.getString("date");
+ long dateMs = StatUtils.toMs(date);
+
+ ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
+
+ // delete data with the same date, and data older than two days ago (keep yesterday's
+ // data)
+ ContentProviderOperation deleteGroupOp = ContentProviderOperation
+ .newDelete(StatsContentProvider.STATS_REFERRER_GROUP_URI)
+ .withSelection("blogId=? AND (date=? OR date<=?)", new String[] {
+ mRequestBlogId, dateMs + "", (dateMs - TWO_DAYS) + ""
+ }).build();
+ operations.add(deleteGroupOp);
+
+ ContentProviderOperation deleteOp = ContentProviderOperation
+ .newDelete(StatsContentProvider.STATS_REFERRERS_URI)
+ .withSelection("blogId=? AND (date=? OR date<=?)", new String[] {
+ mRequestBlogId, dateMs + "", (dateMs - TWO_DAYS) + ""
+ }).build();
+ operations.add(deleteOp);
+
+ JSONArray groups = currentReferrersJsonObject.getJSONArray("referrers");
+ int groupsCount = Math.min(groups.length(), StatsActivity.STATS_GROUP_MAX_ITEMS);
+
+ // insert groups
+ for (int i = 0; i < groupsCount; i++) {
+ JSONObject group = groups.getJSONObject(i);
+ StatsReferrerGroup statGroup = new StatsReferrerGroup(mRequestBlogId, date, group);
+ ContentValues values = StatsReferrerGroupsTable.getContentValues(statGroup);
+ ContentProviderOperation insertGroupOp = ContentProviderOperation
+ .newInsert(StatsContentProvider.STATS_REFERRER_GROUP_URI).withValues(values)
+ .build();
+ operations.add(insertGroupOp);
+
+ // insert children, only if there is more than one entry
+ JSONArray referrers = group.getJSONArray("results");
+ int childCount = Math.min(referrers.length(), StatsActivity.STATS_CHILD_MAX_ITEMS);
+ if (childCount > 1) {
+ for (int j = 0; j < childCount; j++) {
+ StatsReferrer stat = new StatsReferrer(mRequestBlogId, date,
+ statGroup.getGroupId(), referrers.getJSONArray(j));
+ ContentValues v = StatsReferrersTable.getContentValues(stat);
+ ContentProviderOperation insertChildOp = ContentProviderOperation
+ .newInsert(StatsContentProvider.STATS_REFERRERS_URI).withValues(v)
+ .build();
+ operations.add(insertChildOp);
+ }
+ }
+ }
+ getContentResolver().applyBatch(BuildConfig.STATS_PROVIDER_AUTHORITY, operations);
+ }
+ } catch (RemoteException e) {
+ logSingleCallError(currentReferrerPath, e);
+ } catch (OperationApplicationException e) {
+ logSingleCallError(currentReferrerPath, e);
+ } catch (JSONException e) {
+ logSingleCallError(currentReferrerPath, e);
+ }
+ }
+ }
+ getContentResolver().notifyChange(StatsContentProvider.STATS_REFERRER_GROUP_URI, null);
+ getContentResolver().notifyChange(StatsContentProvider.STATS_REFERRERS_URI, null);
+ }
+
+ private void parseTopPostsAndPagesResponse(final JSONObject response) {
+ String currentServiceBlogId = getServiceBlogId();
+ if (currentServiceBlogId == null || !currentServiceBlogId.equals(mRequestBlogId)) {
+ return;
+ }
+ String[] topPostsAndPagesPaths = {mTopPostsAndPagesTodayPath, mTopPostsAndPagesYesterdayPath};
+ for (String currentTopPostsAndPagesPath : topPostsAndPagesPaths) {
+ if (response.has(currentTopPostsAndPagesPath)) {
+ try {
+ final JSONObject currentTopPostsAndPagesJsonObject =
+ response.getJSONObject(currentTopPostsAndPagesPath);
+ if (!isSingleCallResponseError(
+ currentTopPostsAndPagesPath, currentTopPostsAndPagesJsonObject)) {
+ if (!currentTopPostsAndPagesJsonObject.has("top-posts")) {
+ return;
+ }
+
+ JSONArray results = currentTopPostsAndPagesJsonObject.getJSONArray("top-posts");
+ int count = Math.min(results.length(), StatsActivity.STATS_GROUP_MAX_ITEMS);
+
+ String date = currentTopPostsAndPagesJsonObject.getString("date");
+ long dateMs = StatUtils.toMs(date);
+
+ ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
+ // delete data with the same date, and data older than two days ago (keep yesterday's data)
+ ContentProviderOperation deleteOp = ContentProviderOperation
+ .newDelete(StatsContentProvider.STATS_TOP_POSTS_AND_PAGES_URI)
+ .withSelection("blogId=? AND (date=? OR date<=?)", new String[] {
+ mRequestBlogId, dateMs + "", (dateMs - TWO_DAYS) + ""
+ }).build();
+ operations.add(deleteOp);
+
+ for (int i = 0; i < count; i++) {
+ JSONObject result = results.getJSONObject(i);
+ StatsTopPostsAndPages stat = new StatsTopPostsAndPages(mRequestBlogId, result);
+ ContentValues values = StatsTopPostsAndPagesTable.getContentValues(stat);
+ ContentProviderOperation op = ContentProviderOperation
+ .newInsert(StatsContentProvider.STATS_TOP_POSTS_AND_PAGES_URI)
+ .withValues(values)
+ .build();
+ operations.add(op);
+ }
+
+ getContentResolver().applyBatch(BuildConfig.STATS_PROVIDER_AUTHORITY, operations);
+ }
+ } catch (RemoteException e) {
+ logSingleCallError(currentTopPostsAndPagesPath, e);
+ } catch (OperationApplicationException e) {
+ logSingleCallError(currentTopPostsAndPagesPath, e);
+ } catch (JSONException e) {
+ logSingleCallError(currentTopPostsAndPagesPath, e);
+ }
+ }
+ }
+ getContentResolver().notifyChange(StatsContentProvider.STATS_TOP_POSTS_AND_PAGES_URI, null);
+ }
+
+ private void parseBarChartResponse(final JSONObject response) {
+ String currentServiceBlogId = getServiceBlogId();
+ if (currentServiceBlogId == null || !currentServiceBlogId.equals(mRequestBlogId)) {
+ return;
+ }
+ String[] barChartPaths = {mBarChartWeekPath, mBarChartMonthPath};
+ for (String currentBarChartPath : barChartPaths) {
+ if (response.has(currentBarChartPath)) {
+ try {
+ final JSONObject barChartJsonObject = response.getJSONObject(currentBarChartPath);
+ final StatsBarChartUnit currentUnit = currentBarChartPath.equals(mBarChartWeekPath)
+ ? StatsBarChartUnit.WEEK : StatsBarChartUnit.MONTH;
+ if (!isSingleCallResponseError(currentBarChartPath, barChartJsonObject)) {
+ if (!barChartJsonObject.has("data")) {
+ return;
+ }
+
+ Uri uri = StatsContentProvider.STATS_BAR_CHART_DATA_URI;
+ JSONArray results = barChartJsonObject.getJSONArray("data");
+
+ int count = results.length();
+
+ ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
+
+ // delete old stats and insert new ones
+ if (count > 0) {
+ ContentProviderOperation op = ContentProviderOperation.newDelete(uri)
+ .withSelection("blogId=? AND unit=?", new String[] {
+ mRequestBlogId, currentUnit.name()
+ }).build();
+ operations.add(op);
+ }
+
+ for (int i = 0; i < count; i++) {
+ JSONArray result = results.getJSONArray(i);
+ StatsBarChartData stat = new StatsBarChartData(mRequestBlogId, currentUnit, result);
+ ContentValues values = StatsBarChartDataTable.getContentValues(stat);
+
+ if (values != null && uri != null) {
+ ContentProviderOperation op = ContentProviderOperation.newInsert(uri)
+ .withValues(values).build();
+ operations.add(op);
+ }
+ }
+
+ getContentResolver().applyBatch(BuildConfig.STATS_PROVIDER_AUTHORITY, operations);
+ }
+ } catch (RemoteException e) {
+ logSingleCallError(currentBarChartPath, e);
+ } catch (OperationApplicationException e) {
+ logSingleCallError(currentBarChartPath, e);
+ } catch (JSONException e) {
+ logSingleCallError(currentBarChartPath, e);
+ }
+ }
+ }
+ getContentResolver().notifyChange(StatsContentProvider.STATS_BAR_CHART_DATA_URI, null);
+ }
+
+ private void parseSummaryResponse(final JSONObject response) {
+ String currentServiceBlogId = getServiceBlogId();
+ if (currentServiceBlogId == null || !currentServiceBlogId.equals(mRequestBlogId)) {
+ return;
+ }
+ final String currentPath = mSummaryAPICallPath;
+ if (response.has(currentPath)) {
+ try {
+ JSONObject summaryJsonObject = response.getJSONObject(currentPath);
+ if (!isSingleCallResponseError(currentPath, summaryJsonObject)) {
+ if (summaryJsonObject == null) {
+ return;
+ }
+ // save summary, then send broadcast that they've changed
+ StatUtils.saveSummary(mRequestBlogId, summaryJsonObject);
+ StatsSummary stats = StatUtils.getSummary(mRequestBlogId);
+ if (stats != null) {
+ LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(WordPress.getContext());
+ Intent intent = new Intent(StatsService.ACTION_STATS_SUMMARY_UPDATED);
+ intent.putExtra(StatsService.STATS_SUMMARY_UPDATED_EXTRA, stats);
+ lbm.sendBroadcast(intent);
+ }
+ }
+ } catch (JSONException e) {
+ logSingleCallError(mSummaryAPICallPath, e);
+ }
+ }
+ }
+
+ private boolean isSingleCallResponseError(String restCallPATH, final JSONObject response) {
+ if (response.has("errors")) {
+ mErrorObject = response.toString();
+ AppLog.e(AppLog.T.STATS, "The single call " + restCallPATH
+ + " failed with the following response: " + response.toString());
+ return true;
+ }
+
+ return false;
+ }
+
+ private void logSingleCallError(String restCallPATH, Exception e) {
+ AppLog.e(AppLog.T.STATS, "Single call failed " + restCallPATH, e);
+ }
+
+ private void stopService() {
+ broadcastUpdate(false);
+ stopSelf(mServiceStartId);
+ }
+
+ @Override
+ public void onErrorResponse(final VolleyError volleyError) {
+ if (volleyError != null) {
+ mErrorObject = volleyError;
+ AppLog.e(T.STATS, "Error while reading Stats summary for " + mRequestBlogId + " "
+ + volleyError.getMessage(), volleyError);
+ }
+ stopService();
+ }
+ }
+
+ /*
+ * broadcast that the update has started/ended - used by StatsActivity to animate refresh icon
+ * while update is in progress
+ */
+ private void broadcastUpdate(boolean isUpdating) {
+ Intent intent = new Intent()
+ .setAction(ACTION_STATS_UPDATING)
+ .putExtra(EXTRA_IS_UPDATING, isUpdating);
+ if (mErrorObject != null) {
+ intent.putExtra(EXTRA_IS_ERROR, true);
+ intent.putExtra(EXTRA_ERROR_OBJECT, mErrorObject);
+ }
+
+ LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeBrowserActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeBrowserActivity.java
new file mode 100644
index 000000000..12e2fe154
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeBrowserActivity.java
@@ -0,0 +1,514 @@
+package org.wordpress.android.ui.themes;
+
+import android.app.ActionBar;
+import android.app.Fragment;
+import android.app.FragmentManager;
+import android.app.FragmentTransaction;
+import android.content.Intent;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.support.v13.app.FragmentStatePagerAdapter;
+import android.support.v4.view.ViewPager;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.Toast;
+
+import com.android.volley.AuthFailureError;
+import com.android.volley.VolleyError;
+import com.wordpress.rest.RestRequest.ErrorListener;
+import com.wordpress.rest.RestRequest.Listener;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Theme;
+import org.wordpress.android.ui.HorizontalTabView;
+import org.wordpress.android.ui.HorizontalTabView.TabListener;
+import org.wordpress.android.ui.WPActionBarActivity;
+import org.wordpress.android.ui.posts.PostsActivity;
+import org.wordpress.android.ui.themes.ThemeDetailsFragment.ThemeDetailsFragmentCallback;
+import org.wordpress.android.ui.themes.ThemePreviewFragment.ThemePreviewFragmentCallback;
+import org.wordpress.android.ui.themes.ThemeTabFragment.ThemeSortType;
+import org.wordpress.android.ui.themes.ThemeTabFragment.ThemeTabFragmentCallback;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.DisplayUtils;
+import org.wordpress.android.util.Utils;
+import org.wordpress.android.util.WPAlertDialogFragment;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+
+/**
+ * The theme browser. Accessible via side menu drawer.
+ */
+public class ThemeBrowserActivity extends WPActionBarActivity implements
+ ThemeTabFragmentCallback, ThemeDetailsFragmentCallback, ThemePreviewFragmentCallback,
+ TabListener {
+ private HorizontalTabView mTabView;
+ private ThemePagerAdapter mThemePagerAdapter;
+ private ViewPager mViewPager;
+ private ThemeSearchFragment mSearchFragment;
+ private ThemePreviewFragment mPreviewFragment;
+ private ThemeDetailsFragment mDetailsFragment;
+ private boolean mFetchingThemes = false;
+ private boolean mIsRunning;
+
+ private boolean mIsActivatingTheme = false;
+ private static final String KEY_IS_ACTIVATING_THEME = "is_activating_theme";
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (WordPress.wpDB == null) {
+ Toast.makeText(this, R.string.fatal_db_error, Toast.LENGTH_LONG).show();
+ finish();
+ return;
+ }
+
+ if (savedInstanceState == null) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.THEMES_ACCESSED_THEMES_BROWSER);
+ }
+
+ setTitle(R.string.themes);
+
+ createMenuDrawer(R.layout.theme_browser_activity);
+
+ mThemePagerAdapter = new ThemePagerAdapter(getFragmentManager());
+
+ final ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayShowTitleEnabled(true);
+ actionBar.setHomeButtonEnabled(true);
+ }
+
+ mViewPager = (ViewPager) findViewById(R.id.theme_browser_pager);
+ mViewPager.setAdapter(mThemePagerAdapter);
+ mViewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
+ @Override
+ public void onPageSelected(int position) {
+ mTabView.setSelectedTab(position);
+ }
+ });
+
+ mTabView = (HorizontalTabView) findViewById(R.id.horizontalTabView1);
+ mTabView.setTabListener(this);
+
+ int count = ThemeSortType.values().length;
+ for (int i = 0; i < count; i++) {
+ String title = ThemeSortType.values()[i].getTitle();
+
+ mTabView.addTab(mTabView.newTab().setText(title));
+ }
+ mTabView.setSelectedTab(0);
+
+ FragmentManager fm = getFragmentManager();
+ fm.addOnBackStackChangedListener(mOnBackStackChangedListener);
+ setupBaseLayout();
+ mPreviewFragment = (ThemePreviewFragment) fm.findFragmentByTag(ThemePreviewFragment.TAG);
+ mDetailsFragment = (ThemeDetailsFragment) fm.findFragmentByTag(ThemeDetailsFragment.TAG);
+ mSearchFragment = (ThemeSearchFragment) fm.findFragmentByTag(ThemeSearchFragment.TAG);
+ }
+
+ private boolean areThemesAccessible() {
+ // themes are only accessible to admin wordpress.com users
+ if (WordPress.getCurrentBlog() != null && !WordPress.getCurrentBlog().isDotcomFlag()) {
+ Intent intent = new Intent(ThemeBrowserActivity.this, PostsActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivityWithDelay(intent);
+ return false;
+ }
+ return true;
+ }
+
+ private FragmentManager.OnBackStackChangedListener mOnBackStackChangedListener = new FragmentManager.OnBackStackChangedListener() {
+ public void onBackStackChanged() {
+ setupBaseLayout();
+ }
+ };
+
+ private void setupBaseLayout() {
+ if (getFragmentManager().getBackStackEntryCount() == 0) {
+ mMenuDrawer.setDrawerIndicatorEnabled(true);
+ mViewPager.setVisibility(View.VISIBLE);
+ mTabView.setVisibility(View.VISIBLE);
+ } else {
+ mMenuDrawer.setDrawerIndicatorEnabled(false);
+ mViewPager.setVisibility(View.GONE);
+ mTabView.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (areThemesAccessible()) {
+ mIsRunning = true;
+
+ // fetch themes if we don't have any
+ if (WordPress.getCurrentBlog() != null && WordPress.wpDB.getThemeCount(getBlogId()) == 0) {
+ fetchThemes(mViewPager.getCurrentItem());
+ setRefreshing(true, mViewPager.getCurrentItem());
+ }
+ }
+ }
+
+ @Override
+ public void onTabSelected(HorizontalTabView.Tab tab) {
+ mViewPager.setCurrentItem(tab.getPosition());
+ }
+
+ public class ThemePagerAdapter extends FragmentStatePagerAdapter {
+ ThemeTabFragment[] mTabFragment = new ThemeTabFragment[ThemeSortType.values().length];
+
+ public ThemePagerAdapter(FragmentManager fm) {
+ super(fm);
+ }
+
+ @Override
+ public ThemeTabFragment getItem(int i) {
+ if (mTabFragment[i] == null) {
+ mTabFragment[i] = ThemeTabFragment.newInstance(ThemeSortType.getTheme(i), i);
+ }
+ return mTabFragment[i];
+ }
+
+ @Override
+ public int getCount() {
+ return ThemeSortType.values().length;
+ }
+
+ @Override
+ public CharSequence getPageTitle(int position) {
+ return ThemeSortType.getTheme(position).getTitle();
+ }
+ }
+
+ public void fetchThemes(final int page) {
+ if (mFetchingThemes) {
+ return;
+ }
+ String siteId = getBlogId();
+ mFetchingThemes = true;
+ WordPress.getRestClientUtils().getThemes(siteId, 0, 0, new Listener() {
+ @Override
+ public void onResponse(JSONObject response) {
+ new FetchThemesTask(page).execute(response);
+ }
+ }, new ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError response) {
+ if (response.toString().equals(AuthFailureError.class.getName())) {
+ String errorTitle = getString(R.string.theme_auth_error_title);
+ String errorMsg = getString(R.string.theme_auth_error_message);
+
+ if (mIsRunning) {
+ FragmentTransaction ft = getFragmentManager().beginTransaction();
+ WPAlertDialogFragment fragment = WPAlertDialogFragment.newAlertDialog(errorMsg,
+ errorTitle);
+ ft.add(fragment, "alert");
+ ft.commitAllowingStateLoss();
+ }
+ AppLog.d(T.THEMES, "Failed to fetch themes: failed authenticate user");
+ } else {
+ Toast.makeText(ThemeBrowserActivity.this, R.string.theme_fetch_failed, Toast.LENGTH_LONG)
+ .show();
+ AppLog.d(T.THEMES, "Failed to fetch themes: " + response.toString());
+ }
+
+ mFetchingThemes = false;
+ setRefreshing(false, page);
+ }
+ }
+ );
+ }
+
+ private void fetchCurrentTheme(final int page) {
+ final String siteId = getBlogId();
+
+ WordPress.getRestClientUtils().getCurrentTheme(siteId, new Listener() {
+ @Override
+ public void onResponse(JSONObject response) {
+ try {
+ Theme theme = Theme.fromJSON(response);
+ if (theme != null) {
+ WordPress.wpDB.setCurrentTheme(siteId, theme.getThemeId());
+ setRefreshing(false, page);
+ }
+ } catch (JSONException e) {
+ AppLog.e(T.THEMES, e);
+ }
+ }
+ }, new ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError response) {
+ }
+ }
+ );
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.theme, menu);
+ return super.onCreateOptionsMenu(menu);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ int itemId = item.getItemId();
+
+ if (itemId == android.R.id.home) {
+ FragmentManager fm = getFragmentManager();
+ if (fm.getBackStackEntryCount() > 0) {
+ fm.popBackStack();
+ setupBaseLayout();
+ return true;
+ }
+ } else if (itemId == R.id.menu_search) {
+ FragmentTransaction ft = getFragmentManager().beginTransaction();
+ if (mSearchFragment == null) {
+ mSearchFragment = ThemeSearchFragment.newInstance();
+ }
+ ft.add(R.id.theme_browser_container, mSearchFragment, ThemeSearchFragment.TAG);
+ ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE);
+ ft.addToBackStack(null);
+ ft.commit();
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ public void onBackPressed() {
+ FragmentManager fm = getFragmentManager();
+ if (mMenuDrawer.isMenuVisible()) {
+ super.onBackPressed();
+ } else if (fm.getBackStackEntryCount() > 0) {
+ fm.popBackStack();
+ setupBaseLayout();
+ } else {
+ super.onBackPressed();
+ }
+ }
+
+ private String getBlogId() {
+ if (WordPress.getCurrentBlog() == null)
+ return "0";
+ return String.valueOf(WordPress.getCurrentBlog().getRemoteBlogId());
+ }
+
+ @Override
+ public void onThemeSelected(String themeId) {
+ FragmentManager fm = getFragmentManager();
+
+ if (!Utils.isXLarge(ThemeBrowserActivity.this)) {
+ // show details as a fragment on top
+ FragmentTransaction ft = fm.beginTransaction();
+
+ if (mSearchFragment != null && mSearchFragment.isVisible()) {
+ fm.popBackStack();
+ }
+
+ setupBaseLayout();
+ mDetailsFragment = ThemeDetailsFragment.newInstance(themeId);
+ ft.add(R.id.theme_browser_container, mDetailsFragment, ThemeDetailsFragment.TAG);
+ ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE);
+ ft.addToBackStack(null);
+ ft.commit();
+ } else {
+ // show details as a dialog
+ mDetailsFragment = ThemeDetailsFragment.newInstance(themeId);
+ mDetailsFragment.show(getFragmentManager(), ThemeDetailsFragment.TAG);
+ getFragmentManager().executePendingTransactions();
+ int minWidth = getResources().getDimensionPixelSize(R.dimen.theme_details_dialog_min_width);
+ int height = getResources().getDimensionPixelSize(R.dimen.theme_details_dialog_height);
+ int width = Math.max((int) (DisplayUtils.getDisplayPixelWidth(this) * 0.6), minWidth);
+ mDetailsFragment.getDialog().getWindow().setLayout(width, height);
+ }
+ }
+
+ public class FetchThemesTask extends AsyncTask<JSONObject, Void, ArrayList<Theme>> {
+ private int mFetchPage;
+
+ public FetchThemesTask(int page) {
+ mFetchPage = page;
+ }
+
+ @Override
+ protected ArrayList<Theme> doInBackground(JSONObject... args) {
+ JSONObject response = args[0];
+ final ArrayList<Theme> themes = new ArrayList<Theme>();
+
+ if (response != null) {
+ JSONArray array = null;
+ try {
+ array = response.getJSONArray("themes");
+
+ if (array != null) {
+ int count = array.length();
+ for (int i = 0; i < count; i++) {
+ JSONObject object = array.getJSONObject(i);
+ Theme theme = Theme.fromJSON(object);
+ if (theme != null) {
+ theme.save();
+ themes.add(theme);
+ }
+ }
+ }
+ } catch (JSONException e) {
+ AppLog.e(T.THEMES, e);
+ }
+ }
+
+ fetchCurrentTheme(mFetchPage);
+
+ if (themes != null && themes.size() > 0) {
+ return themes;
+ }
+
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(final ArrayList<Theme> result) {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mFetchingThemes = false;
+ if (result == null) {
+ Toast.makeText(ThemeBrowserActivity.this, R.string.theme_fetch_failed, Toast.LENGTH_SHORT)
+ .show();
+ }
+ setRefreshing(false, mFetchPage);
+ }
+ });
+ }
+ }
+
+ @Override
+ public void onResume(Fragment fragment) {
+ invalidateOptionsMenu();
+ }
+
+ @Override
+ public void onPause(Fragment fragment) {
+ invalidateOptionsMenu();
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ mIsRunning = false;
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ if (Utils.isXLarge(ThemeBrowserActivity.this) && mDetailsFragment != null) {
+ mDetailsFragment.dismiss();
+ }
+ super.onSaveInstanceState(outState);
+ outState.putBoolean(KEY_IS_ACTIVATING_THEME, mIsActivatingTheme);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ if (savedInstanceState.getBoolean(KEY_IS_ACTIVATING_THEME) && mDetailsFragment!=null)
+ mDetailsFragment.setIsActivatingTheme(true);
+ }
+
+ @Override
+ public void onActivateThemeClicked(String themeId, final Fragment fragment) {
+ final String siteId = getBlogId();
+ if (themeId == null) {
+ themeId = mPreviewFragment.getThemeId();
+ }
+
+ final String newThemeId = themeId;
+ final WeakReference<ThemeBrowserActivity> ref = new WeakReference<ThemeBrowserActivity>(this);
+ mIsActivatingTheme = true;
+ final int page = mViewPager.getCurrentItem();
+ WordPress.getRestClientUtils().setTheme(siteId, themeId, new Listener() {
+ @Override
+ public void onResponse(JSONObject arg0) {
+ mIsActivatingTheme = false;
+ Toast.makeText(ThemeBrowserActivity.this, R.string.theme_set_success, Toast.LENGTH_LONG).show();
+
+ WordPress.wpDB.setCurrentTheme(siteId, newThemeId);
+ if (mDetailsFragment != null) {
+ mDetailsFragment.onThemeActivated(true);
+ }
+ setRefreshing(false, page);
+
+ if (ref.get() != null && mIsRunning && fragment instanceof ThemePreviewFragment) {
+ FragmentManager fm = ref.get().getFragmentManager();
+
+ if (fm.getBackStackEntryCount() > 0) {
+ fm.popBackStack();
+ setupBaseLayout();
+ invalidateOptionsMenu();
+ }
+ }
+ }
+ }, new ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError arg0) {
+ mIsActivatingTheme = false;
+ if (mDetailsFragment != null && mDetailsFragment.isVisible()) mDetailsFragment.onThemeActivated(
+ false);
+ if (ref.get() != null) Toast.makeText(ref.get(), R.string.theme_set_failed, Toast.LENGTH_LONG)
+ .show();
+ }
+ }
+ );
+
+ }
+
+ @Override
+ public void onBlogChanged() {
+ super.onBlogChanged();
+ if (areThemesAccessible()) {
+ fetchThemes(mViewPager.getCurrentItem());
+ setRefreshing(true, mViewPager.getCurrentItem());
+ }
+ }
+
+ @Override
+ public void onLivePreviewClicked(String themeId, String previewURL) {
+ FragmentManager fm = getFragmentManager();
+ FragmentTransaction ft = fm.beginTransaction();
+
+ if (mPreviewFragment == null) {
+ mPreviewFragment = ThemePreviewFragment.newInstance(themeId, previewURL);
+ } else {
+ mPreviewFragment.load(themeId, previewURL);
+ }
+
+ if (mDetailsFragment != null) {
+ if (Utils.isXLarge(ThemeBrowserActivity.this)) {
+ mDetailsFragment.dismiss();
+ } else {
+ ft.hide(mDetailsFragment);
+ }
+ }
+ ft.add(R.id.theme_browser_container, mPreviewFragment, ThemePreviewFragment.TAG);
+ ft.addToBackStack(null);
+ ft.commit();
+ setupBaseLayout();
+ }
+
+ private void setRefreshing(boolean refreshing, int page) {
+ // We have to nofify all contiguous fragments since the ViewPager cache them
+ for (int i = Math.max(page - 1, 0); i <= Math.min(page + 1, mThemePagerAdapter.getCount() - 1); ++i) {
+ mThemePagerAdapter.getItem(i).setRefreshing(refreshing);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeDetailsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeDetailsFragment.java
new file mode 100644
index 000000000..38b3214c2
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeDetailsFragment.java
@@ -0,0 +1,362 @@
+package org.wordpress.android.ui.themes;
+
+import android.app.Activity;
+import android.app.DialogFragment;
+import android.app.Fragment;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.Html;
+import android.view.Display;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.View;
+import android.view.View.MeasureSpec;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.LinearLayout.LayoutParams;
+import android.widget.ListView;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import com.android.volley.toolbox.NetworkImageView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Theme;
+import org.wordpress.android.ui.ViewSiteActivity;
+import org.wordpress.android.util.Utils;
+import org.wordpress.android.util.WPLinkMovementMethod;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+
+import java.util.ArrayList;
+
+/**
+ * A fragment to show the theme's details, including its description and features.
+ */
+public class ThemeDetailsFragment extends DialogFragment {
+ public static final String TAG = ThemeDetailsFragment.class.getName();
+ private static final String ARGS_THEME_ID = "ARGS_THEME_ID";
+
+ public static ThemeDetailsFragment newInstance(String themeId) {
+ ThemeDetailsFragment fragment = new ThemeDetailsFragment();
+
+ Bundle args = new Bundle();
+ args.putString(ARGS_THEME_ID, themeId);
+ fragment.setArguments(args);
+
+ return fragment;
+ }
+
+ private TextView mNameView;
+ private NetworkImageView mImageView;
+ private TextView mDescriptionView;
+ private Button mLivePreviewButton;
+ private String mPreviewURL;
+ private Button mActivateThemeButton;
+
+ private ThemeDetailsFragmentCallback mCallback;
+ private Button mViewSiteButton;
+ private View mCurrentThemeView;
+ private View mActivatingProgressView;
+ private FrameLayout mActivateThemeContainer;
+ private View mPremiumThemeView;
+ private LinearLayout mFeaturesContainer;
+ private View mLeftContainer;
+ private View mParentView;
+
+ public interface ThemeDetailsFragmentCallback {
+ public void onResume(Fragment fragment);
+ public void onPause(Fragment fragment);
+ public void onLivePreviewClicked(String themeId, String previewURL);
+ public void onActivateThemeClicked(String themeId, Fragment fragment);
+ }
+
+ public String getThemeId() {
+ if (getArguments() != null)
+ return getArguments().getString(ARGS_THEME_ID);
+ else
+ return null;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+
+ // retain this fragment across configuration changes
+ setRetainInstance(true);
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ try {
+ mCallback = (ThemeDetailsFragmentCallback) getActivity();
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString() + " must implement ThemeDetailsFragmentCallback");
+ }
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ // set callback to null so we don't accidentally leak the activity instance
+ mCallback = null;
+ }
+
+ private boolean hasCallback() {
+ return (mCallback != null);
+ }
+
+ @Override
+ public void onStart()
+ {
+ super.onStart();
+
+ // safety check
+ if (getDialog() == null)
+ return;
+
+ int dialogWidth = (int) getActivity().getResources().getDimension(R.dimen.theme_details_fragment_width);
+
+ int dialogHeight = (int) getActivity().getResources().getDimension(R.dimen.theme_details_fragment_height);
+
+ getDialog().getWindow().setLayout(dialogWidth, dialogHeight);
+
+ }
+
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ mParentView = inflater.inflate(R.layout.theme_details_fragment, container, false);
+
+ mNameView = (TextView) mParentView.findViewById(R.id.theme_details_fragment_name);
+ mImageView = (NetworkImageView) mParentView.findViewById(R.id.theme_details_fragment_image);
+ mDescriptionView = (TextView) mParentView.findViewById(R.id.theme_details_fragment_details_description);
+
+ mCurrentThemeView = (View) mParentView.findViewById(R.id.theme_details_fragment_current_theme_text);
+ mPremiumThemeView = (View) mParentView.findViewById(R.id.theme_details_fragment_premium_theme_text);
+
+ mLivePreviewButton = (Button) mParentView.findViewById(R.id.theme_details_fragment_preview_button);
+ mActivateThemeButton = (Button) mParentView.findViewById(R.id.theme_details_fragment_activate_button);
+ mActivatingProgressView = (View) mParentView.findViewById(R.id.theme_details_fragment_activating_progress);
+ mActivateThemeContainer = (FrameLayout) mParentView.findViewById(R.id.theme_details_fragment_activate_button_container);
+
+ mFeaturesContainer = (LinearLayout) mParentView.findViewById(R.id.theme_details_fragment_features_container);
+ mLeftContainer = (View) mParentView.findViewById(R.id.theme_details_fragment_left_container);
+
+ mViewSiteButton = (Button) mParentView.findViewById(R.id.theme_details_fragment_view_site_button);
+ mViewSiteButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent intent = new Intent(getActivity(), ViewSiteActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivity(intent);
+ }
+ });
+
+ mLivePreviewButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (mPreviewURL != null && hasCallback())
+ mCallback.onLivePreviewClicked(getThemeId(), mPreviewURL);
+ }
+ });
+
+ mActivateThemeButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ String themeId = getThemeId();
+ if (themeId != null) {
+ if (hasCallback())
+ mCallback.onActivateThemeClicked(themeId, ThemeDetailsFragment.this);
+ setIsActivatingTheme(true);
+ AnalyticsTracker.track(AnalyticsTracker.Stat.THEMES_CHANGED_THEME);
+ }
+
+ }
+ });
+
+ loadTheme(getThemeId());
+
+ return mParentView;
+ }
+
+ public void showViewSite() {
+ mLivePreviewButton.setVisibility(View.GONE);
+ mActivateThemeContainer.setVisibility(View.GONE);
+ mViewSiteButton.setVisibility(View.VISIBLE);
+ mCurrentThemeView.setVisibility(View.VISIBLE);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ if (hasCallback())
+ mCallback.onResume(this);
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ if (hasCallback())
+ mCallback.onPause(this);
+ };
+
+ @Override
+ public void onPrepareOptionsMenu(Menu menu) {
+ menu.removeItem(R.id.menu_search);
+ }
+
+ /*
+ * update views to indicate that a theme is being activated, or has finished being activated
+ */
+ protected void setIsActivatingTheme(boolean isActivating) {
+ if (isActivating) {
+ mActivateThemeButton.setEnabled(false);
+ mActivateThemeButton.setText("");
+ mActivatingProgressView.setVisibility(View.VISIBLE);
+ } else {
+ mActivateThemeButton.setEnabled(true);
+ mActivateThemeButton.setText(R.string.theme_activate_button);
+ mActivatingProgressView.setVisibility(View.GONE);
+ }
+ }
+
+ public void onThemeActivated(boolean activated) {
+ setIsActivatingTheme(false);
+ if (activated)
+ showViewSite();
+ }
+
+ public void loadTheme(String themeId) {
+ String blogId = String.valueOf(WordPress.getCurrentBlog().getRemoteBlogId());
+ Theme theme = WordPress.wpDB.getTheme(blogId, themeId);
+ if (theme != null) {
+ if (mNameView != null) {
+ mNameView.setText(theme.getName());
+ }
+ mImageView.setImageUrl(theme.getScreenshotURL(), WordPress.imageLoader);
+ mDescriptionView.setText(Html.fromHtml(theme.getDescription()));
+ mDescriptionView.setMovementMethod(WPLinkMovementMethod.getInstance());
+ mPreviewURL = theme.getPreviewURL();
+
+ loadFeatureView(theme.getFeaturesArray());
+ if (theme.isPremium()) {
+ mPremiumThemeView.setVisibility(View.VISIBLE);
+ } else {
+ mPremiumThemeView.setVisibility(View.GONE);
+ }
+
+ if (theme.isCurrent()) {
+ showViewSite();
+ }
+
+ if (getDialog() != null) {
+ getDialog().setTitle(theme.getName());
+ }
+ }
+
+ }
+
+ private void loadFeatureView(ArrayList<String> featuresArray) {
+ int size = featuresArray.size();
+ View views[] = new View[size];
+
+ LayoutInflater inflater = LayoutInflater.from(getActivity());
+
+ for (int i = 0; i < size; i++) {
+ TextView tv = (TextView) inflater.inflate(R.layout.theme_feature_text, mFeaturesContainer, false);
+ tv.setText(featuresArray.get(i));
+ views[i] = tv;
+ }
+
+ // make the list of features appear in such a way that the text appear on the next line
+ // when reaching the end of the current line
+ populateViews(mFeaturesContainer, views, getActivity());
+ }
+
+ /**
+ * Copyright 2011 Sherif
+ * Updated by Karim Varela to handle LinearLayouts with other views on either side.
+ * @param linearLayout
+ * @param views : The views to wrap within LinearLayout
+ * @param context
+ * @author Karim Varela
+ **/
+ private void populateViews(LinearLayout linearLayout, View[] views, Context context)
+ {
+ RelativeLayout.LayoutParams llParams = (android.widget.RelativeLayout.LayoutParams) linearLayout.getLayoutParams();
+
+ Display display = getActivity().getWindowManager().getDefaultDisplay();
+ linearLayout.removeAllViews();
+
+ int maxWidth = display.getWidth() - llParams.leftMargin - llParams.rightMargin - mParentView.getPaddingLeft() - mParentView.getPaddingRight();
+
+
+ if (Utils.isXLarge(getActivity())) {
+ int minDialogWidth = getResources().getDimensionPixelSize(R.dimen.theme_details_dialog_min_width);
+ int dialogWidth = Math.max((int) (display.getWidth() * 0.6), minDialogWidth);
+ maxWidth = dialogWidth / 2 - llParams.leftMargin - llParams.rightMargin;
+
+ } else if (Utils.isTablet() && mLeftContainer != null) {
+ int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
+ mLeftContainer.measure(spec, spec);
+
+ LinearLayout.LayoutParams params = (LayoutParams) mLeftContainer.getLayoutParams();
+
+ maxWidth -= mLeftContainer.getMeasuredWidth() + params.rightMargin + params.leftMargin;
+ }
+
+ linearLayout.setOrientation(LinearLayout.VERTICAL);
+
+ LinearLayout.LayoutParams params;
+ LinearLayout newLL = new LinearLayout(context);
+ newLL.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
+ newLL.setGravity(Gravity.LEFT);
+ newLL.setOrientation(LinearLayout.HORIZONTAL);
+
+ int widthSoFar = 0;
+
+ int dp4 = (int) Utils.dpToPx(4);
+ int dp2 = (int) Utils.dpToPx(2);
+
+ for (int i = 0; i < views.length; i++)
+ {
+ LinearLayout LL = new LinearLayout(context);
+ LL.setOrientation(LinearLayout.HORIZONTAL);
+ LL.setLayoutParams(new ListView.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT));
+
+ views[i].measure(0, 0);
+ params = new LinearLayout.LayoutParams(views[i].getMeasuredWidth(), LayoutParams.WRAP_CONTENT);
+ params.setMargins(0, dp2, dp4, dp2);
+
+ LL.addView(views[i], params);
+ LL.measure(0, 0);
+ widthSoFar += views[i].getMeasuredWidth() + views[i].getPaddingLeft() + views[i].getPaddingRight();
+ if (widthSoFar >= maxWidth)
+ {
+ linearLayout.addView(newLL);
+
+ newLL = new LinearLayout(context);
+ newLL.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
+ newLL.setOrientation(LinearLayout.HORIZONTAL);
+ newLL.setGravity(Gravity.LEFT);
+ params = new LinearLayout.LayoutParams(LL.getMeasuredWidth(), LL.getMeasuredHeight());
+ newLL.addView(LL, params);
+ widthSoFar = LL.getMeasuredWidth();
+ }
+ else
+ {
+ newLL.addView(LL);
+ }
+ }
+ linearLayout.addView(newLL);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemePreviewFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemePreviewFragment.java
new file mode 100644
index 000000000..3ae1a2e7e
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemePreviewFragment.java
@@ -0,0 +1,196 @@
+package org.wordpress.android.ui.themes;
+
+import android.app.Activity;
+import android.app.Fragment;
+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.ViewGroup;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.widget.ProgressBar;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.WPWebChromeClient;
+import org.wordpress.android.util.WPWebViewClient;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+
+/**
+ * A fragment to display a preview of the theme being applied on a blog.
+ *
+ */
+public class ThemePreviewFragment extends Fragment {
+ public static final String TAG = ThemePreviewFragment.class.getName();
+ private static final String ARGS_THEME_ID = "theme_id";
+ private static final String ARGS_PREVIEW_URL = "preview_url";
+
+ // sample desktop user-agent to force desktop view of site
+ private static final String DESKTOP_UA = "Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.0.4) Gecko/20100101 Firefox/4.0";
+
+
+ private ThemePreviewFragmentCallback mCallback;
+ private WebView mWebView;
+ private Blog mBlog;
+ private String mThemeId;
+ private String mPreviewURL;
+
+ public interface ThemePreviewFragmentCallback {
+ public void onResume(Fragment fragment);
+ public void onPause(Fragment fragment);
+ public void onActivateThemeClicked(String themeId, Fragment fragment);
+ }
+
+
+ public static ThemePreviewFragment newInstance(String themeId, String previewURL) {
+ ThemePreviewFragment fragment = new ThemePreviewFragment();
+
+ Bundle args = new Bundle();
+ args.putString(ARGS_THEME_ID, themeId);
+ args.putString(ARGS_PREVIEW_URL, previewURL);
+ fragment.setArguments(args);
+
+ return fragment;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ try {
+ mCallback = (ThemePreviewFragmentCallback) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString() + " must implement ThemePreviewFragmentCallback");
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ mCallback.onResume(this);
+ setHasOptionsMenu(true);
+ setMenuVisibility(true);
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ mCallback.onPause(this);
+ }
+
+ public String getThemeId() {
+ if (mThemeId != null) {
+ return mThemeId;
+ } else if (getArguments() != null) {
+ mThemeId = getArguments().getString(ARGS_THEME_ID);
+ return mThemeId;
+ } else {
+ return null;
+ }
+ }
+
+ private String getPreviewURL() {
+ if (mPreviewURL != null) {
+ return mPreviewURL;
+ } else if (getArguments() != null) {
+ mPreviewURL = getArguments().getString(ARGS_PREVIEW_URL);
+ return mPreviewURL;
+ } else {
+ return null;
+ }
+ }
+
+ public void load(String themeId, String previewURL) {
+ mThemeId = themeId;
+ mPreviewURL = previewURL;
+ refreshViews();
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ mBlog = WordPress.getCurrentBlog();
+ String previewURL = getPreviewURL();
+
+ if (previewURL == null || mBlog == null)
+ getActivity().getFragmentManager().beginTransaction().remove(this).commit();
+
+ View view = inflater.inflate(R.layout.webview, container, false);
+
+ mWebView = (WebView) view.findViewById(R.id.webView);
+ mWebView.getSettings().setUserAgentString(DESKTOP_UA);
+ mWebView.getSettings().setBuiltInZoomControls(true);
+ mWebView.setScrollBarStyle(View.SCROLLBARS_INSIDE_OVERLAY);
+
+ mWebView.setWebChromeClient(new WPWebChromeClient(getActivity(), (ProgressBar) view.findViewById(
+ R.id.progress_bar)));
+
+ mWebView.setWebViewClient(new WPWebViewClient(mBlog));
+
+ mWebView.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE);
+ mWebView.getSettings().setSavePassword(false);
+
+ loadAuthenticatedUrl(previewURL);
+
+ return view;
+ }
+
+ private void refreshViews() {
+ loadAuthenticatedUrl(getPreviewURL());
+ }
+
+
+ /**
+ * Login to the WordPress blog and load the specified URL.
+ *
+ * @param url URL to be loaded in the webview.
+ */
+ protected void loadAuthenticatedUrl(String url) {
+ try {
+ if (mBlog == null || url == null) {
+ return;
+ }
+ String postData = String.format("log=%s&pwd=%s&redirect_to=%s",
+ URLEncoder.encode(mBlog.getUsername(), "UTF-8"),
+ URLEncoder.encode(mBlog.getPassword(), "UTF-8"),
+ URLEncoder.encode(url, "UTF-8"));
+ mWebView.postUrl(WordPress.getLoginUrl(mBlog), postData.getBytes());
+ } catch (UnsupportedEncodingException e) {
+ AppLog.e(T.THEMES, e);
+ }
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ inflater.inflate(R.menu.theme_preview, menu);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.menu_activate:
+ mCallback.onActivateThemeClicked(getThemeId(), ThemePreviewFragment.this);
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ @Override
+ public void onPrepareOptionsMenu(Menu menu) {
+ menu.removeItem(R.id.menu_search);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeSearchFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeSearchFragment.java
new file mode 100644
index 000000000..ab5074d65
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeSearchFragment.java
@@ -0,0 +1,150 @@
+package org.wordpress.android.ui.themes;
+
+import android.database.Cursor;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.MenuItem.OnActionExpandListener;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.SearchView;
+import android.widget.SearchView.OnQueryTextListener;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+
+/**
+ * A fragment for display the results of a theme search
+ */
+public class ThemeSearchFragment extends ThemeTabFragment implements OnQueryTextListener, OnActionExpandListener {
+ public static final String TAG = ThemeSearchFragment.class.getName();
+ private static final String BUNDLE_LAST_SEARCH = "BUNDLE_LAST_SEARCH";
+
+ public static ThemeSearchFragment newInstance() {
+ ThemeSearchFragment fragment = new ThemeSearchFragment();
+
+ Bundle args = new Bundle();
+ args.putInt(ARGS_SORT, ThemeSortType.POPULAR.ordinal());
+ fragment.setArguments(args);
+
+ return fragment;
+ }
+
+ private String mLastSearch = "";
+ private SearchView mSearchView;
+ private MenuItem mSearchMenuItem;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = super.onCreateView(inflater, container, savedInstanceState);
+
+ restoreState(savedInstanceState);
+
+ return view;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ }
+
+ private void restoreState(Bundle savedInstanceState) {
+ if (savedInstanceState != null) {
+ if (savedInstanceState.containsKey(BUNDLE_LAST_SEARCH)) {
+ mLastSearch = savedInstanceState.getString(BUNDLE_LAST_SEARCH);
+ }
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ saveState(outState);
+ }
+
+ private void saveState(Bundle outState) {
+ outState.putString(BUNDLE_LAST_SEARCH, mLastSearch);
+ }
+
+ @Override
+ public void onPrepareOptionsMenu(Menu menu) {
+ super.onPrepareOptionsMenu(menu);
+ menu.removeItem(R.id.menu_search);
+
+ mSearchMenuItem = menu.findItem(R.id.menu_theme_search);
+ mSearchMenuItem.expandActionView();
+ mSearchMenuItem.setOnActionExpandListener(this);
+
+ mSearchView = (SearchView) mSearchMenuItem.getActionView();
+ mSearchView.setIconified(false);
+ mSearchView.setOnQueryTextListener(this);
+ mSearchView.setQuery(mLastSearch, true);
+ }
+
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ super.onItemClick(parent, view, position, id);
+ mSearchView.clearFocus();
+ }
+
+
+ @Override
+ public boolean onMenuItemActionExpand(MenuItem item) {
+ if (item.getItemId() == R.id.menu_theme_search) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onMenuItemActionCollapse(MenuItem item) {
+ getActivity().getFragmentManager().popBackStack();
+ return true;
+ }
+
+ @Override
+ public boolean onQueryTextSubmit(String query) {
+ search(query);
+ mSearchView.clearFocus();
+ return true;
+ }
+
+ @Override
+ public boolean onQueryTextChange(String newText) {
+ search(newText);
+ return true;
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ inflater.inflate(R.menu.theme_search, menu);
+ }
+
+ public void search(String searchTerm) {
+ mLastSearch = searchTerm;
+
+ String blogId = getBlogId();
+ Cursor cursor = WordPress.wpDB.getThemes(blogId, searchTerm);
+ if (mAdapter == null) {
+ return;
+ } else {
+ mAdapter.changeCursor(cursor);
+ mGridView.invalidateViews();
+
+ if (cursor == null || cursor.getCount() == 0) {
+ mNoResultText.setVisibility(View.VISIBLE);
+ } else {
+ mNoResultText.setVisibility(View.GONE);
+ }
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeTabAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeTabAdapter.java
new file mode 100644
index 000000000..dc1e26ea4
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeTabAdapter.java
@@ -0,0 +1,128 @@
+package org.wordpress.android.ui.themes;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.drawable.Drawable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CursorAdapter;
+import android.widget.GridView;
+import android.widget.ImageView;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import com.android.volley.toolbox.NetworkImageView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.Utils;
+
+/**
+ * Adapter for the {@link ThemeTabFragment}'s gridview
+ *
+ */
+public class ThemeTabAdapter extends CursorAdapter {
+ private final LayoutInflater mInflater;
+ private final int mColumnWidth;
+ private final int mColumnHeight;
+ private final Drawable mIconPremium;
+ private final Drawable mIconCurrent;
+ private final int m32DpToPx;
+
+ public ThemeTabAdapter(Context context, Cursor c, boolean autoRequery) {
+ super(context, c, autoRequery);
+ mInflater = LayoutInflater.from(context);
+ mColumnWidth = getColumnWidth(context);
+ mColumnHeight = (int) (0.75f * mColumnWidth);
+ mIconPremium = context.getResources().getDrawable(R.drawable.theme_icon_tag_premium);
+ mIconCurrent = context.getResources().getDrawable(R.drawable.theme_icon_tag_current);
+ m32DpToPx = (int) Utils.dpToPx(32);
+ }
+
+ private static class ThemeViewHolder {
+ private final NetworkImageView imageView;
+ private final TextView nameView;
+ private final ImageView themeAttr;
+
+ ThemeViewHolder(View view) {
+ imageView = (NetworkImageView) view.findViewById(R.id.theme_grid_item_image);
+ nameView = (TextView) view.findViewById(R.id.theme_grid_item_name);
+ themeAttr = (ImageView) view.findViewById(R.id.theme_grid_attributes);
+ }
+ }
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup parent) {
+ View view = mInflater.inflate(R.layout.theme_grid_item, parent, false);
+
+ ThemeViewHolder themeViewHolder = new ThemeViewHolder(view);
+ view.setTag(themeViewHolder);
+
+ // size the imageView to fit the column - image will be requested at this same width
+ RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) themeViewHolder.imageView.getLayoutParams();
+ params.width = mColumnWidth;
+ params.height = mColumnHeight;
+
+ return view;
+ }
+
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ final ThemeViewHolder themeViewHolder = (ThemeViewHolder) view.getTag();
+
+ final String screenshotURL = cursor.getString(cursor.getColumnIndex("screenshotURL"));
+ final String name = cursor.getString(cursor.getColumnIndex("name"));
+ final int isPremiumTheme = cursor.getInt(cursor.getColumnIndex("isPremium"));
+ final int isCurrentTheme = cursor.getInt(cursor.getColumnIndex("isCurrent"));
+
+ themeViewHolder.nameView.setText(name);
+
+ if (isCurrentTheme != 0) {
+ themeViewHolder.themeAttr.setVisibility(View.VISIBLE);
+ themeViewHolder.themeAttr.setImageDrawable(mIconCurrent);
+ } else if (isPremiumTheme != 0) {
+ themeViewHolder.themeAttr.setVisibility(View.VISIBLE);
+ themeViewHolder.themeAttr.setImageDrawable(mIconPremium);
+ } else {
+ themeViewHolder.themeAttr.setVisibility(View.GONE);
+ }
+
+ ScreenshotHolder urlHolder = (ScreenshotHolder) themeViewHolder.imageView.getTag();
+ if (urlHolder == null) {
+ urlHolder = new ScreenshotHolder();
+ urlHolder.requestURL = screenshotURL;
+ themeViewHolder.imageView.setTag(urlHolder);
+ }
+
+ if (!urlHolder.requestURL.equals(screenshotURL)) {
+ themeViewHolder.imageView.setImageBitmap(null);
+ urlHolder.requestURL = screenshotURL;
+ }
+
+ themeViewHolder.imageView.setImageUrl(screenshotURL + "?w=" + mColumnWidth, WordPress.imageLoader);
+ view.setLayoutParams(new GridView.LayoutParams(mColumnWidth, mColumnHeight + m32DpToPx));
+ }
+
+ // The theme previews are 600x450 px, resulting in a ratio of 0.75
+ // We'll try to max the width, while keeping the padding ratio correct.
+ // Then we'll determine the height based on the width and the 0.75 ratio
+ private static int getColumnWidth(Context context) {
+ // Padding is 4 dp between the grid columns and on the outside
+ int columnCount = context.getResources().getInteger(R.integer.themes_grid_num_columns);
+ int dp4 = (int) Utils.dpToPx(4);
+ int padding = (columnCount + 1) * dp4;
+
+ // the max width of the themes is either:
+ // = width of entire screen (phone and tablet portrait)
+ // = width of entire screen - menu drawer width (tablet landscape)
+ int maxWidth = context.getResources().getDisplayMetrics().widthPixels;
+ if (Utils.isXLarge(context) && Utils.isLandscape(context))
+ maxWidth -= context.getResources().getDimensionPixelSize(R.dimen.menu_drawer_width);
+
+ return (maxWidth - padding) / columnCount;
+ }
+
+ static class ScreenshotHolder {
+ String requestURL;
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeTabFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeTabFragment.java
new file mode 100644
index 000000000..46e372be5
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/themes/ThemeTabFragment.java
@@ -0,0 +1,248 @@
+package org.wordpress.android.ui.themes;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AbsListView.RecyclerListener;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemClickListener;
+import android.widget.GridView;
+import android.widget.TextView;
+
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.ImageLoader.ImageContainer;
+import com.android.volley.toolbox.ImageLoader.ImageListener;
+import com.android.volley.toolbox.NetworkImageView;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.ui.PullToRefreshHelper;
+import org.wordpress.android.ui.PullToRefreshHelper.RefreshListener;
+import org.wordpress.android.ui.themes.ThemeTabAdapter.ScreenshotHolder;
+import org.wordpress.android.util.NetworkUtils;
+
+import uk.co.senab.actionbarpulltorefresh.library.PullToRefreshLayout;
+
+/**
+ * A fragment display the themes on a grid view.
+ */
+public class ThemeTabFragment extends Fragment implements OnItemClickListener, RecyclerListener {
+ public enum ThemeSortType {
+ TRENDING("Trending"),
+ NEWEST("Newest"),
+ POPULAR("Popular");
+
+ private String mTitle;
+
+ private ThemeSortType(String title) {
+ mTitle = title;
+ }
+
+ public String getTitle() {
+ return mTitle;
+ }
+
+ public static ThemeSortType getTheme(int position) {
+ if (position < ThemeSortType.values().length)
+ return ThemeSortType.values()[position];
+ else
+ return TRENDING;
+ }
+ }
+
+ public interface ThemeTabFragmentCallback {
+ public void onThemeSelected(String themeId);
+ }
+
+ protected static final String ARGS_SORT = "ARGS_SORT";
+ protected static final String ARGS_PAGE = "ARGS_PAGE";
+ protected static final String BUNDLE_SCROLL_POSTION = "BUNDLE_SCROLL_POSTION";
+
+ protected GridView mGridView;
+ protected TextView mNoResultText;
+ protected ThemeTabAdapter mAdapter;
+ protected ThemeTabFragmentCallback mCallback;
+ protected int mSavedScrollPosition = 0;
+ private boolean mShouldRefreshOnStart;
+ private PullToRefreshHelper mPullToRefreshHelper;
+
+ public static ThemeTabFragment newInstance(ThemeSortType sort, int page) {
+ ThemeTabFragment fragment = new ThemeTabFragment();
+ Bundle args = new Bundle();
+ args.putInt(ARGS_SORT, sort.ordinal());
+ args.putInt(ARGS_PAGE, page);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ try {
+ mCallback = (ThemeTabFragmentCallback) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString() + " must implement ThemeTabFragmentCallback");
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.theme_tab_fragment, container, false);
+
+ setRetainInstance(true);
+
+ mNoResultText = (TextView) view.findViewById(R.id.theme_no_search_result_text);
+
+ mGridView = (GridView) view.findViewById(R.id.theme_gridview);
+ mGridView.setRecyclerListener(this);
+
+ // pull to refresh setup but not for the search view
+ if (!(this instanceof ThemeSearchFragment)) {
+ mPullToRefreshHelper = new PullToRefreshHelper(getActivity(), (PullToRefreshLayout) view.findViewById(
+ R.id.ptr_layout), new RefreshListener() {
+ @Override
+ public void onRefreshStarted(View view) {
+ if (getActivity() == null || !NetworkUtils.checkConnection(getActivity())) {
+ mPullToRefreshHelper.setRefreshing(false);
+ return;
+ }
+ if (getActivity() instanceof ThemeBrowserActivity) {
+ ((ThemeBrowserActivity) getActivity()).fetchThemes(getArguments().getInt(ARGS_PAGE));
+ }
+ }
+ });
+ mPullToRefreshHelper.setRefreshing(mShouldRefreshOnStart);
+ }
+ restoreState(savedInstanceState);
+ return view;
+ }
+
+ public void setRefreshing(boolean refreshing) {
+ mShouldRefreshOnStart = refreshing;
+ if (mPullToRefreshHelper != null) {
+ mPullToRefreshHelper.setRefreshing(refreshing);
+ if (!refreshing) {
+ refreshView();
+ }
+ }
+ }
+
+ private void restoreState(Bundle savedInstanceState) {
+ if (savedInstanceState != null) {
+ mSavedScrollPosition = savedInstanceState.getInt(BUNDLE_SCROLL_POSTION, 0);
+ }
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ if (WordPress.getCurrentBlog() == null)
+ return;
+
+ Cursor cursor = fetchThemes(getThemeSortType());
+ mAdapter = new ThemeTabAdapter(getActivity(), cursor, false);
+ mGridView.setAdapter(mAdapter);
+ mGridView.setOnItemClickListener(this);
+ mGridView.setSelection(mSavedScrollPosition);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ if (mGridView != null)
+ outState.putInt(BUNDLE_SCROLL_POSTION, mGridView.getFirstVisiblePosition());
+ }
+
+ private ThemeSortType getThemeSortType() {
+ int sortType = ThemeSortType.TRENDING.ordinal();
+ if (getArguments().containsKey(ARGS_SORT)) {
+ sortType = getArguments().getInt(ARGS_SORT);
+ }
+
+ return ThemeSortType.getTheme(sortType);
+ }
+
+ private Cursor fetchThemes(ThemeSortType themeSortType) {
+ String blogId = getBlogId();
+
+ switch(themeSortType) {
+ case POPULAR:
+ return WordPress.wpDB.getThemesPopularity(blogId);
+ case NEWEST:
+ return WordPress.wpDB.getThemesNewest(blogId);
+ case TRENDING:
+ default:
+ return WordPress.wpDB.getThemesTrending(blogId);
+
+ }
+
+ }
+
+ private void refreshView() {
+ Cursor cursor = fetchThemes(getThemeSortType());
+ if (mAdapter == null) {
+ mAdapter = new ThemeTabAdapter(getActivity(), cursor, false);
+ }
+ if (mNoResultText.isShown()) {
+ mNoResultText.setVisibility(View.GONE);
+ }
+ mAdapter.changeCursor(cursor);
+ }
+
+ protected String getBlogId() {
+ return String.valueOf(WordPress.getCurrentBlog().getRemoteBlogId());
+ }
+
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ Cursor cursor = ((ThemeTabAdapter) parent.getAdapter()).getCursor();
+ String themeId = cursor.getString(cursor.getColumnIndex("themeId"));
+ mCallback.onThemeSelected(themeId);
+ }
+
+ @Override
+ public void onMovedToScrapHeap(View view) {
+ // cancel image fetch requests if the view has been moved to recycler.
+
+ NetworkImageView niv = (NetworkImageView) view.findViewById(R.id.theme_grid_item_image);
+ if (niv != null) {
+ // this tag is set in the ThemeTabAdapter class
+ ScreenshotHolder tag = (ScreenshotHolder) niv.getTag();
+ if (tag != null && tag.requestURL != null) {
+ // need a listener to cancel request, even if the listener does nothing
+ ImageContainer container = WordPress.imageLoader.get(tag.requestURL, new ImageListener() {
+ @Override
+ public void onErrorResponse(VolleyError error) { }
+
+ @Override
+ public void onResponse(ImageContainer response, boolean isImmediate) { }
+
+ });
+ container.cancelRequest();
+ }
+ }
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ // mPullToRefreshHelper is null when current fragment is a ThemeSearchFragment
+ if (mPullToRefreshHelper != null) {
+ mPullToRefreshHelper.registerReceiver(getActivity());
+ }
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ if (mPullToRefreshHelper != null) {
+ mPullToRefreshHelper.unregisterReceiver(getActivity());
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/AlertUtil.java b/WordPress/src/main/java/org/wordpress/android/util/AlertUtil.java
new file mode 100644
index 000000000..76800de4c
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/AlertUtil.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2011 wordpress.org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.wordpress.android.util;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.DialogInterface;
+
+public class AlertUtil {
+ /**
+ * Show Alert Dialog
+ * @param context
+ * @param titleId
+ * @param messageId
+ */
+ public static void showAlert(Context context, int titleId, int messageId) {
+ Dialog dlg = new AlertDialog.Builder(context)
+ .setTitle(titleId)
+ .setPositiveButton(android.R.string.ok, null)
+ .setMessage(messageId)
+ .create();
+
+ dlg.show();
+ }
+
+ /**
+ * Show Alert Dialog
+ * @param context
+ * @param titleId
+ * @param messageId
+ */
+ public static void showAlert(Context context, int titleId, String message) {
+ Dialog dlg = new AlertDialog.Builder(context)
+ .setTitle(titleId)
+ .setPositiveButton(android.R.string.ok, null)
+ .setMessage(message)
+ .create();
+
+ dlg.show();
+ }
+
+ /**
+ * Show Alert Dialog
+ * @param context
+ * @param titleId
+ * @param messageId
+ * @param positiveButtontxt
+ * @param positiveListener
+ * @param negativeButtontxt
+ * @param negativeListener
+ */
+ public static void showAlert(Context context, int titleId, int messageId,
+ CharSequence positiveButtontxt, DialogInterface.OnClickListener positiveListener,
+ CharSequence negativeButtontxt, DialogInterface.OnClickListener negativeListener) {
+ Dialog dlg = new AlertDialog.Builder(context)
+ .setTitle(titleId)
+ .setPositiveButton(positiveButtontxt, positiveListener)
+ .setNegativeButton(negativeButtontxt, negativeListener)
+ .setMessage(messageId)
+ .setCancelable(false)
+ .create();
+
+ dlg.show();
+ }
+
+ /**
+ * Show Alert Dialog
+ * @param context
+ * @param titleId
+ * @param messageId
+ * @param positiveButtontxt
+ * @param positiveListener
+ */
+ public static void showAlert(Context context, int titleId, String message,
+ CharSequence positiveButtontxt, DialogInterface.OnClickListener positiveListener) {
+ Dialog dlg = new AlertDialog.Builder(context)
+ .setTitle(titleId)
+ .setPositiveButton(positiveButtontxt, positiveListener)
+ .setMessage(message)
+ .setCancelable(false)
+ .create();
+
+ dlg.show();
+ }
+}
+
diff --git a/WordPress/src/main/java/org/wordpress/android/util/AniUtils.java b/WordPress/src/main/java/org/wordpress/android/util/AniUtils.java
new file mode 100644
index 000000000..0a59f060c
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/AniUtils.java
@@ -0,0 +1,66 @@
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.view.View;
+import android.view.animation.Animation;
+import android.view.animation.Animation.AnimationListener;
+import android.view.animation.AnimationUtils;
+import android.view.animation.OvershootInterpolator;
+
+import org.wordpress.android.R;
+
+public class AniUtils {
+ private AniUtils() {
+ throw new AssertionError();
+ }
+
+ public static void fadeIn(View target) {
+ startAnimation(target, android.R.anim.fade_in, null);
+ if (target.getVisibility() != View.VISIBLE)
+ target.setVisibility(View.VISIBLE);
+ }
+
+ public static void flyIn(View target) {
+ Context context = target.getContext();
+ Animation animation = AnimationUtils.loadAnimation(context, R.anim.reader_flyin);
+ if (animation==null)
+ return;
+
+ // add small overshoot for bounce effect
+ animation.setInterpolator(new OvershootInterpolator(0.9f));
+ long duration = context.getResources().getInteger(android.R.integer.config_mediumAnimTime);
+ animation.setDuration((long)(duration * 1.5f));
+
+ target.startAnimation(animation);
+ target.setVisibility(View.VISIBLE);
+ }
+
+ public static void flyOut(final View target) {
+ AnimationListener listener = new AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation animation) { }
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ target.setVisibility(View.GONE);
+ }
+ @Override
+ public void onAnimationRepeat(Animation animation) { }
+ };
+ startAnimation(target, R.anim.reader_flyout, listener);
+ }
+
+ public static void startAnimation(View target, int aniResId) {
+ startAnimation(target, aniResId, null);
+ }
+ public static void startAnimation(View target, int aniResId, AnimationListener listener) {
+ if (target==null)
+ return;
+ Animation animation = AnimationUtils.loadAnimation(target.getContext(), aniResId);
+ if (animation==null)
+ return;
+ if (listener!=null)
+ animation.setAnimationListener(listener);
+
+ target.startAnimation(animation);
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/util/AppLog.java b/WordPress/src/main/java/org/wordpress/android/util/AppLog.java
new file mode 100644
index 000000000..a2fa3f036
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/AppLog.java
@@ -0,0 +1,234 @@
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.android.volley.VolleyError;
+
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.CrashlyticsUtils.ExceptionType;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+
+/**
+ * simple wrapper for Android log calls, enables recording & displaying log
+ */
+public class AppLog {
+ // T for Tag
+ public enum T {READER, EDITOR, MEDIA, NUX, API, STATS, UTILS, NOTIFS, DB, POSTS, COMMENTS, THEMES, TESTS, PROFILING, SIMPERIUM}
+ public static final String TAG = "WordPress";
+
+ private static boolean mEnableRecording = false;
+ private static boolean mEnableCrashlytics = false;
+
+ private AppLog() {
+ throw new AssertionError();
+ }
+
+ /*
+ * defaults to false, pass true to capture log so it can be displayed by AppLogViewerActivity
+ */
+ public static void enableRecording(boolean enable) {
+ mEnableRecording = enable;
+ }
+
+ public static void enableCrashlytics(boolean enable) {
+ mEnableCrashlytics = enable;
+ }
+
+ private static void crashlyticsLog(T tag, Throwable throwable, String message) {
+ if (mEnableCrashlytics) {
+ CrashlyticsUtils.logException(throwable, ExceptionType.USUAL, tag, message);
+ }
+ }
+
+ public static void v(T tag, String message) {
+ Log.v(TAG + "-" + tag.toString(), message);
+ addEntry(tag, LogLevel.v, message);
+ }
+
+ public static void d(T tag, String message) {
+ Log.d(TAG + "-" + tag.toString(), message);
+ addEntry(tag, LogLevel.d, message);
+ }
+
+ public static void i(T tag, String message) {
+ Log.i(TAG + "-" + tag.toString(), message);
+ addEntry(tag, LogLevel.i, message);
+ }
+
+ public static void w(T tag, String message) {
+ Log.w(TAG + "-" + tag.toString(), message);
+ addEntry(tag, LogLevel.w, message);
+ }
+
+ public static void e(T tag, String message) {
+ Log.e(TAG + "-" + tag.toString(), message);
+ addEntry(tag, LogLevel.e, message);
+ }
+
+ public static void e(T tag, String message, Throwable tr) {
+ Log.e(TAG + "-" + tag.toString(), message, tr);
+ addEntry(tag, LogLevel.e, message + " - exception: " + tr.getMessage());
+ addEntry(tag, LogLevel.e, "StackTrace: " + getHTMLStringStackTrace(tr));
+ crashlyticsLog(tag, tr, message);
+ }
+
+ public static void e(T tag, Throwable tr) {
+ Log.e(TAG + "-" + tag.toString(), tr.getMessage(), tr);
+ addEntry(tag, LogLevel.e, tr.getMessage());
+ addEntry(tag, LogLevel.e, "StackTrace: " + getHTMLStringStackTrace(tr));
+ crashlyticsLog(tag, tr, null);
+ }
+
+ public static void e(T tag, VolleyError volleyError) {
+ if (volleyError == null) {
+ return;
+ }
+ String logText;
+ if (volleyError.networkResponse == null) {
+ logText = volleyError.getMessage();
+ } else {
+ logText = volleyError.getMessage() + ", status " + volleyError.networkResponse.statusCode + " - " +
+ volleyError.networkResponse.toString();
+ }
+ Log.e(TAG + "-" + tag.toString(), logText, volleyError);
+ addEntry(tag, LogLevel.w, logText);
+ addEntry(tag, LogLevel.e, "StackTrace: " + getHTMLStringStackTrace(volleyError));
+ crashlyticsLog(tag, volleyError, logText);
+ }
+
+ // --------------------------------------------------------------------------------------------------------
+
+ private static final int MAX_ENTRIES = 99;
+
+ private enum LogLevel {
+ v, d, i, w, e;
+ private String toHtmlColor() {
+ switch(this) {
+ case v:
+ return "grey";
+ case i:
+ return "black";
+ case w:
+ return "purple";
+ case e:
+ return "red";
+ case d:
+ default:
+ return "teal";
+ }
+ }
+ }
+
+ private static class LogEntry {
+ LogLevel logLevel;
+ String logText;
+ T logTag;
+
+ private String toHtml() {
+ StringBuilder sb = new StringBuilder()
+ .append("<font color='")
+ .append(logLevel.toHtmlColor())
+ .append("'>")
+ .append("[")
+ .append(logTag.name())
+ .append("] ")
+ .append(logLevel.name())
+ .append(": ")
+ .append(logText)
+ .append("</font>");
+ return sb.toString();
+ }
+ }
+
+ private static class LogEntryList extends ArrayList<LogEntry> {
+ private synchronized boolean addEntry(LogEntry entry) {
+ if (size() >= MAX_ENTRIES)
+ removeFirstEntry();
+ return add(entry);
+ }
+ private void removeFirstEntry() {
+ Iterator<LogEntry> it = iterator();
+ if (!it.hasNext())
+ return;
+ try {
+ remove(it.next());
+ } catch (NoSuchElementException e) {
+ // ignore
+ }
+ }
+ }
+
+ private static LogEntryList mLogEntries = new LogEntryList();
+
+ private static void addEntry(T tag, LogLevel level, String text) {
+ // skip if recording is disabled (default)
+ if (!mEnableRecording)
+ return;
+ LogEntry entry = new LogEntry();
+ entry.logLevel = level;
+ entry.logText = text;
+ entry.logTag = tag;
+ mLogEntries.addEntry(entry);
+ }
+
+ private static String getStringStackTrace(Throwable throwable) {
+ StringWriter errors = new StringWriter();
+ throwable.printStackTrace(new PrintWriter(errors));
+ return errors.toString();
+ }
+
+ private static String getHTMLStringStackTrace(Throwable throwable) {
+ return getStringStackTrace(throwable).replace("\n", "<br/>");
+ }
+
+ /*
+ * returns entire log as html for display (see AppLogViewerActivity)
+ */
+ public static String toHtml(Context context) {
+ StringBuilder sb = new StringBuilder();
+
+ // add version & device info
+ sb.append("WordPress Android version: " + WordPress.getVersionName(context)).append("<br />")
+ .append("Android device name: " + DeviceUtils.getInstance().getDeviceName(context)).append("<br />");
+
+ Iterator<LogEntry> it = mLogEntries.iterator();
+ int lineNum = 1;
+ while (it.hasNext()) {
+ sb.append("<font color='silver'>")
+ .append(String.format("%02d", lineNum))
+ .append("</font> ")
+ .append(it.next().toHtml())
+ .append("<br />");
+ lineNum++;
+ }
+ return sb.toString();
+ }
+
+
+ /*
+ * returns entire log as plain text
+ */
+ public static String toPlainText(Context context) {
+ StringBuilder sb = new StringBuilder();
+
+ // add version & device info
+ sb.append("WordPress Android version: " + WordPress.getVersionName(context)).append("\n")
+ .append("Android device name: " + DeviceUtils.getInstance().getDeviceName(context)).append("\n\n");
+
+ Iterator<LogEntry> it = mLogEntries.iterator();
+ int lineNum = 1;
+ while (it.hasNext()) {
+ sb.append(String.format("%02d - ", lineNum))
+ .append(it.next().logText)
+ .append("\n");
+ lineNum++;
+ }
+ return sb.toString();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/AuthErrorDialogFragment.java b/WordPress/src/main/java/org/wordpress/android/util/AuthErrorDialogFragment.java
new file mode 100644
index 000000000..e963819c4
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/AuthErrorDialogFragment.java
@@ -0,0 +1,85 @@
+package org.wordpress.android.util;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.os.Bundle;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.ui.accounts.WPComLoginActivity;
+import org.wordpress.android.ui.prefs.BlogPreferencesActivity;
+
+/**
+ * An alert dialog fragment for XML-RPC authentication failures
+ */
+public class AuthErrorDialogFragment extends DialogFragment {
+ public static int DEFAULT_RESOURCE_ID = -1;
+
+ private static boolean mIsWPCom;
+ private static int mMessageId;
+ private static int mTitleId;
+
+ public static AuthErrorDialogFragment newInstance(boolean isWPCom, int titleResourceId, int messageResourceId) {
+ mIsWPCom = isWPCom;
+
+ if (titleResourceId != DEFAULT_RESOURCE_ID) {
+ mTitleId = titleResourceId;
+ } else if (mIsWPCom) {
+ mTitleId = R.string.wpcom_signin_dialog_title;
+ } else {
+ mTitleId = R.string.connection_error;
+ }
+
+ if (messageResourceId != DEFAULT_RESOURCE_ID) {
+ mMessageId = messageResourceId;
+ } else {
+ mMessageId = R.string.incorrect_credentials;
+ }
+ return new AuthErrorDialogFragment();
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ this.setCancelable(true);
+ int style = DialogFragment.STYLE_NORMAL, theme = 0;
+ setStyle(style, theme);
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ AlertDialog.Builder b = new AlertDialog.Builder(getActivity());
+ b.setTitle(mTitleId);
+ b.setMessage(mMessageId);
+ if (mIsWPCom) {
+ b.setPositiveButton(R.string.sign_in, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ Intent authIntent = new Intent(getActivity(), WPComLoginActivity.class);
+ authIntent.putExtra("wpcom", true);
+ authIntent.putExtra("auth-only", true);
+ getActivity().startActivityForResult(authIntent, WPComLoginActivity.REQUEST_CODE);
+ }
+ });
+ } else {
+ b.setCancelable(true);
+ b.setPositiveButton(R.string.settings, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ Intent settingsIntent = new Intent(getActivity(), BlogPreferencesActivity.class);
+ settingsIntent.putExtra("id", WordPress.getCurrentBlog().getLocalTableBlogId());
+ getActivity().startActivity(settingsIntent);
+ }
+ });
+ b.setNegativeButton(R.string.cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ }
+ });
+ }
+ return b.create();
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/util/BitmapLruCache.java b/WordPress/src/main/java/org/wordpress/android/util/BitmapLruCache.java
new file mode 100644
index 000000000..75910ff0f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/BitmapLruCache.java
@@ -0,0 +1,31 @@
+
+package org.wordpress.android.util;
+
+import android.graphics.Bitmap;
+import android.support.v4.util.LruCache;
+
+import com.android.volley.toolbox.ImageLoader.ImageCache;
+
+public class BitmapLruCache extends LruCache<String, Bitmap> implements ImageCache {
+ public BitmapLruCache(int maxSize) {
+ super(maxSize);
+ }
+
+ @Override
+ protected int sizeOf(String key, Bitmap value) {
+ // The cache size will be measured in kilobytes rather than
+ // number of items.
+ int bytes = (value.getRowBytes() * value.getHeight());
+ return (bytes / 1024); //value.getByteCount() introduced in HONEYCOMB_MR1 or higher.
+ }
+
+ @Override
+ public Bitmap getBitmap(String key) {
+ return this.get(key);
+ }
+
+ @Override
+ public void putBitmap(String key, Bitmap bitmap) {
+ this.put(key, bitmap);
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/util/CheckedLinearLayout.java b/WordPress/src/main/java/org/wordpress/android/util/CheckedLinearLayout.java
new file mode 100644
index 000000000..eaa21f37f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/CheckedLinearLayout.java
@@ -0,0 +1,47 @@
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.Checkable;
+import android.widget.CheckedTextView;
+import android.widget.LinearLayout;
+
+public class CheckedLinearLayout extends LinearLayout implements Checkable {
+ private CheckedTextView mCheckbox;
+
+ public CheckedLinearLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ int childCount = getChildCount();
+ for (int i = 0; i < childCount; ++i) {
+ View v = getChildAt(i);
+ if (v instanceof CheckedTextView) {
+ mCheckbox = (CheckedTextView)v;
+ }
+ }
+ }
+
+ @Override
+ public boolean isChecked() {
+ return mCheckbox != null ? mCheckbox.isChecked() : false;
+ }
+
+ @Override
+ public void setChecked(boolean checked) {
+ if (mCheckbox != null) {
+ mCheckbox.setChecked(checked);
+ }
+ }
+
+ @Override
+ public void toggle() {
+ if (mCheckbox != null) {
+ mCheckbox.toggle();
+ }
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/util/CommentBadgeTextView.java b/WordPress/src/main/java/org/wordpress/android/util/CommentBadgeTextView.java
new file mode 100644
index 000000000..c2326d345
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/CommentBadgeTextView.java
@@ -0,0 +1,37 @@
+
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.util.AttributeSet;
+import android.widget.TextView;
+
+public class CommentBadgeTextView extends TextView {
+ public CommentBadgeTextView(Context context) {
+ super(context);
+ }
+
+ public CommentBadgeTextView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public CommentBadgeTextView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ canvas.save();
+
+ //calculate the center position based on the device's screen density
+ float centerPos = 20.0f;
+ float scale = getResources().getDisplayMetrics().density;
+ int fCenter = (int) (centerPos * scale + 0.5f);
+
+ canvas.rotate(45, fCenter, fCenter);
+
+ super.onDraw(canvas);
+ canvas.restore();
+
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/CrashlyticsUtils.java b/WordPress/src/main/java/org/wordpress/android/util/CrashlyticsUtils.java
new file mode 100644
index 000000000..7092e26aa
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/CrashlyticsUtils.java
@@ -0,0 +1,48 @@
+package org.wordpress.android.util;
+
+import com.crashlytics.android.Crashlytics;
+
+public class CrashlyticsUtils {
+ final private static String EXCEPTION_KEY = "exception";
+ final private static String TAG_KEY = "tag";
+ final private static String MESSAGE_KEY = "message";
+ public enum ExceptionType {USUAL, SPECIFIC}
+ public enum ExtraKey {IMAGE_ANGLE, IMAGE_WIDTH, IMAGE_HEIGHT, IMAGE_RESIZE_SCALE, NOTE_HTMLDATA}
+
+ public static void logException(Throwable tr, ExceptionType exceptionType, AppLog.T tag, String message) {
+ if (tag != null) {
+ Crashlytics.setString(TAG_KEY, tag.name());
+ }
+ if (message != null) {
+ Crashlytics.setString(MESSAGE_KEY, message);
+ }
+ Crashlytics.setString(EXCEPTION_KEY, exceptionType.name());
+ Crashlytics.logException(tr);
+ }
+
+ public static void logException(Throwable tr, ExceptionType exceptionType, AppLog.T tag) {
+ logException(tr, exceptionType, tag, null);
+ }
+
+ public static void logException(Throwable tr, ExceptionType exceptionType) {
+ logException(tr, exceptionType, null, null);
+ }
+
+ // Utility functions to force us to use and reuse a limited set of keys
+
+ public static void setInt(ExtraKey key, int value) {
+ Crashlytics.setInt(key.name(), value);
+ }
+
+ public static void setFloat(ExtraKey key, float value) {
+ Crashlytics.setFloat(key.name(), value);
+ }
+
+ public static void setString(ExtraKey key, String value) {
+ Crashlytics.setString(key.name(), value);
+ }
+
+ public static void setBool(ExtraKey key, boolean value) {
+ Crashlytics.setBool(key.name(), value);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/DateTimeUtils.java b/WordPress/src/main/java/org/wordpress/android/util/DateTimeUtils.java
new file mode 100644
index 000000000..ef451974c
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/DateTimeUtils.java
@@ -0,0 +1,148 @@
+package org.wordpress.android.util;
+
+import android.text.format.DateUtils;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+
+public class DateTimeUtils {
+ private DateTimeUtils() {
+ throw new AssertionError();
+ }
+
+ /*
+ * see http://drdobbs.com/java/184405382
+ */
+ private static final ThreadLocal<DateFormat> ISO8601Format = new ThreadLocal<DateFormat>() {
+ @Override
+ protected DateFormat initialValue() {
+ return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US);
+ }
+ };
+
+ /*
+ * converts a date to a relative time span ("8h", "3d", etc.) - similar to
+ * DateUtils.getRelativeTimeSpanString but returns shorter result
+ */
+ public static String javaDateToTimeSpan(final Date date) {
+ if (date == null)
+ return "";
+
+ long passedTime = date.getTime();
+ long currentTime = System.currentTimeMillis();
+
+ // return "now" if less than a minute has elapsed
+ long secondsSince = (currentTime - passedTime) / 1000;
+ if (secondsSince < 60)
+ return WordPress.getContext().getString(R.string.reader_timespan_now);
+
+ // less than an hour (ex: 12m)
+ long minutesSince = secondsSince / 60;
+ if (minutesSince < 60)
+ return Long.toString(minutesSince) + "m";
+
+ // less than a day (ex: 17h)
+ long hoursSince = minutesSince / 60;
+ if (hoursSince < 24)
+ return Long.toString(hoursSince) + "h";
+
+ // less than a week (ex: 5d)
+ long daysSince = hoursSince / 24;
+ if (daysSince < 7)
+ return Long.toString(daysSince) + "d";
+
+ // less than a year old, so return day/month without year (ex: Jan 30)
+ if (daysSince < 365)
+ return DateUtils.formatDateTime(WordPress.getContext(), passedTime, DateUtils.FORMAT_NO_YEAR | DateUtils.FORMAT_ABBREV_ALL);
+
+ // date is older, so include year (ex: Jan 30, 2013)
+ return DateUtils.formatDateTime(WordPress.getContext(), passedTime, DateUtils.FORMAT_ABBREV_ALL);
+ }
+
+ /*
+ * converts an ISO8601 date to a Java date
+ */
+ public static Date iso8601ToJavaDate(final String strDate) {
+ try {
+ DateFormat formatter = ISO8601Format.get();
+ return formatter.parse(strDate);
+ } catch (ParseException e) {
+ return null;
+ }
+ }
+
+ /*
+ * converts a Java date to ISO8601
+ */
+ public static String javaDateToIso8601(Date date) {
+ if (date==null)
+ return "";
+ DateFormat formatter = ISO8601Format.get();
+ return formatter.format(date);
+ }
+
+ /*
+ * returns the current UTC date
+ */
+ public static Date nowUTC() {
+ Date dateTimeNow = new Date();
+ return localDateToUTC(dateTimeNow);
+ }
+
+ public static Date localDateToUTC(Date dtLocal) {
+ if (dtLocal==null)
+ return null;
+ TimeZone tz = TimeZone.getDefault();
+ int currentOffsetFromUTC = tz.getRawOffset() + (tz.inDaylightTime(dtLocal) ? tz.getDSTSavings() : 0);
+ return new Date(dtLocal.getTime() - currentOffsetFromUTC);
+ }
+
+ /*
+ * routines to return a diff between two dates - always return a positive number
+ */
+ public static int minutesBetween(Date dt1, Date dt2) {
+ long msDiff = millisecondsBetween(dt1, dt2);
+ if (msDiff==0)
+ return 0;
+ return (int)(msDiff / 60000);
+ }
+ public static int secondsBetween(Date dt1, Date dt2) {
+ long msDiff = millisecondsBetween(dt1, dt2);
+ if (msDiff == 0) {
+ return 0;
+ }
+ return (int)(msDiff / 1000);
+ }
+ public static long millisecondsBetween(Date dt1, Date dt2) {
+ if (dt1==null || dt2==null)
+ return 0;
+ return Math.abs(dt1.getTime() - dt2.getTime());
+ }
+ public static long iso8601ToTimestamp(final String strDate) {
+ Date date = iso8601ToJavaDate(strDate);
+ if (date==null)
+ return 0;
+ return (date.getTime() / 1000);
+ }
+
+ /*
+ * routines involving Unix timestamps (GMT assumed)
+ */
+ public static Date timestampToDate(long timeStamp) {
+ return new java.util.Date(timeStamp*1000);
+ }
+ public static String timestampToIso8601Str(long timestamp) {
+ return javaDateToIso8601(timestampToDate(timestamp));
+ }
+ public static String timestampToTimeSpan(long timeStamp) {
+ Date dtGmt = timestampToDate(timeStamp);
+ return javaDateToTimeSpan(dtGmt);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/DeviceUtils.java b/WordPress/src/main/java/org/wordpress/android/util/DeviceUtils.java
new file mode 100644
index 000000000..639d5479c
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/DeviceUtils.java
@@ -0,0 +1,94 @@
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Build;
+
+import org.wordpress.android.util.AppLog.T;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Properties;
+
+public class DeviceUtils {
+ private static DeviceUtils instance;
+ private boolean isKindleFire = false;
+
+ public boolean isKindleFire() {
+ return isKindleFire;
+ }
+
+ public static DeviceUtils getInstance() {
+ if (instance == null) {
+ instance = new DeviceUtils();
+ }
+ return instance;
+ }
+
+ private DeviceUtils() {
+ isKindleFire = android.os.Build.MODEL.equalsIgnoreCase("kindle fire") ? true: false;
+ }
+
+ /**
+ * Checks camera availability recursively based on API level.
+ *
+ * TODO: change "android.hardware.camera.front" and "android.hardware.camera.any" to
+ * {@link PackageManager#FEATURE_CAMERA_FRONT} and {@link PackageManager#FEATURE_CAMERA_ANY},
+ * respectively, once they become accessible or minSdk version is incremented.
+ *
+ * @param context The context.
+ * @return Whether camera is available.
+ */
+ public boolean hasCamera(Context context) {
+ final PackageManager pm = context.getPackageManager();
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ return pm.hasSystemFeature(PackageManager.FEATURE_CAMERA)
+ || pm.hasSystemFeature("android.hardware.camera.front");
+ }
+
+ return pm.hasSystemFeature("android.hardware.camera.any");
+ }
+
+ public String getDeviceName(Context context) {
+ String manufacturer = Build.MANUFACTURER;
+ String undecodedModel = Build.MODEL;
+ String model = null;
+
+ try {
+ Properties prop = new Properties();
+ InputStream fileStream;
+ // Read the device name from a precomplied list:
+ // see http://making.meetup.com/post/29648976176/human-readble-android-device-names
+ fileStream = context.getAssets().open("android_models.properties");
+ prop.load(fileStream);
+ fileStream.close();
+ String decodedModel = prop.getProperty(undecodedModel.replaceAll(" ", "_"));
+ if (decodedModel != null && !decodedModel.trim().equals("")) {
+ model = decodedModel;
+ }
+ } catch (IOException e) {
+ AppLog.e(T.UTILS, e.getMessage());
+ }
+
+ if (model == null) { //Device model not found in the list
+ if (undecodedModel.startsWith(manufacturer)) {
+ model = capitalize(undecodedModel);
+ } else {
+ model = capitalize(manufacturer) + " " + undecodedModel;
+ }
+ }
+ return model;
+ }
+
+ private String capitalize(String s) {
+ if (s == null || s.length() == 0) {
+ return "";
+ }
+ char first = s.charAt(0);
+ if (Character.isUpperCase(first)) {
+ return s;
+ } else {
+ return Character.toUpperCase(first) + s.substring(1);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/DisplayUtils.java b/WordPress/src/main/java/org/wordpress/android/util/DisplayUtils.java
new file mode 100644
index 000000000..bd3695a73
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/DisplayUtils.java
@@ -0,0 +1,87 @@
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.graphics.Point;
+import android.util.DisplayMetrics;
+import android.util.TypedValue;
+import android.view.Display;
+import android.view.Window;
+import android.view.WindowManager;
+
+public class DisplayUtils {
+ private DisplayUtils() {
+ throw new AssertionError();
+ }
+
+ public static boolean isLandscape(Context context) {
+ if (context == null)
+ return false;
+ return context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
+ }
+
+ public static boolean isLandscapeTablet(Context context) {
+ return isLandscape(context) && isTablet(context);
+ }
+
+ public static Point getDisplayPixelSize(Context context) {
+ WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
+ Display display = wm.getDefaultDisplay();
+ Point size = new Point();
+ display.getSize(size);
+ return size;
+ }
+
+ public static int getDisplayPixelWidth(Context context) {
+ Point size = getDisplayPixelSize(context);
+ return (size.x);
+ }
+
+ public static int getDisplayPixelHeight(Context context) {
+ Point size = getDisplayPixelSize(context);
+ return (size.y);
+ }
+
+ public static int dpToPx(Context context, int dp) {
+ float px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics());
+ return (int) px;
+ }
+
+ public static int pxToDp(Context context, int px) {
+ DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
+ return (int) ((px/displayMetrics.density)+0.5);
+ }
+
+ public static boolean isTablet(Context context) {
+ // http://stackoverflow.com/a/8427523/1673548
+ if (context == null)
+ return false;
+ return (context.getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE;
+ }
+
+ /**
+ * returns the height of the ActionBar if one is enabled - supports both the native ActionBar
+ * and ActionBarSherlock - http://stackoverflow.com/a/15476793/1673548
+ */
+ public static int getActionBarHeight(Context context) {
+ if (context == null) {
+ return 0;
+ }
+ TypedValue tv = new TypedValue();
+ if (context.getTheme() != null
+ && context.getTheme().resolveAttribute(android.R.attr.actionBarSize, tv, true)) {
+ return TypedValue.complexToDimensionPixelSize(tv.data, context.getResources().getDisplayMetrics());
+ }
+
+ // if we get this far, it's because the device doesn't support an ActionBar,
+ // so return the standard ActionBar height (48dp)
+ return dpToPx(context, 48);
+ }
+
+ /**
+ * detect when FEATURE_ACTION_BAR_OVERLAY has been set
+ */
+ public static boolean hasActionBarOverlay(Window window) {
+ return window.hasFeature(Window.FEATURE_ACTION_BAR_OVERLAY);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/DrawableManager.java b/WordPress/src/main/java/org/wordpress/android/util/DrawableManager.java
new file mode 100644
index 000000000..c636e36c8
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/DrawableManager.java
@@ -0,0 +1,80 @@
+package org.wordpress.android.util;
+
+import android.graphics.drawable.Drawable;
+import android.os.Handler;
+import android.os.Message;
+import android.widget.ImageView;
+
+import org.apache.http.HttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.DefaultHttpClient;
+import org.wordpress.android.util.AppLog.T;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.util.HashMap;
+import java.util.Map;
+
+public class DrawableManager {
+ private final Map<String, Drawable> drawableMap;
+
+ public DrawableManager() {
+ drawableMap = new HashMap<String, Drawable>();
+ }
+
+ public Drawable fetchDrawable(String urlString) {
+ if (drawableMap.containsKey(urlString)) {
+ return (Drawable) drawableMap.get(urlString);
+ }
+
+ AppLog.d(T.UTILS, "image url:" + urlString);
+ try {
+ InputStream is = fetch(urlString);
+ Drawable drawable = Drawable.createFromStream(is, "src");
+ drawableMap.put(urlString, drawable);
+ AppLog.d(T.UTILS, "got a thumbnail drawable: " + drawable.getBounds() + ", "
+ + drawable.getIntrinsicHeight() + "," + drawable.getIntrinsicWidth() + ", "
+ + drawable.getMinimumHeight() + "," + drawable.getMinimumWidth());
+ return drawable;
+ } catch (MalformedURLException e) {
+ AppLog.e(T.UTILS, "fetchDrawable failed", e);
+ return null;
+ } catch (IOException e) {
+ AppLog.e(T.UTILS, "fetchDrawable failed", e);
+ return null;
+ }
+ }
+
+ public void fetchDrawableOnThread(final String urlString, final ImageView imageView) {
+ if (drawableMap.containsKey(urlString)) {
+ imageView.setImageDrawable((Drawable) drawableMap.get(urlString));
+ }
+
+ final Handler handler = new Handler() {
+ @Override
+ public void handleMessage(Message message) {
+ imageView.setImageDrawable((Drawable) message.obj);
+ }
+ };
+
+ Thread thread = new Thread() {
+ @Override
+ public void run() {
+ //TODO : set imageView to a "pending" image
+ Drawable drawable = fetchDrawable(urlString);
+ Message message = handler.obtainMessage(1, drawable);
+ handler.sendMessage(message);
+ }
+ };
+ thread.start();
+ }
+
+ private InputStream fetch(String urlString) throws MalformedURLException, IOException {
+ DefaultHttpClient httpClient = new DefaultHttpClient();
+ HttpGet request = new HttpGet(urlString);
+ HttpResponse response = httpClient.execute(request);
+ return response.getEntity().getContent();
+ }
+}
+
diff --git a/WordPress/src/main/java/org/wordpress/android/util/EditTextUtils.java b/WordPress/src/main/java/org/wordpress/android/util/EditTextUtils.java
new file mode 100644
index 000000000..64ee67e56
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/EditTextUtils.java
@@ -0,0 +1,77 @@
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.EditText;
+
+/**
+ * EditText utils
+ */
+public class EditTextUtils {
+ private EditTextUtils() {
+ throw new AssertionError();
+ }
+
+ /**
+ * returns text string from passed EditText
+ */
+ public static String getText(EditText edit) {
+ if (edit.getText() == null) {
+ return "";
+ }
+ return edit.getText().toString();
+ }
+
+ /**
+ * moves caret to end of text
+ */
+ public static void moveToEnd(EditText edit) {
+ if (edit.getText() == null) {
+ return;
+ }
+ edit.setSelection(edit.getText().toString().length());
+ }
+
+ /**
+ * returns true if nothing has been entered into passed editor
+ */
+ public static boolean isEmpty(EditText edit) {
+ return TextUtils.isEmpty(getText(edit));
+ }
+
+ /**
+ * hide the soft keyboard for the passed EditText
+ */
+ public static void hideSoftInput(EditText edit) {
+ if (edit == null) {
+ return;
+ }
+
+ InputMethodManager imm = getInputMethodManager(edit);
+ if (imm != null) {
+ imm.hideSoftInputFromWindow(edit.getWindowToken(), 0);
+ }
+ }
+
+ /**
+ * show the soft keyboard for the passed EditText
+ */
+ public static void showSoftInput(EditText edit) {
+ if (edit == null) {
+ return;
+ }
+
+ edit.requestFocus();
+
+ InputMethodManager imm = getInputMethodManager(edit);
+ if (imm != null) {
+ imm.showSoftInput(edit, InputMethodManager.SHOW_IMPLICIT);
+ }
+ }
+
+ private static InputMethodManager getInputMethodManager(EditText edit) {
+ Context context = edit.getContext();
+ return (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/Emoticons.java b/WordPress/src/main/java/org/wordpress/android/util/Emoticons.java
new file mode 100644
index 000000000..5a7566a96
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/Emoticons.java
@@ -0,0 +1,106 @@
+package org.wordpress.android.util;
+
+import android.text.Html;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.ImageSpan;
+import android.util.SparseArray;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static android.os.Build.VERSION.SDK_INT;
+import static android.os.Build.VERSION_CODES;
+
+public class Emoticons {
+ public static final int EMOTICON_COLOR = 0xFF21759B;
+ private static final boolean HAS_EMOJI = SDK_INT >= VERSION_CODES.JELLY_BEAN;
+ private static final Map<String, String> wpSmilies;
+ public static final SparseArray<String> wpSmiliesCodePointToText;
+
+ static {
+ Map<String, String> smilies = new HashMap<String, String>();
+ smilies.put("icon_mrgreen.gif", HAS_EMOJI ? "\uD83D\uDE00" : ":mrgreen:" );
+ smilies.put("icon_neutral.gif", HAS_EMOJI ? "\uD83D\uDE14" : ":|" );
+ smilies.put("icon_twisted.gif", HAS_EMOJI ? "\uD83D\uDE16" : ":twisted:" );
+ smilies.put("icon_arrow.gif", HAS_EMOJI ? "\u27A1" : ":arrow:" );
+ smilies.put("icon_eek.gif", HAS_EMOJI ? "\uD83D\uDE32" : "8-O" );
+ smilies.put("icon_smile.gif", HAS_EMOJI ? "\uD83D\uDE0A" : ":)" );
+ smilies.put("icon_confused.gif", HAS_EMOJI ? "\uD83D\uDE15" : ":?" );
+ smilies.put("icon_cool.gif", HAS_EMOJI ? "\uD83D\uDE0A" : "8)" );
+ smilies.put("icon_evil.gif", HAS_EMOJI ? "\uD83D\uDE21" : ":evil:" );
+ smilies.put("icon_biggrin.gif", HAS_EMOJI ? "\uD83D\uDE03" : ":D" );
+ smilies.put("icon_idea.gif", HAS_EMOJI ? "\uD83D\uDCA1" : ":idea:" );
+ smilies.put("icon_redface.gif", HAS_EMOJI ? "\uD83D\uDE33" : ":oops:" );
+ smilies.put("icon_razz.gif", HAS_EMOJI ? "\uD83D\uDE1D" : ":P" );
+ smilies.put("icon_rolleyes.gif", HAS_EMOJI ? "\uD83D\uDE0F" : ":roll:" );
+ smilies.put("icon_wink.gif", HAS_EMOJI ? "\uD83D\uDE09" : ";)" );
+ smilies.put("icon_cry.gif", HAS_EMOJI ? "\uD83D\uDE22" : ":'(" );
+ smilies.put("icon_surprised.gif", HAS_EMOJI ? "\uD83D\uDE32" : ":o" );
+ smilies.put("icon_lol.gif", HAS_EMOJI ? "\uD83D\uDE03" : ":lol:" );
+ smilies.put("icon_mad.gif", HAS_EMOJI ? "\uD83D\uDE21" : ":x" );
+ smilies.put("icon_sad.gif", HAS_EMOJI ? "\uD83D\uDE1E" : ":(" );
+ smilies.put("icon_exclaim.gif", HAS_EMOJI ? "\u2757" : ":!:" );
+ smilies.put("icon_question.gif", HAS_EMOJI ? "\u2753" : ":?:" );
+
+ wpSmilies = Collections.unmodifiableMap(smilies);
+
+ wpSmiliesCodePointToText = new SparseArray<String>(20);
+ wpSmiliesCodePointToText.put(10145, ":arrow:");
+ wpSmiliesCodePointToText.put(128161, ":idea:");
+ wpSmiliesCodePointToText.put(128512, ":mrgreen:");
+ wpSmiliesCodePointToText.put(128515, ":D");
+ wpSmiliesCodePointToText.put(128522, ":)");
+ wpSmiliesCodePointToText.put(128521, ";)");
+ wpSmiliesCodePointToText.put(128532, ":|");
+ wpSmiliesCodePointToText.put(128533, ":?");
+ wpSmiliesCodePointToText.put(128534, ":twisted:");
+ wpSmiliesCodePointToText.put(128542, ":(");
+ wpSmiliesCodePointToText.put(128545, ":evil:");
+ wpSmiliesCodePointToText.put(128546, ":'(");
+ wpSmiliesCodePointToText.put(128562, ":o");
+ wpSmiliesCodePointToText.put(128563, ":oops:");
+ wpSmiliesCodePointToText.put(128527, ":roll:");
+ wpSmiliesCodePointToText.put(10071, ":!:");
+ wpSmiliesCodePointToText.put(10067, ":?:");
+ }
+
+ public static String lookupImageSmiley(String url){
+ return lookupImageSmiley(url, "");
+ }
+
+ public static String lookupImageSmiley(String url, String ifNone){
+ String file = url.substring(url.lastIndexOf("/") + 1);
+ if (wpSmilies.containsKey(file)) {
+ return wpSmilies.get(file);
+ }
+ return ifNone;
+ }
+
+ public static Spanned replaceEmoticonsWithEmoji(SpannableStringBuilder html){
+ ImageSpan imgs[] = html.getSpans(0, html.length(), ImageSpan.class);
+ for (ImageSpan img : imgs) {
+ String emoticon = Emoticons.lookupImageSmiley(img.getSource());
+ if (!emoticon.equals("")) {
+ int start = html.getSpanStart(img);
+ html.replace(start, html.getSpanEnd(img), emoticon);
+ html.setSpan(new ForegroundColorSpan(EMOTICON_COLOR), start,
+ start + emoticon.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ html.removeSpan(img);
+ }
+ }
+ return html;
+ }
+
+ public static String replaceEmoticonsWithEmoji(final String text) {
+ if (text != null && text.contains("icon_")) {
+ final SpannableStringBuilder html = (SpannableStringBuilder)replaceEmoticonsWithEmoji((SpannableStringBuilder) Html.fromHtml(text));
+ // Html.toHtml() is used here rather than toString() since the latter strips html
+ return Html.toHtml(html);
+ } else {
+ return text;
+ }
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/util/FlowLayout.java b/WordPress/src/main/java/org/wordpress/android/util/FlowLayout.java
new file mode 100644
index 000000000..2075350cd
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/FlowLayout.java
@@ -0,0 +1,135 @@
+/*
+ * Sample FlowLayout wrote by Romain Guy: http://www.parleys.com/play/514892280364bc17fc56c0e2/chapter38/about
+ * Fixed and tweaked since
+ */
+
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Paint;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup;
+import org.wordpress.android.R;
+
+public class FlowLayout extends ViewGroup {
+ private int mHorizontalSpacing;
+ private int mVerticalSpacing;
+ private Paint mPaint;
+
+ public FlowLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FlowLayout);
+ try {
+ mHorizontalSpacing = a.getDimensionPixelSize(R.styleable.FlowLayout_horizontalSpacing, 0);
+ mVerticalSpacing = a.getDimensionPixelSize(R.styleable.FlowLayout_verticalSpacing, 0);
+ } finally {
+ a.recycle();
+ }
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int widthSize = MeasureSpec.getSize(widthMeasureSpec) - getPaddingRight() - getPaddingLeft();
+ int widthMode = MeasureSpec.getMode(widthMeasureSpec);
+
+ boolean growHeight = widthMode != MeasureSpec.UNSPECIFIED;
+
+ int width = 0;
+ int height = getPaddingTop();
+
+ int currentWidth = getPaddingLeft();
+ int currentHeight = 0;
+
+ boolean newLine = false;
+ int spacing = 0;
+
+ final int count = getChildCount();
+ for (int i = 0; i < count; i++) {
+ View child = getChildAt(i);
+ measureChild(child, widthMeasureSpec, heightMeasureSpec);
+
+ LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ spacing = mHorizontalSpacing;
+ if (lp.horizontalSpacing >= 0) {
+ spacing = lp.horizontalSpacing;
+ }
+
+ if (growHeight && currentWidth + child.getMeasuredWidth() > widthSize) {
+ height += currentHeight + mVerticalSpacing;
+ currentHeight = 0;
+ width = Math.max(width, currentWidth - spacing);
+ currentWidth = getPaddingLeft();
+ newLine = true;
+ } else {
+ newLine = false;
+ }
+
+ lp.x = currentWidth;
+ lp.y = height;
+
+ currentWidth += child.getMeasuredWidth() + spacing;
+ currentHeight = Math.max(currentHeight, child.getMeasuredHeight());
+ }
+
+ if (!newLine) {
+ width = Math.max(width, currentWidth - spacing);
+ }
+ width += getPaddingRight();
+ height += currentHeight + getPaddingBottom();
+
+ setMeasuredDimension(resolveSize(width, widthMeasureSpec), resolveSize(height, heightMeasureSpec));
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+ final int count = getChildCount();
+ for (int i = 0; i < count; i++) {
+ View child = getChildAt(i);
+ LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ child.layout(lp.x, lp.y, lp.x + child.getMeasuredWidth(), lp.y + child.getMeasuredHeight());
+ }
+ }
+
+ @Override
+ protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
+ return p instanceof LayoutParams;
+ }
+
+ @Override
+ protected LayoutParams generateDefaultLayoutParams() {
+ return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
+ }
+
+ @Override
+ public LayoutParams generateLayoutParams(AttributeSet attrs) {
+ return new LayoutParams(getContext(), attrs);
+ }
+
+ @Override
+ protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
+ return new LayoutParams(p.width, p.height);
+ }
+
+ public static class LayoutParams extends ViewGroup.LayoutParams {
+ int x;
+ int y;
+
+ public int horizontalSpacing;
+
+ public LayoutParams(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FlowLayout_LayoutParams);
+ try {
+ horizontalSpacing = a.getDimensionPixelSize(R.styleable.FlowLayout_LayoutParams_layout_horizontalSpacing, -1);
+ } finally {
+ a.recycle();
+ }
+ }
+
+ public LayoutParams(int w, int h) {
+ super(w, h);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/FormatUtils.java b/WordPress/src/main/java/org/wordpress/android/util/FormatUtils.java
new file mode 100644
index 000000000..28282ed5f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/FormatUtils.java
@@ -0,0 +1,35 @@
+package org.wordpress.android.util;
+
+import java.text.DecimalFormat;
+import java.text.NumberFormat;
+
+public class FormatUtils {
+ /*
+ * NumberFormat isn't synchronized, so a separate instance must be created for each thread
+ * http://developer.android.com/reference/java/text/NumberFormat.html
+ */
+ private static final ThreadLocal<NumberFormat> IntegerInstance = new ThreadLocal<NumberFormat>() {
+ @Override
+ protected NumberFormat initialValue() {
+ return NumberFormat.getIntegerInstance();
+ }
+ };
+
+ private static final ThreadLocal<DecimalFormat> DecimalInstance = new ThreadLocal<DecimalFormat>() {
+ @Override
+ protected DecimalFormat initialValue() {
+ return (DecimalFormat) DecimalFormat.getInstance();
+ }
+ };
+
+ /*
+ * returns the passed integer formatted with thousands-separators based on the current locale
+ */
+ public static final String formatInt(int value) {
+ return IntegerInstance.get().format(value).toString();
+ }
+
+ public static final String formatDecimal(int value) {
+ return DecimalInstance.get().format(value).toString();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/GeocoderUtils.java b/WordPress/src/main/java/org/wordpress/android/util/GeocoderUtils.java
new file mode 100644
index 000000000..e861a88b8
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/GeocoderUtils.java
@@ -0,0 +1,116 @@
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.location.Address;
+import android.location.Geocoder;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+
+public final class GeocoderUtils {
+ private GeocoderUtils() {
+ throw new AssertionError();
+ }
+
+ public static Geocoder getGeocoder(Context context) {
+ // first make sure a Geocoder service exists on this device (requires API 9)
+ if (!Geocoder.isPresent()) {
+ return null;
+ }
+
+ Geocoder gcd;
+
+ try {
+ gcd = new Geocoder(context, Locale.getDefault());
+ } catch (NullPointerException cannotIstantiateEx) {
+ AppLog.e(AppLog.T.UTILS, "Cannot instantiate Geocoder", cannotIstantiateEx);
+ return null;
+ }
+
+ return gcd;
+ }
+
+ public static Address getAddressFromCoords(Context context, double latitude, double longitude) {
+ Address address = null;
+ List<Address> addresses = null;
+
+ Geocoder gcd = getGeocoder(context);
+
+ if (gcd == null) {
+ return null;
+ }
+
+ try {
+ addresses = gcd.getFromLocation(latitude, longitude, 1);
+ } catch (IOException e) {
+ // may get "Unable to parse response from server" IOException here if Geocoder
+ // service is hit too frequently
+ AppLog.e(AppLog.T.UTILS,
+ "Unable to parse response from server. Is Geocoder service hitting the server too frequently?",
+ e
+ );
+ }
+
+ // addresses may be null or empty if network isn't connected
+ if (addresses != null && addresses.size() > 0) {
+ address = addresses.get(0);
+ }
+
+ return address;
+ }
+
+ public static Address getAddressFromLocationName(Context context, String locationName) {
+ int maxResults = 1;
+ Address address = null;
+ List<Address> addresses = null;
+
+ Geocoder gcd = getGeocoder(context);
+
+ if (gcd == null) {
+ return null;
+ }
+
+ try {
+ addresses = gcd.getFromLocationName(locationName, maxResults);
+ } catch (IOException e) {
+ AppLog.e(AppLog.T.UTILS, "Failed to get coordinates from location", e);
+ }
+
+ // addresses may be null or empty if network isn't connected
+ if (addresses != null && addresses.size() > 0) {
+ address = addresses.get(0);
+ }
+
+ return address;
+ }
+
+ public static String getLocationNameFromAddress(Address address) {
+ String locality = "", adminArea = "", country = "";
+ if (address.getLocality() != null) {
+ locality = address.getLocality();
+ }
+
+ if (address.getAdminArea() != null) {
+ adminArea = address.getAdminArea();
+ }
+
+ if (address.getCountryName() != null) {
+ country = address.getCountryName();
+ }
+
+ return ((locality.equals("")) ? locality : locality + ", ")
+ + ((adminArea.equals("")) ? adminArea : adminArea + " ") + country;
+ }
+
+ public static double[] getCoordsFromAddress(Address address) {
+ double[] coordinates = new double[2];
+
+ if (address.hasLatitude() && address.hasLongitude()) {
+ coordinates[0] = address.getLatitude();
+ coordinates[1] = address.getLongitude();
+ }
+
+ return coordinates;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/GravatarUtils.java b/WordPress/src/main/java/org/wordpress/android/util/GravatarUtils.java
new file mode 100644
index 000000000..c10ce69c8
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/GravatarUtils.java
@@ -0,0 +1,22 @@
+package org.wordpress.android.util;
+
+import android.text.TextUtils;
+
+public class GravatarUtils {
+ /*
+ * see https://en.gravatar.com/site/implement/images/
+ */
+ public static String gravatarUrlFromEmail(final String email, int size) {
+ if (TextUtils.isEmpty(email))
+ return "";
+
+ String url = "http://gravatar.com/avatar/"
+ + StringUtils.getMd5Hash(email)
+ + "?d=mm";
+
+ if (size > 0)
+ url += "&s=" + Integer.toString(size);
+
+ return url;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/HtmlUtils.java b/WordPress/src/main/java/org/wordpress/android/util/HtmlUtils.java
new file mode 100644
index 000000000..56b71868d
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/HtmlUtils.java
@@ -0,0 +1,134 @@
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.text.Html;
+import android.text.SpannableStringBuilder;
+import android.text.TextUtils;
+import android.text.style.QuoteSpan;
+
+import org.apache.commons.lang.StringEscapeUtils;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.CrashlyticsUtils.ExceptionType;
+import org.wordpress.android.util.CrashlyticsUtils.ExtraKey;
+
+public class HtmlUtils {
+ /*
+ * removes html from the passed string - relies on Html.fromHtml which handles invalid HTML,
+ * but it's very slow, so avoid using this where performance is important
+ */
+ public static String stripHtml(final String text) {
+ if (TextUtils.isEmpty(text))
+ return "";
+ return Html.fromHtml(text).toString().trim();
+ }
+
+ /*
+ * this is much faster than stripHtml() but should only be used when we know the html is valid
+ * since the regex will be unpredictable with invalid html
+ */
+ public static String fastStripHtml(String str) {
+ if (TextUtils.isEmpty(str)) {
+ return str;
+ }
+
+ // insert a line break before P tags unless the only one is at the start
+ if (str.lastIndexOf("<p") > 0) {
+ str = str.replaceAll("<p(.|\n)*?>", "\n<p>");
+ }
+
+ // convert BR tags to line breaks
+ if (str.contains("<br")) {
+ str = str.replaceAll("<br(.|\n)*?>", "\n");
+ }
+
+ // use regex to strip tags, then convert entities in the result
+ return trimStart(fastUnescapeHtml(str.replaceAll("<(.|\n)*?>", "")));
+ }
+
+ /*
+ * same as apache.commons.lang.StringUtils.stripStart() but also removes non-breaking
+ * space (160) chars
+ */
+ private static String trimStart(final String str) {
+ int strLen;
+ if (str == null || (strLen = str.length()) == 0) {
+ return "";
+ }
+ int start = 0;
+ while (start != strLen
+ && (Character.isWhitespace(str.charAt(start)) || str.charAt(start) == 160)) {
+ start++;
+ }
+ return str.substring(start);
+ }
+
+ /*
+ * convert html entities to actual Unicode characters - relies on commons apache lang
+ */
+ public static String fastUnescapeHtml(final String text) {
+ if (text == null || !text.contains("&"))
+ return text;
+ return StringEscapeUtils.unescapeHtml(text);
+ }
+
+ /*
+ * converts an R.color.xxx resource to an HTML hex color
+ */
+ public static String colorResToHtmlColor(Context context, int resId) {
+ try {
+ return String.format("#%06X", 0xFFFFFF & context.getResources().getColor(resId));
+ } catch (Resources.NotFoundException e) {
+ return "#000000";
+ }
+ }
+
+ /*
+ * remove <script>..</script> blocks from the passed string - added to project after noticing
+ * comments on posts that use the "Sociable" plugin ( http://wordpress.org/plugins/sociable/ )
+ * may have a script block which contains <!--//--> followed by a CDATA section followed by <!]]>,
+ * all of which will show up if we don't strip it here (example: http://cl.ly/image/0J0N3z3h1i04 )
+ * first seen at http://houseofgeekery.com/2013/11/03/13-terrible-x-men-we-wont-see-in-the-movies/
+ */
+ public static String stripScript(final String text) {
+ if (text == null)
+ return null;
+
+ StringBuilder sb = new StringBuilder(text);
+ int start = sb.indexOf("<script");
+
+ while (start > -1) {
+ int end = sb.indexOf("</script>", start);
+ if (end == -1)
+ return sb.toString();
+ sb.delete(start, end+9);
+ start = sb.indexOf("<script", start);
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * an alternative to Html.fromHtml() supporting <ul>, <ol>, <blockquote> tags and replacing Emoticons with Emojis
+ */
+ public static SpannableStringBuilder fromHtml(String source) {
+ SpannableStringBuilder html;
+ try {
+ html = (SpannableStringBuilder) Html.fromHtml(source, null, new WPHtmlTagHandler());
+ } catch (RuntimeException runtimeException) {
+ // In case our tag handler fails
+ html = (SpannableStringBuilder) Html.fromHtml(source, null, null);
+ // Log the exception and text that produces the error
+ CrashlyticsUtils.setString(ExtraKey.NOTE_HTMLDATA, source);
+ CrashlyticsUtils.logException(runtimeException, ExceptionType.SPECIFIC, T.NOTIFS);
+ }
+ Emoticons.replaceEmoticonsWithEmoji(html);
+ QuoteSpan spans[] = html.getSpans(0, html.length(), QuoteSpan.class);
+ for (QuoteSpan span : spans) {
+ html.setSpan(new WPHtml.WPQuoteSpan(), html.getSpanStart(span), html.getSpanEnd(span), html.getSpanFlags(
+ span));
+ html.removeSpan(span);
+ }
+ return html;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/ImageHelper.java b/WordPress/src/main/java/org/wordpress/android/util/ImageHelper.java
new file mode 100644
index 000000000..ed19ce46e
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/ImageHelper.java
@@ -0,0 +1,502 @@
+package org.wordpress.android.util;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Matrix;
+import android.media.ExifInterface;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.provider.MediaStore;
+import android.text.TextUtils;
+import android.widget.ImageView;
+
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpResponse;
+import org.apache.http.HttpStatus;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.DefaultHttpClient;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.ref.WeakReference;
+
+public class ImageHelper {
+ public static int[] getImageSize(Uri uri, Context context){
+ String path = null;
+ BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inJustDecodeBounds = true;
+
+ if (uri.toString().contains("content:")) {
+ String[] projection = new String[] { MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA };
+ Cursor cur = context.getContentResolver().query(uri, projection, null, null, null);
+ if (cur != null) {
+ if (cur.moveToFirst()) {
+ int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA);
+ path = cur.getString(dataColumn);
+ }
+ cur.close();
+ }
+ }
+
+ if (TextUtils.isEmpty(path)) {
+ //The file isn't ContentResolver, or it can't be access by ContentResolver. Try to access the file directly.
+ path = uri.toString().replace("content://media", "");
+ path = path.replace("file://", "");
+ }
+
+ BitmapFactory.decodeFile(path, options);
+ int imageHeight = options.outHeight;
+ int imageWidth = options.outWidth;
+ return new int[]{imageWidth, imageHeight};
+ }
+
+ // Read the orientation from ContentResolver. If it fails, read from EXIF.
+ public static int getImageOrientation(Context ctx, String filePath) {
+ Uri curStream;
+ int orientation = 0;
+
+ // Remove file protocol
+ filePath = filePath.replace("file://", "");
+
+ if (!filePath.contains("content://"))
+ curStream = Uri.parse("content://media" + filePath);
+ else
+ curStream = Uri.parse(filePath);
+
+ try {
+ Cursor cur = ctx.getContentResolver().query(curStream, new String[]{MediaStore.Images.Media.ORIENTATION}, null, null, null);
+ if (cur != null) {
+ if (cur.moveToFirst()) {
+ orientation = cur.getInt(cur.getColumnIndex(MediaStore.Images.Media.ORIENTATION));
+ }
+ cur.close();
+ }
+ } catch (Exception errReadingContentResolver) {
+ AppLog.e(AppLog.T.UTILS, errReadingContentResolver);
+ }
+
+ if (orientation == 0) {
+ orientation = getExifOrientation(filePath);
+ }
+
+ return orientation;
+ }
+
+
+ public static int getExifOrientation(String path) {
+ ExifInterface exif;
+ try {
+ exif = new ExifInterface(path);
+ } catch (IOException e) {
+ AppLog.e(AppLog.T.UTILS, e);
+ return 0;
+ }
+
+ int exifOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0);
+
+ switch (exifOrientation) {
+ case ExifInterface.ORIENTATION_NORMAL:
+ return 0;
+ case ExifInterface.ORIENTATION_ROTATE_90:
+ return 90;
+ case ExifInterface.ORIENTATION_ROTATE_180:
+ return 180;
+ case ExifInterface.ORIENTATION_ROTATE_270:
+ return 270;
+ default:
+ return 0;
+ }
+ }
+
+ public static Bitmap downloadBitmap(String url) {
+ final DefaultHttpClient client = new DefaultHttpClient();
+
+ final HttpGet getRequest = new HttpGet(url);
+
+ try {
+ HttpResponse response = client.execute(getRequest);
+ final int statusCode = response.getStatusLine().getStatusCode();
+ if (statusCode != HttpStatus.SC_OK) {
+ AppLog.w(AppLog.T.UTILS, "ImageDownloader Error " + statusCode
+ + " while retrieving bitmap from " + url);
+ return null;
+ }
+
+ final HttpEntity entity = response.getEntity();
+ if (entity != null) {
+ InputStream inputStream = null;
+ try {
+ inputStream = entity.getContent();
+ return BitmapFactory.decodeStream(inputStream);
+ } finally {
+ if (inputStream != null) {
+ inputStream.close();
+ }
+ entity.consumeContent();
+ }
+ }
+ } catch (Exception e) {
+ // Could provide a more explicit error message for IOException or
+ // IllegalStateException
+ getRequest.abort();
+ AppLog.w(AppLog.T.UTILS, "ImageDownloader Error while retrieving bitmap from " + url);
+ }
+ return null;
+ }
+
+ /** From http://developer.android.com/training/displaying-bitmaps/load-bitmap.html **/
+ public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
+ // Raw height and width of image
+ final int height = options.outHeight;
+ final int width = options.outWidth;
+ int inSampleSize = 1;
+
+ if (height > reqHeight || width > reqWidth) {
+ // Calculate ratios of height and width to requested height and width
+ final int heightRatio = Math.round((float) height / (float) reqHeight);
+ final int widthRatio = Math.round((float) width / (float) reqWidth);
+
+ // Choose the smallest ratio as inSampleSize value, this will guarantee
+ // a final image with both dimensions larger than or equal to the
+ // requested height and width.
+ inSampleSize = heightRatio < widthRatio ? heightRatio : widthRatio;
+ }
+
+ return inSampleSize;
+ }
+
+
+ public interface BitmapWorkerCallback {
+ public void onBitmapReady(String filePath, ImageView imageView, Bitmap bitmap);
+ }
+
+ public static class BitmapWorkerTask extends AsyncTask<String, Void, Bitmap> {
+ private final WeakReference<ImageView> imageViewReference;
+ private final BitmapWorkerCallback callback;
+ private int targetWidth;
+ private int targetHeight;
+ private String path;
+
+ public BitmapWorkerTask(ImageView imageView, int width, int height, BitmapWorkerCallback callback) {
+ // Use a WeakReference to ensure the ImageView can be garbage collected
+ imageViewReference = new WeakReference<ImageView>(imageView);
+ this.callback = callback;
+ targetWidth = width;
+ targetHeight = height;
+ }
+
+ // Decode image in background.
+ @Override
+ protected Bitmap doInBackground(String... params) {
+ path = params[0];
+
+ BitmapFactory.Options bfo = new BitmapFactory.Options();
+ bfo.inJustDecodeBounds = true;
+ BitmapFactory.decodeFile(path, bfo);
+
+ bfo.inSampleSize = calculateInSampleSize(bfo, targetWidth, targetHeight);
+ bfo.inJustDecodeBounds = false;
+
+ // get proper rotation
+ int bitmapWidth = 0;
+ int bitmapHeight = 0;
+ try {
+ File f = new File(path);
+ ExifInterface exif = new ExifInterface(f.getPath());
+ int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
+ int angle = 0;
+ if (orientation == ExifInterface.ORIENTATION_NORMAL) { // no need to rotate
+ return BitmapFactory.decodeFile(path, bfo);
+ } else if (orientation == ExifInterface.ORIENTATION_ROTATE_90) {
+ angle = 90;
+ } else if (orientation == ExifInterface.ORIENTATION_ROTATE_180) {
+ angle = 180;
+ } else if (orientation == ExifInterface.ORIENTATION_ROTATE_270) {
+ angle = 270;
+ }
+
+ Matrix mat = new Matrix();
+ mat.postRotate(angle);
+
+ try {
+ Bitmap bmp = BitmapFactory.decodeStream(new FileInputStream(f), null, bfo);
+ if (bmp == null) {
+ AppLog.e(AppLog.T.UTILS, "can't decode bitmap: " + f.getPath());
+ return null;
+ }
+ bitmapWidth = bmp.getWidth();
+ bitmapHeight = bmp.getHeight();
+ return Bitmap.createBitmap(bmp, 0, 0, bmp.getWidth(), bmp.getHeight(), mat, true);
+ } catch (OutOfMemoryError oom) {
+ CrashlyticsUtils.setInt(CrashlyticsUtils.ExtraKey.IMAGE_ANGLE, angle);
+ CrashlyticsUtils.setInt(CrashlyticsUtils.ExtraKey.IMAGE_WIDTH, bitmapWidth);
+ CrashlyticsUtils.setInt(CrashlyticsUtils.ExtraKey.IMAGE_HEIGHT, bitmapHeight);
+ CrashlyticsUtils.logException(oom, CrashlyticsUtils.ExceptionType.SPECIFIC, AppLog.T.UTILS);
+ AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + oom);
+ }
+ } catch (IOException e) {
+ AppLog.e(AppLog.T.UTILS, "Error in setting image", e);
+ }
+
+ return null;
+ }
+
+ // Once complete, see if ImageView is still around and set bitmap.
+ @Override
+ protected void onPostExecute(Bitmap bitmap) {
+ if (imageViewReference == null || bitmap == null)
+ return;
+
+ final ImageView imageView = imageViewReference.get();
+
+ if (callback != null)
+ callback.onBitmapReady(path, imageView, bitmap);
+
+ }
+ }
+
+
+ public static String getTitleForWPImageSpan(Context ctx, String filePath) {
+ if (filePath == null)
+ return null;
+
+ Uri curStream;
+ String title;
+
+ if (!filePath.contains("content://"))
+ curStream = Uri.parse("content://media" + filePath);
+ else
+ curStream = Uri.parse(filePath);
+
+ if (filePath.contains("video")) {
+ return "Video";
+ } else {
+ String[] projection = new String[] { MediaStore.Images.Thumbnails.DATA };
+
+ Cursor cur;
+ try {
+ cur = ctx.getContentResolver().query(curStream, projection, null, null, null);
+ } catch (Exception e1) {
+ AppLog.e(AppLog.T.UTILS, e1);
+ return null;
+ }
+ File jpeg;
+ if (cur != null) {
+ String thumbData = "";
+ if (cur.moveToFirst()) {
+ int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA);
+ thumbData = cur.getString(dataColumn);
+ }
+ cur.close();
+ if (thumbData == null) {
+ return null;
+ }
+ jpeg = new File(thumbData);
+ } else {
+ String path = filePath.toString().replace("file://", "");
+ jpeg = new File(path);
+ }
+ title = jpeg.getName();
+ return title;
+ }
+ }
+
+ /**
+ * Resizes an image to be placed in the Post Content Editor
+ *
+ * @return resized bitmap
+ */
+ public static Bitmap getWPImageSpanThumbnailFromFilePath(Context context, String filePath, int targetWidth) {
+ if (filePath == null || context == null) {
+ return null;
+ }
+
+ Uri curUri;
+ if (!filePath.contains("content://")) {
+ curUri = Uri.parse("content://media" + filePath);
+ } else {
+ curUri = Uri.parse(filePath);
+ }
+
+ if (filePath.contains("video")) {
+ // Load the video thumbnail from the MediaStore
+ int videoId = 0;
+ try {
+ videoId = Integer.parseInt(curUri.getLastPathSegment());
+ } catch (NumberFormatException e) {
+ }
+ ContentResolver crThumb = context.getContentResolver();
+ BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inSampleSize = 1;
+ Bitmap videoThumbnail = MediaStore.Video.Thumbnails.getThumbnail(crThumb, videoId, MediaStore.Video.Thumbnails.MINI_KIND,
+ options);
+ if (videoThumbnail != null) {
+ return getScaledBitmapAtLongestSide(videoThumbnail, targetWidth);
+ } else {
+ return null;
+ }
+ } else {
+ // Create resized bitmap
+ int rotation = getImageOrientation(context, filePath);
+ byte[] bytes = createThumbnailFromUri(context, curUri, targetWidth, null, rotation);
+
+ if (bytes != null && bytes.length > 0) {
+ try {
+ Bitmap resizedBitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
+ if (resizedBitmap != null) {
+ return getScaledBitmapAtLongestSide(resizedBitmap, targetWidth);
+ }
+ } catch (OutOfMemoryError e) {
+ CrashlyticsUtils.logException(e, CrashlyticsUtils.ExceptionType.SPECIFIC, AppLog.T.UTILS);
+ return null;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /*
+ Resize a bitmap to the targetSize on its longest side.
+ */
+ public static Bitmap getScaledBitmapAtLongestSide(Bitmap bitmap, int targetSize) {
+ if (bitmap.getWidth() <= targetSize && bitmap.getHeight() <= targetSize) {
+ // Do not resize.
+ return bitmap;
+ }
+
+ int targetWidth, targetHeight;
+ if (bitmap.getHeight() > bitmap.getWidth()) {
+ // Resize portrait bitmap
+ targetHeight = targetSize;
+ float percentage = (float) targetSize / bitmap.getHeight();
+ targetWidth = (int)(bitmap.getWidth() * percentage);
+ } else {
+ // Resize landscape or square image
+ targetWidth = targetSize;
+ float percentage = (float) targetSize / bitmap.getWidth();
+ targetHeight = (int)(bitmap.getHeight() * percentage);
+ }
+
+ return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true);
+ }
+
+ /**
+ * nbradbury - 21-Feb-2014 - similar to createThumbnail but more efficient since it doesn't
+ * require passing the full-size image as an array of bytes[]
+ */
+ public static byte[] createThumbnailFromUri(Context context,
+ Uri imageUri,
+ int maxWidth,
+ String fileExtension,
+ int rotation) {
+ if (context == null || imageUri == null || maxWidth <= 0)
+ return null;
+
+ String filePath = null;
+ if (imageUri.toString().contains("content:")) {
+ String[] projection = new String[] { MediaStore.Images.Media.DATA };
+ Cursor cur = context.getContentResolver().query(imageUri, projection, null, null, null);
+ if (cur != null) {
+ if (cur.moveToFirst()) {
+ int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA);
+ filePath = cur.getString(dataColumn);
+ }
+ cur.close();
+ }
+ }
+
+ if (TextUtils.isEmpty(filePath)) {
+ //access the file directly
+ filePath = imageUri.toString().replace("content://media", "");
+ filePath = filePath.replace("file://", "");
+ }
+
+ // get just the image bounds
+ BitmapFactory.Options optBounds = new BitmapFactory.Options();
+ optBounds.inJustDecodeBounds = true;
+
+ try {
+ BitmapFactory.decodeFile(filePath, optBounds);
+ } catch (OutOfMemoryError e) {
+ CrashlyticsUtils.logException(e, CrashlyticsUtils.ExceptionType.SPECIFIC, AppLog.T.UTILS);
+ return null;
+ }
+
+ // determine correct scale value (should be power of 2)
+ // http://stackoverflow.com/questions/477572/android-strange-out-of-memory-issue/3549021#3549021
+ int scale = 1;
+ if (maxWidth > 0 && optBounds.outWidth > maxWidth) {
+ double d = Math.pow(2, (int) Math.round(Math.log(maxWidth / (double) optBounds.outWidth) / Math.log(0.5)));
+ scale = (int) d;
+ }
+
+ BitmapFactory.Options optActual = new BitmapFactory.Options();
+ optActual.inSampleSize = scale;
+
+ // Get the roughly resized bitmap
+ Bitmap bmpResized;
+ try {
+ bmpResized = BitmapFactory.decodeFile(filePath, optActual);
+ } catch (OutOfMemoryError e) {
+ CrashlyticsUtils.setFloat(CrashlyticsUtils.ExtraKey.IMAGE_RESIZE_SCALE, scale);
+ CrashlyticsUtils.logException(e, CrashlyticsUtils.ExceptionType.SPECIFIC, AppLog.T.UTILS);
+ return null;
+ }
+
+ if (bmpResized == null)
+ return null;
+
+ ByteArrayOutputStream stream = new ByteArrayOutputStream();
+
+ // Now calculate exact scale in order to resize accurately
+ float percentage = (float) maxWidth / bmpResized.getWidth();
+ float proportionateHeight = bmpResized.getHeight() * percentage;
+ int finalHeight = (int) Math.rint(proportionateHeight);
+
+ float scaleWidth = ((float) maxWidth) / bmpResized.getWidth();
+ float scaleHeight = ((float) finalHeight) / bmpResized.getHeight();
+
+ float scaleBy = Math.min(scaleWidth, scaleHeight);
+
+ // Resize the bitmap to exact size
+ Matrix matrix = new Matrix();
+ matrix.postScale(scaleBy, scaleBy);
+
+ // apply rotation
+ if (rotation != 0) {
+ matrix.setRotate(rotation);
+ }
+
+ Bitmap.CompressFormat fmt;
+ if (fileExtension != null && fileExtension.equalsIgnoreCase("png")) {
+ fmt = Bitmap.CompressFormat.PNG;
+ } else {
+ fmt = Bitmap.CompressFormat.JPEG;
+ }
+
+ final Bitmap bmpRotated;
+ try {
+ bmpRotated = Bitmap.createBitmap(bmpResized, 0, 0, bmpResized.getWidth(), bmpResized.getHeight(), matrix,
+ true);
+ } catch (OutOfMemoryError e) {
+ CrashlyticsUtils.setInt(CrashlyticsUtils.ExtraKey.IMAGE_ANGLE, rotation);
+ CrashlyticsUtils.setInt(CrashlyticsUtils.ExtraKey.IMAGE_WIDTH, bmpResized.getWidth());
+ CrashlyticsUtils.setInt(CrashlyticsUtils.ExtraKey.IMAGE_HEIGHT, bmpResized.getHeight());
+ CrashlyticsUtils.setFloat(CrashlyticsUtils.ExtraKey.IMAGE_RESIZE_SCALE, scaleBy);
+ CrashlyticsUtils.logException(e, CrashlyticsUtils.ExceptionType.SPECIFIC, AppLog.T.UTILS);
+ return null;
+ }
+ bmpRotated.compress(fmt, 100, stream);
+ bmpResized.recycle();
+ bmpRotated.recycle();
+
+ return stream.toByteArray();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/ImageUtils.java b/WordPress/src/main/java/org/wordpress/android/util/ImageUtils.java
new file mode 100644
index 000000000..9172b899c
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/ImageUtils.java
@@ -0,0 +1,72 @@
+package org.wordpress.android.util;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffXfermode;
+import android.graphics.Rect;
+import android.graphics.RectF;
+
+public class ImageUtils {
+ /*
+ * used for round avatars in Reader
+ */
+ public static Bitmap getRoundedBitmap(final Bitmap bitmap) {
+ if (bitmap==null)
+ return null;
+
+ final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888);
+ final Canvas canvas = new Canvas(output);
+ final Paint paint = new Paint();
+ final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight());
+ final RectF rectF = new RectF(rect);
+
+ paint.setAntiAlias(true);
+ canvas.drawARGB(0, 0, 0, 0);
+ paint.setColor(Color.RED);
+ canvas.drawOval(rectF, paint);
+
+ paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
+ canvas.drawBitmap(bitmap, rect, rect, paint);
+
+ // outline
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeWidth(1f);
+ paint.setColor(Color.DKGRAY);
+ canvas.drawOval(rectF, paint);
+
+ return output;
+ }
+
+ /* public static Bitmap addVideoOverlay(final Bitmap bitmap) {
+ if (bitmap==null)
+ return null;
+
+ Bitmap bmpOverlay = BitmapFactory.decodeResource(WPReader.getInstance().getResources(), R.drawable.video_overlay, null);
+ int overlayWidth = (int)(bmpOverlay.getWidth() * 1.75f);
+ int overlayHeight = (int)(bmpOverlay.getHeight() * 1.75f);
+
+ int srcWidth = bitmap.getWidth();
+ int srcHeight = bitmap.getHeight();
+
+ // return passed bitmap w/o overlay if it's smaller than our overlay
+ if (srcWidth < overlayWidth || srcHeight < overlayHeight)
+ return bitmap;
+
+ Bitmap bmpCopy = Bitmap.createBitmap(srcWidth, srcHeight, bitmap.getConfig());
+
+ Canvas canvas = new Canvas(bmpCopy);
+ Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG);
+ canvas.drawBitmap(bitmap, 0, 0, paint);
+
+ int left = (srcWidth / 2) - (overlayWidth / 2);
+ int top = (srcHeight / 2) - (overlayHeight / 2);
+ Rect rcDst = new Rect(left, top, left + overlayWidth, top + overlayHeight);
+
+ canvas.drawBitmap(bmpOverlay, null, rcDst, paint);
+
+ return bmpCopy;
+ }*/
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/IntHashMap.java b/WordPress/src/main/java/org/wordpress/android/util/IntHashMap.java
new file mode 100644
index 000000000..1410b6867
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/IntHashMap.java
@@ -0,0 +1,339 @@
+package org.wordpress.android.util;
+
+/**
+ * <p>A hash map that uses primitive ints for the key rather than objects.</p>
+ *
+ * <p>Note that this class is for internal optimization purposes only, and may
+ * not be supported in future releases of Apache Commons Lang. Utilities of
+ * this sort may be included in future releases of Apache Commons Collections.</p>
+ *
+ * @author Justin Couch
+ * @author Alex Chaffee (alex@apache.org)
+ * @author Stephen Colebourne
+ * @since 2.0
+ * @version $Revision: 561230 $
+ * @see java.util.HashMap
+ */
+class IntHashMap {
+ /**
+ * The hash table data.
+ */
+ private transient Entry table[];
+
+ /**
+ * The total number of entries in the hash table.
+ */
+ private transient int count;
+
+ /**
+ * The table is rehashed when its size exceeds this threshold. (The
+ * value of this field is (int)(capacity * loadFactor).)
+ *
+ * @serial
+ */
+ private int threshold;
+
+ /**
+ * The load factor for the hashtable.
+ *
+ * @serial
+ */
+ private float loadFactor;
+
+ /**
+ * <p>Innerclass that acts as a datastructure to create a new entry in the
+ * table.</p>
+ */
+ private static class Entry {
+ int hash;
+ Object value;
+ Entry next;
+
+ /**
+ * <p>Create a new entry with the given values.</p>
+ *
+ * @param hash The code used to hash the object with
+ * @param key The key used to enter this in the table
+ * @param value The value for this key
+ * @param next A reference to the next entry in the table
+ */
+ protected Entry(int hash, Object value, Entry next) {
+ this.hash = hash;
+ this.value = value;
+ this.next = next;
+ }
+ }
+
+ /**
+ * <p>Constructs a new, empty hashtable with a default capacity and load
+ * factor, which is <code>20</code> and <code>0.75</code> respectively.</p>
+ */
+ public IntHashMap() {
+ this(20, 0.75f);
+ }
+
+ /**
+ * <p>Constructs a new, empty hashtable with the specified initial capacity
+ * and default load factor, which is <code>0.75</code>.</p>
+ *
+ * @param initialCapacity the initial capacity of the hashtable.
+ * @throws IllegalArgumentException if the initial capacity is less
+ * than zero.
+ */
+ public IntHashMap(int initialCapacity) {
+ this(initialCapacity, 0.75f);
+ }
+
+ /**
+ * <p>Constructs a new, empty hashtable with the specified initial
+ * capacity and the specified load factor.</p>
+ *
+ * @param initialCapacity the initial capacity of the hashtable.
+ * @param loadFactor the load factor of the hashtable.
+ * @throws IllegalArgumentException if the initial capacity is less
+ * than zero, or if the load factor is nonpositive.
+ */
+ public IntHashMap(int initialCapacity, float loadFactor) {
+ super();
+ if (initialCapacity < 0) {
+ throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity);
+ }
+ if (loadFactor <= 0) {
+ throw new IllegalArgumentException("Illegal Load: " + loadFactor);
+ }
+ if (initialCapacity == 0) {
+ initialCapacity = 1;
+ }
+
+ this.loadFactor = loadFactor;
+ table = new Entry[initialCapacity];
+ threshold = (int) (initialCapacity * loadFactor);
+ }
+
+ /**
+ * <p>Returns the number of keys in this hashtable.</p>
+ *
+ * @return the number of keys in this hashtable.
+ */
+ public int size() {
+ return count;
+ }
+
+ /**
+ * <p>Tests if this hashtable maps no keys to values.</p>
+ *
+ * @return <code>true</code> if this hashtable maps no keys to values;
+ * <code>false</code> otherwise.
+ */
+ public boolean isEmpty() {
+ return count == 0;
+ }
+
+ /**
+ * <p>Tests if some key maps into the specified value in this hashtable.
+ * This operation is more expensive than the <code>containsKey</code>
+ * method.</p>
+ *
+ * <p>Note that this method is identical in functionality to containsValue,
+ * (which is part of the Map interface in the collections framework).</p>
+ *
+ * @param value a value to search for.
+ * @return <code>true</code> if and only if some key maps to the
+ * <code>value</code> argument in this hashtable as
+ * determined by the <tt>equals</tt> method;
+ * <code>false</code> otherwise.
+ * @throws NullPointerException if the value is <code>null</code>.
+ * @see #containsKey(int)
+ * @see #containsValue(Object)
+ * @see java.util.Map
+ */
+ public boolean contains(Object value) {
+ if (value == null) {
+ throw new NullPointerException();
+ }
+
+ Entry tab[] = table;
+ for (int i = tab.length; i-- > 0;) {
+ for (Entry e = tab[i]; e != null; e = e.next) {
+ if (e.value.equals(value)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * <p>Returns <code>true</code> if this HashMap maps one or more keys
+ * to this value.</p>
+ *
+ * <p>Note that this method is identical in functionality to contains
+ * (which predates the Map interface).</p>
+ *
+ * @param value value whose presence in this HashMap is to be tested.
+ * @return boolean <code>true</code> if the value is contained
+ * @see java.util.Map
+ * @since JDK1.2
+ */
+ public boolean containsValue(Object value) {
+ return contains(value);
+ }
+
+ /**
+ * <p>Tests if the specified object is a key in this hashtable.</p>
+ *
+ * @param key possible key.
+ * @return <code>true</code> if and only if the specified object is a
+ * key in this hashtable, as determined by the <tt>equals</tt>
+ * method; <code>false</code> otherwise.
+ * @see #contains(Object)
+ */
+ public boolean containsKey(int key) {
+ Entry tab[] = table;
+ int hash = key;
+ int index = (hash & 0x7FFFFFFF) % tab.length;
+ for (Entry e = tab[index]; e != null; e = e.next) {
+ if (e.hash == hash) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * <p>Returns the value to which the specified key is mapped in this map.</p>
+ *
+ * @param key a key in the hashtable.
+ * @return the value to which the key is mapped in this hashtable;
+ * <code>null</code> if the key is not mapped to any value in
+ * this hashtable.
+ * @see #put(int, Object)
+ */
+ public Object get(int key) {
+ Entry tab[] = table;
+ int hash = key;
+ int index = (hash & 0x7FFFFFFF) % tab.length;
+ for (Entry e = tab[index]; e != null; e = e.next) {
+ if (e.hash == hash) {
+ return e.value;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * <p>Increases the capacity of and internally reorganizes this
+ * hashtable, in order to accommodate and access its entries more
+ * efficiently.</p>
+ *
+ * <p>This method is called automatically when the number of keys
+ * in the hashtable exceeds this hashtable's capacity and load
+ * factor.</p>
+ */
+ protected void rehash() {
+ int oldCapacity = table.length;
+ Entry oldMap[] = table;
+
+ int newCapacity = oldCapacity * 2 + 1;
+ Entry newMap[] = new Entry[newCapacity];
+
+ threshold = (int) (newCapacity * loadFactor);
+ table = newMap;
+
+ for (int i = oldCapacity; i-- > 0;) {
+ for (Entry old = oldMap[i]; old != null;) {
+ Entry e = old;
+ old = old.next;
+
+ int index = (e.hash & 0x7FFFFFFF) % newCapacity;
+ e.next = newMap[index];
+ newMap[index] = e;
+ }
+ }
+ }
+
+ /**
+ * <p>Maps the specified <code>key</code> to the specified
+ * <code>value</code> in this hashtable. The key cannot be
+ * <code>null</code>. </p>
+ *
+ * <p>The value can be retrieved by calling the <code>get</code> method
+ * with a key that is equal to the original key.</p>
+ *
+ * @param key the hashtable key.
+ * @param value the value.
+ * @return the previous value of the specified key in this hashtable,
+ * or <code>null</code> if it did not have one.
+ * @throws NullPointerException if the key is <code>null</code>.
+ * @see #get(int)
+ */
+ public Object put(int key, Object value) {
+ // Makes sure the key is not already in the hashtable.
+ Entry tab[] = table;
+ int hash = key;
+ int index = (hash & 0x7FFFFFFF) % tab.length;
+ for (Entry e = tab[index]; e != null; e = e.next) {
+ if (e.hash == hash) {
+ Object old = e.value;
+ e.value = value;
+ return old;
+ }
+ }
+
+ if (count >= threshold) {
+ // Rehash the table if the threshold is exceeded
+ rehash();
+
+ tab = table;
+ index = (hash & 0x7FFFFFFF) % tab.length;
+ }
+
+ // Creates the new entry.
+ Entry e = new Entry(hash, value, tab[index]);
+ tab[index] = e;
+ count++;
+ return null;
+ }
+
+ /**
+ * <p>Removes the key (and its corresponding value) from this
+ * hashtable.</p>
+ *
+ * <p>This method does nothing if the key is not present in the
+ * hashtable.</p>
+ *
+ * @param key the key that needs to be removed.
+ * @return the value to which the key had been mapped in this hashtable,
+ * or <code>null</code> if the key did not have a mapping.
+ */
+ public Object remove(int key) {
+ Entry tab[] = table;
+ int hash = key;
+ int index = (hash & 0x7FFFFFFF) % tab.length;
+ for (Entry e = tab[index], prev = null; e != null; prev = e, e = e.next) {
+ if (e.hash == hash) {
+ if (prev != null) {
+ prev.next = e.next;
+ } else {
+ tab[index] = e.next;
+ }
+ count--;
+ Object oldValue = e.value;
+ e.value = null;
+ return oldValue;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * <p>Clears this hashtable so that it contains no keys.</p>
+ */
+ public synchronized void clear() {
+ Entry tab[] = table;
+ for (int index = tab.length; --index >= 0;) {
+ tab[index] = null;
+ }
+ count = 0;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/JSONUtil.java b/WordPress/src/main/java/org/wordpress/android/util/JSONUtil.java
new file mode 100644
index 000000000..199fba703
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/JSONUtil.java
@@ -0,0 +1,236 @@
+package org.wordpress.android.util;
+
+import android.text.TextUtils;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.util.AppLog.T;
+
+import java.util.ArrayList;
+
+public class JSONUtil {
+ private static String QUERY_SEPERATOR=".";
+ private static String QUERY_ARRAY_INDEX_START="[";
+ private static String QUERY_ARRAY_INDEX_END="]";
+ private static String QUERY_ARRAY_FIRST="first";
+ private static String QUERY_ARRAY_LAST="last";
+
+ private static final String JSON_NULL_STR = "null";
+
+ private static final String TAG="JSONUtil";
+ /**
+ * Given a JSONObject and a key path (e.g property.child) and a default it will
+ * traverse the object graph and pull out the desired property
+ */
+ public static <U> U queryJSON(JSONObject source, String query, U defaultObject) {
+ int nextSeperator = query.indexOf(QUERY_SEPERATOR);
+ int nextIndexStart = query.indexOf(QUERY_ARRAY_INDEX_START);
+ if (nextSeperator == -1 && nextIndexStart == -1) {
+ // last item let's get it
+ try {
+ if (!source.has(query)) {
+ return defaultObject;
+ }
+ Object result = source.get(query);
+ if (result.getClass().isAssignableFrom(defaultObject.getClass())) {
+ return (U) result;
+ } else {
+ return defaultObject;
+ }
+ } catch (java.lang.ClassCastException e) {
+ AppLog.e(T.UTILS, "Unable to cast the object to " + defaultObject.getClass().getName(), e);
+ return defaultObject;
+ } catch (JSONException e) {
+ AppLog.e(T.UTILS, "Unable to get the Key from the input object. Key:" + query, e);
+ return defaultObject;
+ }
+ }
+ int endQuery;
+ if (nextSeperator == -1 || nextIndexStart == -1) {
+ endQuery = Math.max(nextSeperator, nextIndexStart);
+ } else {
+ endQuery = Math.min(nextSeperator, nextIndexStart);
+ }
+ String nextQuery = query.substring(endQuery);
+ String key = query.substring(0, endQuery);
+ try {
+ if (source == null) {
+ return defaultObject;
+ }
+ if (nextQuery.indexOf(QUERY_SEPERATOR) == 0) {
+ return queryJSON(source.getJSONObject(key), nextQuery.substring(1), defaultObject);
+ } else if (nextQuery.indexOf(QUERY_ARRAY_INDEX_START) == 0) {
+ return queryJSON(source.getJSONArray(key), nextQuery, defaultObject);
+ } else if (!nextQuery.equals("")) {
+ return defaultObject;
+ }
+ Object result = source.get(key);
+ if (result.getClass().isAssignableFrom(defaultObject.getClass())) {
+ return (U) result;
+ } else {
+ AppLog.w(T.UTILS, String.format("The returned object type %s is not assignable to the type %s. Using default!",
+ result.getClass(),defaultObject.getClass()));
+ return defaultObject;
+ }
+ } catch (java.lang.ClassCastException e) {
+ AppLog.e(T.UTILS, "Unable to cast the object to " + defaultObject.getClass().getName(), e);
+ return defaultObject;
+ } catch (JSONException e) {
+ AppLog.e(T.UTILS, "Unable to get the Key from the input object. Key:" + query, e);
+ return defaultObject;
+ }
+ }
+
+ /**
+ * Given a JSONArray and a query (e.g. [0].property) it will traverse the array and
+ * pull out the requested property.
+ *
+ * Acceptable indexes include negative numbers to reference items from the end of
+ * the list as well as "last" and "first" as more explicit references to "0" and "-1"
+ */
+ public static <U> U queryJSON(JSONArray source, String query, U defaultObject){
+ // query must start with [ have an index and then have ]
+ int indexStart = query.indexOf(QUERY_ARRAY_INDEX_START);
+ int indexEnd = query.indexOf(QUERY_ARRAY_INDEX_END);
+ if (indexStart == -1 || indexEnd == -1 || indexStart > indexEnd) {
+ return defaultObject;
+ }
+ // get "index" from "[index]"
+ String indexStr = query.substring(indexStart + 1, indexEnd);
+ int index;
+ if (indexStr.equals(QUERY_ARRAY_FIRST)) {
+ index = 0;
+ } else if (indexStr.equals(QUERY_ARRAY_LAST)) {
+ index = -1;
+ } else {
+ index = Integer.parseInt(indexStr);
+ }
+ if (index < 0) {
+ index = source.length() + index;
+ }
+ // copy remaining query
+ String remainingQuery = query.substring(indexEnd + 1);
+ try {
+ if (remainingQuery.indexOf(QUERY_ARRAY_INDEX_START) == 0) {
+ return queryJSON(source.getJSONArray(index), remainingQuery, defaultObject);
+ } else if (remainingQuery.indexOf(QUERY_SEPERATOR) == 0) {
+ return queryJSON(source.getJSONObject(index), remainingQuery.substring(1), defaultObject);
+ } else if (!remainingQuery.equals("")) {
+ // TODO throw an exception since the query isn't valid?
+ AppLog.w(T.UTILS, String.format("Incorrect query for next object %s", remainingQuery));
+ return defaultObject;
+ }
+ Object result = source.get(index);
+ if (result.getClass().isAssignableFrom(defaultObject.getClass())) {
+ return (U) result;
+ } else {
+ AppLog.w(T.UTILS, String.format("The returned object type %s is not assignable to the type %s. Using default!",
+ result.getClass(),defaultObject.getClass()));
+ return defaultObject;
+ }
+ } catch (java.lang.ClassCastException e) {
+ AppLog.e(T.UTILS, "Unable to cast the object to "+defaultObject.getClass().getName(), e);
+ return defaultObject;
+ } catch (JSONException e) {
+ AppLog.e(T.UTILS, "Unable to get the Key from the input object. Key:" + query, e);
+ return defaultObject;
+ }
+ }
+
+ /**
+ * Convert a JSONArray (expected to contain strings) in a string list
+ */
+ public static ArrayList<String> fromJSONArrayToStringList(JSONArray jsonArray) {
+ ArrayList<String> stringList = new ArrayList<String>();
+ for (int i = 0; i < jsonArray.length(); i++) {
+ try {
+ stringList.add(jsonArray.getString(i));
+ } catch (JSONException e) {
+ AppLog.e(T.UTILS, e);
+ }
+ }
+ return stringList;
+ }
+
+ /**
+ * Convert a string list in a JSONArray
+ */
+ public static JSONArray fromStringListToJSONArray(ArrayList<String> stringList) {
+ JSONArray jsonArray = new JSONArray();
+ if (stringList != null) {
+ for (int i = 0; i < stringList.size(); i++) {
+ jsonArray.put(stringList.get(i));
+ }
+ }
+ return jsonArray;
+ }
+
+ /*
+ * wrapper for JSONObject.optString() which handles "null" values
+ */
+ public static String getString(JSONObject json, String name) {
+ String value = json.optString(name);
+ // return empty string for "null"
+ if (JSON_NULL_STR.equals(value))
+ return "";
+ return value;
+ }
+
+ /*
+ * use with strings that contain HTML entities
+ */
+ public static String getStringDecoded(JSONObject json, String name) {
+ String value = getString(json, name);
+ return HtmlUtils.fastUnescapeHtml(value);
+ }
+
+ /*
+ * replacement for JSONObject.optBoolean() - optBoolean() only checks for "true" and "false",
+ * but our API sometimes uses "0" to denote false
+ */
+ public static boolean getBool(JSONObject json, String name) {
+ String value = getString(json, name);
+ if (TextUtils.isEmpty(value))
+ return false;
+ if (value.equals("0"))
+ return false;
+ if (value.equalsIgnoreCase("false"))
+ return false;
+ return true;
+ }
+
+ /*
+ * returns the JSONObject child of the passed parent that matches the passed query
+ * this is basically an "optJSONObject" that supports nested queries, for example:
+ *
+ * getJSONChild("meta/data/site")
+ *
+ * would find this:
+ *
+ * "meta": {
+ * "data": {
+ * "site": {
+ * "ID": 3584907,
+ * "name": "WordPress.com News",
+ * }
+ * }
+ * }
+ */
+ public static JSONObject getJSONChild(final JSONObject jsonParent, final String query) {
+ if (jsonParent == null || TextUtils.isEmpty(query))
+ return null;
+ String[] names = query.split("/");
+ JSONObject jsonChild = null;
+ for (int i = 0; i < names.length; i++) {
+ if (jsonChild == null) {
+ jsonChild = jsonParent.optJSONObject(names[i]);
+ } else {
+ jsonChild = jsonChild.optJSONObject(names[i]);
+ }
+ if (jsonChild == null)
+ return null;
+ }
+ return jsonChild;
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/util/LinePageIndicator.java b/WordPress/src/main/java/org/wordpress/android/util/LinePageIndicator.java
new file mode 100644
index 000000000..f81b4f331
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/LinePageIndicator.java
@@ -0,0 +1,432 @@
+/*
+ * Copyright (C) 2012 Jake Wharton
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.drawable.Drawable;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.support.v4.view.MotionEventCompat;
+import android.support.v4.view.ViewConfigurationCompat;
+import android.support.v4.view.ViewPager;
+import android.util.AttributeSet;
+import android.util.FloatMath;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewConfiguration;
+
+import org.wordpress.android.R;
+
+/**
+ * Draws a line for each page. The current page line is colored differently
+ * than the unselected page lines.
+ */
+public class LinePageIndicator extends View implements PageIndicator {
+ private static final int INVALID_POINTER = -1;
+
+ private final Paint mPaintUnselected = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint mPaintSelected = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private ViewPager mViewPager;
+ private ViewPager.OnPageChangeListener mListener;
+ private int mCurrentPage;
+ private boolean mCentered;
+ private float mLineWidth;
+ private float mGapWidth;
+
+ private int mTouchSlop;
+ private float mLastMotionX = -1;
+ private int mActivePointerId = INVALID_POINTER;
+ private boolean mIsDragging;
+
+
+ public LinePageIndicator(Context context) {
+ this(context, null);
+ }
+
+ public LinePageIndicator(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public LinePageIndicator(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ if (isInEditMode()) return;
+
+ final Resources res = getResources();
+
+ mCentered = true;
+ mLineWidth = res.getDimension(R.dimen.page_indicator_line_width);
+ mGapWidth = res.getDimension(R.dimen.page_indicator_gap_width);
+ setStrokeWidth(res.getDimension(R.dimen.page_indicator_stroke_width));
+ mPaintUnselected.setColor(res.getColor(R.color.page_indicator_unselected_color));
+ mPaintSelected.setColor(res.getColor(R.color.page_indicator_selected_color));
+
+ final ViewConfiguration configuration = ViewConfiguration.get(context);
+ mTouchSlop = ViewConfigurationCompat.getScaledPagingTouchSlop(configuration);
+ }
+
+
+ public void setCentered(boolean centered) {
+ mCentered = centered;
+ invalidate();
+ }
+
+ public boolean isCentered() {
+ return mCentered;
+ }
+
+ public void setUnselectedColor(int unselectedColor) {
+ mPaintUnselected.setColor(unselectedColor);
+ invalidate();
+ }
+
+ public int getUnselectedColor() {
+ return mPaintUnselected.getColor();
+ }
+
+ public void setSelectedColor(int selectedColor) {
+ mPaintSelected.setColor(selectedColor);
+ invalidate();
+ }
+
+ public int getSelectedColor() {
+ return mPaintSelected.getColor();
+ }
+
+ public void setLineWidth(float lineWidth) {
+ mLineWidth = lineWidth;
+ invalidate();
+ }
+
+ public float getLineWidth() {
+ return mLineWidth;
+ }
+
+ public void setStrokeWidth(float lineHeight) {
+ mPaintSelected.setStrokeWidth(lineHeight);
+ mPaintUnselected.setStrokeWidth(lineHeight);
+ invalidate();
+ }
+
+ public float getStrokeWidth() {
+ return mPaintSelected.getStrokeWidth();
+ }
+
+ public void setGapWidth(float gapWidth) {
+ mGapWidth = gapWidth;
+ invalidate();
+ }
+
+ public float getGapWidth() {
+ return mGapWidth;
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ if (mViewPager == null) {
+ return;
+ }
+ final int count = mViewPager.getAdapter().getCount();
+ if (count == 0) {
+ return;
+ }
+
+ if (mCurrentPage >= count) {
+ setCurrentItem(count - 1);
+ return;
+ }
+
+ final float lineWidthAndGap = mLineWidth + mGapWidth;
+ final float indicatorWidth = (count * lineWidthAndGap) - mGapWidth;
+ final float paddingTop = getPaddingTop();
+ final float paddingLeft = getPaddingLeft();
+ final float paddingRight = getPaddingRight();
+
+ float verticalOffset = paddingTop + ((getHeight() - paddingTop - getPaddingBottom()) / 2.0f);
+ float horizontalOffset = paddingLeft;
+ if (mCentered) {
+ horizontalOffset += ((getWidth() - paddingLeft - paddingRight) / 2.0f) - (indicatorWidth / 2.0f);
+ }
+
+ //Draw stroked circles
+ for (int i = 0; i < count; i++) {
+ float dx1 = horizontalOffset + (i * lineWidthAndGap);
+ float dx2 = dx1 + mLineWidth;
+ canvas.drawLine(dx1, verticalOffset, dx2, verticalOffset, (i == mCurrentPage) ? mPaintSelected : mPaintUnselected);
+ }
+ }
+
+ public boolean onTouchEvent(MotionEvent ev) {
+ if (super.onTouchEvent(ev)) {
+ return true;
+ }
+ if ((mViewPager == null) || (mViewPager.getAdapter().getCount() == 0)) {
+ return false;
+ }
+
+ final int action = ev.getAction() & MotionEventCompat.ACTION_MASK;
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
+ mLastMotionX = ev.getX();
+ break;
+
+ case MotionEvent.ACTION_MOVE: {
+ final int activePointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId);
+ final float x = MotionEventCompat.getX(ev, activePointerIndex);
+ final float deltaX = x - mLastMotionX;
+
+ if (!mIsDragging) {
+ if (Math.abs(deltaX) > mTouchSlop) {
+ mIsDragging = true;
+ }
+ }
+
+ if (mIsDragging) {
+ mLastMotionX = x;
+ if (mViewPager.isFakeDragging() || mViewPager.beginFakeDrag()) {
+ mViewPager.fakeDragBy(deltaX);
+ }
+ }
+
+ break;
+ }
+
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP:
+ if (!mIsDragging) {
+ final int count = mViewPager.getAdapter().getCount();
+ final int width = getWidth();
+ final float halfWidth = width / 2f;
+ final float sixthWidth = width / 6f;
+
+ if ((mCurrentPage > 0) && (ev.getX() < halfWidth - sixthWidth)) {
+ if (action != MotionEvent.ACTION_CANCEL) {
+ mViewPager.setCurrentItem(mCurrentPage - 1);
+ }
+ return true;
+ } else if ((mCurrentPage < count - 1) && (ev.getX() > halfWidth + sixthWidth)) {
+ if (action != MotionEvent.ACTION_CANCEL) {
+ mViewPager.setCurrentItem(mCurrentPage + 1);
+ }
+ return true;
+ }
+ }
+
+ mIsDragging = false;
+ mActivePointerId = INVALID_POINTER;
+ if (mViewPager.isFakeDragging()) mViewPager.endFakeDrag();
+ break;
+
+ case MotionEventCompat.ACTION_POINTER_DOWN: {
+ final int index = MotionEventCompat.getActionIndex(ev);
+ mLastMotionX = MotionEventCompat.getX(ev, index);
+ mActivePointerId = MotionEventCompat.getPointerId(ev, index);
+ break;
+ }
+
+ case MotionEventCompat.ACTION_POINTER_UP:
+ final int pointerIndex = MotionEventCompat.getActionIndex(ev);
+ final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex);
+ if (pointerId == mActivePointerId) {
+ final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
+ mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex);
+ }
+ mLastMotionX = MotionEventCompat.getX(ev, MotionEventCompat.findPointerIndex(ev, mActivePointerId));
+ break;
+ }
+
+ return true;
+ }
+
+ @Override
+ public void setViewPager(ViewPager viewPager) {
+ if (mViewPager == viewPager) {
+ return;
+ }
+ if (mViewPager != null) {
+ //Clear us from the old pager.
+ mViewPager.setOnPageChangeListener(null);
+ }
+ if (viewPager.getAdapter() == null) {
+ throw new IllegalStateException("ViewPager does not have adapter instance.");
+ }
+ mViewPager = viewPager;
+ mViewPager.setOnPageChangeListener(this);
+ invalidate();
+ }
+
+ @Override
+ public void setViewPager(ViewPager view, int initialPosition) {
+ setViewPager(view);
+ setCurrentItem(initialPosition);
+ }
+
+ @Override
+ public void setCurrentItem(int item) {
+ if (mViewPager == null) {
+ throw new IllegalStateException("ViewPager has not been bound.");
+ }
+ mViewPager.setCurrentItem(item);
+ mCurrentPage = item;
+ invalidate();
+ }
+
+ @Override
+ public void notifyDataSetChanged() {
+ invalidate();
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int state) {
+ if (mListener != null) {
+ mListener.onPageScrollStateChanged(state);
+ }
+ }
+
+ @Override
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ if (mListener != null) {
+ mListener.onPageScrolled(position, positionOffset, positionOffsetPixels);
+ }
+ }
+
+ @Override
+ public void onPageSelected(int position) {
+ mCurrentPage = position;
+ invalidate();
+
+ if (mListener != null) {
+ mListener.onPageSelected(position);
+ }
+ }
+
+ @Override
+ public void setOnPageChangeListener(ViewPager.OnPageChangeListener listener) {
+ mListener = listener;
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ setMeasuredDimension(measureWidth(widthMeasureSpec), measureHeight(heightMeasureSpec));
+ }
+
+ /**
+ * Determines the width of this view
+ *
+ * @param measureSpec
+ * A measureSpec packed into an int
+ * @return The width of the view, honoring constraints from measureSpec
+ */
+ private int measureWidth(int measureSpec) {
+ float result;
+ int specMode = MeasureSpec.getMode(measureSpec);
+ int specSize = MeasureSpec.getSize(measureSpec);
+
+ if ((specMode == MeasureSpec.EXACTLY) || (mViewPager == null)) {
+ //We were told how big to be
+ result = specSize;
+ } else {
+ //Calculate the width according the views count
+ final int count = mViewPager.getAdapter().getCount();
+ result = getPaddingLeft() + getPaddingRight() + (count * mLineWidth) + ((count - 1) * mGapWidth);
+ //Respect AT_MOST value if that was what is called for by measureSpec
+ if (specMode == MeasureSpec.AT_MOST) {
+ result = Math.min(result, specSize);
+ }
+ }
+ return (int)FloatMath.ceil(result);
+ }
+
+ /**
+ * Determines the height of this view
+ *
+ * @param measureSpec
+ * A measureSpec packed into an int
+ * @return The height of the view, honoring constraints from measureSpec
+ */
+ private int measureHeight(int measureSpec) {
+ float result;
+ int specMode = MeasureSpec.getMode(measureSpec);
+ int specSize = MeasureSpec.getSize(measureSpec);
+
+ if (specMode == MeasureSpec.EXACTLY) {
+ //We were told how big to be
+ result = specSize;
+ } else {
+ //Measure the height
+ result = mPaintSelected.getStrokeWidth() + getPaddingTop() + getPaddingBottom();
+ //Respect AT_MOST value if that was what is called for by measureSpec
+ if (specMode == MeasureSpec.AT_MOST) {
+ result = Math.min(result, specSize);
+ }
+ }
+ return (int)FloatMath.ceil(result);
+ }
+
+ @Override
+ public void onRestoreInstanceState(Parcelable state) {
+ SavedState savedState = (SavedState)state;
+ super.onRestoreInstanceState(savedState.getSuperState());
+ mCurrentPage = savedState.currentPage;
+ requestLayout();
+ }
+
+ @Override
+ public Parcelable onSaveInstanceState() {
+ Parcelable superState = super.onSaveInstanceState();
+ SavedState savedState = new SavedState(superState);
+ savedState.currentPage = mCurrentPage;
+ return savedState;
+ }
+
+ static class SavedState extends BaseSavedState {
+ int currentPage;
+
+ public SavedState(Parcelable superState) {
+ super(superState);
+ }
+
+ private SavedState(Parcel in) {
+ super(in);
+ currentPage = in.readInt();
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ super.writeToParcel(dest, flags);
+ dest.writeInt(currentPage);
+ }
+
+ @SuppressWarnings("UnusedDeclaration")
+ public static final Creator<SavedState> CREATOR = new Creator<SavedState>() {
+ @Override
+ public SavedState createFromParcel(Parcel in) {
+ return new SavedState(in);
+ }
+
+ @Override
+ public SavedState[] newArray(int size) {
+ return new SavedState[size];
+ }
+ };
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java b/WordPress/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java
new file mode 100644
index 000000000..d60e9da6c
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java
@@ -0,0 +1,36 @@
+package org.wordpress.android.util;
+
+import android.view.View;
+import android.widget.ListView;
+
+public class ListScrollPositionManager {
+ private int mSelectedPosition;
+ private int mListViewScrollStateIndex;
+ private int mListViewScrollStateOffset;
+ private ListView mListView;
+ private boolean mSetSelection;
+
+ public ListScrollPositionManager(ListView listView, boolean setSelection) {
+ mListView = listView;
+ mSetSelection = setSelection;
+ }
+
+ public void saveScrollOffset() {
+ mListViewScrollStateIndex = mListView.getFirstVisiblePosition();
+ View view = mListView.getChildAt(0);
+ mListViewScrollStateOffset = 0;
+ if (view != null) {
+ mListViewScrollStateOffset = view.getTop();
+ }
+ if (mSetSelection) {
+ mSelectedPosition = mListView.getCheckedItemPosition();
+ }
+ }
+
+ public void restoreScrollOffset() {
+ mListView.setSelectionFromTop(mListViewScrollStateIndex, mListViewScrollStateOffset);
+ if (mSetSelection) {
+ mListView.setItemChecked(mSelectedPosition, true);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/LocationHelper.java b/WordPress/src/main/java/org/wordpress/android/util/LocationHelper.java
new file mode 100644
index 000000000..12439fd28
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/LocationHelper.java
@@ -0,0 +1,132 @@
+//This Handy-Dandy class acquired and tweaked from http://stackoverflow.com/a/3145655/309558
+package org.wordpress.android.util;
+
+import java.util.Timer;
+import java.util.TimerTask;
+
+import android.content.Context;
+import android.location.Location;
+import android.location.LocationListener;
+import android.location.LocationManager;
+import android.os.Bundle;
+
+public class LocationHelper {
+ Timer timer1;
+ LocationManager lm;
+ LocationResult locationResult;
+ boolean gps_enabled = false;
+ boolean network_enabled = false;
+
+ public boolean getLocation(Context context, LocationResult result) {
+ locationResult = result;
+ if (lm == null)
+ lm = (LocationManager) context
+ .getSystemService(Context.LOCATION_SERVICE);
+
+ // exceptions will be thrown if provider is not permitted.
+ try {
+ gps_enabled = lm.isProviderEnabled(LocationManager.GPS_PROVIDER);
+ } catch (Exception ex) {
+ }
+ try {
+ network_enabled = lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER);
+ } catch (Exception ex) {
+ }
+
+ // don't start listeners if no provider is enabled
+ if (!gps_enabled && !network_enabled)
+ return false;
+
+ if (gps_enabled)
+ lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, locationListenerGps);
+
+ if (network_enabled)
+ lm.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0, 0, locationListenerNetwork);
+
+ timer1 = new Timer();
+ timer1.schedule(new GetLastLocation(), 30000);
+ return true;
+ }
+
+ LocationListener locationListenerGps = new LocationListener() {
+ public void onLocationChanged(Location location) {
+ timer1.cancel();
+ locationResult.gotLocation(location);
+ lm.removeUpdates(this);
+ lm.removeUpdates(locationListenerNetwork);
+ }
+
+ public void onProviderDisabled(String provider) {
+ }
+
+ public void onProviderEnabled(String provider) {
+ }
+
+ public void onStatusChanged(String provider, int status, Bundle extras) {
+ }
+ };
+
+ LocationListener locationListenerNetwork = new LocationListener() {
+ public void onLocationChanged(Location location) {
+ timer1.cancel();
+ locationResult.gotLocation(location);
+ lm.removeUpdates(this);
+ lm.removeUpdates(locationListenerGps);
+ }
+
+ public void onProviderDisabled(String provider) {
+ }
+
+ public void onProviderEnabled(String provider) {
+ }
+
+ public void onStatusChanged(String provider, int status, Bundle extras) {
+ }
+ };
+
+ class GetLastLocation extends TimerTask {
+ @Override
+ public void run() {
+ lm.removeUpdates(locationListenerGps);
+ lm.removeUpdates(locationListenerNetwork);
+
+ Location net_loc = null, gps_loc = null;
+ if (gps_enabled)
+ gps_loc = lm.getLastKnownLocation(LocationManager.GPS_PROVIDER);
+ if (network_enabled)
+ net_loc = lm
+ .getLastKnownLocation(LocationManager.NETWORK_PROVIDER);
+
+ // if there are both values use the latest one
+ if (gps_loc != null && net_loc != null) {
+ if (gps_loc.getTime() > net_loc.getTime())
+ locationResult.gotLocation(gps_loc);
+ else
+ locationResult.gotLocation(net_loc);
+ return;
+ }
+
+ if (gps_loc != null) {
+ locationResult.gotLocation(gps_loc);
+ return;
+ }
+ if (net_loc != null) {
+ locationResult.gotLocation(net_loc);
+ return;
+ }
+ locationResult.gotLocation(null);
+ }
+ }
+
+ public static abstract class LocationResult {
+ public abstract void gotLocation(Location location);
+ }
+
+ public void cancelTimer() {
+ if (timer1 != null) {
+ timer1.cancel();
+ lm.removeUpdates(locationListenerGps);
+ lm.removeUpdates(locationListenerNetwork);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/MapUtils.java b/WordPress/src/main/java/org/wordpress/android/util/MapUtils.java
new file mode 100644
index 000000000..981e537d2
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/MapUtils.java
@@ -0,0 +1,79 @@
+package org.wordpress.android.util;
+
+import java.util.Date;
+import java.util.Map;
+
+/**
+ * wrappers for extracting values from a Map object
+ */
+public class MapUtils {
+ /*
+ * returns a String value for the passed key in the passed map
+ * always returns "" instead of null
+ */
+ public static String getMapStr(final Map<?, ?> map, final String key) {
+ if (map == null || key == null || !map.containsKey(key) || map.get(key) == null) {
+ return "";
+ }
+ return map.get(key).toString();
+ }
+
+ /*
+ * returns an int value for the passed key in the passed map
+ * defaultValue is returned if key doesn't exist or isn't a number
+ */
+ public static int getMapInt(final Map<?, ?> map, final String key) {
+ return getMapInt(map, key, 0);
+ }
+ public static int getMapInt(final Map<?, ?> map, final String key, int defaultValue) {
+ try {
+ return Integer.parseInt(getMapStr(map, key));
+ } catch (NumberFormatException e) {
+ return defaultValue;
+ }
+ }
+
+ /*
+ * long version of above
+ */
+ public static long getMapLong(final Map<?, ?> map, final String key) {
+ return getMapLong(map, key, 0);
+ }
+ public static long getMapLong(final Map<?, ?> map, final String key, long defaultValue) {
+ try {
+ return Long.parseLong(getMapStr(map, key));
+ } catch (NumberFormatException e) {
+ return defaultValue;
+ }
+ }
+
+ /*
+ * returns a date object from the passed key in the passed map
+ * returns null if key doesn't exist or isn't a date
+ */
+ public static Date getMapDate(final Map<?, ?> map, final String key) {
+ if (map==null || key==null || !map.containsKey(key))
+ return null;
+ try {
+ return (Date) map.get(key);
+ } catch (ClassCastException e) {
+ return null;
+ }
+ }
+
+ /*
+ * returns a boolean value from the passed key in the passed map
+ * returns true unless key doesn't exist, or the value is "0" or "false"
+ */
+ public static boolean getMapBool(final Map<?, ?> map, final String key) {
+ String value = getMapStr(map, key);
+ if (value.isEmpty())
+ return false;
+ if (value.startsWith("0")) // handles "0" and "0.0"
+ return false;
+ if (value.equalsIgnoreCase("false"))
+ return false;
+ // all other values are assume to be true
+ return true;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/MediaDeleteService.java b/WordPress/src/main/java/org/wordpress/android/util/MediaDeleteService.java
new file mode 100644
index 000000000..9ca806c83
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/MediaDeleteService.java
@@ -0,0 +1,120 @@
+package org.wordpress.android.util;
+
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.os.Handler;
+import android.os.IBinder;
+
+import org.wordpress.android.WordPress;
+import org.xmlrpc.android.ApiHelper;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A service for deleting media files from the media browser.
+ * Only one file is deleted at a time.
+ */
+public class MediaDeleteService extends Service {
+ // time to wait before trying to delete the next file
+ private static final int DELETE_WAIT_TIME = 1000;
+
+ private Context mContext;
+ private Handler mHandler = new Handler();
+ private boolean mDeleteInProgress;
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+
+ mContext = this.getApplicationContext();
+ mDeleteInProgress = false;
+ }
+
+ @Override
+ public void onStart(Intent intent, int startId) {
+ mHandler.post(mFetchQueueTask);
+ }
+
+ private Runnable mFetchQueueTask = new Runnable() {
+ @Override
+ public void run() {
+ Cursor cursor = getQueueItem();
+ try {
+ if ((cursor == null || cursor.getCount() == 0 || mContext == null) && !mDeleteInProgress) {
+ MediaDeleteService.this.stopSelf();
+ return;
+ } else {
+ if (mDeleteInProgress) {
+ mHandler.postDelayed(this, DELETE_WAIT_TIME);
+ } else {
+ deleteMediaFile(cursor);
+ }
+ }
+ } finally {
+ if (cursor != null)
+ cursor.close();
+ }
+
+ }
+ };
+
+ private Cursor getQueueItem() {
+ if (WordPress.getCurrentBlog() == null)
+ return null;
+
+ String blogId = String.valueOf(WordPress.getCurrentBlog().getLocalTableBlogId());
+ return WordPress.wpDB.getMediaDeleteQueueItem(blogId);
+ }
+
+ private void deleteMediaFile(Cursor cursor) {
+ if (!cursor.moveToFirst())
+ return;
+
+ mDeleteInProgress = true;
+
+ final String blogId = cursor.getString((cursor.getColumnIndex("blogId")));
+ final String mediaId = cursor.getString(cursor.getColumnIndex("mediaId"));
+
+ ApiHelper.DeleteMediaTask task = new ApiHelper.DeleteMediaTask(mediaId,
+ new ApiHelper.GenericCallback() {
+ @Override
+ public void onSuccess() {
+ // only delete them once we get an ok from the server
+ if (WordPress.getCurrentBlog() != null && mediaId != null) {
+ WordPress.wpDB.deleteMediaFile(blogId, mediaId);
+ }
+
+ mDeleteInProgress = false;
+ mHandler.post(mFetchQueueTask);
+ }
+
+ @Override
+ public void onFailure(ApiHelper.ErrorType errorType, String errorMessage, Throwable throwable) {
+ // Ideally we would do handle the 401 (unauthorized) and 404 (not found) errors,
+ // but the XMLRPCExceptions don't seem to give messages when they are thrown.
+
+ // Instead we'll just set them as "deleted" so they don't show up in the delete queue.
+ // Otherwise the service will continuously try to delete an item they can't delete.
+
+ WordPress.wpDB.updateMediaUploadState(blogId, mediaId, "deleted");
+
+ mDeleteInProgress = false;
+ mHandler.post(mFetchQueueTask);
+ }
+ });
+
+ List<Object> apiArgs = new ArrayList<Object>();
+ apiArgs.add(WordPress.getCurrentBlog());
+ task.execute(apiArgs) ;
+
+ mHandler.post(mFetchQueueTask);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/MediaGalleryImageSpan.java b/WordPress/src/main/java/org/wordpress/android/util/MediaGalleryImageSpan.java
new file mode 100644
index 000000000..37083963e
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/MediaGalleryImageSpan.java
@@ -0,0 +1,24 @@
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.text.style.ImageSpan;
+
+import org.wordpress.android.R;
+import org.wordpress.android.models.MediaGallery;
+
+public class MediaGalleryImageSpan extends ImageSpan {
+ private MediaGallery mMediaGallery;
+
+ public MediaGalleryImageSpan(Context context, MediaGallery mediaGallery) {
+ super(context, R.drawable.icon_mediagallery_placeholder);
+ setMediaGallery(mediaGallery);
+ }
+
+ public MediaGallery getMediaGallery() {
+ return mMediaGallery;
+ }
+
+ public void setMediaGallery(MediaGallery mediaGallery) {
+ this.mMediaGallery = mediaGallery;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/MediaUploadService.java b/WordPress/src/main/java/org/wordpress/android/util/MediaUploadService.java
new file mode 100644
index 000000000..2780f8e63
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/MediaUploadService.java
@@ -0,0 +1,192 @@
+package org.wordpress.android.util;
+
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.os.Handler;
+import android.os.IBinder;
+import android.support.v4.content.LocalBroadcastManager;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.MediaFile;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.CrashlyticsUtils.ExceptionType;
+import org.xmlrpc.android.ApiHelper;
+import org.xmlrpc.android.ApiHelper.ErrorType;
+import org.xmlrpc.android.ApiHelper.GetMediaItemTask;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A service for uploading media files from the media browser.
+ * Only one file is uploaded at a time.
+ */
+public class MediaUploadService extends Service {
+ // time to wait before trying to upload the next file
+ private static final int UPLOAD_WAIT_TIME = 1000;
+
+ /** Listen to this Intent for when there are updates to the upload queue **/
+ public static final String MEDIA_UPLOAD_INTENT_NOTIFICATION = "MEDIA_UPLOAD_INTENT_NOTIFICATION";
+ public static final String MEDIA_UPLOAD_INTENT_NOTIFICATION_EXTRA = "MEDIA_UPLOAD_INTENT_NOTIFICATION_EXTRA";
+ public static final String MEDIA_UPLOAD_INTENT_NOTIFICATION_ERROR = "MEDIA_UPLOAD_INTENT_NOTIFICATION_ERROR";
+
+ private Context mContext;
+ private Handler mHandler = new Handler();
+ private boolean mUploadInProgress;
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+
+ mContext = this.getApplicationContext();
+ mUploadInProgress = false;
+
+ cancelOldUploads();
+ }
+
+ @Override
+ public void onStart(Intent intent, int startId) {
+ mHandler.post(mFetchQueueTask);
+ }
+
+ private Runnable mFetchQueueTask = new Runnable() {
+ @Override
+ public void run() {
+ Cursor cursor = getQueue();
+ try {
+ if ((cursor == null || cursor.getCount() == 0 || mContext == null) && !mUploadInProgress) {
+ MediaUploadService.this.stopSelf();
+ return;
+ } else {
+ if (mUploadInProgress) {
+ mHandler.postDelayed(this, UPLOAD_WAIT_TIME);
+ } else {
+ uploadMediaFile(cursor);
+ }
+ }
+ } finally {
+ if (cursor != null)
+ cursor.close();
+ }
+
+ }
+ };
+
+ private void cancelOldUploads() {
+ // There should be no media files with an upload state of 'uploading' at the start of this service.
+ // Since we won't be able to receive notifications for these, set them to 'failed'.
+
+ if(WordPress.getCurrentBlog() != null){
+ String blogId = String.valueOf(WordPress.getCurrentBlog().getLocalTableBlogId());
+ WordPress.wpDB.setMediaUploadingToFailed(blogId);
+ sendUpdateBroadcast(null, null);
+ }
+ }
+
+ private Cursor getQueue() {
+ if (WordPress.getCurrentBlog() == null)
+ return null;
+
+ String blogId = String.valueOf(WordPress.getCurrentBlog().getLocalTableBlogId());
+ return WordPress.wpDB.getMediaUploadQueue(blogId);
+ }
+
+ private void uploadMediaFile(Cursor cursor) {
+ if (!cursor.moveToFirst())
+ return;
+
+ mUploadInProgress = true;
+
+ final String blogIdStr = cursor.getString((cursor.getColumnIndex("blogId")));
+ final String mediaId = cursor.getString(cursor.getColumnIndex("mediaId"));
+ String fileName = cursor.getString(cursor.getColumnIndex("fileName"));
+ String filePath = cursor.getString(cursor.getColumnIndex("filePath"));
+ String mimeType = cursor.getString(cursor.getColumnIndex("mimeType"));
+
+ MediaFile mediaFile = new MediaFile();
+ mediaFile.setBlogId(blogIdStr);
+ mediaFile.setFileName(fileName);
+ mediaFile.setFilePath(filePath);
+ mediaFile.setMimeType(mimeType);
+
+ ApiHelper.UploadMediaTask task = new ApiHelper.UploadMediaTask(mContext, mediaFile,
+ new ApiHelper.UploadMediaTask.Callback() {
+ @Override
+ public void onSuccess(String id) {
+ // once the file has been uploaded, delete the local database entry and
+ // download the new one so that we are up-to-date and so that users can edit it.
+ WordPress.wpDB.deleteMediaFile(blogIdStr, mediaId);
+ sendUpdateBroadcast(mediaId, null);
+ fetchMediaFile(id);
+ }
+
+ @Override
+ public void onFailure(ApiHelper.ErrorType errorType, String errorMessage, Throwable throwable) {
+ WordPress.wpDB.updateMediaUploadState(blogIdStr, mediaId, "failed");
+ mUploadInProgress = false;
+ sendUpdateBroadcast(mediaId, getString(R.string.upload_failed));
+ mHandler.post(mFetchQueueTask);
+ // Only log the error if it's not caused by the network (internal inconsistency)
+ if (errorType != ErrorType.NETWORK_XMLRPC) {
+ CrashlyticsUtils.logException(throwable, ExceptionType.SPECIFIC, T.MEDIA, errorMessage);
+ }
+ }
+ });
+
+ WordPress.wpDB.updateMediaUploadState(blogIdStr, mediaId, "uploading");
+ sendUpdateBroadcast(mediaId, null);
+ List<Object> apiArgs = new ArrayList<Object>();
+ apiArgs.add(WordPress.getCurrentBlog());
+ task.execute(apiArgs);
+ mHandler.post(mFetchQueueTask);
+ }
+
+ private void fetchMediaFile(final String id) {
+ List<Object> apiArgs = new ArrayList<Object>();
+ apiArgs.add(WordPress.getCurrentBlog());
+ GetMediaItemTask task = new GetMediaItemTask(Integer.valueOf(id),
+ new ApiHelper.GetMediaItemTask.Callback() {
+ @Override
+ public void onSuccess(MediaFile mediaFile) {
+ String blogId = mediaFile.getBlogId();
+ String mediaId = mediaFile.getMediaId();
+ WordPress.wpDB.updateMediaUploadState(blogId, mediaId, "uploaded");
+ mUploadInProgress = false;
+ sendUpdateBroadcast(id, null);
+ mHandler.post(mFetchQueueTask);
+ }
+
+ @Override
+ public void onFailure(ApiHelper.ErrorType errorType, String errorMessage, Throwable throwable) {
+ mUploadInProgress = false;
+ sendUpdateBroadcast(id, getString(R.string.error_refresh_media));
+ mHandler.post(mFetchQueueTask);
+ // Only log the error if it's not caused by the network (internal inconsistency)
+ if (errorType != ErrorType.NETWORK_XMLRPC) {
+ CrashlyticsUtils.logException(throwable, ExceptionType.SPECIFIC, T.MEDIA, errorMessage);
+ }
+ }
+ });
+ task.execute(apiArgs);
+ }
+
+ private void sendUpdateBroadcast(String mediaId, String errorMessage) {
+ LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(mContext);
+ Intent intent = new Intent(MEDIA_UPLOAD_INTENT_NOTIFICATION);
+ if (mediaId != null) {
+ intent.putExtra(MEDIA_UPLOAD_INTENT_NOTIFICATION_EXTRA, mediaId);
+ }
+ if (errorMessage != null) {
+ intent.putExtra(MEDIA_UPLOAD_INTENT_NOTIFICATION_ERROR, errorMessage);
+ }
+ lbm.sendBroadcast(intent);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/MediaUtils.java b/WordPress/src/main/java/org/wordpress/android/util/MediaUtils.java
new file mode 100644
index 000000000..bd1f36d8a
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/MediaUtils.java
@@ -0,0 +1,499 @@
+package org.wordpress.android.util;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Fragment;
+import android.content.Context;
+import android.content.CursorLoader;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.database.Cursor;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import android.os.Environment;
+import android.provider.MediaStore;
+import android.text.TextUtils;
+import android.webkit.MimeTypeMap;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.MediaFile;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.passcodelock.AppLockManager;
+
+import java.io.DataInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+
+public class MediaUtils {
+ public class RequestCode {
+ public static final int ACTIVITY_REQUEST_CODE_PICTURE_LIBRARY = 1000;
+ public static final int ACTIVITY_REQUEST_CODE_TAKE_PHOTO = 1100;
+ public static final int ACTIVITY_REQUEST_CODE_VIDEO_LIBRARY = 1200;
+ public static final int ACTIVITY_REQUEST_CODE_TAKE_VIDEO = 1300;
+ }
+
+ public interface LaunchCameraCallback {
+ public void onMediaCapturePathReady(String mediaCapturePath);
+ }
+
+ public static boolean isValidImage(String url) {
+ if (url == null)
+ return false;
+
+ if (url.endsWith(".png") || url.endsWith(".jpg") || url.endsWith(".jpeg") || url.endsWith(".gif"))
+ return true;
+ return false;
+ }
+
+ private static boolean isDocument(String url) {
+ if (url == null)
+ return false;
+
+ if (url.endsWith(".doc") || url.endsWith(".docx") || url.endsWith(".odt") || url.endsWith(".pdf"))
+ return true;
+ return false;
+ }
+
+ private static boolean isPowerpoint(String url) {
+ if (url == null)
+ return false;
+
+ if (url.endsWith(".ppt") || url.endsWith(".pptx") || url.endsWith(".pps") || url.endsWith(".ppsx") || url.endsWith(".key"))
+ return true;
+ return false;
+ }
+
+ private static boolean isSpreadsheet(String url) {
+ if (url == null)
+ return false;
+
+ if (url.endsWith(".xls") || url.endsWith(".xlsx"))
+ return true;
+ return false;
+ }
+
+ private static boolean isVideo(String url) {
+ if (url == null)
+ return false;
+ if (url.endsWith(".ogv") || url.endsWith(".mp4") || url.endsWith(".m4v") || url.endsWith(".mov") ||
+ url.endsWith(".wmv") || url.endsWith(".avi") || url.endsWith(".mpg") || url.endsWith(".3gp") || url.endsWith(".3g2"))
+ return true;
+ return false;
+ }
+
+ public static int getPlaceholder(String url) {
+ if (isValidImage(url))
+ return R.drawable.media_image_placeholder;
+ else if(isDocument(url))
+ return R.drawable.media_document;
+ else if(isPowerpoint(url))
+ return R.drawable.media_powerpoint;
+ else if(isSpreadsheet(url))
+ return R.drawable.media_spreadsheet;
+ else if(isVideo(url))
+ return R.drawable.media_movieclip;
+ return 0;
+ }
+
+ /** E.g. Jul 2, 2013 @ 21:57 **/
+ public static String getDate(long ms) {
+ Date date = new Date(ms);
+ SimpleDateFormat sdf = new SimpleDateFormat("MMM d, yyyy '@' HH:mm", Locale.ENGLISH);
+
+ // The timezone on the website is at GMT
+ sdf.setTimeZone(TimeZone.getTimeZone("GMT"));
+
+ return sdf.format(date);
+ }
+
+
+ public static void launchPictureLibrary(Fragment fragment) {
+ Intent intent = new Intent(Intent.ACTION_PICK);
+ intent.setType("image/*");
+ intent.setAction(Intent.ACTION_GET_CONTENT);
+
+ AppLockManager.getInstance().setExtendedTimeout();
+ fragment.startActivityForResult(Intent.createChooser(intent, fragment.getString(R.string.pick_photo)), RequestCode.ACTIVITY_REQUEST_CODE_PICTURE_LIBRARY);
+ }
+
+ public static void launchCamera(Fragment fragment, LaunchCameraCallback callback) {
+ String state = android.os.Environment.getExternalStorageState();
+ if (!state.equals(android.os.Environment.MEDIA_MOUNTED)) {
+ showSDCardRequiredDialog(fragment.getActivity());
+ } else {
+ Intent intent = prepareLaunchCameraIntent(callback);
+ fragment.startActivityForResult(intent, RequestCode.ACTIVITY_REQUEST_CODE_TAKE_PHOTO);
+ AppLockManager.getInstance().setExtendedTimeout();
+ }
+ }
+
+ private static Intent prepareLaunchCameraIntent(LaunchCameraCallback callback) {
+ File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM);
+
+ String mediaCapturePath = path + File.separator + "Camera" + File.separator + "wp-" + System.currentTimeMillis() + ".jpg";
+ Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
+ intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, Uri.fromFile(new File(mediaCapturePath)));
+
+ if (callback != null) {
+ callback.onMediaCapturePathReady(mediaCapturePath);
+ }
+
+ // make sure the directory we plan to store the recording in exists
+ File directory = new File(mediaCapturePath).getParentFile();
+ if (!directory.exists() && !directory.mkdirs()) {
+ try {
+ throw new IOException("Path to file could not be created.");
+ } catch (IOException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ }
+ return intent;
+ }
+
+ private static void showSDCardRequiredDialog(Activity activity) {
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(activity);
+ dialogBuilder.setTitle(activity.getResources().getText(R.string.sdcard_title));
+ dialogBuilder.setMessage(activity.getResources().getText(R.string.sdcard_message));
+ dialogBuilder.setPositiveButton(activity.getString(R.string.ok), new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ dialog.dismiss();
+ }
+ });
+ dialogBuilder.setCancelable(true);
+ dialogBuilder.create().show();
+ }
+
+ public static void launchVideoLibrary(Fragment fragment) {
+ Intent intent = new Intent(Intent.ACTION_PICK);
+ intent.setType("video/*");
+ intent.setAction(Intent.ACTION_GET_CONTENT);
+
+ AppLockManager.getInstance().setExtendedTimeout();
+ fragment.startActivityForResult(Intent.createChooser(intent, fragment.getString(R.string.pick_video)), RequestCode.ACTIVITY_REQUEST_CODE_PICTURE_LIBRARY);
+ }
+
+ public static void launchVideoCamera(Fragment fragment) {
+ Intent intent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE);
+ fragment.startActivityForResult(intent, RequestCode.ACTIVITY_REQUEST_CODE_TAKE_VIDEO);
+ AppLockManager.getInstance().setExtendedTimeout();
+ }
+
+ public static boolean isLocalFile(String state) {
+ if (state == null)
+ return false;
+
+ if (state.equals("queued") || state.equals("uploading") || state.equals("retry") || state.equals("failed"))
+ return true;
+
+ return false;
+ }
+
+ public static Uri getLastRecordedVideoUri(Activity activity) {
+ String[] proj = { MediaStore.Video.Media._ID };
+ Uri contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
+ String sortOrder = MediaStore.Video.VideoColumns.DATE_TAKEN + " DESC";
+ CursorLoader loader = new CursorLoader(activity, contentUri, proj, null, null, sortOrder);
+ Cursor cursor = loader.loadInBackground();
+ cursor.moveToFirst();
+
+ return Uri.parse(contentUri.toString() + "/" + cursor.getLong(0));
+ }
+
+ /**
+ * This is a workaround for WP3.4.2 that deletes the media from the server when editing media properties within the app.
+ * See: https://github.com/wordpress-mobile/WordPress-Android/issues/204
+ * @return
+ */
+ public static boolean isWordPressVersionWithMediaEditingCapabilities() {
+ if (WordPress.currentBlog == null)
+ return false;
+
+ if (WordPress.currentBlog.getWpVersion() == null)
+ return true;
+
+ if (WordPress.currentBlog.isDotcomFlag())
+ return true;
+
+ Version minVersion;
+ Version currentVersion;
+ try {
+ minVersion = new Version("3.5.2");
+ currentVersion = new Version(WordPress.currentBlog.getWpVersion());
+
+ if( currentVersion.compareTo(minVersion) == -1 )
+ return false;
+
+ } catch (IllegalArgumentException e) {
+ AppLog.e(T.POSTS, e);
+ }
+
+ return true;
+ }
+
+ public static boolean canDeleteMedia(String blogId, String mediaID) {
+ Cursor cursor = WordPress.wpDB.getMediaFile(blogId, mediaID);
+ if (!cursor.moveToFirst()) {
+ cursor.close();
+ return false;
+ }
+ String state = cursor.getString(cursor.getColumnIndex("uploadState"));
+ cursor.close();
+ if (state != null && state.equals("uploading")) {
+ return false;
+ }
+ return true;
+ }
+
+ public static WPImageSpan prepareWPImageSpan(Context context, String blogId, final String mediaId) {
+ Cursor cursor = WordPress.wpDB.getMediaFile(blogId, mediaId);
+ if (cursor == null || !cursor.moveToFirst()){
+ if (cursor != null)
+ cursor.close();
+ return null;
+ }
+
+ String url = cursor.getString(cursor.getColumnIndex("fileURL"));
+ if (url == null) {
+ cursor.close();
+ return null;
+ }
+
+ String mimeType = cursor.getString(cursor.getColumnIndex("mimeType"));
+ boolean isVideo = mimeType != null && mimeType.contains("video");
+
+ Uri uri = Uri.parse(url);
+ WPImageSpan imageSpan = new WPImageSpan(context, isVideo ? R.drawable.media_movieclip : R.drawable.remote_image, uri);
+ MediaFile mediaFile = imageSpan.getMediaFile();
+ mediaFile.setMediaId(mediaId);
+ mediaFile.setBlogId(blogId);
+ mediaFile.setCaption(cursor.getString(cursor.getColumnIndex("caption")));
+ mediaFile.setDescription(cursor.getString(cursor.getColumnIndex("description")));
+ mediaFile.setTitle(cursor.getString(cursor.getColumnIndex("title")));
+ mediaFile.setWidth(cursor.getInt(cursor.getColumnIndex("width")));
+ mediaFile.setHeight(cursor.getInt(cursor.getColumnIndex("height")));
+ mediaFile.setMimeType(mimeType);
+ mediaFile.setFileName(cursor.getString(cursor.getColumnIndex("fileName")));
+ mediaFile.setThumbnailURL(cursor.getString(cursor.getColumnIndex("thumbnailURL")));
+ mediaFile.setDateCreatedGMT(cursor.getLong(cursor.getColumnIndex("date_created_gmt")));
+ mediaFile.setVideoPressShortCode(cursor.getString(cursor.getColumnIndex("videoPressShortcode")));
+ mediaFile.setFileURL(cursor.getString(cursor.getColumnIndex("fileURL")));
+ mediaFile.setVideo(isVideo);
+ mediaFile.save();
+ cursor.close();
+
+ return imageSpan;
+ }
+
+ // Calculate the minimun width between the blog setting and picture real width
+ public static int getMinimumImageWidth(Context context, Uri curStream) {
+ String imageWidth = WordPress.getCurrentBlog().getMaxImageWidth();
+ int imageWidthBlogSetting = Integer.MAX_VALUE;
+
+ if (!imageWidth.equals("Original Size")) {
+ try {
+ imageWidthBlogSetting = Integer.valueOf(imageWidth);
+ } catch (NumberFormatException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ }
+
+ int[] dimensions = ImageHelper.getImageSize(curStream, context);
+ int imageWidthPictureSetting = dimensions[0] == 0 ? Integer.MAX_VALUE : dimensions[0];
+
+ if (Math.min(imageWidthPictureSetting, imageWidthBlogSetting) == Integer.MAX_VALUE) {
+ //Default value in case of errors reading the picture size and the blog settings is set to Original size
+ return 1024;
+ } else {
+ return Math.min(imageWidthPictureSetting, imageWidthBlogSetting);
+ }
+ }
+
+ public static void setWPImageSpanWidth(Context context, Uri curStream, WPImageSpan is) {
+ MediaFile mediaFile = is.getMediaFile();
+ if (mediaFile != null)
+ mediaFile.setWidth(getMinimumImageWidth(context, curStream));
+ }
+
+ public static boolean isInMediaStore(Uri mediaUri) {
+ // Check if the image is externally hosted (Picasa/Google Photos for example)
+ if (mediaUri != null && mediaUri.toString().startsWith("content://media/")) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ public static Uri downloadExternalMedia(Context context, Uri imageUri) {
+ if (context == null || imageUri == null) {
+ return null;
+ }
+ File cacheDir = null;
+
+ String mimeType = context.getContentResolver().getType(imageUri);
+ boolean isVideo = (mimeType != null && mimeType.contains("video"));
+
+ // If the device has an SD card
+ if (android.os.Environment.getExternalStorageState().equals(android.os.Environment.MEDIA_MOUNTED)) {
+ String mediaFolder = isVideo ? "video" : "images";
+ cacheDir = new File(android.os.Environment.getExternalStorageDirectory() + "/WordPress/" + mediaFolder);
+ } else {
+ if (context.getApplicationContext() != null) {
+ cacheDir = context.getApplicationContext().getCacheDir();
+ }
+ }
+
+ if (cacheDir != null && !cacheDir.exists()) {
+ cacheDir.mkdirs();
+ }
+ try {
+ InputStream input;
+ // Download the file
+ if (imageUri.toString().startsWith("content://")) {
+ input = context.getContentResolver().openInputStream(imageUri);
+ if (input == null) {
+ AppLog.e(T.UTILS, "openInputStream returned null");
+ return null;
+ }
+ } else {
+ input = new URL(imageUri.toString()).openStream();
+ }
+
+ String fileName = "wp-" + System.currentTimeMillis();
+ if (isVideo) {
+ fileName += "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
+ }
+
+ File f = new File(cacheDir, fileName);
+
+ OutputStream output = new FileOutputStream(f);
+
+ byte data[] = new byte[1024];
+ int count;
+ while ((count = input.read(data)) != -1) {
+ output.write(data, 0, count);
+ }
+
+ output.flush();
+ output.close();
+ input.close();
+
+ return Uri.fromFile(f);
+ } catch (FileNotFoundException e) {
+ AppLog.e(T.UTILS, e);
+ } catch (MalformedURLException e) {
+ AppLog.e(T.UTILS, e);
+ } catch (IOException e) {
+ AppLog.e(T.UTILS, e);
+ }
+
+ return null;
+ }
+
+ public static String getMimeTypeOfInputStream(InputStream stream) {
+ BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inJustDecodeBounds = true;
+ BitmapFactory.decodeStream(stream, null, options);
+ return options.outMimeType;
+ }
+
+ public static String getMediaFileMimeType(File mediaFile) {
+ String originalFileName = mediaFile.getName().toLowerCase();
+ String mimeType = UrlUtils.getUrlMimeType(originalFileName);
+
+ if (TextUtils.isEmpty(mimeType)) {
+ try {
+ String filePathForGuessingMime;
+ if (mediaFile.getPath().contains("://")) {
+ filePathForGuessingMime = Uri.encode(mediaFile.getPath(), ":/");
+ } else {
+ filePathForGuessingMime = "file://"+ Uri.encode(mediaFile.getPath(), "/");
+ }
+ URL urlForGuessingMime = new URL(filePathForGuessingMime);
+ URLConnection uc = urlForGuessingMime.openConnection();
+ String guessedContentType = uc.getContentType(); //internally calls guessContentTypeFromName(url.getFile()); and guessContentTypeFromStream(is);
+ // check if returned "content/unknown"
+ if (!TextUtils.isEmpty(guessedContentType) && !guessedContentType.equals("content/unknown")) {
+ mimeType = guessedContentType;
+ }
+ } catch (MalformedURLException e) {
+ AppLog.e(AppLog.T.API, "MalformedURLException while trying to guess the content type for the file here " + mediaFile.getPath() + " with URLConnection", e);
+ }
+ catch (IOException e) {
+ AppLog.e(AppLog.T.API, "Error while trying to guess the content type for the file here " + mediaFile.getPath() +" with URLConnection", e);
+ }
+ }
+
+ // No mimeType yet? Try to decode the image and get the mimeType from there
+ if (TextUtils.isEmpty(mimeType)) {
+ try {
+ DataInputStream inputStream = new DataInputStream(new FileInputStream(mediaFile));
+ String mimeTypeFromStream = getMimeTypeOfInputStream(inputStream);
+ if (!TextUtils.isEmpty(mimeTypeFromStream)) {
+ mimeType = mimeTypeFromStream;
+ }
+ inputStream.close();
+ } catch (FileNotFoundException e) {
+ AppLog.e(AppLog.T.API, "FileNotFoundException while trying to guess the content type for the file " + mediaFile.getPath(), e);
+ } catch (IOException e) {
+ AppLog.e(AppLog.T.API, "IOException while trying to guess the content type for the file " + mediaFile.getPath(), e);
+ }
+ }
+
+ if (TextUtils.isEmpty(mimeType)) {
+ mimeType = "";
+ } else {
+ if (mimeType.equalsIgnoreCase("video/mp4v-es")) { //Fixes #533. See: http://tools.ietf.org/html/rfc3016
+ mimeType = "video/mp4";
+ }
+ }
+
+ return mimeType;
+ }
+
+ public static String getMediaFileName(File mediaFile, String mimeType) {
+ String originalFileName = mediaFile.getName().toLowerCase();
+ String extension = MimeTypeMap.getFileExtensionFromUrl(originalFileName);
+ if (!TextUtils.isEmpty(extension)) //File name already has the extension in it
+ return originalFileName;
+
+ if (!TextUtils.isEmpty(mimeType)) { //try to get the extension from mimeType
+ String fileExtension = getExtensionForMimeType(mimeType);
+ if (!TextUtils.isEmpty(fileExtension)) {
+ originalFileName += "." + fileExtension;
+ }
+ } else {
+ //No mimetype and no extension!!
+ AppLog.e(AppLog.T.API, "No mimetype and no extension for " + mediaFile.getPath());
+ }
+
+ return originalFileName;
+ }
+
+ public static String getExtensionForMimeType(String mimeType) {
+ if (TextUtils.isEmpty(mimeType))
+ return "";
+
+ MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
+ String fileExtensionFromMimeType = mimeTypeMap.getExtensionFromMimeType(mimeType);
+ if (TextUtils.isEmpty(fileExtensionFromMimeType)) {
+ // We're still without an extension - split the mime type and retrieve it
+ String[] split = mimeType.split("/");
+ fileExtensionFromMimeType = split.length > 1 ? split[1] : split[0];
+ }
+
+ return fileExtensionFromMimeType.toLowerCase();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/MessageBarUtils.java b/WordPress/src/main/java/org/wordpress/android/util/MessageBarUtils.java
new file mode 100644
index 000000000..1565be74a
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/MessageBarUtils.java
@@ -0,0 +1,81 @@
+package org.wordpress.android.util;
+
+import android.app.Activity;
+import android.os.Handler;
+import android.view.View;
+import android.view.animation.Animation;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+
+/*
+ * used by activities to animate a message in from the bottom then animate it back out after
+ * a brief delay - note that the activity's layout must contain message_bar_include.xml for
+ * this to work
+ */
+public class MessageBarUtils {
+ private static final long DELAY_MILLIS = 1500;
+ public static enum MessageBarType { INFO, ALERT }
+
+ public static void showMessageBar(final Activity activity,
+ final String message,
+ final MessageBarType messageBarType) {
+ if (activity == null) {
+ return;
+ }
+
+ final TextView txtMessageBar = (TextView) activity.findViewById(R.id.text_message_bar);
+ if (txtMessageBar == null || txtMessageBar.getVisibility() == View.VISIBLE) {
+ return;
+ }
+
+ switch (messageBarType) {
+ case INFO:
+ txtMessageBar.setBackgroundResource(R.color.reader_message_bar_blue);
+ break;
+ case ALERT:
+ txtMessageBar.setBackgroundResource(R.color.reader_message_bar_orange);
+ break;
+ default :
+ return;
+ }
+
+ txtMessageBar.clearAnimation();
+ txtMessageBar.setText(message);
+
+ AniUtils.startAnimation(txtMessageBar, R.anim.reader_message_bar_in);
+ txtMessageBar.setVisibility(View.VISIBLE);
+
+ new Handler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ hideMessageBar(activity);
+ }
+ }, DELAY_MILLIS);
+ }
+
+ private static void hideMessageBar(final Activity activity) {
+ if (activity == null) {
+ return;
+ }
+
+ final TextView txtMessageBar = (TextView) activity.findViewById(R.id.text_message_bar);
+ if (txtMessageBar == null || txtMessageBar.getVisibility() != View.VISIBLE) {
+ return;
+ }
+
+ txtMessageBar.clearAnimation();
+
+ Animation.AnimationListener listener = new Animation.AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation animation) { }
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ txtMessageBar.setVisibility(View.GONE);
+ }
+ @Override
+ public void onAnimationRepeat(Animation animation) { }
+ };
+ AniUtils.startAnimation(txtMessageBar, R.anim.reader_message_bar_out, listener);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/NetworkUtils.java b/WordPress/src/main/java/org/wordpress/android/util/NetworkUtils.java
new file mode 100644
index 000000000..8b7d79019
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/NetworkUtils.java
@@ -0,0 +1,81 @@
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.os.Build;
+import android.provider.Settings;
+
+import org.wordpress.android.R;
+
+/**
+ * requires android.permission.ACCESS_NETWORK_STATE
+ */
+public class NetworkUtils {
+ public static final int TYPE_UNKNOWN = -1;
+
+ /**
+ * returns information on the active network connection
+ */
+ private static NetworkInfo getActiveNetworkInfo(Context context){
+ if (context == null)
+ return null;
+ ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ if (cm == null)
+ return null;
+ // note that this may return null if no network is currently active
+ return cm.getActiveNetworkInfo();
+ }
+
+ /**
+ * returns the ConnectivityManager.TYPE_xxx if there's an active connection, otherwise
+ * returns TYPE_UNKNOWN
+ */
+ private static int getActiveNetworkType(Context context) {
+ NetworkInfo info = getActiveNetworkInfo(context);
+ if (info == null || !info.isConnected())
+ return TYPE_UNKNOWN;
+ return info.getType();
+ }
+
+ /**
+ * returns true if a network connection is available
+ */
+ public static boolean isNetworkAvailable(Context context) {
+ NetworkInfo info = getActiveNetworkInfo(context);
+ return (info != null && info.isConnected());
+ }
+
+ /**
+ * returns true if the user is connected to WiFi
+ */
+ public static boolean isWiFiConnected(Context context) {
+ return (getActiveNetworkType(context) == ConnectivityManager.TYPE_WIFI);
+ }
+
+ /**
+ * returns true if airplane mode has been enabled
+ */
+ public static boolean isAirplaneModeOn(Context context) {
+ // prior to JellyBean 4.2 this was Settings.System.AIRPLANE_MODE_ON, JellyBean 4.2
+ // moved it to Settings.Global
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ return Settings.System.getInt(context.getContentResolver(),
+ Settings.System.AIRPLANE_MODE_ON, 0) != 0;
+ } else {
+ return Settings.Global.getInt(context.getContentResolver(),
+ Settings.Global.AIRPLANE_MODE_ON, 0) != 0;
+ }
+ }
+
+ /**
+ * returns true if there's an active network connection, otherwise displays a toast error
+ * and returns false
+ **/
+ public static boolean checkConnection(Context context) {
+ if (isNetworkAvailable(context))
+ return true;
+ ToastUtils.showToast(context, R.string.no_network_message);
+ return false;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/NotificationDismissBroadcastReceiver.java b/WordPress/src/main/java/org/wordpress/android/util/NotificationDismissBroadcastReceiver.java
new file mode 100644
index 000000000..2a1f66541
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/NotificationDismissBroadcastReceiver.java
@@ -0,0 +1,17 @@
+package org.wordpress.android.util;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import org.wordpress.android.GCMIntentService;
+
+/*
+ * Clears the notification map when a user dismisses a notification
+ */
+public class NotificationDismissBroadcastReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ GCMIntentService.clearNotificationsMap();
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/util/PageIndicator.java b/WordPress/src/main/java/org/wordpress/android/util/PageIndicator.java
new file mode 100644
index 000000000..4ab98a2b0
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/PageIndicator.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2011 Patrik Akerfeldt
+ * Copyright (C) 2011 Jake Wharton
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.wordpress.android.util;
+
+import android.support.v4.view.ViewPager;
+
+/**
+ * A PageIndicator is responsible to show an visual indicator on the total views
+ * number and the current visible view.
+ */
+public interface PageIndicator extends ViewPager.OnPageChangeListener {
+ /**
+ * Bind the indicator to a ViewPager.
+ *
+ * @param view
+ */
+ void setViewPager(ViewPager view);
+
+ /**
+ * Bind the indicator to a ViewPager.
+ *
+ * @param view
+ * @param initialPosition
+ */
+ void setViewPager(ViewPager view, int initialPosition);
+
+ /**
+ * <p>Set the current page of both the ViewPager and indicator.</p>
+ *
+ * <p>This <strong>must</strong> be used if you need to set the page before
+ * the views are drawn on screen (e.g., default start page).</p>
+ *
+ * @param item
+ */
+ void setCurrentItem(int item);
+
+ /**
+ * Set a page change listener which will receive forwarded events.
+ *
+ * @param listener
+ */
+ void setOnPageChangeListener(ViewPager.OnPageChangeListener listener);
+
+ /**
+ * Notify the indicator that the fragment list has changed.
+ */
+ void notifyDataSetChanged();
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/PhotonUtils.java b/WordPress/src/main/java/org/wordpress/android/util/PhotonUtils.java
new file mode 100644
index 000000000..497d756ee
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/PhotonUtils.java
@@ -0,0 +1,96 @@
+package org.wordpress.android.util;
+
+import android.text.TextUtils;
+
+/**
+ * routines related to the Photon API
+ * http://developer.wordpress.com/docs/photon/
+ */
+public class PhotonUtils {
+ private PhotonUtils() {
+ throw new AssertionError();
+ }
+
+ /*
+ * gravatars often contain the ?s= parameter which determines their size - detect this and
+ * replace it with a new ?s= parameter which requests the avatar at the exact size needed
+ */
+ public static String fixAvatar(final String imageUrl, int avatarSz) {
+ if (TextUtils.isEmpty(imageUrl))
+ return "";
+
+ // if this isn't a gravatar image, return as resized photon image url
+ if (!imageUrl.contains("gravatar.com"))
+ return getPhotonImageUrl(imageUrl, avatarSz, avatarSz);
+
+ // remove all other params, then add query string for size and "mystery man" default
+ return UrlUtils.removeQuery(imageUrl) + String.format("?s=%d&d=mm", avatarSz);
+ }
+
+ /*
+ * returns true if the passed url is an obvious "mshots" url
+ */
+ public static boolean isMshotsUrl(final String imageUrl) {
+ return (imageUrl != null && imageUrl.contains("/mshots/"));
+ }
+
+ /*
+ * returns a photon url for the passed image with the resize query set to the passed dimensions
+ */
+ public static String getPhotonImageUrl(String imageUrl, int width, int height) {
+ if (TextUtils.isEmpty(imageUrl)) {
+ return "";
+ }
+
+ // make sure it's valid
+ int schemePos = imageUrl.indexOf("://");
+ if (schemePos == -1) {
+ return imageUrl;
+ }
+
+ // remove existing query string since it may contain params that conflict with the passed ones
+ imageUrl = UrlUtils.removeQuery(imageUrl);
+
+ // don't use with GIFs - photon breaks animated GIFs, and sometimes returns a GIF that
+ // can't be read by BitmapFactory.decodeByteArray (used by Volley in ImageRequest.java
+ // to decode the downloaded image)
+ // ex: http://i0.wp.com/lusianne.files.wordpress.com/2013/08/193.gif?resize=768,320
+ if (imageUrl.endsWith(".gif")) {
+ return imageUrl;
+ }
+
+ // if this is an "mshots" url, skip photon and return it with a query that sets the width/height
+ // (these are screenshots of the blog that often appear in freshly pressed posts)
+ // see http://wp.tutsplus.com/tutorials/how-to-generate-website-screenshots-for-your-wordpress-site/
+ // ex: http://s.wordpress.com/mshots/v1/http%3A%2F%2Fnickbradbury.com?w=600
+ if (isMshotsUrl(imageUrl)) {
+ return imageUrl + String.format("?w=%d&h=%d", width, height);
+ }
+
+ // if both width & height are passed use the "resize" param, use only "w" or "h" if just
+ // one of them is set, otherwise no query string
+ final String query;
+ if (width > 0 && height > 0) {
+ query = String.format("?resize=%d,%d", width, height);
+ } else if (width > 0) {
+ query = String.format("?w=%d", width);
+ } else if (height > 0) {
+ query = String.format("?h=%d", height);
+ } else {
+ query = "";
+ }
+
+ // return passed url+query if it's already a photon url
+ if (imageUrl.contains(".wp.com")) {
+ if (imageUrl.contains("i0.wp.com") || imageUrl.contains("i1.wp.com") || imageUrl.contains("i2.wp.com"))
+ return imageUrl + query;
+ }
+
+ // must use https for https image urls
+ if (UrlUtils.isHttps(imageUrl)) {
+ return "https://i0.wp.com/" + imageUrl.substring(schemePos+3, imageUrl.length()) + query;
+ } else {
+ return "http://i0.wp.com/" + imageUrl.substring(schemePos+3, imageUrl.length()) + query;
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/PostUploadService.java b/WordPress/src/main/java/org/wordpress/android/util/PostUploadService.java
new file mode 100644
index 000000000..16a1d6a63
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/PostUploadService.java
@@ -0,0 +1,870 @@
+package org.wordpress.android.util;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.IBinder;
+import android.preference.PreferenceManager;
+import android.provider.MediaStore.Images;
+import android.provider.MediaStore.Video;
+import android.support.v4.content.IntentCompat;
+import android.text.TextUtils;
+import android.webkit.MimeTypeMap;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.Constants;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.models.FeatureSet;
+import org.wordpress.android.models.MediaFile;
+import org.wordpress.android.models.Post;
+import org.wordpress.android.models.PostLocation;
+import org.wordpress.android.ui.posts.PagesActivity;
+import org.wordpress.android.ui.posts.PostsActivity;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.stats.AnalyticsTracker;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlrpc.android.ApiHelper;
+import org.xmlrpc.android.XMLRPCClientInterface;
+import org.xmlrpc.android.XMLRPCException;
+import org.xmlrpc.android.XMLRPCFactory;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class PostUploadService extends Service {
+ private static Context context;
+ private static final ArrayList<Post> listOfPosts = new ArrayList<Post>();
+ private static NotificationManager nm;
+ private static Post currentUploadingPost = null;
+ private UploadPostTask currentTask = null;
+ private FeatureSet mFeatureSet;
+
+ public static void addPostToUpload(Post currentPost) {
+ synchronized (listOfPosts) {
+ listOfPosts.add(currentPost);
+ }
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ context = this.getApplicationContext();
+ }
+
+ @Override
+ public void onStart(Intent intent, int startId) {
+ synchronized (listOfPosts) {
+ if (listOfPosts.size() == 0 || context == null) {
+ this.stopSelf();
+ return;
+ }
+ }
+ uploadNextPost();
+ }
+
+ private FeatureSet synchronousGetFeatureSet() {
+ if (WordPress.getCurrentBlog() == null || !WordPress.getCurrentBlog().isDotcomFlag())
+ return null;
+ ApiHelper.GetFeatures task = new ApiHelper.GetFeatures();
+ List<Object> apiArgs = new ArrayList<Object>();
+ apiArgs.add(WordPress.getCurrentBlog());
+ mFeatureSet = task.doSynchronously(apiArgs);
+ return mFeatureSet;
+ }
+
+ private void uploadNextPost(){
+ synchronized (listOfPosts) {
+ if( currentTask == null ) { //make sure nothing is running
+ currentUploadingPost = null;
+ if ( listOfPosts.size() > 0 ) {
+ currentUploadingPost = listOfPosts.remove(0);
+ currentTask = new UploadPostTask();
+ currentTask.execute(currentUploadingPost);
+ } else {
+ this.stopSelf();
+ }
+ }
+ }
+ }
+
+ private void postUploaded() {
+ synchronized (listOfPosts) {
+ currentTask = null;
+ currentUploadingPost = null;
+ }
+ uploadNextPost();
+ }
+
+ public static boolean isUploading(Post post) {
+ if ( currentUploadingPost != null && currentUploadingPost.equals(post) )
+ return true;
+ if( listOfPosts != null && listOfPosts.size() > 0 && listOfPosts.contains(post))
+ return true;
+ return false;
+ }
+
+ private class UploadPostTask extends AsyncTask<Post, Boolean, Boolean> {
+ private Post post;
+ private String mErrorMessage = "";
+ private boolean mIsMediaError = false;
+ private boolean mErrorUnavailableVideoPress = false;
+ private int featuredImageID = -1;
+ private int notificationID;
+ private Notification n;
+
+ @Override
+ protected void onPostExecute(Boolean postUploadedSuccessfully) {
+ if (postUploadedSuccessfully) {
+ WordPress.postUploaded(post.getLocalTableBlogId(), post.getRemotePostId(), post.isPage());
+ nm.cancel(notificationID);
+ WordPress.wpDB.deleteMediaFilesForPost(post);
+ } else {
+ String postOrPage = (String) (post.isPage() ? context.getResources().getText(R.string.page_id) : context.getResources()
+ .getText(R.string.post_id));
+ Intent notificationIntent = new Intent(context, post.isPage() ? PagesActivity.class : PostsActivity.class);
+ notificationIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP
+ | Intent.FLAG_ACTIVITY_NEW_TASK
+ | IntentCompat.FLAG_ACTIVITY_CLEAR_TASK);
+ notificationIntent.setAction(Intent.ACTION_MAIN);
+ notificationIntent.addCategory(Intent.CATEGORY_LAUNCHER);
+ notificationIntent.setData((Uri.parse("custom://wordpressNotificationIntent" + post.getLocalTableBlogId())));
+ notificationIntent.putExtra(PostsActivity.EXTRA_VIEW_PAGES, post.isPage());
+ notificationIntent.putExtra(PostsActivity.EXTRA_ERROR_MSG, mErrorMessage);
+ if (mErrorUnavailableVideoPress) {
+ notificationIntent.putExtra(PostsActivity.EXTRA_ERROR_INFO_TITLE, getString(R.string.learn_more));
+ notificationIntent.putExtra(PostsActivity.EXTRA_ERROR_INFO_LINK, Constants.videoPressURL);
+ }
+ notificationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ PendingIntent pendingIntent = PendingIntent.getActivity(context, 0,
+ notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+ n.flags |= Notification.FLAG_AUTO_CANCEL;
+ n.icon = android.R.drawable.stat_notify_error;
+ String errorText = context.getResources().getText(R.string.upload_failed).toString();
+ if (mIsMediaError)
+ errorText = context.getResources().getText(R.string.media) + " " + context.getResources().getText(R.string.error);
+ n.setLatestEventInfo(context, (mIsMediaError) ? errorText : context.getResources().getText(R.string.upload_failed),
+ (mIsMediaError) ? mErrorMessage : postOrPage + " " + errorText + ": " + mErrorMessage, pendingIntent);
+
+ nm.notify(notificationID, n); // needs a unique id
+ }
+
+ postUploaded();
+ }
+
+ @Override
+ protected Boolean doInBackground(Post... posts) {
+ mErrorUnavailableVideoPress = false;
+ post = posts[0];
+
+ // add the uploader to the notification bar
+ nm = (NotificationManager) SystemServiceFactory.get(context, Context.NOTIFICATION_SERVICE);
+
+ String postOrPage = (String) (post.isPage() ? context.getResources().getText(R.string.page_id) : context.getResources()
+ .getText(R.string.post_id));
+ String message = context.getResources().getText(R.string.uploading) + " " + postOrPage;
+ n = new Notification(R.drawable.notification_icon, message, System.currentTimeMillis());
+
+ Intent notificationIntent = new Intent(context, post.isPage() ? PagesActivity.class : PostsActivity.class);
+ notificationIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP
+ | Intent.FLAG_ACTIVITY_NEW_TASK
+ | IntentCompat.FLAG_ACTIVITY_CLEAR_TASK);
+ notificationIntent.setAction(Intent.ACTION_MAIN);
+ notificationIntent.addCategory(Intent.CATEGORY_LAUNCHER);
+ notificationIntent.setData((Uri.parse("custom://wordpressNotificationIntent" + post.getLocalTableBlogId())));
+ notificationIntent.putExtra(PostsActivity.EXTRA_VIEW_PAGES, post.isPage());
+ PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, notificationIntent, Intent.FLAG_ACTIVITY_CLEAR_TOP);
+
+ n.setLatestEventInfo(context, message, message, pendingIntent);
+
+ notificationID = (new Random()).nextInt() + post.getLocalTableBlogId();
+ nm.notify(notificationID, n); // needs a unique id
+
+ Blog blog = WordPress.wpDB.instantiateBlogByLocalId(post.getLocalTableBlogId());
+ if (blog == null) {
+ mErrorMessage = context.getString(R.string.blog_not_found);
+ return false;
+ }
+
+ boolean isFirstTimePublishing = false;
+ if (TextUtils.isEmpty(post.getPostStatus())) {
+ post.setPostStatus("publish");
+ }
+
+ if (post.hasChangedFromLocalDraftToPublished()) {
+ isFirstTimePublishing = true;
+ }
+
+ if (!post.isUploaded() && post.getPostStatus().equals("publish")) {
+ isFirstTimePublishing = true;
+ }
+
+ Boolean publishThis = false;
+
+ // These are used for stats purposes
+ Boolean hasImage = false;
+ Boolean hasVideo = false;
+ Boolean hasCategory = false;
+ Boolean hasTag = !post.getKeywords().equals("");
+
+
+ String descriptionContent = "", moreContent = "";
+ int moreCount = 1;
+ if (!TextUtils.isEmpty(post.getMoreText()))
+ moreCount++;
+ String imgTags = "<img[^>]+android-uri\\s*=\\s*['\"]([^'\"]+)['\"][^>]*>";
+ Pattern pattern = Pattern.compile(imgTags);
+
+ for (int x = 0; x < moreCount; x++) {
+ if (x == 0)
+ descriptionContent = post.getDescription();
+ else
+ moreContent = post.getMoreText();
+
+ Matcher matcher;
+
+ if (x == 0) {
+ matcher = pattern.matcher(descriptionContent);
+ } else {
+ matcher = pattern.matcher(moreContent);
+ }
+
+ List<String> imageTags = new ArrayList<String>();
+ while (matcher.find()) {
+ imageTags.add(matcher.group());
+ }
+
+ for (String tag : imageTags) {
+ Pattern p = Pattern.compile("android-uri=\"([^\"]+)\"");
+ Matcher m = p.matcher(tag);
+ if (m.find()) {
+ String imgPath = m.group(1);
+ if (!imgPath.equals("")) {
+ MediaFile mf = WordPress.wpDB.getMediaFile(imgPath, post);
+ if (mf != null) {
+ if (mf.isVideo()) {
+ hasVideo = true;
+ } else {
+ hasImage = true;
+ }
+ String imgHTML = uploadMediaFile(mf, blog);
+ if (imgHTML != null) {
+ if (x == 0) {
+ descriptionContent = descriptionContent.replace(tag, imgHTML);
+ } else {
+ moreContent = moreContent.replace(tag, imgHTML);
+ }
+ } else {
+ if (x == 0)
+ descriptionContent = descriptionContent.replace(tag, "");
+ else
+ moreContent = moreContent.replace(tag, "");
+ mIsMediaError = true;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // If media file upload failed, let's stop here and prompt the user
+ if (mIsMediaError)
+ return false;
+
+ JSONArray categoriesJsonArray = post.getJSONCategories();
+ String[] postCategories = null;
+ if (categoriesJsonArray != null) {
+ if (categoriesJsonArray.length() > 0) {
+ hasCategory = true;
+ }
+
+ postCategories = new String[categoriesJsonArray.length()];
+ for (int i = 0; i < categoriesJsonArray.length(); i++) {
+ try {
+ postCategories[i] = TextUtils.htmlEncode(categoriesJsonArray.getString(i));
+ } catch (JSONException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ }
+ }
+
+ Map<String, Object> contentStruct = new HashMap<String, Object>();
+
+ if (!post.isPage() && post.isLocalDraft()) {
+ // add the tagline
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
+
+ if (prefs.getBoolean("wp_pref_signature_enabled", false)) {
+ String tagline = prefs.getString("wp_pref_post_signature", "");
+ if (!TextUtils.isEmpty(tagline)) {
+ String tag = "\n\n<span class=\"post_sig\">" + tagline + "</span>\n\n";
+ if (TextUtils.isEmpty(moreContent))
+ descriptionContent += tag;
+ else
+ moreContent += tag;
+ }
+ }
+ }
+
+ // post format
+ if (!post.isPage()) {
+ if (!TextUtils.isEmpty(post.getPostFormat())) {
+ contentStruct.put("wp_post_format", post.getPostFormat());
+ }
+ }
+
+ contentStruct.put("post_type", (post.isPage()) ? "page" : "post");
+ contentStruct.put("title", post.getTitle());
+ long pubDate = post.getDate_created_gmt();
+ if (pubDate != 0) {
+ Date date_created_gmt = new Date(pubDate);
+ contentStruct.put("date_created_gmt", date_created_gmt);
+ Date dateCreated = new Date(pubDate + (date_created_gmt.getTimezoneOffset() * 60000));
+ contentStruct.put("dateCreated", dateCreated);
+ }
+
+ if (!moreContent.equals("")) {
+ descriptionContent = descriptionContent.trim() + "<!--more-->" + moreContent;
+ post.setMoreText("");
+ }
+
+ // get rid of the p and br tags that the editor adds.
+ if (post.isLocalDraft()) {
+ descriptionContent = descriptionContent.replace("<p>", "").replace("</p>", "\n").replace("<br>", "");
+ }
+
+ // gets rid of the weird character android inserts after images
+ descriptionContent = descriptionContent.replaceAll("\uFFFC", "");
+
+ contentStruct.put("description", descriptionContent);
+ if (!post.isPage()) {
+ contentStruct.put("mt_keywords", post.getKeywords());
+
+ if (postCategories != null && postCategories.length > 0)
+ contentStruct.put("categories", postCategories);
+ }
+
+ contentStruct.put("mt_excerpt", post.getPostExcerpt());
+
+ contentStruct.put((post.isPage()) ? "page_status" : "post_status", post.getPostStatus());
+ if (post.supportsLocation()) {
+ JSONObject remoteGeoLatitude = post.getCustomField("geo_latitude");
+ JSONObject remoteGeoLongitude = post.getCustomField("geo_longitude");
+ JSONObject remoteGeoPublic = post.getCustomField("geo_public");
+
+ Map<Object, Object> hLatitude = new HashMap<Object, Object>();
+ Map<Object, Object> hLongitude = new HashMap<Object, Object>();
+ Map<Object, Object> hPublic = new HashMap<Object, Object>();
+
+ try {
+ if (remoteGeoLatitude != null) {
+ hLatitude.put("id", remoteGeoLatitude.getInt("id"));
+ }
+
+ if (remoteGeoLongitude != null) {
+ hLongitude.put("id", remoteGeoLongitude.getInt("id"));
+ }
+
+ if (remoteGeoPublic != null) {
+ hPublic.put("id", remoteGeoPublic.getInt("id"));
+ }
+
+ if (post.hasLocation()) {
+ PostLocation location = post.getLocation();
+ if (!hLatitude.containsKey("id")) {
+ hLatitude.put("key", "geo_latitude");
+ }
+
+ if (!hLongitude.containsKey("id")) {
+ hLongitude.put("key", "geo_longitude");
+ }
+
+ if (!hPublic.containsKey("id")) {
+ hPublic.put("key", "geo_public");
+ }
+
+ hLatitude.put("value", location.getLatitude());
+ hLongitude.put("value", location.getLongitude());
+ hPublic.put("value", 1);
+ }
+ } catch (JSONException e) {
+ AppLog.e(T.EDITOR, e);
+ }
+
+ if (!hLatitude.isEmpty() && !hLongitude.isEmpty() && !hPublic.isEmpty()) {
+ Object[] geo = {hLatitude, hLongitude, hPublic};
+ contentStruct.put("custom_fields", geo);
+ }
+ }
+
+ // featured image
+ if (featuredImageID != -1) {
+ contentStruct.put("wp_post_thumbnail", featuredImageID);
+ }
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+ if (!TextUtils.isEmpty(post.getQuickPostType())) {
+ client.addQuickPostHeader(post.getQuickPostType());
+ }
+ n.setLatestEventInfo(context, message, message, n.contentIntent);
+ nm.notify(notificationID, n);
+
+ contentStruct.put("wp_password", post.getPassword());
+
+ Object[] params;
+ if (post.isLocalDraft() && !post.isUploaded())
+ params = new Object[]{blog.getRemoteBlogId(), blog.getUsername(), blog.getPassword(),
+ contentStruct, publishThis};
+ else
+ params = new Object[]{post.getRemotePostId(), blog.getUsername(), blog.getPassword(), contentStruct,
+ publishThis};
+
+ try {
+ if (post.isLocalDraft() && !post.isUploaded()) {
+ Object newPostId = client.call("metaWeblog.newPost", params);
+ if (newPostId instanceof String) {
+ post.setRemotePostId((String) newPostId);
+ }
+ } else {
+ client.call("metaWeblog.editPost", params);
+ }
+
+ post.setUploaded(true);
+ post.setLocalChange(false);
+ WordPress.wpDB.updatePost(post);
+
+ if (isFirstTimePublishing) {
+ if (hasImage) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.EDITOR_PUBLISHED_POST_WITH_PHOTO);
+ }
+ if (hasVideo) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.EDITOR_PUBLISHED_POST_WITH_VIDEO);
+ }
+ if (hasCategory) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.EDITOR_PUBLISHED_POST_WITH_CATEGORIES);
+ }
+ if (hasTag) {
+ AnalyticsTracker.track(AnalyticsTracker.Stat.EDITOR_PUBLISHED_POST_WITH_TAGS);
+ }
+ }
+
+ return true;
+ } catch (final XMLRPCException e) {
+ setUploadPostErrorMessage(e);
+ } catch (IOException e) {
+ setUploadPostErrorMessage(e);
+ } catch (XmlPullParserException e) {
+ setUploadPostErrorMessage(e);
+ }
+
+ return false;
+ }
+
+
+ private void setUploadPostErrorMessage(Exception e) {
+ mErrorMessage = String.format(context.getResources().getText(R.string.error_upload).toString(), post.isPage() ? context
+ .getResources().getText(R.string.page).toString() : context.getResources().getText(R.string.post).toString())
+ + " " + e.getMessage();
+ mIsMediaError = false;
+ AppLog.e(T.EDITOR, mErrorMessage, e);
+ }
+
+ public String uploadMediaFile(MediaFile mediaFile, Blog blog) {
+ String content = "";
+
+ String curImagePath = mediaFile.getFilePath();
+ if (curImagePath == null) {
+ return null;
+ }
+
+ if (curImagePath.contains("video")) {
+ // Upload the video
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+ // create temp file for media upload
+ String tempFileName = "wp-" + System.currentTimeMillis();
+ try {
+ context.openFileOutput(tempFileName, Context.MODE_PRIVATE);
+ } catch (FileNotFoundException e) {
+ mErrorMessage = getResources().getString(R.string.file_error_create);
+ mIsMediaError = true;
+ return null;
+ }
+
+ Uri videoUri = Uri.parse(curImagePath);
+ File videoFile = null;
+ String mimeType = "", xRes = "", yRes = "";
+
+ if (videoUri.toString().contains("content:")) {
+ String[] projection = new String[]{Video.Media._ID, Video.Media.DATA, Video.Media.MIME_TYPE, Video.Media.RESOLUTION};
+ Cursor cur = context.getContentResolver().query(videoUri, projection, null, null, null);
+
+ if (cur != null && cur.moveToFirst()) {
+ int mimeTypeColumn, resolutionColumn, dataColumn;
+
+ dataColumn = cur.getColumnIndex(Video.Media.DATA);
+ mimeTypeColumn = cur.getColumnIndex(Video.Media.MIME_TYPE);
+ resolutionColumn = cur.getColumnIndex(Video.Media.RESOLUTION);
+
+ mediaFile = new MediaFile();
+
+ String thumbData = cur.getString(dataColumn);
+ mimeType = cur.getString(mimeTypeColumn);
+
+ videoFile = new File(thumbData);
+ mediaFile.setFilePath(videoFile.getPath());
+ String resolution = cur.getString(resolutionColumn);
+ if (resolution != null) {
+ String[] resx = resolution.split("x");
+ xRes = resx[0];
+ yRes = resx[1];
+ } else {
+ // set the width of the video to the thumbnail width, else 640x480
+ if (!blog.getMaxImageWidth().equals("Original Size")) {
+ xRes = blog.getMaxImageWidth();
+ yRes = String.valueOf(Math.round(Integer.valueOf(blog.getMaxImageWidth()) * 0.75));
+ } else {
+ xRes = "640";
+ yRes = "480";
+ }
+ }
+ }
+ } else { // file is not in media library
+ String filePath = videoUri.toString().replace("file://", "");
+ mediaFile.setFilePath(filePath);
+ videoFile = new File(filePath);
+ }
+
+ if (videoFile == null) {
+ mErrorMessage = context.getResources().getString(R.string.error_media_upload);
+ return null;
+ }
+
+ if (TextUtils.isEmpty(mimeType)) {
+ mimeType = MediaUtils.getMediaFileMimeType(videoFile);
+ }
+ String videoName = MediaUtils.getMediaFileName(videoFile, mimeType);
+
+ // try to upload the video
+ Map<String, Object> m = new HashMap<String, Object>();
+ m.put("name", videoName);
+ m.put("type", mimeType);
+ m.put("bits", mediaFile);
+ m.put("overwrite", true);
+
+ Object[] params = {1, blog.getUsername(), blog.getPassword(), m};
+
+ FeatureSet featureSet = synchronousGetFeatureSet();
+ boolean selfHosted = WordPress.currentBlog != null && !WordPress.currentBlog.isDotcomFlag();
+ boolean isVideoEnabled = selfHosted || (featureSet != null && mFeatureSet.isVideopressEnabled());
+ if (isVideoEnabled) {
+ File tempFile;
+ try {
+ String fileExtension = MimeTypeMap.getFileExtensionFromUrl(videoName);
+ tempFile = createTempUploadFile(fileExtension);
+ } catch (IOException e) {
+ mErrorMessage = getResources().getString(R.string.file_error_create);
+ mIsMediaError = true;
+ return null;
+ }
+
+ Object result = uploadFileHelper(client, params, tempFile);
+ Map<?, ?> resultMap = (HashMap<?, ?>) result;
+ if (resultMap != null && resultMap.containsKey("url")) {
+ String resultURL = resultMap.get("url").toString();
+ if (resultMap.containsKey("videopress_shortcode")) {
+ resultURL = resultMap.get("videopress_shortcode").toString() + "\n";
+ } else {
+ resultURL = String.format(
+ "<video width=\"%s\" height=\"%s\" controls=\"controls\"><source src=\"%s\" type=\"%s\" /><a href=\"%s\">Click to view video</a>.</video>",
+ xRes, yRes, resultURL, mimeType, resultURL);
+ }
+ content = content + resultURL;
+ } else {
+ return null;
+ }
+ } else {
+ mErrorMessage = getString(R.string.media_no_video_message);
+ mErrorUnavailableVideoPress = true;
+ return null;
+ }
+ } else {
+ // Upload the image
+ curImagePath = mediaFile.getFilePath();
+
+ Uri imageUri = Uri.parse(curImagePath);
+ File imageFile = null;
+ String mimeType = "", path = "";
+ int orientation;
+
+ if (imageUri.toString().contains("content:")) {
+ String[] projection;
+ Uri imgPath;
+
+ projection = new String[]{Images.Media._ID, Images.Media.DATA, Images.Media.MIME_TYPE};
+
+ imgPath = imageUri;
+
+ Cursor cur = context.getContentResolver().query(imgPath, projection, null, null, null);
+ if (cur != null && cur.moveToFirst()) {
+ int dataColumn, mimeTypeColumn;
+ dataColumn = cur.getColumnIndex(Images.Media.DATA);
+ mimeTypeColumn = cur.getColumnIndex(Images.Media.MIME_TYPE);
+
+ String thumbData = cur.getString(dataColumn);
+ mimeType = cur.getString(mimeTypeColumn);
+ imageFile = new File(thumbData);
+ path = thumbData;
+ mediaFile.setFilePath(imageFile.getPath());
+ }
+ } else { // file is not in media library
+ path = imageUri.toString().replace("file://", "");
+ imageFile = new File(path);
+ mediaFile.setFilePath(path);
+ }
+
+ // check if the file exists
+ if (imageFile == null) {
+ mErrorMessage = context.getString(R.string.file_not_found);
+ mIsMediaError = true;
+ return null;
+ }
+
+ if (TextUtils.isEmpty(mimeType)) {
+ mimeType = MediaUtils.getMediaFileMimeType(imageFile);
+ }
+ String fileName = MediaUtils.getMediaFileName(imageFile, mimeType);
+ String fileExtension = MimeTypeMap.getFileExtensionFromUrl(fileName).toLowerCase();
+
+ orientation = ImageHelper.getImageOrientation(context, path);
+
+ String resizedPictureURL = null;
+
+ // We need to upload a resized version of the picture when the blog settings != original size, or when
+ // the user has selected a smaller size for the current picture in the picture settings screen
+ // We won't resize gif images to keep them awesome.
+ boolean shouldUploadResizedVersion = false;
+ // If it's not a gif and blog don't keep original size, there is a chance we need to resize
+ if (!mimeType.equals("image/gif") && !blog.getMaxImageWidth().equals("Original Size")) {
+ //check the picture settings
+ int pictureSettingWidth = mediaFile.getWidth();
+ BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inJustDecodeBounds = true;
+ BitmapFactory.decodeFile(path, options);
+ int imageHeight = options.outHeight;
+ int imageWidth = options.outWidth;
+ int[] dimensions = {imageWidth, imageHeight};
+ if (dimensions[0] != 0 && dimensions[0] != pictureSettingWidth) {
+ shouldUploadResizedVersion = true;
+ }
+ }
+
+ boolean shouldAddImageWidthCSS = false;
+
+ if (shouldUploadResizedVersion) {
+ MediaFile resizedMediaFile = new MediaFile(mediaFile);
+ // Create resized image
+ byte[] bytes = ImageHelper.createThumbnailFromUri(context, imageUri, resizedMediaFile.getWidth(),
+ fileExtension, orientation);
+
+ if (bytes == null) {
+ // We weren't able to resize the image, so we will upload the full size image with css to resize it
+ shouldUploadResizedVersion = false;
+ shouldAddImageWidthCSS = true;
+ } else {
+ // Save temp image
+ String tempFilePath;
+ File resizedImageFile;
+ try {
+ resizedImageFile = File.createTempFile("wp-image-", fileExtension);
+ FileOutputStream out = new FileOutputStream(resizedImageFile);
+ out.write(bytes);
+ out.close();
+ tempFilePath = resizedImageFile.getPath();
+ } catch (IOException e) {
+ AppLog.w(T.POSTS, "failed to create image temp file");
+ mErrorMessage = context.getString(R.string.error_media_upload);
+ mIsMediaError = true;
+ return null;
+ }
+
+ // upload resized picture
+ if (!TextUtils.isEmpty(tempFilePath)) {
+ resizedMediaFile.setFilePath(tempFilePath);
+ Map<String, Object> parameters = new HashMap<String, Object>();
+
+ parameters.put("name", fileName);
+ parameters.put("type", mimeType);
+ parameters.put("bits", resizedMediaFile);
+ parameters.put("overwrite", true);
+ resizedPictureURL = uploadPicture(parameters, resizedMediaFile, blog);
+ if (resizedPictureURL == null) {
+ AppLog.w(T.POSTS, "failed to upload resized picture");
+ return null;
+ } else if (resizedImageFile != null && resizedImageFile.exists()) {
+ resizedImageFile.delete();
+ }
+ } else {
+ AppLog.w(T.POSTS, "failed to create resized picture");
+ mErrorMessage = context.getString(R.string.out_of_memory);
+ mIsMediaError = true;
+ return null;
+ }
+ }
+ }
+
+ String fullSizeUrl = null;
+ // Upload the full size picture if "Original Size" is selected in settings, or if 'link to full size' is checked.
+ if (!shouldUploadResizedVersion || blog.isFullSizeImage()) {
+ // try to upload the image
+ Map<String, Object> parameters = new HashMap<String, Object>();
+ parameters.put("name", fileName);
+ parameters.put("type", mimeType);
+ parameters.put("bits", mediaFile);
+ parameters.put("overwrite", true);
+
+ fullSizeUrl = uploadPicture(parameters, mediaFile, blog);
+ if (fullSizeUrl == null)
+ return null;
+ }
+
+ String alignment = "";
+ switch (mediaFile.getHorizontalAlignment()) {
+ case 0:
+ alignment = "alignnone";
+ break;
+ case 1:
+ alignment = "alignleft";
+ break;
+ case 2:
+ alignment = "aligncenter";
+ break;
+ case 3:
+ alignment = "alignright";
+ break;
+ }
+
+ String alignmentCSS = "class=\"" + alignment + " size-full\" ";
+
+ if (shouldAddImageWidthCSS) {
+ alignmentCSS += "style=\"max-width: " + mediaFile.getWidth() + "px\" ";
+ }
+
+ // Check if we uploaded a featured picture that is not added to the post content (normal case)
+ if ((fullSizeUrl != null && fullSizeUrl.equalsIgnoreCase("")) ||
+ (resizedPictureURL != null && resizedPictureURL.equalsIgnoreCase(""))) {
+ return ""; // Not featured in post. Do not add to the content.
+ }
+
+ if (fullSizeUrl == null && resizedPictureURL != null) {
+ fullSizeUrl = resizedPictureURL;
+ } else if (fullSizeUrl != null && resizedPictureURL == null){
+ resizedPictureURL = fullSizeUrl;
+ }
+
+ String mediaTitle = TextUtils.isEmpty(mediaFile.getTitle()) ? "" : mediaFile.getTitle();
+
+ content = content + "<a href=\"" + fullSizeUrl + "\"><img title=\"" + mediaTitle + "\" "
+ + alignmentCSS + "alt=\"image\" src=\"" + resizedPictureURL + "\" /></a>";
+
+ if (!TextUtils.isEmpty(mediaFile.getCaption())) {
+ content = String.format("[caption id=\"\" align=\"%s\" width=\"%d\" caption=\"%s\"]%s[/caption]",
+ alignment, mediaFile.getWidth(), TextUtils.htmlEncode(mediaFile.getCaption()), content);
+ }
+ }
+ return content;
+ }
+
+ private String uploadPicture(Map<String, Object> pictureParams, MediaFile mf, Blog blog) {
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+
+ // create temporary upload file
+ File tempFile;
+ try {
+ String fileExtension = MimeTypeMap.getFileExtensionFromUrl(mf.getFileName());
+ tempFile = createTempUploadFile(fileExtension);
+ } catch (IOException e) {
+ mIsMediaError = true;
+ mErrorMessage = context.getString(R.string.file_not_found);
+ return null;
+ }
+
+ Object[] params = { 1, blog.getUsername(), blog.getPassword(), pictureParams };
+ Object result = uploadFileHelper(client, params, tempFile);
+ if (result == null) {
+ mIsMediaError = true;
+ return null;
+ }
+
+ Map<?, ?> contentHash = (HashMap<?, ?>) result;
+ String pictureURL = contentHash.get("url").toString();
+
+ if (mf.isFeatured()) {
+ try {
+ if (contentHash.get("id") != null) {
+ featuredImageID = Integer.parseInt(contentHash.get("id").toString());
+ if (!mf.isFeaturedInPost())
+ return "";
+ }
+ } catch (NumberFormatException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ }
+
+ return pictureURL;
+ }
+
+ private Object uploadFileHelper(XMLRPCClientInterface client, Object[] params, File tempFile) {
+ try {
+ return client.call("wp.uploadFile", params, tempFile);
+ } catch (XMLRPCException e) {
+ AppLog.e(T.API, e);
+ mErrorMessage = context.getResources().getString(R.string.error_media_upload) + ": " + e.getMessage();
+ return null;
+ } catch (IOException e) {
+ AppLog.e(T.API, e);
+ mErrorMessage = context.getResources().getString(R.string.error_media_upload) + ": " + e.getMessage();
+ return null;
+ } catch (XmlPullParserException e) {
+ AppLog.e(T.API, e);
+ mErrorMessage = context.getResources().getString(R.string.error_media_upload) + ": " + e.getMessage();
+ return null;
+ } finally {
+ // remove the temporary upload file now that we're done with it
+ if (tempFile != null && tempFile.exists())
+ tempFile.delete();
+ }
+ }
+ }
+
+ private File createTempUploadFile(String fileExtension) throws IOException {
+ return File.createTempFile("wp-", fileExtension, context.getCacheDir());
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/ProfilingUtils.java b/WordPress/src/main/java/org/wordpress/android/util/ProfilingUtils.java
new file mode 100644
index 000000000..991c7680b
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/ProfilingUtils.java
@@ -0,0 +1,77 @@
+package org.wordpress.android.util;
+
+import android.os.SystemClock;
+
+import org.wordpress.android.util.AppLog.T;
+
+import java.util.ArrayList;
+
+/**
+ * forked from android.util.TimingLogger to use AppLog instead of Log + new static interface.
+ */
+public class ProfilingUtils {
+ private static ProfilingUtils sInstance;
+
+ private String mLabel;
+ private ArrayList<Long> mSplits;
+ private ArrayList<String> mSplitLabels;
+
+ public static void start(String label) {
+ getInstance().reset(label);
+ }
+
+ public static void split(String splitLabel) {
+ getInstance().addSplit(splitLabel);
+ }
+
+ public static void dump() {
+ getInstance().dumpToLog();
+ }
+
+ private static ProfilingUtils getInstance() {
+ if (sInstance == null) {
+ sInstance = new ProfilingUtils();
+ }
+ return sInstance;
+ }
+
+ public ProfilingUtils() {
+ reset("init");
+ }
+
+ public void reset(String label) {
+ mLabel = label;
+ reset();
+ }
+
+ public void reset() {
+ if (mSplits == null) {
+ mSplits = new ArrayList<Long>();
+ mSplitLabels = new ArrayList<String>();
+ } else {
+ mSplits.clear();
+ mSplitLabels.clear();
+ }
+ addSplit(null);
+ }
+
+ public void addSplit(String splitLabel) {
+ long now = SystemClock.elapsedRealtime();
+ mSplits.add(now);
+ mSplitLabels.add(splitLabel);
+ }
+
+ public void dumpToLog() {
+ AppLog.d(T.PROFILING, mLabel + ": begin");
+ final long first = mSplits.get(0);
+ long now = first;
+ for (int i = 1; i < mSplits.size(); i++) {
+ now = mSplits.get(i);
+ final String splitLabel = mSplitLabels.get(i);
+ final long prev = mSplits.get(i - 1);
+ AppLog.d(T.PROFILING, mLabel + ": " + (now - prev) + " ms, " + splitLabel);
+ }
+ AppLog.d(T.PROFILING, mLabel + ": end, " + (now - first) + " ms");
+ }
+}
+
diff --git a/WordPress/src/main/java/org/wordpress/android/util/RateLimitedTask.java b/WordPress/src/main/java/org/wordpress/android/util/RateLimitedTask.java
new file mode 100644
index 000000000..04c3d37b8
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/RateLimitedTask.java
@@ -0,0 +1,37 @@
+package org.wordpress.android.util;
+
+import java.util.Date;
+
+public abstract class RateLimitedTask {
+ private Date mLastUpdate;
+ private int mMinRateInSeconds;
+
+ public RateLimitedTask(int minRateInSeconds) {
+ mMinRateInSeconds = minRateInSeconds;
+ }
+
+ public void forceLastUpdate() {
+ mLastUpdate = new Date();
+ }
+
+ public synchronized boolean forceRun() {
+ if (run()) {
+ mLastUpdate = new Date();
+ return true;
+ }
+ return false;
+ }
+
+ public synchronized boolean runIfNotLimited() {
+ Date now = new Date();
+ if (mLastUpdate == null || DateTimeUtils.secondsBetween(now, mLastUpdate) >= mMinRateInSeconds) {
+ if (run()) {
+ mLastUpdate = now;
+ return true;
+ }
+ }
+ return false;
+ }
+
+ protected abstract boolean run();
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/ReaderVideoUtils.java b/WordPress/src/main/java/org/wordpress/android/util/ReaderVideoUtils.java
new file mode 100644
index 000000000..6afe7384d
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/ReaderVideoUtils.java
@@ -0,0 +1,172 @@
+package org.wordpress.android.util;
+
+import android.net.Uri;
+import android.text.TextUtils;
+
+import com.android.volley.Response;
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.JsonArrayRequest;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.AppLog.T;
+
+public class ReaderVideoUtils {
+ private ReaderVideoUtils() {
+ throw new AssertionError();
+ }
+
+ /*
+ * returns the url to get the full-size (480x360) thumbnail url for the passed video
+ * see http://www.reelseo.com/youtube-thumbnail-image/ for other sizes
+ */
+ public static String getYouTubeThumbnailUrl(final String videoUrl) {
+ String videoId = getYouTubeVideoId(videoUrl);
+ if (TextUtils.isEmpty(videoId))
+ return "";
+ // note that this *must* use https rather than http - ex: https://img.youtube.com/vi/ClbE019cLNI/0.jpg
+ return "https://img.youtube.com/vi/" + videoId + "/0.jpg";
+ }
+
+ /*
+ * returns true if the passed url is a link to a YouTube video
+ */
+ public static boolean isYouTubeVideoLink(final String link) {
+ return (!TextUtils.isEmpty(getYouTubeVideoId(link)));
+ }
+
+ /*
+ * accepts a YouTube link in any format (such as the /embed/ format) and turns it into a
+ * standard YouTube video link
+ */
+ public static String fixYouTubeVideoLink(final String videoUrl) {
+ String videoId = getYouTubeVideoId(videoUrl);
+ if (TextUtils.isEmpty(videoId))
+ return videoUrl;
+ return "http://www.youtube.com/watch?v=" + videoId;
+ }
+
+ /*
+ * extract the video id from the passed YouTube url
+ */
+ private static String getYouTubeVideoId(final String link) {
+ if (link==null)
+ return "";
+
+ Uri uri = Uri.parse(link);
+ try {
+ String host = uri.getHost();
+ if (host==null)
+ return "";
+
+ // youtube.com links
+ if (host.equals("youtube.com") || host.equals("www.youtube.com")) {
+ // if link contains "watch" in the path, then the id is in the "v=" query param
+ if (link.contains("watch"))
+ return uri.getQueryParameter("v");
+ // if the link contains "embed" in the path, then the id is the last path segment
+ // ex: https://www.youtube.com/embed/fw3w68YrKwc?version=3&#038;rel=1&#038;
+ if (link.contains("/embed/"))
+ return uri.getLastPathSegment();
+ return "";
+ }
+
+ // youtu.be urls have the videoId as the path - ex: http://youtu.be/pEnXclbO9jg
+ if (host.equals("youtu.be")) {
+ String path = uri.getPath();
+ if (path==null)
+ return "";
+ // remove the leading slash
+ return path.replace("/", "");
+ }
+
+ // YouTube mobile urls include video id in fragment, ex: http://m.youtube.com/?dc=organic&source=mog#/watch?v=t77Vlme_pf8
+ if (host.equals("m.youtube.com")) {
+ String fragment = uri.getFragment();
+ if (fragment==null)
+ return "";
+ int index = fragment.lastIndexOf("v=");
+ if (index!=-1)
+ return fragment.substring(index+2, fragment.length());
+ }
+
+ return "";
+ } catch (UnsupportedOperationException e) {
+ AppLog.e(T.READER, e);
+ return "";
+ } catch (IndexOutOfBoundsException e) {
+ // thrown by substring
+ AppLog.e(T.READER, e);
+ return "";
+ }
+ }
+
+ /*
+ * returns true if the passed url is a link to a Vimeo video
+ */
+ public static boolean isVimeoLink(final String link) {
+ return (!TextUtils.isEmpty(getVimeoVideoId(link)));
+ }
+
+ /*
+ * extract the video id from the passed Vimeo url
+ * ex: http://player.vimeo.com/video/72386905 -> 72386905
+ */
+ public static String getVimeoVideoId(final String link) {
+ if (link==null)
+ return "";
+ if (!link.contains("player.vimeo.com"))
+ return "";
+
+ Uri uri = Uri.parse(link);
+ return uri.getLastPathSegment();
+ }
+
+ /*
+ * unlike YouTube thumbnails, Vimeo thumbnails require network request
+ */
+ public static void requestVimeoThumbnail(final String videoUrl, final VideoThumbnailListener thumbListener) {
+ // useless without a listener
+ if (thumbListener==null)
+ return;
+
+ String id = getVimeoVideoId(videoUrl);
+ if (TextUtils.isEmpty(id)) {
+ thumbListener.onResponse(false, null);
+ return;
+ }
+
+ Response.Listener<JSONArray> listener = new Response.Listener<JSONArray>() {
+ public void onResponse(JSONArray response) {
+ String thumbnailUrl = null;
+ if (response!=null && response.length() > 0) {
+ JSONObject json = response.optJSONObject(0);
+ if (json!=null && json.has("thumbnail_large"))
+ thumbnailUrl = JSONUtil.getString(json, "thumbnail_large");
+ }
+ if (TextUtils.isEmpty(thumbnailUrl)) {
+ thumbListener.onResponse(false, null);
+ } else {
+ thumbListener.onResponse(true, thumbnailUrl);
+ }
+ }
+ };
+ Response.ErrorListener errorListener = new Response.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ AppLog.e(T.READER, volleyError);
+ thumbListener.onResponse(false, null);
+ }
+ };
+
+ String url = "http://vimeo.com/api/v2/video/" + id + ".json";
+ JsonArrayRequest request = new JsonArrayRequest(url, listener, errorListener);
+
+ WordPress.requestQueue.add(request);
+ }
+
+ public static interface VideoThumbnailListener {
+ public void onResponse(boolean successful, String thumbnailUrl);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/SimperiumUtils.java b/WordPress/src/main/java/org/wordpress/android/util/SimperiumUtils.java
new file mode 100644
index 000000000..4647bf69f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/SimperiumUtils.java
@@ -0,0 +1,109 @@
+/**
+ * Simperium integration with WordPress.com
+ * Currently used with Notifications
+ */
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.content.Intent;
+import android.support.v4.content.LocalBroadcastManager;
+
+import com.simperium.Simperium;
+import com.simperium.client.Bucket;
+import com.simperium.client.BucketNameInvalid;
+import com.simperium.client.BucketObject;
+import com.simperium.client.User;
+
+import org.wordpress.android.BuildConfig;
+import org.wordpress.android.models.Note;
+
+public class SimperiumUtils {
+ public static final String BROADCAST_ACTION_SIMPERIUM_NOT_AUTHORIZED = "simperium-not-authorized";
+
+ private static Simperium mSimperium;
+ private static Bucket<Note> mNotesBucket;
+ private static Bucket<BucketObject> mMetaBucket;
+
+ public static Simperium getSimperium() {
+ return mSimperium;
+ }
+
+ public static Bucket<Note> getNotesBucket() {
+ return mNotesBucket;
+ }
+
+ public static Bucket<BucketObject> getMetaBucket() {
+ return mMetaBucket;
+ }
+
+ public static Simperium configureSimperium(final Context context, String token) {
+ // Create a new instance of Simperium if it doesn't exist yet.
+ // In any case, authorize the user.
+ if (mSimperium == null) {
+ mSimperium = Simperium.newClient(BuildConfig.SIMPERIUM_APP_NAME,
+ BuildConfig.SIMPERIUM_APP_SECRET, context);
+
+ try {
+ mNotesBucket = mSimperium.bucket(new Note.Schema());
+ mMetaBucket = mSimperium.bucket("meta");
+
+ mSimperium.setUserStatusChangeListener(new User.StatusChangeListener() {
+
+ @Override
+ public void onUserStatusChange(User.Status status) {
+ switch (status) {
+ case AUTHORIZED:
+ mNotesBucket.start();
+ mMetaBucket.start();
+ break;
+ case NOT_AUTHORIZED:
+ mNotesBucket.stop();
+ mMetaBucket.stop();
+ Intent simperiumNotAuthorizedIntent = new Intent();
+ simperiumNotAuthorizedIntent.setAction(BROADCAST_ACTION_SIMPERIUM_NOT_AUTHORIZED);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(simperiumNotAuthorizedIntent);
+ break;
+ default:
+ AppLog.d(AppLog.T.SIMPERIUM, "User not authorized yet");
+ break;
+ }
+ }
+
+ });
+
+ } catch (BucketNameInvalid e) {
+ AppLog.e(AppLog.T.SIMPERIUM, e.getMessage());
+ }
+ }
+
+ authorizeUser(mSimperium, token);
+
+ return mSimperium;
+ }
+
+ public static void authorizeUser(Simperium simperium, String token) {
+ User user = simperium.getUser();
+
+ String tokenFormat = "WPCC/%s/%s";
+ String wpccToken = String.format(tokenFormat, BuildConfig.SIMPERIUM_APP_SECRET, StringUtils.notNullStr(token));
+
+ user.setAccessToken(wpccToken);
+
+ // we'll assume the user is AUTHORIZED, and catch NOT_AUTHORIZED if something goes wrong.
+ user.setStatus(User.Status.AUTHORIZED);
+ }
+
+ public static void resetBucketsAndDeauthorize() {
+ if (mNotesBucket != null) {
+ mNotesBucket.reset();
+ }
+ if (mMetaBucket != null) {
+ mMetaBucket.reset();
+ }
+
+ // Reset user status
+ if (mSimperium != null) {
+ mSimperium.getUser().setStatus(User.Status.UNKNOWN);
+ }
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/util/SqlUtils.java b/WordPress/src/main/java/org/wordpress/android/util/SqlUtils.java
new file mode 100644
index 000000000..8d1b4b437
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/SqlUtils.java
@@ -0,0 +1,121 @@
+package org.wordpress.android.util;
+
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteDoneException;
+import android.database.sqlite.SQLiteException;
+import android.database.sqlite.SQLiteStatement;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class SqlUtils {
+ private SqlUtils() {
+ throw new AssertionError();
+ }
+
+ /*
+ * SQLite doesn't have a boolean datatype, so booleans are stored as 0=false, 1=true
+ */
+ public static long boolToSql(boolean value) {
+ return (value ? 1 : 0);
+ }
+ public static boolean sqlToBool(int value) {
+ return (value != 0);
+ }
+
+ public static void closeStatement(SQLiteStatement stmt) {
+ if (stmt != null) {
+ stmt.close();
+ }
+ }
+
+ public static void closeCursor(Cursor c) {
+ if (c != null && !c.isClosed()) {
+ c.close();
+ }
+ }
+
+ /*
+ * wrapper for DatabaseUtils.longForQuery() which returns 0 if query returns no rows
+ */
+ public static long longForQuery(SQLiteDatabase db, String query, String[] selectionArgs) {
+ try {
+ return DatabaseUtils.longForQuery(db, query, selectionArgs);
+ } catch (SQLiteDoneException e) {
+ return 0;
+ }
+ }
+
+ public static int intForQuery(SQLiteDatabase db, String query, String[] selectionArgs) {
+ long value = longForQuery(db, query, selectionArgs);
+ return (int)value;
+ }
+
+ public static boolean boolForQuery(SQLiteDatabase db, String query, String[] selectionArgs) {
+ long value = longForQuery(db, query, selectionArgs);
+ return sqlToBool((int) value);
+ }
+
+ /*
+ * wrapper for DatabaseUtils.stringForQuery(), returns "" if query returns no rows
+ */
+ public static String stringForQuery(SQLiteDatabase db, String query, String[] selectionArgs) {
+ try {
+ return DatabaseUtils.stringForQuery(db, query, selectionArgs);
+ } catch (SQLiteDoneException e) {
+ return "";
+ }
+ }
+
+ /*
+ * returns the number of rows in the passed table
+ */
+ public static long getRowCount(SQLiteDatabase db, String tableName) {
+ return DatabaseUtils.queryNumEntries(db, tableName);
+ }
+
+ /*
+ * removes all rows from the passed table
+ */
+ public static void deleteAllRowsInTable(SQLiteDatabase db, String tableName) {
+ db.delete(tableName, null, null);
+ }
+
+ /*
+ * drop all tables from the passed SQLiteDatabase - make sure to pass a
+ * writable database
+ */
+ public static boolean dropAllTables(SQLiteDatabase db) throws SQLiteException {
+ if (db == null) {
+ return false;
+ }
+
+ if (db.isReadOnly()) {
+ throw new SQLiteException("can't drop tables from a read-only database");
+ }
+
+ List<String> tableNames = new ArrayList<String>();
+ Cursor cursor = db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null);
+ if (cursor.moveToFirst()) {
+ do {
+ String tableName = cursor.getString(0);
+ if (!tableName.equals("android_metadata") && !tableName.equals("sqlite_sequence")) {
+ tableNames.add(tableName);
+ }
+ } while (cursor.moveToNext());
+ }
+
+ db.beginTransaction();
+ try {
+ for (String tableName: tableNames) {
+ db.execSQL("DROP TABLE IF EXISTS " + tableName);
+ }
+ db.setTransactionSuccessful();
+ return true;
+ } finally {
+ db.endTransaction();
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/StatUtils.java b/WordPress/src/main/java/org/wordpress/android/util/StatUtils.java
new file mode 100644
index 000000000..c52cd73e6
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/StatUtils.java
@@ -0,0 +1,222 @@
+package org.wordpress.android.util;
+
+import android.annotation.SuppressLint;
+import android.content.ContentProviderOperation;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.OperationApplicationException;
+import android.net.Uri;
+import android.os.RemoteException;
+
+import com.google.gson.Gson;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.BuildConfig;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.StatsBarChartDataTable;
+import org.wordpress.android.models.StatsBarChartData;
+import org.wordpress.android.models.StatsSummary;
+import org.wordpress.android.models.StatsVideoSummary;
+import org.wordpress.android.providers.StatsContentProvider;
+import org.wordpress.android.ui.stats.StatsBarChartUnit;
+import org.wordpress.android.util.AppLog.T;
+
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+
+/**
+ * A utility class to help with date parsing and saving summaries in stats
+ */
+public class StatUtils {
+ private static final String STAT_SUMMARY = "StatSummary_";
+ private static final String STAT_VIDEO_SUMMARY = "StatVideoSummary_";
+ private static final long ONE_DAY = 24 * 60 * 60 * 1000;
+
+ /** Converts date in the form of 2013-07-18 to ms **/
+ @SuppressLint("SimpleDateFormat")
+ public static long toMs(String date) {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+ try {
+ return sdf.parse(date).getTime();
+ } catch (ParseException e) {
+ AppLog.e(T.UTILS, e);
+ }
+ return -1;
+ }
+
+ public static String msToString(long ms, String format) {
+ SimpleDateFormat sdf = new SimpleDateFormat(format);
+ return sdf.format(new Date(ms));
+ }
+
+ public static String getCurrentDate() {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+ return sdf.format(new Date());
+ }
+
+ public static String getYesterdaysDate() {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+ return sdf.format(new Date(getCurrentDateMs() - ONE_DAY));
+ }
+
+ public static long getCurrentDateMs() {
+ return toMs(getCurrentDate());
+ }
+
+ public static String parseDate(String timestamp, String fromFormat, String toFormat) {
+ SimpleDateFormat from = new SimpleDateFormat(fromFormat);
+ SimpleDateFormat to = new SimpleDateFormat(toFormat);
+ try {
+ Date date = from.parse(timestamp);
+ return to.format(date);
+ } catch (ParseException e) {
+ AppLog.e(T.STATS, e);
+ }
+ return "";
+ }
+
+ public static void saveSummary(String blogId, JSONObject stat) {
+ try {
+ JSONObject statsObject = stat.getJSONObject("stats");
+ statsObject.put("date", getCurrentDate());
+ FileOutputStream fos = WordPress.getContext().openFileOutput(STAT_SUMMARY + blogId, Context.MODE_PRIVATE);
+ fos.write(statsObject.toString().getBytes());
+ fos.close();
+ } catch (FileNotFoundException e) {
+ AppLog.e(T.STATS, e);
+ } catch (IOException e) {
+ AppLog.e(T.STATS, e);
+ } catch (JSONException e) {
+ AppLog.e(T.STATS, e);
+ }
+
+ saveGraphData(blogId, stat);
+ }
+
+ private static void saveGraphData(String blogId, JSONObject stat) {
+ try {
+ JSONArray data = stat.getJSONObject("visits").getJSONArray("data");
+ Uri uri = StatsContentProvider.STATS_BAR_CHART_DATA_URI;
+ Context context = WordPress.getContext();
+
+ int length = data.length();
+
+ ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
+
+ if (length > 0) {
+ ContentProviderOperation op = ContentProviderOperation.newDelete(uri).withSelection("blogId=? AND unit=?", new String[] { blogId, StatsBarChartUnit.DAY.name() }).build();
+ operations.add(op);
+ }
+
+ for (int i = 0; i < length; i++) {
+ StatsBarChartData item = new StatsBarChartData(blogId, StatsBarChartUnit.DAY, data.getJSONArray(i));
+ ContentValues values = StatsBarChartDataTable.getContentValues(item);
+
+ if (values != null) {
+ ContentProviderOperation op = ContentProviderOperation.newInsert(uri).withValues(values).build();
+ operations.add(op);
+ }
+ }
+
+ ContentResolver resolver = context.getContentResolver();
+ resolver.applyBatch(BuildConfig.STATS_PROVIDER_AUTHORITY, operations);
+ resolver.notifyChange(uri, null);
+ } catch (JSONException e) {
+ AppLog.e(T.STATS, e);
+ } catch (RemoteException e) {
+ AppLog.e(T.STATS, e);
+ } catch (OperationApplicationException e) {
+ AppLog.e(T.STATS, e);
+ }
+ }
+
+ public static void deleteSummary(String blogId) {
+ WordPress.getContext().deleteFile(STAT_SUMMARY + blogId);
+ }
+
+ public static StatsSummary getSummary(String blogId) {
+ StatsSummary stat = null;
+ try {
+ FileInputStream fis = WordPress.getContext().openFileInput(STAT_SUMMARY + blogId);
+ StringBuilder fileContent = new StringBuilder();
+
+ byte[] buffer = new byte[1024];
+
+ int bytesRead = fis.read(buffer);
+ while (bytesRead != -1) {
+ fileContent.append(new String(buffer, 0, bytesRead, "ISO-8859-1"));
+ bytesRead = fis.read(buffer);
+ }
+
+ Gson gson = new Gson();
+ stat = gson.fromJson(fileContent.toString(), StatsSummary.class);
+
+ } catch (FileNotFoundException e) {
+ // stats haven't been downloaded yet
+ } catch (IOException e) {
+ AppLog.e(T.STATS, e);
+ }
+ return stat;
+ }
+
+ public static void saveVideoSummary(String blogId, JSONObject stat) {
+ try {
+ stat.put("date", getCurrentDate());
+ FileOutputStream fos = WordPress.getContext().openFileOutput(STAT_VIDEO_SUMMARY + blogId, Context.MODE_PRIVATE);
+ fos.write(stat.toString().getBytes());
+ fos.close();
+ } catch (FileNotFoundException e) {
+ AppLog.e(T.STATS, e);
+ } catch (IOException e) {
+ AppLog.e(T.STATS, e);
+ } catch (JSONException e) {
+ AppLog.e(T.STATS, e);
+ }
+ }
+
+ public static void deleteVideoSummary(String blogId) {
+ WordPress.getContext().deleteFile(STAT_VIDEO_SUMMARY + blogId);
+ }
+
+ public static StatsVideoSummary getVideoSummary(String blogId) {
+ StatsVideoSummary stat = null;
+ try {
+ FileInputStream fis = WordPress.getContext().openFileInput(STAT_VIDEO_SUMMARY + blogId);
+ StringBuilder fileContent = new StringBuilder();
+
+ byte[] buffer = new byte[1024];
+
+ while (fis.read(buffer) != -1) {
+ fileContent.append(new String(buffer));
+ }
+
+ JSONObject object = new JSONObject(fileContent.toString());
+
+ String timeframe = object.getString("timeframe");
+ int plays = object.getInt("plays");
+ int impressions = object.getInt("impressions");
+ int minutes = object.getInt("minutes");
+ String bandwidth = object.getString("bandwidth");
+ String date = object.getString("date");
+
+ stat = new StatsVideoSummary(timeframe, plays, impressions, minutes, bandwidth, date);
+
+ } catch (FileNotFoundException e) {
+ AppLog.e(T.STATS, e);
+ } catch (IOException e) {
+ AppLog.e(T.STATS, e);
+ } catch (JSONException e) {
+ AppLog.e(T.STATS, e);
+ }
+ return stat;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/StringUtils.java b/WordPress/src/main/java/org/wordpress/android/util/StringUtils.java
new file mode 100644
index 000000000..eca31ffd1
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/StringUtils.java
@@ -0,0 +1,278 @@
+package org.wordpress.android.util;
+
+import android.text.Html;
+import android.text.TextUtils;
+
+import org.wordpress.android.util.AppLog.T;
+
+import java.math.BigInteger;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public class StringUtils {
+ public static String[] mergeStringArrays(String array1[], String array2[]) {
+ if (array1 == null || array1.length == 0) {
+ return array2;
+ }
+ if (array2 == null || array2.length == 0) {
+ return array1;
+ }
+ List<String> array1List = Arrays.asList(array1);
+ List<String> array2List = Arrays.asList(array2);
+ List<String> result = new ArrayList<String>(array1List);
+ List<String> tmp = new ArrayList<String>(array1List);
+ tmp.retainAll(array2List);
+ result.addAll(array2List);
+ return ((String[]) result.toArray(new String[result.size()]));
+ }
+
+ public static String convertHTMLTagsForUpload(String source) {
+ // bold
+ source = source.replace("<b>", "<strong>");
+ source = source.replace("</b>", "</strong>");
+
+ // italics
+ source = source.replace("<i>", "<em>");
+ source = source.replace("</i>", "</em>");
+
+ return source;
+ }
+
+ public static String convertHTMLTagsForDisplay(String source) {
+ // bold
+ source = source.replace("<strong>", "<b>");
+ source = source.replace("</strong>", "</b>");
+
+ // italics
+ source = source.replace("<em>", "<i>");
+ source = source.replace("</em>", "</i>");
+
+ return source;
+ }
+
+ public static String addPTags(String source) {
+ String[] asploded = source.split("\n\n");
+
+ if (asploded.length > 0) {
+ StringBuilder wrappedHTML = new StringBuilder();
+ for (int i = 0; i < asploded.length; i++) {
+ String trimmed = asploded[i].trim();
+ if (trimmed.length() > 0) {
+ trimmed = trimmed.replace("<br />", "<br>").replace("<br/>", "<br>").replace("<br>\n", "<br>")
+ .replace("\n", "<br>");
+ wrappedHTML.append("<p>");
+ wrappedHTML.append(trimmed);
+ wrappedHTML.append("</p>");
+ }
+ }
+ return wrappedHTML.toString();
+ } else {
+ return source;
+ }
+ }
+
+ public static BigInteger getMd5IntHash(String input) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("MD5");
+ byte[] messageDigest = md.digest(input.getBytes());
+ BigInteger number = new BigInteger(1, messageDigest);
+ return number;
+ } catch (NoSuchAlgorithmException e) {
+ AppLog.e(T.UTILS, e);
+ return null;
+ }
+ }
+
+ public static String getMd5Hash(String input) {
+ BigInteger number = getMd5IntHash(input);
+ String md5 = number.toString(16);
+ while (md5.length() < 32) {
+ md5 = "0" + md5;
+ }
+ return md5;
+ }
+
+ public static String unescapeHTML(String html) {
+ if (html != null) {
+ return Html.fromHtml(html).toString();
+ } else {
+ return "";
+ }
+ }
+
+ /*
+ * nbradbury - adapted from Html.escapeHtml(), which was added in API Level 16
+ * TODO: not thoroughly tested yet, so marked as private - not sure I like the way
+ * this replaces two spaces with "&nbsp;"
+ */
+ private static String escapeHtml(final String text) {
+ if (text == null) {
+ return "";
+ }
+
+ StringBuilder out = new StringBuilder();
+ int length = text.length();
+
+ for (int i = 0; i < length; i++) {
+ char c = text.charAt(i);
+
+ if (c == '<') {
+ out.append("&lt;");
+ } else if (c == '>') {
+ out.append("&gt;");
+ } else if (c == '&') {
+ out.append("&amp;");
+ } else if (c > 0x7E || c < ' ') {
+ out.append("&#").append((int) c).append(";");
+ } else if (c == ' ') {
+ while (i + 1 < length && text.charAt(i + 1) == ' ') {
+ out.append("&nbsp;");
+ i++;
+ }
+
+ out.append(' ');
+ } else {
+ out.append(c);
+ }
+ }
+
+ return out.toString();
+ }
+
+ /*
+ * returns empty string if passed string is null, otherwise returns passed string
+ */
+ public static String notNullStr(String s) {
+ if (s == null) {
+ return "";
+ }
+ return s;
+ }
+
+ /**
+ * returns true if two strings are equal or two strings are null
+ */
+ public static boolean equals(String s1, String s2) {
+ if (s1 == null) {
+ return s2 == null;
+ }
+ return s1.equals(s2);
+ }
+
+ /*
+ * capitalizes the first letter in the passed string - based on Apache commons/lang3/StringUtils
+ * http://svn.apache.org/viewvc/commons/proper/lang/trunk/src/main/java/org/apache/commons/lang3/StringUtils.java?revision=1497829&view=markup
+ */
+ public static String capitalize(final String str) {
+ int strLen;
+ if (str == null || (strLen = str.length()) == 0) {
+ return str;
+ }
+
+ char firstChar = str.charAt(0);
+ if (Character.isTitleCase(firstChar)) {
+ return str;
+ }
+
+ return new StringBuilder(strLen).append(Character.toTitleCase(firstChar)).append(str.substring(1)).toString();
+ }
+
+ /*
+ * Wrap an image URL in a photon URL
+ * Check out http://developer.wordpress.com/docs/photon/
+ */
+ public static String getPhotonUrl(String imageUrl, int size) {
+ imageUrl = imageUrl.replace("http://", "").replace("https://", "");
+ return "http://i0.wp.com/" + imageUrl + "?w=" + size;
+ }
+
+ public static String getHost(String url) {
+ if (TextUtils.isEmpty(url)) {
+ return "";
+ }
+
+ int doubleslash = url.indexOf("//");
+ if (doubleslash == -1) {
+ doubleslash = 0;
+ } else {
+ doubleslash += 2;
+ }
+
+ int end = url.indexOf('/', doubleslash);
+ end = (end >= 0) ? end : url.length();
+
+ return url.substring(doubleslash, end);
+ }
+
+ public static String replaceUnicodeSurrogateBlocksWithHTMLEntities(final String inputString) {
+ final int length = inputString.length();
+ StringBuilder out = new StringBuilder(); // Used to hold the output.
+ for (int offset = 0; offset < length; ) {
+ final int codepoint = inputString.codePointAt(offset);
+ final char current = inputString.charAt(offset);
+ if (Character.isHighSurrogate(current) || Character.isLowSurrogate(current)) {
+ if (Emoticons.wpSmiliesCodePointToText.get(codepoint) != null) {
+ out.append(Emoticons.wpSmiliesCodePointToText.get(codepoint));
+ } else {
+ final String htmlEscapedChar = "&#x" + Integer.toHexString(codepoint) + ";";
+ out.append(htmlEscapedChar);
+ }
+ } else {
+ out.append(current);
+ }
+ offset += Character.charCount(codepoint);
+ }
+ return out.toString();
+ }
+
+ /**
+ * This method ensures that the output String has only
+ * valid XML unicode characters as specified by the
+ * XML 1.0 standard. For reference, please see
+ * <a href="http://www.w3.org/TR/2000/REC-xml-20001006#NT-Char">the
+ * standard</a>. This method will return an empty
+ * String if the input is null or empty.
+ *
+ * @param in The String whose non-valid characters we want to remove.
+ * @return The in String, stripped of non-valid characters.
+ */
+ public static final String stripNonValidXMLCharacters(String in) {
+ StringBuilder out = new StringBuilder(); // Used to hold the output.
+ char current; // Used to reference the current character.
+
+ if (in == null || ("".equals(in))) {
+ return ""; // vacancy test.
+ }
+ for (int i = 0; i < in.length(); i++) {
+ current = in.charAt(i); // NOTE: No IndexOutOfBoundsException caught here; it should not happen.
+ if ((current == 0x9) ||
+ (current == 0xA) ||
+ (current == 0xD) ||
+ ((current >= 0x20) && (current <= 0xD7FF)) ||
+ ((current >= 0xE000) && (current <= 0xFFFD)) ||
+ ((current >= 0x10000) && (current <= 0x10FFFF))) {
+ out.append(current);
+ }
+ }
+ return out.toString();
+ }
+
+ /*
+ * simple wrapper for Integer.valueOf(string) so caller doesn't need to catch NumberFormatException
+ */
+ public static int stringToInt(String s) {
+ return stringToInt(s, 0);
+ }
+ public static int stringToInt(String s, int defaultValue) {
+ if (s == null)
+ return defaultValue;
+ try {
+ return Integer.valueOf(s);
+ } catch (NumberFormatException e) {
+ return defaultValue;
+ }
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/util/SystemServiceFactory.java b/WordPress/src/main/java/org/wordpress/android/util/SystemServiceFactory.java
new file mode 100644
index 000000000..4ba0c96ed
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/SystemServiceFactory.java
@@ -0,0 +1,17 @@
+package org.wordpress.android.util;
+
+import android.content.Context;
+
+import org.wordpress.android.util.AppLog.T;
+
+public class SystemServiceFactory {
+ public static SystemServiceFactoryAbstract sFactory;
+
+ public static Object get(Context context, String name) {
+ if (sFactory == null) {
+ sFactory = new SystemServiceFactoryDefault();
+ }
+ AppLog.v(T.UTILS, "instantiate SystemService using sFactory: " + sFactory.getClass());
+ return sFactory.get(context, name);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java b/WordPress/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java
new file mode 100644
index 000000000..a9d522db4
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java
@@ -0,0 +1,7 @@
+package org.wordpress.android.util;
+
+import android.content.Context;
+
+public interface SystemServiceFactoryAbstract {
+ public Object get(Context context, String name);
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java b/WordPress/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java
new file mode 100644
index 000000000..eb488dde9
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java
@@ -0,0 +1,9 @@
+package org.wordpress.android.util;
+
+import android.content.Context;
+
+public class SystemServiceFactoryDefault implements SystemServiceFactoryAbstract {
+ public Object get(Context context, String name) {
+ return context.getSystemService(name);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/ThemeHelper.java b/WordPress/src/main/java/org/wordpress/android/util/ThemeHelper.java
new file mode 100644
index 000000000..68f8ea48a
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/ThemeHelper.java
@@ -0,0 +1,66 @@
+package org.wordpress.android.util;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.commons.lang.WordUtils;
+
+/**
+ * A helper class to retrieve the labels for a given feature based on the tag supplied by the api.
+ *
+ */
+public class ThemeHelper {
+ private static Map<String, String> mTagToLabelMap;
+
+ public static String getLabel(String feature_tag) {
+ if (mTagToLabelMap == null) {
+ initMap();
+ }
+ if (mTagToLabelMap.containsKey(feature_tag)) {
+ return mTagToLabelMap.get(feature_tag);
+ } else {
+ return WordUtils.capitalizeFully(feature_tag.replace("-", " "));
+ }
+ }
+
+ private static void initMap() {
+ mTagToLabelMap = new HashMap<String, String>();
+ //Columns
+ mTagToLabelMap.put("one-column", "One Column");
+ mTagToLabelMap.put("two-columns", "Two Columns");
+ mTagToLabelMap.put("three-columns", "Three Columns");
+ mTagToLabelMap.put("four-columns", "Four Columns");
+ mTagToLabelMap.put("left-sidebar", "Left Sidebar");
+ mTagToLabelMap.put("right-sidebar", "Right Sidebar");
+ //Layout
+ mTagToLabelMap.put("fixed-width", "Fixed Width");
+ mTagToLabelMap.put("flexible-width", "Flexible Width");
+ mTagToLabelMap.put("responsive-width", "Responsive Width");
+ mTagToLabelMap.put("fixed-layout", "Fixed Layout");
+ mTagToLabelMap.put("flexible-layout", "Flexible Layout");
+ mTagToLabelMap.put("responsive-layout", "Responsive Layout");
+ //Features
+ mTagToLabelMap.put("custom-background", "Custom Background");
+ mTagToLabelMap.put("custom-colors", "Custom Colors");
+ mTagToLabelMap.put("custom-header", "Custom Header");
+ mTagToLabelMap.put("custom-menu", "Custom Menu");
+ mTagToLabelMap.put("editor-style", "Editor Style");
+ mTagToLabelMap.put("featured-image-header", "Featured Header");
+ mTagToLabelMap.put("featured-images", "Featured Images");
+ mTagToLabelMap.put("flexible-header", "Flexible Header");
+ mTagToLabelMap.put("front-page-post-form", "Front Page Posting");
+ mTagToLabelMap.put("full-width-template", "Full Width Template");
+ mTagToLabelMap.put("infinite-scroll", "Infinite Scroll");
+ mTagToLabelMap.put("microformats", "Microformats");
+ mTagToLabelMap.put("post-formats", "Post Formats");
+ mTagToLabelMap.put("post-slider", "Post Slider");
+ mTagToLabelMap.put("rtl-language-support", "RTL Language Support");
+ mTagToLabelMap.put("sticky-post", "Sticky Post");
+ mTagToLabelMap.put("theme-options", "Theme Options");
+ mTagToLabelMap.put("translation-ready", "Translation Ready");
+ //Subject
+ mTagToLabelMap.put("holiday", "Holiday");
+ mTagToLabelMap.put("photoblogging", "Photoblogging");
+ mTagToLabelMap.put("seasonal", "Seasonal");
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/ToastUtils.java b/WordPress/src/main/java/org/wordpress/android/util/ToastUtils.java
new file mode 100644
index 000000000..3a7204d14
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/ToastUtils.java
@@ -0,0 +1,119 @@
+package org.wordpress.android.util;
+
+import android.app.Activity;
+import android.app.FragmentTransaction;
+import android.content.Context;
+import android.text.TextUtils;
+import android.view.Gravity;
+import android.widget.Toast;
+
+import com.android.volley.VolleyError;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.AppLog.T;
+
+/**
+ * Provides a simplified way to show toast messages without having to create the toast, set the
+ * desired gravity, etc.
+ */
+public class ToastUtils {
+ public enum Duration {SHORT, LONG}
+
+ private ToastUtils() {
+ throw new AssertionError();
+ }
+
+ public static void showToast(Context context, int stringResId) {
+ showToast(context, stringResId, Duration.SHORT);
+ }
+
+ public static void showToast(Context context, int stringResId, Duration duration) {
+ showToast(context, context.getString(stringResId), duration);
+ }
+
+ public static void showToast(Context context, String text) {
+ showToast(context, text, Duration.SHORT);
+ }
+
+ public static void showToast(Context context, String text, Duration duration) {
+ Toast toast = Toast.makeText(context, text,
+ (duration == Duration.SHORT ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG));
+ toast.setGravity(Gravity.CENTER, 0, 0);
+ toast.show();
+ }
+
+ /*
+ * Shows a toast message, unless there is an authentication issue which will show an alert dialog.
+ */
+ public static void showToastOrAuthAlert(Context context, VolleyError error, String friendlyMessage) {
+ if (context == null)
+ return;
+
+ String message = null;
+ boolean isInvalidTokenError = false;
+ JSONObject errorObj = VolleyUtils.volleyErrorToJSON(error);
+ if (errorObj != null) {
+ try {
+ if (errorObj.has("error_description")) { // OAuth token request error
+ message = (String) errorObj.get("error_description");
+ String error_code = (String) errorObj.get("error");
+ if (error_code != null && error_code.equals("invalid_request") && message.toLowerCase().contains(
+ "incorrect username or password")) {
+ isInvalidTokenError = true;
+ }
+ } else {
+ message = (String) errorObj.get("message");
+ String error_code = (String) errorObj.get("error");
+ if (error_code != null && error_code.equals("invalid_token")) {
+ isInvalidTokenError = true;
+ }
+ }
+ } catch (JSONException e) {
+ AppLog.e(T.API, e);
+ }
+ } else {
+ message = error.getMessage();
+ }
+
+ if (isInvalidTokenError && (context instanceof Activity)) {
+ showAuthErrorDialog((Activity) context);
+ } else {
+ String fallbackErrorMessage = TextUtils.isEmpty(friendlyMessage) ? context.getString(
+ R.string.error_generic) : friendlyMessage;
+ if (message != null && message.contains("Limit reached") ) {
+ message = context.getString(R.string.limit_reached);
+ }
+ String errorMessage = TextUtils.isEmpty(message) ? fallbackErrorMessage : message;
+ showToast(context, errorMessage, Duration.LONG);
+ }
+ }
+
+
+ public static void showAuthErrorDialog(Activity activity) {
+ showAuthErrorDialog(activity, AuthErrorDialogFragment.DEFAULT_RESOURCE_ID, AuthErrorDialogFragment.DEFAULT_RESOURCE_ID);
+ }
+
+ public static void showAuthErrorDialog(Activity activity, int titleResId, int messageResId) {
+ final String ALERT_TAG = "alert_ask_credentials";
+ if (activity.isFinishing()) {
+ return;
+ }
+ // abort if the dialog is already visible
+ if (activity.getFragmentManager().findFragmentByTag(ALERT_TAG) != null) {
+ return;
+ }
+ FragmentTransaction ft = activity.getFragmentManager().beginTransaction();
+ AuthErrorDialogFragment authAlert;
+ if (WordPress.getCurrentBlog() == null) {
+ // No blogs found, so the user is logged in wpcom and doesn't own any blog
+ authAlert = AuthErrorDialogFragment.newInstance(true, titleResId, messageResId);
+ } else {
+ authAlert = AuthErrorDialogFragment.newInstance(WordPress.getCurrentBlog().isDotcomFlag(), titleResId, messageResId);
+ }
+ ft.add(authAlert, ALERT_TAG);
+ ft.commitAllowingStateLoss();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/UrlUtils.java b/WordPress/src/main/java/org/wordpress/android/util/UrlUtils.java
new file mode 100644
index 000000000..ed8d3f4fc
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/UrlUtils.java
@@ -0,0 +1,165 @@
+package org.wordpress.android.util;
+
+import android.net.Uri;
+import android.webkit.MimeTypeMap;
+import android.webkit.URLUtil;
+
+import java.io.UnsupportedEncodingException;
+import java.net.IDN;
+import java.net.URI;
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.nio.charset.Charset;
+
+public class UrlUtils {
+ public static String urlEncode(final String text) {
+ try {
+ return URLEncoder.encode(text, "UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ return text;
+ }
+ }
+
+ public static String urlDecode(final String text) {
+ try {
+ return URLDecoder.decode(text, "UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ return text;
+ }
+ }
+
+ public static String getDomainFromUrl(final String urlString) {
+ if (urlString == null) {
+ return "";
+ }
+ Uri uri = Uri.parse(urlString);
+ return uri.getHost();
+ }
+
+ /**
+ * Convert IDN names to punycode if necessary
+ */
+ public static String convertUrlToPunycodeIfNeeded(String url) {
+ if (!Charset.forName("US-ASCII").newEncoder().canEncode(url)) {
+ if (url.toLowerCase().startsWith("http://")) {
+ url = "http://" + IDN.toASCII(url.substring(7));
+ } else if (url.toLowerCase().startsWith("https://")) {
+ url = "https://" + IDN.toASCII(url.substring(8));
+ } else {
+ url = IDN.toASCII(url);
+ }
+ }
+ return url;
+ }
+
+ public static String addUrlSchemeIfNeeded(String url, boolean isHTTPS) {
+ if (url == null) {
+ return null;
+ }
+
+ if (!URLUtil.isValidUrl(url)) {
+ if (!(url.toLowerCase().startsWith("http://")) && !(url.toLowerCase().startsWith("https://"))) {
+ url = (isHTTPS ? "https" : "http") + "://" + url;
+ }
+ }
+
+ return url;
+ }
+
+ /**
+ * normalizes a URL, primarily for comparison purposes, for example so that
+ * normalizeUrl("http://google.com/") = normalizeUrl("http://google.com")
+ */
+ public static String normalizeUrl(final String urlString) {
+ if (urlString == null) {
+ return null;
+ }
+
+ // this routine is called from some performance-critical code and creating a URI from a string
+ // is slow, so skip it when possible - if we know it's not a relative path (and 99.9% of the
+ // time it won't be for our purposes) then we can normalize it without java.net.URI.normalize()
+ if (urlString.startsWith("http") && !urlString.contains("..")) {
+ // return without a trailing slash
+ if (urlString.endsWith("/")) {
+ return urlString.substring(0, urlString.length() - 1);
+ }
+ return urlString;
+ }
+
+ // url is relative, so fall back to using slower java.net.URI normalization
+ try {
+ URI uri = URI.create(urlString);
+ return uri.normalize().toString();
+ } catch (IllegalArgumentException e) {
+ return urlString;
+ }
+ }
+
+ /**
+ * returns the passed url without the query parameters
+ */
+ public static String removeQuery(final String urlString) {
+ if (urlString == null) {
+ return null;
+ }
+ int pos = urlString.indexOf("?");
+ if (pos == -1) {
+ return urlString;
+ }
+ return urlString.substring(0, pos);
+ }
+
+ /**
+ * returns true if passed url is https:
+ */
+ public static boolean isHttps(final String urlString) {
+ return (urlString != null && urlString.startsWith("https:"));
+ }
+
+ /**
+ * returns https: version of passed http: url
+ */
+ public static String makeHttps(final String urlString) {
+ if (urlString == null || !urlString.startsWith("http:")) {
+ return urlString;
+ }
+ return "https:" + urlString.substring(5, urlString.length());
+ }
+
+ /**
+ * see http://stackoverflow.com/a/8591230/1673548
+ */
+ public static String getUrlMimeType(final String urlString) {
+ if (urlString == null) {
+ return null;
+ }
+
+ String extension = MimeTypeMap.getFileExtensionFromUrl(urlString);
+ if (extension == null) {
+ return null;
+ }
+
+ MimeTypeMap mime = MimeTypeMap.getSingleton();
+ String mimeType = mime.getMimeTypeFromExtension(extension);
+ if (mimeType == null) {
+ return null;
+ }
+
+ return mimeType;
+ }
+
+ /**
+ * returns false if the url is not valid or if the url host is null, else true
+ */
+ public static boolean isValidUrlAndHostNotNull(String url) {
+ try {
+ URI uri = URI.create(url);
+ if (uri.getHost() == null) {
+ return false;
+ }
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/UserEmail.java b/WordPress/src/main/java/org/wordpress/android/util/UserEmail.java
new file mode 100644
index 000000000..f229d4ae1
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/UserEmail.java
@@ -0,0 +1,30 @@
+package org.wordpress.android.util;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.content.Context;
+import android.util.Patterns;
+
+import java.util.regex.Pattern;
+
+public class UserEmail {
+ public static String getPrimaryEmail(Context context) {
+ try {
+ AccountManager accountManager = AccountManager.get(context);
+ if (accountManager == null)
+ return "";
+ Account[] accounts = accountManager.getAccounts();
+ Pattern emailPattern = Patterns.EMAIL_ADDRESS;
+ for (Account account : accounts) {
+ // make sure account.name is an email address before adding to the list
+ if (emailPattern.matcher(account.name).matches()) {
+ return account.name;
+ }
+ }
+ return "";
+ } catch (SecurityException e) {
+ // exception will occur if app doesn't have GET_ACCOUNTS permission
+ return "";
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/Utils.java b/WordPress/src/main/java/org/wordpress/android/util/Utils.java
new file mode 100644
index 000000000..a21e2ea21
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/Utils.java
@@ -0,0 +1,78 @@
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.util.TypedValue;
+
+import org.wordpress.android.BuildConfig;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Comparator;
+import java.util.Map;
+
+public class Utils {
+ public static Comparator<Object> BlogNameComparator = new Comparator<Object>() {
+ public int compare(Object blog1, Object blog2) {
+ Map<Object, Object> blogMap1 = (Map<Object, Object>) blog1;
+ Map<Object, Object> blogMap2 = (Map<Object, Object>) blog2;
+
+ String blogName1 = MapUtils.getMapStr(blogMap1, "blogName");
+ if (blogName1.length() == 0) {
+ blogName1 = MapUtils.getMapStr(blogMap1, "url");
+ }
+
+ String blogName2 = MapUtils.getMapStr(blogMap2, "blogName");
+ if (blogName2.length() == 0) {
+ blogName2 = MapUtils.getMapStr(blogMap2, "url");
+ }
+
+ return blogName1.compareToIgnoreCase(blogName2);
+ }
+ };
+
+ // logic below based on login in WPActionBarActivity.java
+ public static boolean isXLarge(Context context) {
+ if ((context.getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) == Configuration.SCREENLAYOUT_SIZE_XLARGE)
+ return true;
+ return false;
+ }
+
+ public static boolean isLandscape(Context context) {
+ return (context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE);
+ }
+
+ public static boolean isTablet() {
+ return WordPress.getContext().getResources().getInteger(R.integer.isTablet) == 1;
+ }
+
+ public static float dpToPx(float dp) {
+ return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, WordPress.getContext().getResources().getDisplayMetrics());
+ }
+
+ public static float spToPx(float sp) {
+ float scaledDensity = WordPress.getContext().getResources().getDisplayMetrics().scaledDensity;
+ return sp * scaledDensity;
+ }
+
+ public static int getSmallestWidthDP() {
+ return WordPress.getContext().getResources().getInteger(R.integer.smallest_width_dp);
+ }
+
+ /*
+ * Return true if Debug build. false otherwise.
+ *
+ * ADT (r17) or Higher => BuildConfig.java is generated automatically by Android build tools, and is placed into the gen folder.
+ *
+ * BuildConfig containing a DEBUG constant that is automatically set according to your build type.
+ * You can check the (BuildConfig.DEBUG) constant in your code to run debug-only functions.
+ */
+ public static boolean isDebugBuild() {
+ if (BuildConfig.DEBUG) {
+ return true;
+ }
+ return false;
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/util/Version.java b/WordPress/src/main/java/org/wordpress/android/util/Version.java
new file mode 100644
index 000000000..6e695db45
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/Version.java
@@ -0,0 +1,47 @@
+package org.wordpress.android.util;
+
+//See: http://stackoverflow.com/a/11024200
+public class Version implements Comparable<Version> {
+ private String version;
+
+ public final String get() {
+ return this.version;
+ }
+
+ public Version(String version) {
+ if(version == null)
+ throw new IllegalArgumentException("Version can not be null");
+ if(!version.matches("[0-9]+(\\.[0-9]+)*"))
+ throw new IllegalArgumentException("Invalid version format");
+ this.version = version;
+ }
+
+ @Override public int compareTo(Version that) {
+ if(that == null)
+ return 1;
+ String[] thisParts = this.get().split("\\.");
+ String[] thatParts = that.get().split("\\.");
+ int length = Math.max(thisParts.length, thatParts.length);
+ for(int i = 0; i < length; i++) {
+ int thisPart = i < thisParts.length ?
+ Integer.parseInt(thisParts[i]) : 0;
+ int thatPart = i < thatParts.length ?
+ Integer.parseInt(thatParts[i]) : 0;
+ if(thisPart < thatPart)
+ return -1;
+ if(thisPart > thatPart)
+ return 1;
+ }
+ return 0;
+ }
+
+ @Override public boolean equals(Object that) {
+ if(this == that)
+ return true;
+ if(that == null)
+ return false;
+ if(this.getClass() != that.getClass())
+ return false;
+ return this.compareTo((Version) that) == 0;
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/util/VolleyUtils.java b/WordPress/src/main/java/org/wordpress/android/util/VolleyUtils.java
new file mode 100644
index 000000000..80a5858e3
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/VolleyUtils.java
@@ -0,0 +1,117 @@
+package org.wordpress.android.util;
+
+import java.io.UnsupportedEncodingException;
+
+import android.content.Context;
+
+import com.android.volley.Request;
+import com.android.volley.RequestQueue;
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.HttpStack;
+import com.android.volley.toolbox.ImageRequest;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.networking.WPDelayedHurlStack;
+
+public class VolleyUtils {
+ /*
+ * returns REST API error string from the response in the passed VolleyError
+ * for example, returns "already_subscribed" from this response:
+ * {
+ * "error": "already_subscribed",
+ * "message": "You are already subscribed to the specified topic."
+ * }
+ */
+ public static String errStringFromVolleyError(VolleyError volleyError) {
+ JSONObject json = volleyErrorToJSON(volleyError);
+ if (json==null)
+ return "";
+ return JSONUtil.getString(json, "error");
+ }
+
+ public static int statusCodeFromVolleyError(VolleyError volleyError) {
+ if (volleyError == null || volleyError.networkResponse == null) {
+ return 0;
+ }
+ return volleyError.networkResponse.statusCode;
+ }
+
+ /*
+ * attempts to return JSON from a volleyError - useful for WP REST API failures, which often
+ * contain JSON in the response
+ */
+ public static JSONObject volleyErrorToJSON(VolleyError volleyError) {
+ if (volleyError==null
+ || volleyError.networkResponse==null
+ || volleyError.networkResponse.data==null
+ || volleyError.networkResponse.headers==null)
+ return null;
+
+ String contentType = volleyError.networkResponse.headers.get("Content-Type");
+ if (contentType==null || !contentType.equals("application/json"))
+ return null;
+
+ try {
+ String response = new String(volleyError.networkResponse.data, "UTF-8");
+ JSONObject json = new JSONObject(response);
+ return json;
+ } catch (UnsupportedEncodingException e) {
+ return null;
+ } catch (JSONException e) {
+ return null;
+ }
+ }
+
+ /*
+ * cancel all Volley requests that aren't for images
+ */
+ public static void cancelAllNonImageRequests(RequestQueue requestQueue) {
+ if (requestQueue==null)
+ return;
+ RequestQueue.RequestFilter filter = new RequestQueue.RequestFilter() {
+ @Override
+ public boolean apply(Request<?> request) {
+ if (request instanceof ImageRequest)
+ return false;
+ return true;
+ }
+ };
+ requestQueue.cancelAll(filter);
+ }
+
+ /*
+ * cancel all Volley requests
+ */
+ public static void cancelAllRequests(RequestQueue requestQueue) {
+ if (requestQueue==null)
+ return;
+ RequestQueue.RequestFilter filter = new RequestQueue.RequestFilter() {
+ @Override
+ public boolean apply(Request<?> request) {
+ return true;
+ }
+ };
+ requestQueue.cancelAll(filter);
+ }
+
+ /*
+ * Return true if the blog is protected with HTTP Basic Auth
+ */
+ public static boolean isCustomHTTPClientStackNeeded(Blog currentBlog) {
+ if (currentBlog.hasValidHTTPAuthCredentials())
+ return true;
+
+ return false;
+ }
+
+ public static HttpStack getHTTPClientStack(final Context ctx) {
+ return getHTTPClientStack(ctx, null);
+ }
+
+ public static HttpStack getHTTPClientStack(final Context ctx, final Blog currentBlog) {
+ return new WPDelayedHurlStack(ctx, currentBlog);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/WPAlertDialogFragment.java b/WordPress/src/main/java/org/wordpress/android/util/WPAlertDialogFragment.java
new file mode 100644
index 000000000..eb058d402
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/WPAlertDialogFragment.java
@@ -0,0 +1,139 @@
+package org.wordpress.android.util;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.TextUtils;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+
+public class WPAlertDialogFragment extends DialogFragment implements DialogInterface.OnClickListener {
+ private static enum WPAlertDialogType {ALERT, // simple ok dialog with error message
+ CONFIRM, // dialog with yes/no and callback when positive button clicked
+ URL_INFO} // info dialog that shows url when positive button clicked
+
+ private static final String ARG_TITLE = "title";
+ private static final String ARG_MESSAGE = "message";
+ private static final String ARG_TYPE = "type";
+ private static final String ARG_INFO_TITLE = "info-title";
+ private static final String ARG_INFO_URL = "info-url";
+
+ public interface OnDialogConfirmListener {
+ public void onDialogConfirm();
+ }
+
+ public static WPAlertDialogFragment newAlertDialog(String message) {
+ String title = WordPress.getContext().getString(R.string.error_generic);
+ return newAlertDialog(title, message);
+ }
+ public static WPAlertDialogFragment newAlertDialog(String title, String message) {
+ return newInstance(title, message, WPAlertDialogType.ALERT, null, null);
+ }
+
+ public static WPAlertDialogFragment newConfirmDialog(String title,
+ String message) {
+ return newInstance(title, message, WPAlertDialogType.CONFIRM, null, null);
+ }
+
+ public static WPAlertDialogFragment newUrlInfoDialog(String title,
+ String message,
+ String infoTitle,
+ String infoUrl) {
+ return newInstance(title, message, WPAlertDialogType.URL_INFO, infoTitle, infoUrl);
+ }
+
+ private static WPAlertDialogFragment newInstance(String title,
+ String message,
+ WPAlertDialogType alertType,
+ String infoTitle,
+ String infoUrl) {
+ WPAlertDialogFragment dialog = new WPAlertDialogFragment();
+
+ Bundle bundle = new Bundle();
+
+ bundle.putString(ARG_TITLE, StringUtils.notNullStr(title));
+ bundle.putString(ARG_MESSAGE, StringUtils.notNullStr(message));
+ bundle.putSerializable(ARG_TYPE, (alertType != null ? alertType : WPAlertDialogType.ALERT));
+
+ if (alertType == WPAlertDialogType.URL_INFO) {
+ bundle.putString(ARG_INFO_TITLE, StringUtils.notNullStr(infoTitle));
+ bundle.putString(ARG_INFO_URL, StringUtils.notNullStr(infoUrl));
+ }
+
+ dialog.setArguments(bundle);
+
+ return dialog;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setCancelable(true);
+ int style = DialogFragment.STYLE_NORMAL, theme = 0;
+ setStyle(style, theme);
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ Bundle bundle = getArguments();
+
+ final String title = StringUtils.notNullStr(bundle.getString(ARG_TITLE));
+ final String message = StringUtils.notNullStr(bundle.getString(ARG_MESSAGE));
+
+ final WPAlertDialogType dialogType;
+ if (bundle.containsKey(ARG_TYPE) && bundle.getSerializable(ARG_TYPE) instanceof WPAlertDialogType) {
+ dialogType = (WPAlertDialogType) bundle.getSerializable(ARG_TYPE);
+ } else {
+ dialogType = WPAlertDialogType.ALERT;
+ }
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+
+ builder.setTitle(title);
+ builder.setMessage(message);
+
+ switch (dialogType) {
+ case ALERT:
+ builder.setIcon(android.R.drawable.ic_dialog_alert);
+ builder.setNeutralButton(R.string.ok, this);
+ break;
+
+ case CONFIRM:
+ builder.setPositiveButton(R.string.yes, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (getActivity() instanceof OnDialogConfirmListener) {
+ OnDialogConfirmListener act = (OnDialogConfirmListener) getActivity();
+ act.onDialogConfirm();
+ }
+ }
+ });
+ builder.setNegativeButton(R.string.no, this);
+ break;
+
+ case URL_INFO:
+ final String infoTitle = StringUtils.notNullStr(bundle.getString(ARG_INFO_TITLE));
+ final String infoURL = StringUtils.notNullStr(bundle.getString(ARG_INFO_URL));
+ builder.setPositiveButton(infoTitle, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (!TextUtils.isEmpty(infoURL))
+ startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(infoURL)));
+ }
+ });
+ break;
+ }
+
+ return builder.create();
+ }
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ }
+}
+
diff --git a/WordPress/src/main/java/org/wordpress/android/util/WPEditText.java b/WordPress/src/main/java/org/wordpress/android/util/WPEditText.java
new file mode 100644
index 000000000..b0572a704
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/WPEditText.java
@@ -0,0 +1,56 @@
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.widget.EditText;
+
+public class WPEditText extends EditText {
+ private EditTextImeBackListener mOnImeBack;
+ private OnSelectionChangedListener onSelectionChangedListener;
+
+ public WPEditText(Context context) {
+ super(context);
+ }
+
+ public WPEditText(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public WPEditText(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void onSelectionChanged(int selStart, int selEnd) {
+ if (onSelectionChangedListener != null)
+ onSelectionChangedListener.onSelectionChanged();
+ }
+
+ @Override
+ public boolean onKeyPreIme(int keyCode, KeyEvent event) {
+ if (event.getKeyCode() == KeyEvent.KEYCODE_BACK
+ && event.getAction() == KeyEvent.ACTION_UP) {
+ if (mOnImeBack != null)
+ mOnImeBack.onImeBack(this, this.getText().toString());
+ }
+
+ return super.onKeyPreIme(keyCode, event);
+ }
+
+ public void setOnEditTextImeBackListener(EditTextImeBackListener listener) {
+ mOnImeBack = listener;
+ }
+
+ public interface EditTextImeBackListener {
+ public abstract void onImeBack(WPEditText ctrl, String text);
+ }
+
+ public void setOnSelectionChangedListener(OnSelectionChangedListener listener) {
+ onSelectionChangedListener = listener;
+ }
+
+ public interface OnSelectionChangedListener {
+ public abstract void onSelectionChanged();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/WPEditTextPreference.java b/WordPress/src/main/java/org/wordpress/android/util/WPEditTextPreference.java
new file mode 100644
index 000000000..4f20501df
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/WPEditTextPreference.java
@@ -0,0 +1,28 @@
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.preference.EditTextPreference;
+import android.util.AttributeSet;
+
+public class WPEditTextPreference extends EditTextPreference {
+ public WPEditTextPreference(Context context) {
+ super(context);
+ }
+
+ public WPEditTextPreference(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ public WPEditTextPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onDialogClosed(boolean positiveResult) {
+ super.onDialogClosed(positiveResult);
+ if (!positiveResult) {
+ callChangeListener(null);
+ }
+ }
+}
+
diff --git a/WordPress/src/main/java/org/wordpress/android/util/WPHtml.java b/WordPress/src/main/java/org/wordpress/android/util/WPHtml.java
new file mode 100644
index 000000000..4aa27a85b
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/WPHtml.java
@@ -0,0 +1,1242 @@
+package org.wordpress.android.util;
+
+/*
+ * 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.
+ */
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Typeface;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.text.Editable;
+import android.text.Layout;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.style.AbsoluteSizeSpan;
+import android.text.style.AlignmentSpan;
+import android.text.style.CharacterStyle;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.ParagraphStyle;
+import android.text.style.QuoteSpan;
+import android.text.style.RelativeSizeSpan;
+import android.text.style.StrikethroughSpan;
+import android.text.style.StyleSpan;
+import android.text.style.SubscriptSpan;
+import android.text.style.SuperscriptSpan;
+import android.text.style.TextAppearanceSpan;
+import android.text.style.TypefaceSpan;
+import android.text.style.URLSpan;
+
+import org.ccil.cowan.tagsoup.HTMLSchema;
+import org.ccil.cowan.tagsoup.Parser;
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.models.MediaFile;
+import org.wordpress.android.models.MediaGallery;
+import org.wordpress.android.models.Post;
+import org.wordpress.android.util.AppLog.T;
+import org.xml.sax.Attributes;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.InputSource;
+import org.xml.sax.Locator;
+import org.xml.sax.SAXException;
+import org.xml.sax.XMLReader;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.util.HashMap;
+
+/**
+ * This class processes HTML strings into displayable styled text. Not all HTML
+ * tags are supported.
+ */
+public class WPHtml {
+ /**
+ * Customzed QuoteSpan for use in SpannableString's
+ */
+ public static class WPQuoteSpan extends QuoteSpan {
+ public static final int STRIPE_COLOR = 0xFF21759B;
+ private static final int STRIPE_WIDTH = 5;
+ private static final int GAP_WIDTH = 20;
+
+ public WPQuoteSpan(){
+ super(STRIPE_COLOR);
+ }
+
+ @Override
+ public int getLeadingMargin(boolean first) {
+ int margin = GAP_WIDTH * 2 + STRIPE_WIDTH;
+ return margin;
+ }
+
+ /**
+ * Draw a nice thick gray bar if Ice Cream Sandwhich or newer. There's a
+ * bug on older devices that does not respect the increased margin.
+ */
+ @Override
+ public void drawLeadingMargin(Canvas c, Paint p, int x, int dir, int top, int baseline, int bottom,
+ CharSequence text, int start, int end, boolean first, Layout layout) {
+ Paint.Style style = p.getStyle();
+ int color = p.getColor();
+
+ p.setStyle(Paint.Style.FILL);
+ p.setColor(STRIPE_COLOR);
+
+ c.drawRect(GAP_WIDTH + x, top, x + dir * STRIPE_WIDTH, bottom, p);
+
+ p.setStyle(style);
+ p.setColor(color);
+ }
+ }
+
+ /**
+ * Retrieves images for HTML &lt;img&gt; tags.
+ */
+ public static interface ImageGetter {
+ /**
+ * This method is called when the HTML parser encounters an &lt;img&gt;
+ * tag. The <code>source</code> argument is the string from the "src"
+ * attribute; the return value should be a Drawable representation of
+ * the image or <code>null</code> for a generic replacement image. Make
+ * sure you call setBounds() on your Drawable if it doesn't already have
+ * its bounds set.
+ */
+ public Drawable getDrawable(String source);
+ }
+
+ /**
+ * Is notified when HTML tags are encountered that the parser does not know
+ * how to interpret.
+ */
+ public static interface TagHandler {
+ /**
+ * This method will be called whenn the HTML parser encounters a tag
+ * that it does not know how to interpret.
+ *
+ * @param mysteryTagContent
+ */
+ public void handleTag(boolean opening, String tag, Editable output,
+ XMLReader xmlReader, String mysteryTagContent);
+ }
+
+ private WPHtml() {
+ }
+
+ /**
+ * Returns displayable styled text from the provided HTML string. Any
+ * &lt;img&gt; tags in the HTML will display as a generic replacement image
+ * which your program can then go through and replace with real images.
+ *
+ * <p>
+ * This uses TagSoup to handle real HTML, including all of the brokenness
+ * found in the wild.
+ */
+ public static Spanned fromHtml(String source, Context ctx, Post post, int maxImageWidth) {
+ return fromHtml(source, null, null, ctx, post, maxImageWidth);
+ }
+
+ /**
+ * Lazy initialization holder for HTML parser. This class will a) be
+ * preloaded by the zygote, or b) not loaded until absolutely necessary.
+ */
+ private static class HtmlParser {
+ private static final HTMLSchema schema = new HTMLSchema();
+ }
+
+ /**
+ * Returns displayable styled text from the provided HTML string. Any
+ * &lt;img&gt; tags in the HTML will use the specified ImageGetter to
+ * request a representation of the image (use null if you don't want this)
+ * and the specified TagHandler to handle unknown tags (specify null if you
+ * don't want this).
+ *
+ * <p>
+ * This uses TagSoup to handle real HTML, including all of the brokenness
+ * found in the wild.
+ */
+ public static Spanned fromHtml(String source, ImageGetter imageGetter,
+ TagHandler tagHandler, Context ctx, Post post, int maxImageWidth) {
+ Parser parser = new Parser();
+ try {
+ parser.setProperty(Parser.schemaProperty, HtmlParser.schema);
+ } catch (org.xml.sax.SAXNotRecognizedException e) {
+ // Should not happen.
+ throw new RuntimeException(e);
+ } catch (org.xml.sax.SAXNotSupportedException e) {
+ // Should not happen.
+ throw new RuntimeException(e);
+ }
+
+ HtmlToSpannedConverter converter = new HtmlToSpannedConverter(source,
+ imageGetter, tagHandler, parser, ctx, post, maxImageWidth);
+ return converter.convert();
+ }
+
+ /**
+ * Returns an HTML representation of the provided Spanned text.
+ */
+ public static String toHtml(Spanned text) {
+ StringBuilder out = new StringBuilder();
+ withinHtml(out, text);
+ return out.toString();
+ }
+
+ private static void withinHtml(StringBuilder out, Spanned text) {
+ int len = text.length();
+
+ int next;
+ for (int i = 0; i < text.length(); i = next) {
+ next = text.nextSpanTransition(i, len, ParagraphStyle.class);
+ /*ParagraphStyle[] style = text.getSpans(i, next,
+ ParagraphStyle.class);
+ String elements = " ";
+ boolean needDiv = false;
+
+ for (int j = 0; j < style.length; j++) {
+ if (style[j] instanceof AlignmentSpan) {
+ Layout.Alignment align = ((AlignmentSpan) style[j])
+ .getAlignment();
+ needDiv = true;
+ if (align == Layout.Alignment.ALIGN_CENTER) {
+ elements = "align=\"center\" " + elements;
+ } else if (align == Layout.Alignment.ALIGN_OPPOSITE) {
+ elements = "align=\"right\" " + elements;
+ } else {
+ elements = "align=\"left\" " + elements;
+ }
+ }
+ }
+ if (needDiv) {
+ out.append("<div " + elements + ">");
+ }*/
+
+ withinDiv(out, text, i, next);
+
+ /*if (needDiv) {
+ out.append("</div>");
+ }*/
+ }
+ }
+
+ @SuppressWarnings("unused")
+ private static void withinDiv(StringBuilder out, Spanned text, int start,
+ int end) {
+ int next;
+ for (int i = start; i < end; i = next) {
+ next = text.nextSpanTransition(i, end, QuoteSpan.class);
+ QuoteSpan[] quotes = text.getSpans(i, next, QuoteSpan.class);
+
+ for (QuoteSpan quote : quotes) {
+ out.append("<blockquote>");
+ }
+
+ withinBlockquote(out, text, i, next);
+
+ for (QuoteSpan quote : quotes) {
+ out.append("</blockquote>\n");
+ }
+ }
+ }
+
+ private static void withinBlockquote(StringBuilder out, Spanned text,
+ int start, int end) {
+ out.append("<p>");
+
+ int next;
+ for (int i = start; i < end; i = next) {
+ next = TextUtils.indexOf(text, '\n', i, end);
+ if (next < 0) {
+ next = end;
+ }
+
+ int nl = 0;
+
+ while (next < end && text.charAt(next) == '\n') {
+ nl++;
+ next++;
+ }
+
+ withinParagraph(out, text, i, next - nl, nl, next == end);
+ }
+
+ out.append("</p>\n");
+ }
+
+ private static void withinParagraph(StringBuilder out, Spanned text,
+ int start, int end, int nl, boolean last) {
+ int next;
+ for (int i = start; i < end; i = next) {
+ next = text.nextSpanTransition(i, end, CharacterStyle.class);
+ CharacterStyle[] style = text.getSpans(i, next,
+ CharacterStyle.class);
+
+ for (int j = 0; j < style.length; j++) {
+ if (style[j] instanceof StyleSpan) {
+ int s = ((StyleSpan) style[j]).getStyle();
+
+ if ((s & Typeface.BOLD) != 0) {
+ out.append("<strong>");
+ }
+ if ((s & Typeface.ITALIC) != 0) {
+ out.append("<em>");
+ }
+ }
+ if (style[j] instanceof TypefaceSpan) {
+ String s = ((TypefaceSpan) style[j]).getFamily();
+
+ if (s.equals("monospace")) {
+ out.append("<tt>");
+ }
+ }
+ if (style[j] instanceof SuperscriptSpan) {
+ out.append("<sup>");
+ }
+ if (style[j] instanceof SubscriptSpan) {
+ out.append("<sub>");
+ }
+ if (style[j] instanceof WPUnderlineSpan) {
+ out.append("<u>");
+ }
+ if (style[j] instanceof StrikethroughSpan) {
+ out.append("<strike>");
+ }
+ if (style[j] instanceof URLSpan) {
+ out.append("<a href=\"");
+ out.append(((URLSpan) style[j]).getURL());
+ out.append("\">");
+ }
+ if (style[j] instanceof MediaGalleryImageSpan) {
+ out.append(getGalleryShortcode((MediaGalleryImageSpan) style[j]));
+ } else if (style[j] instanceof WPImageSpan && ((WPImageSpan) style[j]).getMediaFile().getMediaId() != null) {
+ out.append(getContent((WPImageSpan) style[j]));
+ } else if (style[j] instanceof WPImageSpan) {
+ out.append("<img src=\"");
+ out.append(((WPImageSpan) style[j]).getSource());
+ out.append("\" android-uri=\""
+ + ((WPImageSpan) style[j]).getImageSource()
+ .toString() + "\"");
+ out.append(" />");
+ // Don't output the dummy character underlying the image.
+ i = next;
+ }
+ if (style[j] instanceof AbsoluteSizeSpan) {
+ out.append("<font size =\"");
+ out.append(((AbsoluteSizeSpan) style[j]).getSize() / 6);
+ out.append("\">");
+ }
+ if (style[j] instanceof ForegroundColorSpan) {
+ out.append("<font color =\"#");
+ String color = Integer
+ .toHexString(((ForegroundColorSpan) style[j])
+ .getForegroundColor() + 0x01000000);
+ while (color.length() < 6) {
+ color = "0" + color;
+ }
+ out.append(color);
+ out.append("\">");
+ }
+ }
+
+ processWPImage(out, text, i, next);
+
+ for (int j = style.length - 1; j >= 0; j--) {
+ if (style[j] instanceof ForegroundColorSpan) {
+ out.append("</font>");
+ }
+ if (style[j] instanceof AbsoluteSizeSpan) {
+ out.append("</font>");
+ }
+ if (style[j] instanceof URLSpan) {
+ out.append("</a>");
+ }
+ if (style[j] instanceof StrikethroughSpan) {
+ out.append("</strike>");
+ }
+ if (style[j] instanceof WPUnderlineSpan) {
+ out.append("</u>");
+ }
+ if (style[j] instanceof SubscriptSpan) {
+ out.append("</sub>");
+ }
+ if (style[j] instanceof SuperscriptSpan) {
+ out.append("</sup>");
+ }
+ if (style[j] instanceof TypefaceSpan) {
+ String s = ((TypefaceSpan) style[j]).getFamily();
+
+ if (s.equals("monospace")) {
+ out.append("</tt>");
+ }
+ }
+ if (style[j] instanceof StyleSpan) {
+ int s = ((StyleSpan) style[j]).getStyle();
+
+ if ((s & Typeface.BOLD) != 0) {
+ out.append("</strong>");
+ }
+ if ((s & Typeface.ITALIC) != 0) {
+ out.append("</em>");
+ }
+ }
+ }
+ }
+
+ String p = last ? "" : "</p>\n<p>";
+
+ if (nl == 1) {
+ out.append("<br>\n");
+ } else if (nl == 2) {
+ out.append(p);
+ } else {
+ for (int i = 2; i < nl; i++) {
+ out.append("<br>");
+ }
+
+ out.append(p);
+ }
+ }
+
+ /** Get gallery shortcode for a MediaGalleryImageSpan */
+ public static String getGalleryShortcode(MediaGalleryImageSpan gallerySpan) {
+ String shortcode = "";
+ MediaGallery gallery = gallerySpan.getMediaGallery();
+ shortcode += "[gallery ";
+ if (gallery.isRandom())
+ shortcode += " orderby=\"rand\"";
+ if (gallery.getType().equals(""))
+ shortcode += " columns=\"" + gallery.getNumColumns() + "\"";
+ else
+ shortcode += " type=\"" + gallery.getType() + "\"";
+ shortcode += " ids=\"" + gallery.getIdsStr() + "\"";
+ shortcode += "]";
+
+ return shortcode;
+ }
+
+ /** Retrieve an image span content for a media file that exists on the server **/
+ public static String getContent(WPImageSpan imageSpan) {
+ // based on PostUploadService
+
+ String content = "";
+ MediaFile mediaFile = imageSpan.getMediaFile();
+ if (mediaFile == null)
+ return content;
+ String mediaId = mediaFile.getMediaId();
+ if (mediaId == null || mediaId.length() == 0)
+ return content;
+
+ boolean isVideo = mediaFile.isVideo();
+ String url = imageSpan.getImageSource().toString();
+
+ if (isVideo) {
+ if (!TextUtils.isEmpty(mediaFile.getVideoPressShortCode())) {
+ content = mediaFile.getVideoPressShortCode();
+ } else {
+ int xRes = mediaFile.getWidth();
+ int yRes = mediaFile.getHeight();
+ String mimeType = mediaFile.getMimeType();
+ content = String.format("<video width=\"%s\" height=\"%s\" controls=\"controls\"><source src=\"%s\" type=\"%s\" /><a href=\"%s\">Click to view video</a>.</video>",
+ xRes, yRes, url, mimeType, url);
+ }
+ } else {
+ String alignment = "";
+ switch (mediaFile.getHorizontalAlignment()) {
+ case 0:
+ alignment = "alignnone";
+ break;
+ case 1:
+ alignment = "alignleft";
+ break;
+ case 2:
+ alignment = "aligncenter";
+ break;
+ case 3:
+ alignment = "alignright";
+ break;
+ }
+ String alignmentCSS = "class=\"" + alignment + " size-full\" ";
+ String title = mediaFile.getTitle();
+ String caption = mediaFile.getCaption();
+ int width = mediaFile.getWidth();
+
+ String inlineCSS = " ";
+ String localBlogID = imageSpan.getMediaFile().getBlogId();
+ Blog currentBlog = WordPress.wpDB.instantiateBlogByLocalId(Integer.parseInt(localBlogID));
+ // If it's not a gif and blog don't keep original size, there is a chance we need to resize
+ if (currentBlog != null && !mediaFile.getMimeType().equals("image/gif") &&
+ !currentBlog.getMaxImageWidth().equals("Original Size")) {
+ int maxImageWidth = Integer.parseInt(currentBlog.getMaxImageWidth());
+ // use the correct resize settings.
+ width = Math.min(width, maxImageWidth);
+ // Use inline CSS on self-hosted blogs to enforce picture resize settings
+ if (!currentBlog.isDotcomFlag()) {
+ inlineCSS = String.format(" style=\"width:%dpx;max-width:%dpx;\" ", width, width);
+ }
+ }
+
+ content = content + "<a href=\"" + url + "\"><img" + inlineCSS + "title=\"" + title + "\" "
+ + alignmentCSS + "alt=\"image\" src=\"" + url + "?w=" + width +"\" /></a>";
+
+ if (!caption.equals("")) {
+ content = String.format("[caption id=\"\" align=\"%s\" width=\"%d\" caption=\"%s\"]%s[/caption]",
+ alignment, width, TextUtils.htmlEncode(caption), content);
+ }
+ }
+
+ return content;
+ }
+
+ private static void processWPImage(StringBuilder out, Spanned text,
+ int start, int end) {
+ int next;
+
+ for (int i = start; i < end; i = next) {
+ next = text.nextSpanTransition(i, end, SpannableString.class);
+ SpannableString[] images = text.getSpans(i, next,
+ SpannableString.class);
+
+ for (SpannableString image : images) {
+ out.append(image.toString());
+ }
+
+ withinStyle(out, text, i, next);
+
+ }
+ }
+
+ private static void withinStyle(StringBuilder out, Spanned text, int start,
+ int end) {
+ for (int i = start; i < end; i++) {
+ char c = text.charAt(i);
+
+ /*
+ * if (c == '<') { out.append("&lt;"); } else if (c == '>') {
+ * out.append("&gt;"); } else if (c == '&') { out.append("&amp;");
+ * if (c > 0x7E || c < ' ') { out.append("&#" + ((int) c) + ";"); }
+ * else
+ */
+ if (c == ' ') {
+ while (i + 1 < end && text.charAt(i + 1) == ' ') {
+ out.append("&nbsp;");
+ i++;
+ }
+
+ out.append(' ');
+ } else {
+ out.append(c);
+ }
+ }
+ }
+}
+
+class HtmlToSpannedConverter implements ContentHandler {
+ private static final float[] HEADER_SIZES = { 1.5f, 1.4f, 1.3f, 1.2f, 1.1f,
+ 1f, };
+
+ private String mSource;
+ private XMLReader mReader;
+ private SpannableStringBuilder mSpannableStringBuilder;
+ private WPHtml.ImageGetter mImageGetter;
+ private String mysteryTagContent;
+ private boolean mysteryTagFound;
+ private int mMaxImageWidth;
+ private static Context ctx;
+ private static Post post;
+
+ private String mysteryTagName;
+
+ public HtmlToSpannedConverter(String source,
+ WPHtml.ImageGetter imageGetter, WPHtml.TagHandler tagHandler,
+ Parser parser, Context context, Post p, int maxImageWidth) {
+ mSource = source;
+ mSpannableStringBuilder = new SpannableStringBuilder();
+ mImageGetter = imageGetter;
+ mReader = parser;
+ mysteryTagContent = "";
+ mysteryTagName = null;
+ ctx = context;
+ post = p;
+ mMaxImageWidth = maxImageWidth;
+ }
+
+ public Spanned convert() {
+ mReader.setContentHandler(this);
+ try {
+ mReader.parse(new InputSource(new StringReader(mSource)));
+ } catch (IOException e) {
+ // We are reading from a string. There should not be IO problems.
+ throw new RuntimeException(e);
+ } catch (SAXException e) {
+ // TagSoup doesn't throw parse exceptions.
+ throw new RuntimeException(e);
+ }
+
+ // Fix flags and range for paragraph-type markup.
+ Object[] obj = mSpannableStringBuilder.getSpans(0,
+ mSpannableStringBuilder.length(), ParagraphStyle.class);
+ for (int i = 0; i < obj.length; i++) {
+ int start = mSpannableStringBuilder.getSpanStart(obj[i]);
+ int end = mSpannableStringBuilder.getSpanEnd(obj[i]);
+
+ // If the last line of the range is blank, back off by one.
+ if (end - 2 >= 0) {
+ if (mSpannableStringBuilder.charAt(end - 1) == '\n'
+ && mSpannableStringBuilder.charAt(end - 2) == '\n') {
+ end--;
+ }
+ }
+
+ if (end == start) {
+ mSpannableStringBuilder.removeSpan(obj[i]);
+ } else {
+ try {
+ mSpannableStringBuilder.setSpan(obj[i], start, end,
+ Spannable.SPAN_PARAGRAPH);
+ } catch (Exception e) {
+ }
+ }
+ }
+
+ return mSpannableStringBuilder;
+ }
+
+ private void handleStartTag(String tag, Attributes attributes) {
+ if (!mysteryTagFound) {
+ if (post != null) {
+ if (!post.isLocalDraft()) {
+ if (tag.equalsIgnoreCase("img"))
+ startImg(mSpannableStringBuilder, attributes,
+ mImageGetter);
+
+ return;
+ }
+ }
+
+ if (tag.equalsIgnoreCase("br")) {
+ // We don't need to handle this. TagSoup will ensure that
+ // there's a
+ // </br> for each <br>
+ // so we can safely emite the linebreaks when we handle the
+ // close
+ // tag.
+ } else if (tag.equalsIgnoreCase("p")) {
+ handleP(mSpannableStringBuilder);
+ } else if (tag.equalsIgnoreCase("div")) {
+ handleP(mSpannableStringBuilder);
+ } else if (tag.equalsIgnoreCase("em")) {
+ start(mSpannableStringBuilder, new Italic());
+ } else if (tag.equalsIgnoreCase("b")) {
+ start(mSpannableStringBuilder, new Bold());
+ } else if (tag.equalsIgnoreCase("strong")) {
+ start(mSpannableStringBuilder, new Bold());
+ } else if (tag.equalsIgnoreCase("cite")) {
+ start(mSpannableStringBuilder, new Italic());
+ } else if (tag.equalsIgnoreCase("dfn")) {
+ start(mSpannableStringBuilder, new Italic());
+ } else if (tag.equalsIgnoreCase("i")) {
+ start(mSpannableStringBuilder, new Italic());
+ } else if (tag.equalsIgnoreCase("big")) {
+ start(mSpannableStringBuilder, new Big());
+ } else if (tag.equalsIgnoreCase("small")) {
+ start(mSpannableStringBuilder, new Small());
+ } else if (tag.equalsIgnoreCase("font")) {
+ startFont(mSpannableStringBuilder, attributes);
+ } else if (tag.equalsIgnoreCase("blockquote")) {
+ handleP(mSpannableStringBuilder);
+ start(mSpannableStringBuilder, new Blockquote());
+ } else if (tag.equalsIgnoreCase("tt")) {
+ start(mSpannableStringBuilder, new Monospace());
+ } else if (tag.equalsIgnoreCase("a")) {
+ startA(mSpannableStringBuilder, attributes);
+ } else if (tag.equalsIgnoreCase("u")) {
+ start(mSpannableStringBuilder, new Underline());
+ } else if (tag.equalsIgnoreCase("sup")) {
+ start(mSpannableStringBuilder, new Super());
+ } else if (tag.equalsIgnoreCase("sub")) {
+ start(mSpannableStringBuilder, new Sub());
+ } else if (tag.equalsIgnoreCase("strike")) {
+ start(mSpannableStringBuilder, new Strike());
+ } else if (tag.length() == 2
+ && Character.toLowerCase(tag.charAt(0)) == 'h'
+ && tag.charAt(1) >= '1' && tag.charAt(1) <= '6') {
+ handleP(mSpannableStringBuilder);
+ start(mSpannableStringBuilder, new Header(tag.charAt(1) - '1'));
+ } else if (tag.equalsIgnoreCase("img")) {
+ startImg(mSpannableStringBuilder, attributes, mImageGetter);
+ } else {
+ if (tag.equalsIgnoreCase("html")
+ || tag.equalsIgnoreCase("body")) {
+ return;
+ }
+
+ mysteryTagFound = true;
+ mysteryTagName = tag;
+ }
+ // mTagHandler.handleTag(true, tag, mSpannableStringBuilder,
+ // mReader, mysteryTagContent);
+ }
+ }
+
+ private void handleEndTag(String tag) {
+ if (post != null) {
+ if (!post.isLocalDraft())
+ return;
+ }
+ if (!mysteryTagFound) {
+ if (tag.equalsIgnoreCase("br")) {
+ handleBr(mSpannableStringBuilder);
+ } else if (tag.equalsIgnoreCase("p")) {
+ handleP(mSpannableStringBuilder);
+ } else if (tag.equalsIgnoreCase("div")) {
+ handleP(mSpannableStringBuilder);
+ } else if (tag.equalsIgnoreCase("em")) {
+ end(mSpannableStringBuilder, Italic.class, new StyleSpan(
+ Typeface.ITALIC));
+ } else if (tag.equalsIgnoreCase("b")) {
+ end(mSpannableStringBuilder, Bold.class, new StyleSpan(
+ Typeface.BOLD));
+ } else if (tag.equalsIgnoreCase("strong")) {
+ end(mSpannableStringBuilder, Bold.class, new StyleSpan(
+ Typeface.BOLD));
+ } else if (tag.equalsIgnoreCase("cite")) {
+ end(mSpannableStringBuilder, Italic.class, new StyleSpan(
+ Typeface.ITALIC));
+ } else if (tag.equalsIgnoreCase("dfn")) {
+ end(mSpannableStringBuilder, Italic.class, new StyleSpan(
+ Typeface.ITALIC));
+ } else if (tag.equalsIgnoreCase("i")) {
+ end(mSpannableStringBuilder, Italic.class, new StyleSpan(
+ Typeface.ITALIC));
+ } else if (tag.equalsIgnoreCase("big")) {
+ end(mSpannableStringBuilder, Big.class, new RelativeSizeSpan(
+ 1.25f));
+ } else if (tag.equalsIgnoreCase("small")) {
+ end(mSpannableStringBuilder, Small.class, new RelativeSizeSpan(
+ 0.8f));
+ } else if (tag.equalsIgnoreCase("font")) {
+ endFont(mSpannableStringBuilder);
+ } else if (tag.equalsIgnoreCase("blockquote")) {
+ handleP(mSpannableStringBuilder);
+ end(mSpannableStringBuilder, Blockquote.class, new QuoteSpan());
+ } else if (tag.equalsIgnoreCase("tt")) {
+ end(mSpannableStringBuilder, Monospace.class, new TypefaceSpan(
+ "monospace"));
+ } else if (tag.equalsIgnoreCase("a")) {
+ endA(mSpannableStringBuilder);
+ } else if (tag.equalsIgnoreCase("u")) {
+ end(mSpannableStringBuilder, Underline.class,
+ new WPUnderlineSpan());
+ } else if (tag.equalsIgnoreCase("sup")) {
+ end(mSpannableStringBuilder, Super.class, new SuperscriptSpan());
+ } else if (tag.equalsIgnoreCase("sub")) {
+ end(mSpannableStringBuilder, Sub.class, new SubscriptSpan());
+ } else if (tag.equalsIgnoreCase("strike")) {
+ end(mSpannableStringBuilder, Strike.class,
+ new StrikethroughSpan());
+ } else if (tag.length() == 2
+ && Character.toLowerCase(tag.charAt(0)) == 'h'
+ && tag.charAt(1) >= '1' && tag.charAt(1) <= '6') {
+ handleP(mSpannableStringBuilder);
+ endHeader(mSpannableStringBuilder);
+ }
+ } else {
+ if (tag.equalsIgnoreCase("html") || tag.equalsIgnoreCase("body")) {
+ return;
+ }
+
+ if (mysteryTagName.equals(tag)) {
+ mysteryTagFound = false;
+ mSpannableStringBuilder.append(mysteryTagContent);
+ }
+ // mTagHandler.handleTag(false, tag, mSpannableStringBuilder,
+ // mReader,
+ // mysteryTagContent);
+ }
+ }
+
+ private static void handleP(SpannableStringBuilder text) {
+ int len = text.length();
+
+ if (len >= 1 && text.charAt(len - 1) == '\n') {
+ if (len >= 2 && text.charAt(len - 2) == '\n') {
+ return;
+ }
+
+ text.append("\n");
+ return;
+ }
+
+ if (len != 0) {
+ text.append("\n\n");
+ }
+ }
+
+ private static void handleBr(SpannableStringBuilder text) {
+ text.append("\n");
+ }
+
+ private static Object getLast(Spanned text, Class<?> kind) {
+ /*
+ * This knows that the last returned object from getSpans() will be the
+ * most recently added.
+ */
+ Object[] objs = text.getSpans(0, text.length(), kind);
+
+ if (objs.length == 0) {
+ return null;
+ } else {
+ return objs[objs.length - 1];
+ }
+ }
+
+ private static void start(SpannableStringBuilder text, Object mark) {
+ int len = text.length();
+ text.setSpan(mark, len, len, Spannable.SPAN_MARK_MARK);
+ }
+
+ private static void end(SpannableStringBuilder text, Class<?> kind,
+ Object repl) {
+ int len = text.length();
+ Object obj = getLast(text, kind);
+ int where = text.getSpanStart(obj);
+ if (where < 0)
+ where = 0;
+
+ text.removeSpan(obj);
+
+ if (where != len) {
+ text.setSpan(repl, where, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+
+ return;
+ }
+
+ private void startImg(SpannableStringBuilder text, Attributes attributes, WPHtml.ImageGetter img) {
+ String src = attributes.getValue("android-uri");
+
+ Bitmap resizedBitmap = null;
+ try {
+ resizedBitmap = ImageHelper.getWPImageSpanThumbnailFromFilePath(ctx, src, mMaxImageWidth);
+ if (resizedBitmap == null && src != null) {
+ if (src.contains("video")) {
+ resizedBitmap = BitmapFactory.decodeResource(ctx.getResources(), R.drawable.media_movieclip);
+ } else {
+ resizedBitmap = BitmapFactory.decodeResource(ctx.getResources(),
+ R.drawable.media_image_placeholder);
+ }
+ }
+ } catch (OutOfMemoryError e) {
+ CrashlyticsUtils.logException(e, CrashlyticsUtils.ExceptionType.SPECIFIC, AppLog.T.UTILS);
+ }
+
+ if (resizedBitmap != null) {
+ int len = text.length();
+ text.append("\uFFFC");
+
+ Uri curStream = Uri.parse(src);
+
+ if (curStream == null) {
+ return;
+ }
+
+ WPImageSpan is = new WPImageSpan(ctx, resizedBitmap, curStream);
+
+ // get the MediaFile data from db
+ MediaFile mf = WordPress.wpDB.getMediaFile(src, post);
+ if (mf != null) {
+ is.setMediaFile(mf);
+ is.setImageSource(curStream);
+ text.setSpan(is, len, text.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ AlignmentSpan.Standard as = new AlignmentSpan.Standard(
+ Layout.Alignment.ALIGN_CENTER);
+ text.setSpan(as, text.getSpanStart(is), text.getSpanEnd(is),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ } else if (post != null) {
+ if (post.isLocalDraft()) {
+ if (attributes != null) {
+ text.append("<img");
+ for (int i = 0; i < attributes.getLength(); i++) {
+ String aName = attributes.getLocalName(i); // Attr name
+ if ("".equals(aName))
+ aName = attributes.getQName(i);
+ text.append(" ");
+ text.append(aName + "=\"" + attributes.getValue(i) + "\"");
+ }
+ text.append(" />\n");
+ }
+ }
+ }
+ }
+
+ private static void startFont(SpannableStringBuilder text,
+ Attributes attributes) {
+ String color = attributes.getValue("", "color");
+ String face = attributes.getValue("", "face");
+
+ int len = text.length();
+ text.setSpan(new Font(color, face), len, len, Spannable.SPAN_MARK_MARK);
+ }
+
+ private static void endFont(SpannableStringBuilder text) {
+ int len = text.length();
+ Object obj = getLast(text, Font.class);
+ int where = text.getSpanStart(obj);
+
+ text.removeSpan(obj);
+
+ if (where != len) {
+ Font f = (Font) obj;
+
+ if (!TextUtils.isEmpty(f.mColor)) {
+ if (f.mColor.startsWith("@")) {
+ Resources res = Resources.getSystem();
+ String name = f.mColor.substring(1);
+ int colorRes = res.getIdentifier(name, "color", "android");
+ if (colorRes != 0) {
+ ColorStateList colors = res.getColorStateList(colorRes);
+ text.setSpan(new TextAppearanceSpan(null, 0, 0, colors,
+ null), where, len,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ } else {
+ int c = getHtmlColor(f.mColor);
+ if (c != -1) {
+ text.setSpan(new ForegroundColorSpan(c | 0xFF000000),
+ where, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+ }
+
+ if (f.mFace != null) {
+ text.setSpan(new TypefaceSpan(f.mFace), where, len,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+ }
+
+ private static void startA(SpannableStringBuilder text,
+ Attributes attributes) {
+ String href = attributes.getValue("", "href");
+
+ int len = text.length();
+ text.setSpan(new Href(href), len, len, Spannable.SPAN_MARK_MARK);
+ }
+
+ private static void endA(SpannableStringBuilder text) {
+ int len = text.length();
+ Object obj = getLast(text, Href.class);
+ int where = text.getSpanStart(obj);
+
+ text.removeSpan(obj);
+
+ if (where != len) {
+ Href h = (Href) obj;
+
+ if (h != null) {
+ if (h.mHref != null) {
+ text.setSpan(new URLSpan(h.mHref), where, len,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+ }
+ }
+
+ private static void endHeader(SpannableStringBuilder text) {
+ int len = text.length();
+ Object obj = getLast(text, Header.class);
+
+ int where = text.getSpanStart(obj);
+
+ text.removeSpan(obj);
+
+ // Back off not to change only the text, not the blank line.
+ while (len > where && text.charAt(len - 1) == '\n') {
+ len--;
+ }
+
+ if (where != len) {
+ Header h = (Header) obj;
+
+ text.setSpan(new RelativeSizeSpan(HEADER_SIZES[h.mLevel]), where,
+ len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ text.setSpan(new StyleSpan(Typeface.BOLD), where, len,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+
+ public void setDocumentLocator(Locator locator) {
+ }
+
+ public void startDocument() throws SAXException {
+ }
+
+ public void endDocument() throws SAXException {
+ }
+
+ public void startPrefixMapping(String prefix, String uri)
+ throws SAXException {
+ }
+
+ public void endPrefixMapping(String prefix) throws SAXException {
+ }
+
+ public void startElement(String uri, String localName, String qName,
+ Attributes attributes) throws SAXException {
+ if (!mysteryTagFound) {
+ mysteryTagContent = "";
+ }
+
+ String eName = localName; // element name
+ if ("".equals(eName))
+ eName = qName; // not namespace-aware
+ mysteryTagContent += "<" + eName;
+ if (attributes != null) {
+ for (int i = 0; i < attributes.getLength(); i++) {
+ String aName = attributes.getLocalName(i); // Attr name
+ if ("".equals(aName))
+ aName = attributes.getQName(i);
+ mysteryTagContent += " ";
+ mysteryTagContent += aName + "=\"" + attributes.getValue(i)
+ + "\"";
+ }
+ }
+ mysteryTagContent += ">";
+
+ handleStartTag(localName, attributes);
+ }
+
+ public void endElement(String uri, String localName, String qName)
+ throws SAXException {
+ if (mysteryTagFound) {
+ mysteryTagContent += "</" + localName + ">" + "\n";
+ }
+ handleEndTag(localName);
+ }
+
+ public void characters(char ch[], int start, int length)
+ throws SAXException {
+ StringBuilder sb = new StringBuilder();
+
+ /*
+ * Ignore whitespace that immediately follows other whitespace; newlines
+ * count as spaces.
+ */
+
+ for (int i = 0; i < length; i++) {
+ char c = ch[i + start];
+
+ if (c == ' ' || c == '\n') {
+ char pred;
+ int len = sb.length();
+
+ if (len == 0) {
+ len = mSpannableStringBuilder.length();
+
+ if (len == 0) {
+ pred = '\n';
+ } else {
+ pred = mSpannableStringBuilder.charAt(len - 1);
+ }
+ } else {
+ pred = sb.charAt(len - 1);
+ }
+
+ if (pred != ' ' && pred != '\n') {
+ sb.append(' ');
+ }
+ } else {
+ sb.append(c);
+ }
+ }
+
+ try {
+ if (mysteryTagFound) {
+ if (sb.length() < length)
+ mysteryTagContent += sb.toString().substring(start,
+ length - 1);
+ else
+ mysteryTagContent += sb.toString().substring(start, length);
+ } else
+ mSpannableStringBuilder.append(sb);
+ } catch (RuntimeException e) {
+ AppLog.e(T.UTILS, e);
+ }
+ }
+
+ public void ignorableWhitespace(char ch[], int start, int length)
+ throws SAXException {
+ }
+
+ public void processingInstruction(String target, String data)
+ throws SAXException {
+ }
+
+ public void skippedEntity(String name) throws SAXException {
+ }
+
+ private static class Bold {
+ }
+
+ private static class Italic {
+ }
+
+ private static class Underline {
+ }
+
+ private static class Big {
+ }
+
+ private static class Small {
+ }
+
+ private static class Monospace {
+ }
+
+ private static class Blockquote {
+ }
+
+ private static class Super {
+ }
+
+ private static class Sub {
+ }
+
+ private static class Strike {
+ }
+
+ private static class Font {
+ public String mColor;
+ public String mFace;
+
+ public Font(String color, String face) {
+ mColor = color;
+ mFace = face;
+ }
+ }
+
+ private static class Href {
+ public String mHref;
+
+ public Href(String href) {
+ mHref = href;
+ }
+ }
+
+ private static class Header {
+ private int mLevel;
+
+ public Header(int level) {
+ mLevel = level;
+ }
+ }
+
+ private static HashMap<String, Integer> COLORS = buildColorMap();
+
+ private static HashMap<String, Integer> buildColorMap() {
+ HashMap<String, Integer> map = new HashMap<String, Integer>();
+ map.put("aqua", 0x00FFFF);
+ map.put("black", 0x000000);
+ map.put("blue", 0x0000FF);
+ map.put("fuchsia", 0xFF00FF);
+ map.put("green", 0x008000);
+ map.put("grey", 0x808080);
+ map.put("lime", 0x00FF00);
+ map.put("maroon", 0x800000);
+ map.put("navy", 0x000080);
+ map.put("olive", 0x808000);
+ map.put("purple", 0x800080);
+ map.put("red", 0xFF0000);
+ map.put("silver", 0xC0C0C0);
+ map.put("teal", 0x008080);
+ map.put("white", 0xFFFFFF);
+ map.put("yellow", 0xFFFF00);
+ return map;
+ }
+
+ /**
+ * Converts an HTML color (named or numeric) to an integer RGB value.
+ *
+ * @param color
+ * Non-null color string.
+ * @return A color value, or {@code -1} if the color string could not be
+ * interpreted.
+ */
+ private static int getHtmlColor(String color) {
+ Integer i = COLORS.get(color.toLowerCase());
+ if (i != null) {
+ return i;
+ } else {
+ try {
+ return convertValueToInt(color, -1);
+ } catch (NumberFormatException nfe) {
+ return -1;
+ }
+ }
+ }
+
+ public static final int convertValueToInt(CharSequence charSeq,
+ int defaultValue) {
+ if (null == charSeq)
+ return defaultValue;
+
+ String nm = charSeq.toString();
+
+ // XXX This code is copied from Integer.decode() so we don't
+ // have to instantiate an Integer!
+
+ int sign = 1;
+ int index = 0;
+ int len = nm.length();
+ int base = 10;
+
+ if ('-' == nm.charAt(0)) {
+ sign = -1;
+ index++;
+ }
+
+ if ('0' == nm.charAt(index)) {
+ // Quick check for a zero by itself
+ if (index == (len - 1))
+ return 0;
+
+ char c = nm.charAt(index + 1);
+
+ if ('x' == c || 'X' == c) {
+ index += 2;
+ base = 16;
+ } else {
+ index++;
+ base = 8;
+ }
+ } else if ('#' == nm.charAt(index)) {
+ index++;
+ base = 16;
+ }
+
+ return Integer.parseInt(nm.substring(index), base) * sign;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java b/WordPress/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java
new file mode 100644
index 000000000..fa96a998a
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java
@@ -0,0 +1,59 @@
+package org.wordpress.android.util;
+
+import android.text.Editable;
+import android.text.Html;
+import android.text.style.BulletSpan;
+import android.text.style.LeadingMarginSpan;
+
+import org.xml.sax.XMLReader;
+
+import java.util.Vector;
+
+/**
+ * Handle tags that the Html class doesn't understand
+ * Tweaked from source at http://stackoverflow.com/questions/4044509/android-how-to-use-the-html-taghandler
+ */
+public class WPHtmlTagHandler implements Html.TagHandler {
+ private int mListItemCount = 0;
+ private Vector<String> mListParents = new Vector<String>();
+
+ @Override
+ public void handleTag(final boolean opening, final String tag, Editable output,
+ final XMLReader xmlReader) {
+ if (tag.equals("ul") || tag.equals("ol") || tag.equals("dd")) {
+ if (opening) {
+ mListParents.add(tag);
+ } else {
+ mListParents.remove(tag);
+ }
+ mListItemCount = 0;
+ } else if (tag.equals("li") && !opening) {
+ handleListTag(output);
+ }
+ }
+
+ private void handleListTag(Editable output) {
+ if (mListParents.lastElement().equals("ul")) {
+ output.append("\n");
+ String[] split = output.toString().split("\n");
+ int start = 0;
+ if (split.length != 1) {
+ int lastIndex = split.length - 1;
+ start = output.length() - split[lastIndex].length() - 1;
+ }
+ output.setSpan(new BulletSpan(15 * mListParents.size()), start, output.length(), 0);
+ } else if (mListParents.lastElement().equals("ol")) {
+ mListItemCount++;
+ output.append("\n");
+ String[] split = output.toString().split("\n");
+ int start = 0;
+ if (split.length != 1) {
+ int lastIndex = split.length - 1;
+ start = output.length() - split[lastIndex].length() - 1;
+ }
+ output.insert(start, mListItemCount + ". ");
+ output.setSpan(new LeadingMarginSpan.Standard(15 * mListParents.size()), start,
+ output.length(), 0);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/WPImageGetter.java b/WordPress/src/main/java/org/wordpress/android/util/WPImageGetter.java
new file mode 100644
index 000000000..b76c5c9a6
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/WPImageGetter.java
@@ -0,0 +1,158 @@
+package org.wordpress.android.util;
+
+import android.graphics.Canvas;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.text.Html;
+import android.text.TextUtils;
+import android.widget.TextView;
+
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.ImageLoader;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.AppLog.T;
+
+import java.lang.ref.WeakReference;
+
+/**
+ * ImageGetter for Html.fromHtml()
+ * adapted from existing ImageGetter code in NoteCommentFragment
+ */
+public class WPImageGetter implements Html.ImageGetter {
+ private WeakReference<TextView> mWeakView;
+ private int mMaxSize;
+
+ public WPImageGetter(TextView view) {
+ this(view, 0);
+ }
+
+ public WPImageGetter(TextView view, int maxSize) {
+ mWeakView = new WeakReference<TextView>(view);
+ mMaxSize = maxSize;
+ }
+
+ private TextView getView() {
+ return mWeakView.get();
+ }
+
+ @Override
+ public Drawable getDrawable(String source) {
+ if (TextUtils.isEmpty(source))
+ return null;
+
+ // images in reader comments may skip "http:" (no idea why) so make sure to add protocol here
+ if (source.startsWith("//"))
+ source = "http:" + source;
+
+ // use Photon if a max size is requested (otherwise the full-sized image will be downloaded
+ // and then resized)
+ if (mMaxSize > 0)
+ source = PhotonUtils.getPhotonImageUrl(source, mMaxSize, 0);
+
+ TextView view = getView();
+ Drawable loading = view.getContext().getResources().getDrawable(R.drawable.remote_image);
+ Drawable failed = view.getContext().getResources().getDrawable(R.drawable.remote_failed);
+ final RemoteDrawable remote = new RemoteDrawable(loading, failed);
+
+ WordPress.imageLoader.get(source, new ImageLoader.ImageListener(){
+ @Override
+ public void onErrorResponse(VolleyError error){
+ remote.displayFailed();
+ TextView view = getView();
+ if (view != null)
+ view.invalidate();
+ }
+ @Override
+ public void onResponse(ImageLoader.ImageContainer response, boolean isImmediate){
+ if (response.getBitmap() != null) {
+ // make sure view is still valid
+ TextView view = getView();
+ if (view == null) {
+ AppLog.w(T.UTILS, "WPImageGetter view is invalid");
+ return;
+ }
+
+ Drawable drawable = new BitmapDrawable(view.getContext().getResources(), response.getBitmap());
+ final int oldHeight = remote.getBounds().height();
+ int maxWidth = view.getWidth() - view.getPaddingLeft() - view.getPaddingRight();
+ if (mMaxSize > 0 && (maxWidth > mMaxSize || maxWidth == 0))
+ maxWidth = mMaxSize;
+ remote.setRemoteDrawable(drawable, maxWidth);
+
+ // image is from cache? don't need to modify view height
+ if (isImmediate)
+ return;
+
+ int newHeight = remote.getBounds().height();
+ view.invalidate();
+ // For ICS
+ view.setHeight(view.getHeight() + newHeight - oldHeight);
+ // Pre ICS
+ view.setEllipsize(null);
+ }
+ }
+ });
+ return remote;
+ }
+
+ private static class RemoteDrawable extends BitmapDrawable {
+ protected Drawable mRemoteDrawable;
+ protected Drawable mLoadingDrawable;
+ protected Drawable mFailedDrawable;
+ private boolean mDidFail=false;
+ public RemoteDrawable(Drawable loadingDrawable, Drawable failedDrawable){
+ mLoadingDrawable = loadingDrawable;
+ mFailedDrawable = failedDrawable;
+ setBounds(0, 0, mLoadingDrawable.getIntrinsicWidth(), mLoadingDrawable.getIntrinsicHeight());
+ }
+ public void displayFailed(){
+ mDidFail = true;
+ }
+ public void setBounds(int x, int y, int width, int height){
+ super.setBounds(x, y, width, height);
+ if (mRemoteDrawable != null) {
+ mRemoteDrawable.setBounds(x, y, width, height);
+ return;
+ }
+ if (mLoadingDrawable != null) {
+ mLoadingDrawable.setBounds(x, y, width, height);
+ mFailedDrawable.setBounds(x, y, width, height);
+ }
+ }
+ public void setRemoteDrawable(Drawable remote){
+ mRemoteDrawable = remote;
+ setBounds(0, 0, mRemoteDrawable.getIntrinsicWidth(), mRemoteDrawable.getIntrinsicHeight());
+ }
+ public void setRemoteDrawable(Drawable remote, int maxWidth){
+ // null sentinel for now
+ if (remote == null) {
+ // throw error
+ return;
+ }
+ mRemoteDrawable = remote;
+ // determine if we need to scale the image to fit in view
+ int imgWidth = remote.getIntrinsicWidth();
+ int imgHeight = remote.getIntrinsicHeight();
+ float xScale = (float) imgWidth/(float) maxWidth;
+ if (xScale > 1.0f) {
+ setBounds(0, 0, Math.round(imgWidth/xScale), Math.round(imgHeight/xScale));
+ } else {
+ setBounds(0, 0, imgWidth, imgHeight);
+ }
+ }
+ public boolean didFail(){
+ return mDidFail;
+ }
+ public void draw(Canvas canvas){
+ if (mRemoteDrawable != null) {
+ mRemoteDrawable.draw(canvas);
+ } else if (didFail()) {
+ mFailedDrawable.draw(canvas);
+ } else {
+ mLoadingDrawable.draw(canvas);
+ }
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/WPImageSpan.java b/WordPress/src/main/java/org/wordpress/android/util/WPImageSpan.java
new file mode 100644
index 000000000..d3588f489
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/WPImageSpan.java
@@ -0,0 +1,93 @@
+//Add WordPress image fields to ImageSpan object
+
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.net.Uri;
+import android.text.style.ImageSpan;
+
+import org.wordpress.android.R;
+import org.wordpress.android.models.MediaFile;
+import org.wordpress.android.ui.posts.EditPostActivity;
+
+public class WPImageSpan extends ImageSpan {
+ private Context mContext;
+
+ private Uri mImageSource = null;
+ private boolean mNetworkImageLoaded = false;
+ private boolean mIsInPostEditor;
+
+ private MediaFile mMediaFile;
+
+ public WPImageSpan(Context context, Bitmap b, Uri src) {
+ super(context, b);
+ this.mImageSource = src;
+ mContext = context;
+ mMediaFile = new MediaFile();
+ if (mContext instanceof EditPostActivity) {
+ EditPostActivity editPostActivity = (EditPostActivity)mContext;
+ if (editPostActivity.isEditingPostContent())
+ mIsInPostEditor = true;
+ }
+
+ }
+
+ public WPImageSpan(Context context, int resId, Uri src) {
+ super(context, resId);
+ this.mImageSource = src;
+ mContext = context;
+ mMediaFile = new MediaFile();
+ if (mContext instanceof EditPostActivity)
+ mIsInPostEditor = true;
+ }
+
+ public MediaFile getMediaFile() {
+ return mMediaFile;
+ }
+
+ public void setMediaFile(MediaFile mMediaFile) {
+ this.mMediaFile = mMediaFile;
+ }
+
+ public void setImageSource(Uri mImageSource) {
+ this.mImageSource = mImageSource;
+ }
+
+ public Uri getImageSource() {
+ return mImageSource;
+ }
+
+ public boolean isNetworkImageLoaded() {
+ return mNetworkImageLoaded;
+ }
+
+ public void setNetworkImageLoaded(boolean networkImageLoaded) {
+ this.mNetworkImageLoaded = networkImageLoaded;
+ }
+
+ @Override
+ public void draw(Canvas canvas, CharSequence text,
+ int start, int end, float x,
+ int top, int y, int bottom, Paint paint) {
+ super.draw(canvas, text, start, end, x, top, y, bottom, paint);
+
+ if (mIsInPostEditor && !mMediaFile.isVideo()) {
+ // Add 'edit' icon at bottom right of image
+ int width = getSize(paint, text, start, end, paint.getFontMetricsInt());
+ Bitmap editIconBitmap = BitmapFactory.decodeResource(mContext.getResources(), R.drawable.ab_icon_edit);
+ float editIconXPosition = (x + width) - editIconBitmap.getWidth();
+ float editIconYPosition = bottom - editIconBitmap.getHeight();
+ // Add a black background with a bit of alpha
+ Paint bgPaint = new Paint();
+ bgPaint.setColor(Color.argb(200, 0, 0, 0));
+ canvas.drawRect(editIconXPosition, editIconYPosition, editIconXPosition + editIconBitmap.getWidth(), editIconYPosition + editIconBitmap.getHeight(), bgPaint);
+ // Add the icon to the canvas
+ canvas.drawBitmap(editIconBitmap, editIconXPosition, editIconYPosition, paint);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/WPLinkMovementMethod.java b/WordPress/src/main/java/org/wordpress/android/util/WPLinkMovementMethod.java
new file mode 100644
index 000000000..9244aa902
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/WPLinkMovementMethod.java
@@ -0,0 +1,69 @@
+package org.wordpress.android.util;
+
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.text.Spannable;
+import android.text.TextUtils;
+import android.text.method.LinkMovementMethod;
+import android.text.style.URLSpan;
+import android.view.MotionEvent;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+
+/**
+ * Android's LinkMovementMethod crashes on malformed links, including links that have no
+ * protocol (ex: "example.com" instead of "http://example.com"). This class extends
+ * LinkMovementMethod to catch and ignore the exception.
+ */
+
+public class WPLinkMovementMethod extends LinkMovementMethod {
+ protected static WPLinkMovementMethod mMovementMethod;
+
+ public static WPLinkMovementMethod getInstance() {
+ if (mMovementMethod == null)
+ mMovementMethod = new WPLinkMovementMethod();
+ return mMovementMethod;
+ }
+
+ @Override
+ public boolean onTouchEvent(TextView textView, Spannable buffer, MotionEvent event) {
+ try {
+ return super.onTouchEvent(textView, buffer, event) ;
+ } catch (ActivityNotFoundException e) {
+ AppLog.e(AppLog.T.UTILS, e);
+ // attempt to correct the tapped url then launch the intent to display it
+ showTappedUrl(textView.getContext(), fixTappedUrl(buffer));
+ return true;
+ }
+ }
+
+ private static String fixTappedUrl(Spannable buffer) {
+ if (buffer == null)
+ return null;
+
+ URLSpan urlSpans[] = buffer.getSpans(0, buffer.length(), URLSpan.class);
+ if (urlSpans.length == 0)
+ return null;
+
+ // note that there will be only one URLSpan (the one that was tapped)
+ String url = StringUtils.notNullStr(urlSpans[0].getURL());
+ if (Uri.parse(url).getScheme() == null)
+ return "http://" + url.trim();
+
+ return url.trim();
+ }
+
+ private static void showTappedUrl(Context context, String url) {
+ if (context == null || TextUtils.isEmpty(url))
+ return;
+ try {
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
+ context.startActivity(intent);
+ } catch (ActivityNotFoundException e) {
+ ToastUtils.showToast(context, context.getString(R.string.reader_toast_err_url_intent, url), ToastUtils.Duration.LONG);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/WPMeShortlinks.java b/WordPress/src/main/java/org/wordpress/android/util/WPMeShortlinks.java
new file mode 100644
index 000000000..0d5cc8368
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/WPMeShortlinks.java
@@ -0,0 +1,133 @@
+package org.wordpress.android.util;
+
+/**
+ * Enable WP.me-powered shortlinks for Posts, Pages, and Blogs on WordPress.com or Jetpack powered sites.
+ *
+ * Shortlinks are a quick way to get short and simple links to your posts, pages, and blogs.
+ * They use the wp.me domain so you can have more space to write on social media sites.
+ *
+ * See: https://github.com/Automattic/jetpack/blob/master/modules/shortlinks.php
+ *
+ */
+import android.text.TextUtils;
+
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.models.Post;
+import org.wordpress.android.models.PostStatus;
+import org.wordpress.android.util.AppLog.T;
+
+public class WPMeShortlinks {
+ /**
+ * Converts a base-10 number to base-62
+ *
+ * @param num base-10 number
+ * @return String base-62 number
+ */
+ public static String wpme_dec2sixtwo( double num ) {
+ if (num==0)
+ return "0";
+
+ StringBuilder out;
+ try {
+ String index = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ out = new StringBuilder();
+
+ if (num<0) {
+ out.append('-');
+ num = Math.abs( num );
+ }
+
+ double t = Math.floor( Math.log10( num ) / Math.log10( 62 ) );
+ for ( ;t>=0;t--) {
+ int a = (int) Math.floor( num / Math.pow( 62, t ) );
+ out.append( index.substring( a, a+1 ) );
+ num = num - ( a * Math.pow( 62, t ) );
+ }
+ return out.toString();
+ } catch (IndexOutOfBoundsException e) {
+ AppLog.e(T.UTILS, "Connot convert number " + num + " to base 62", e);
+ }
+ return null;
+ }
+
+ /**
+ * Returns The post shortlink
+ *
+ * @param blog Blog that contains the post or the page
+ * @param post Post or page we want calculate the shortlink
+ * @return String The blog shortlink or null (null is returned if the blog object is empty, or it's not a wpcom/jetpack blog, or in case of errors).
+ */
+ public static String getPostShortlink(Blog blog, Post post) {
+ if (post==null || blog==null)
+ return null;
+
+ if (!blog.isDotcomFlag() && !blog.isJetpackPowered())
+ return null;
+
+ String postID = post.getRemotePostId();
+ if (postID==null)
+ return null;
+
+ String id = null;
+ String type = null;
+
+ String postName = StringUtils.notNullStr(post.getSlug());
+ if (post.getStatusEnum()==PostStatus.PUBLISHED && postName.length() > 0 && postName.length() <= 8 && !postName.contains("%") && !postName.contains("-")) {
+ id = postName;
+ type = "s";
+ } else {
+ try {
+ id = wpme_dec2sixtwo(Double.parseDouble(postID));
+ } catch (NumberFormatException e) {
+ AppLog.e(T.UTILS, "Remote postID cannot be converted to double" + postID, e);
+ return null;
+ }
+
+ if (post.isPage()) {
+ type = "P";
+ } else {
+ type = "p";
+ }
+ }
+
+ //Calculate the blog shortlink
+ String blogShortlink = null;
+ try {
+ double blogID = blog.isDotcomFlag() ? blog.getRemoteBlogId() : Double.parseDouble(blog.getApi_blogid());
+ blogShortlink = wpme_dec2sixtwo(blogID);
+ } catch (NumberFormatException e) {
+ AppLog.e(T.UTILS, "Remote Blog ID cannot be converted to double", e);
+ return null;
+ }
+
+ if (TextUtils.isEmpty(type) || TextUtils.isEmpty(id) || TextUtils.isEmpty(blogShortlink))
+ return null;
+
+ return "http://wp.me/" + type + blogShortlink + "-" + id;
+ }
+
+
+ /**
+ * Returns The blog shortlink
+ *
+ * @param blog Blog we want calculate the shortlink
+ * @return String The blog shortlink or null (null is returned if the blog object is empty, or it's not a wpcom/jetpack blog, or in case of errors).
+ */
+ public static String getBlogShortlink(Blog blog) {
+ if (blog==null)
+ return null;
+
+ if (!blog.isDotcomFlag() && !blog.isJetpackPowered())
+ return null;
+
+ try {
+ double blogID = blog.isDotcomFlag() ? blog.getRemoteBlogId() : Double.parseDouble(blog.getApi_blogid());
+ String shortlink = wpme_dec2sixtwo(blogID);
+ String shortlinkWithProtocol = (shortlink == null) ? blog.getHomeURL() : "http://wp.me/" + shortlink;
+ return shortlinkWithProtocol;
+ } catch (NumberFormatException e) {
+ AppLog.e(T.UTILS, "Remote Blog ID cannot be converted to double ", e);
+ return blog.getHomeURL();
+ }
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/util/WPRestClient.java b/WordPress/src/main/java/org/wordpress/android/util/WPRestClient.java
new file mode 100644
index 000000000..9ab125e36
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/WPRestClient.java
@@ -0,0 +1,441 @@
+/**
+ * Interface to the WordPress.com REST API.
+ */
+package org.wordpress.android.util;
+
+import android.os.AsyncTask;
+
+import com.android.volley.DefaultRetryPolicy;
+import com.android.volley.Request.Method;
+import com.android.volley.RequestQueue;
+import com.android.volley.RetryPolicy;
+import com.android.volley.VolleyError;
+import com.wordpress.rest.Oauth;
+import com.wordpress.rest.RestClient;
+import com.wordpress.rest.RestRequest;
+import com.wordpress.rest.RestRequest.ErrorListener;
+import com.wordpress.rest.RestRequest.Listener;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.models.Note;
+import org.wordpress.android.ui.stats.StatsBarChartUnit;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+public class WPRestClient {
+
+ private static final String NOTIFICATION_FIELDS="id,type,unread,body,subject,timestamp";
+ private static final String COMMENT_REPLY_CONTENT_FIELD="content";
+
+ private RestClient mRestClient;
+ private Authenticator mAuthenticator;
+
+ /** Socket timeout in milliseconds for rest requests */
+ public static final int REST_TIMEOUT_MS = 30000;
+
+ /** Default number of retries for POST rest requests */
+ public static final int REST_MAX_RETRIES_POST = 0;
+
+ /** Default number of retries for GET rest requests */
+ public static final int REST_MAX_RETRIES_GET = 3;
+
+ /** Default backoff multiplier for rest requests */
+ public static final float REST_BACKOFF_MULT = 2f;
+
+
+ public WPRestClient(RequestQueue queue, Authenticator authenticator){
+ // load an existing access token from prefs if we have one
+ mAuthenticator = authenticator;
+ mRestClient = new RestClient(queue);
+ mRestClient.setUserAgent("wp-android/" + WordPress.versionName);
+ }
+
+ /**
+ * Reply to a comment using a Note.Reply object.
+ *
+ * https://developer.wordpress.com/docs/api/1/post/sites/%24site/posts/%24post_ID/replies/new/
+ */
+ public void replyToComment(Note.Reply reply, Listener listener, ErrorListener errorListener){
+ Map<String, String> params = new HashMap<String, String>();
+ params.put(COMMENT_REPLY_CONTENT_FIELD, reply.getContent());
+ post(reply.getRestPath(), params, null, listener, errorListener);
+ }
+ /**
+ * Reply to a comment.
+ *
+ * https://developer.wordpress.com/docs/api/1/post/sites/%24site/posts/%24post_ID/replies/new/
+ */
+ public void replyToComment(String siteId, String commentId, String content, Listener listener, ErrorListener errorListener){
+ Map<String, String> params = new HashMap<String, String>();
+ params.put(COMMENT_REPLY_CONTENT_FIELD, content);
+ String path = String.format("sites/%s/comments/%s/replies/new", siteId, commentId);
+ post(path, params, null, listener, errorListener);
+ }
+ /**
+ * Follow a site given an ID or domain
+ *
+ * https://developer.wordpress.com/docs/api/1/post/sites/%24site/follows/new/
+ */
+ public void followSite(String siteId, Listener listener, ErrorListener errorListener){
+ String path = String.format("sites/%s/follows/new", siteId);
+ post(path, listener, errorListener);
+ }
+ /**
+ * Unfollow a site given an ID or domain
+ *
+ * https://developer.wordpress.com/docs/api/1/post/sites/%24site/follows/mine/delete/
+ */
+ public void unfollowSite(String siteId, Listener listener, ErrorListener errorListener){
+ String path = String.format("sites/%s/follows/mine/delete", siteId);
+ post(path, listener, errorListener);
+ }
+
+ /**
+ * Update the seen timestamp.
+ *
+ * https://developer.wordpress.com/docs/api/1/post/notifications/seen
+ */
+ public void markNotificationsSeen(String timestamp, Listener listener, ErrorListener errorListener){
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("time", timestamp);
+ post("notifications/seen", params, null, listener, errorListener);
+ }
+ /**
+ * Moderate a comment.
+ *
+ * http://developer.wordpress.com/docs/api/1/sites/%24site/comments/%24comment_ID/
+ */
+ public void moderateComment(String siteId, String commentId, String status, Listener listener, ErrorListener errorListener){
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("status", status);
+ String path = String.format("sites/%s/comments/%s/", siteId, commentId);
+ post(path, params, null, listener, errorListener);
+ }
+
+ /**
+ * Get all a site's themes
+ */
+ public void getThemes(String siteId, int limit, int offset, Listener listener, ErrorListener errorListener) {
+ String path = String.format("sites/%s/themes?limit=%d&offset=%d", siteId, limit, offset);
+ get(path, listener, errorListener);
+ }
+
+ /**
+ * Set a site's theme
+ */
+ public void setTheme(String siteId, String themeId, Listener listener, ErrorListener errorListener) {
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("theme", themeId);
+ String path = String.format("sites/%s/themes/mine", siteId);
+ post(path, params, null, listener, errorListener);
+ }
+
+ /**
+ * Get a site's current theme
+ */
+ public void getCurrentTheme(String siteId, Listener listener, ErrorListener errorListener) {
+ String path = String.format("sites/%s/themes/mine", siteId);
+ get(path, listener, errorListener);
+ }
+
+ /**
+ * Get a site's stats for clicks
+ */
+ public void getStatsClicks(String siteId, String date, Listener listener, ErrorListener errorListener) {
+ String path = String.format("sites/%s/stats/clicks?date=%s", siteId, date);
+ get(path, listener, errorListener);
+ }
+
+ /**
+ * Get a site's stats for geoviews (views by country)
+ */
+ public void getStatsGeoviews(String siteId, String date, Listener listener, ErrorListener errorListener) {
+ String path = String.format("sites/%s/stats/country-views?date=%s", siteId, date);
+ get(path, listener, errorListener);
+ }
+
+ /**
+ * Get a site's stats for most commented posts
+ */
+ public void getStatsMostCommented(String siteId, Listener listener, ErrorListener errorListener) {
+ String path = "stats/most_commented";
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("blog", siteId);
+ getXL(path, params, listener, errorListener);
+ }
+
+ /**
+ * Get a site's stats for top commenters
+ */
+ public void getStatsTopCommenters(String siteId, Listener listener, ErrorListener errorListener) {
+ String path = "stats/top_commenters";
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("blog", siteId);
+ getXL(path, params, listener, errorListener);
+ }
+
+ /**
+ * Get a site's stats for referrers
+ */
+ public void getStatsReferrers(String siteId, String date, Listener listener, ErrorListener errorListener) {
+ String path = String.format("sites/%s/stats/referrers?date=%s", siteId, date);
+ get(path, listener, errorListener);
+ }
+
+ /**
+ * Get a site's stats for search engine terms
+ */
+ public void getStatsSearchEngineTerms(String siteId, String date, Listener listener, ErrorListener errorListener) {
+ String path = String.format("sites/%s/stats/search-terms?date=%s", siteId, date);
+ get(path, listener, errorListener);
+ }
+
+ /**
+ * Get a site's stats for tags and categories
+ */
+ public void getStatsTagsAndCategories(String siteId, Listener listener, ErrorListener errorListener) {
+ String path = "stats/tags_and_categories";
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("blog", siteId);
+ getXL(path, params, listener, errorListener);
+ }
+
+ /**
+ * Get a site's stats for top authors
+ */
+ public void getStatsTopAuthors(String siteId, Listener listener, ErrorListener errorListener) {
+ String path = "stats/top_authors";
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("blog", siteId);
+ getXL(path, params, listener, errorListener);
+ }
+
+ /**
+ * Get a site's stats for top posts and pages
+ */
+ public void getStatsTopPosts(String siteId, String date, Listener listener, ErrorListener errorListener) {
+ String path = String.format("sites/%s/stats/top-posts?date=%s", siteId, date);
+ get(path, listener, errorListener);
+ }
+
+ /**
+ * Get a site's stats for video plays
+ */
+ public void getStatsVideoPlays(String siteId, Listener listener, ErrorListener errorListener) {
+ String path = "stats/video_plays";
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("blog", siteId);
+ getXL(path, params, listener, errorListener);
+ }
+
+ /**
+ * Get a site's stats summary
+ */
+ public void getStatsSummary(String siteId, Listener listener, ErrorListener errorListener) {
+ String path = String.format("sites/%s/stats", siteId);
+ get(path, listener, errorListener);
+ }
+
+ /**
+ * Get a site's stats summary for videos
+ */
+ public void getStatsVideoSummary(String siteId, Listener listener, ErrorListener errorListener) {
+ String path = "stats/video_summary";
+ Map<String, String> params = new HashMap<String, String>();
+ params.put("blog", siteId);
+ getXL(path, params, listener, errorListener);
+ }
+
+ /**
+ * Get a site's views and visitors stats for days, weeks, and months
+ * Use -1 to get a default value for quantity
+ * Use empty string for unit to get default value
+ */
+ public void getStatsBarChartData(String siteId, StatsBarChartUnit statsBarChartUnit, int quantity, Listener listener, ErrorListener errorListener) {
+ String path = String.format("sites/%s/stats/visits", siteId);
+
+ String unit = statsBarChartUnit.name().toLowerCase(Locale.ENGLISH);
+ path += String.format("?unit=%s", unit);
+
+ if (quantity > 0) {
+ path += String.format("&quantity=%d", quantity);
+ }
+
+ get(path, listener, errorListener);
+ }
+
+ /**
+ * This method is for simulating stats APIs using the XL Studio API simulator. It should be removed once the other APIs are implemented.
+ **/
+ public void getXL(String path, Map<String, String> params, final Listener listener, final ErrorListener errorListener) {
+
+ path = "https://simulator.xlstudio.com/apis/32/" + path;
+
+ final String url_path = path;
+
+ new AsyncTask<Void, Void, JSONObject>() {
+
+ @Override
+ protected JSONObject doInBackground(Void... params) {
+
+ URL url;
+ HttpURLConnection conn;
+ BufferedReader rd;
+ String line;
+ String result = "";
+
+ try {
+ url = new URL(url_path);
+ conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("GET");
+ conn.addRequestProperty("X-SIMULATOR-ACCESS-KEY", "bc88864498a705657486edb636196e31");
+ rd = new BufferedReader(new InputStreamReader(conn.getInputStream()));
+ while ((line = rd.readLine()) != null) {
+ result += line;
+ }
+ rd.close();
+
+ return new JSONObject(result);
+ } catch (JSONException e) {
+ e.printStackTrace();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ return null;
+ }
+
+ protected void onPostExecute(JSONObject result) {
+ if (result != null)
+ listener.onResponse(result);
+ else
+ errorListener.onErrorResponse(new VolleyError("JSONObject null"));
+ };
+
+ }.execute();
+
+ }
+
+ /**
+ *
+ * Make GET request
+ */
+ public void get(String path, Listener listener, ErrorListener errorListener){
+ get(path, null, null, listener, errorListener);
+ }
+ /**
+ * Make GET request with params
+ */
+ public void get(String path, Map<String, String> params, RetryPolicy retryPolicy, Listener listener, ErrorListener errorListener){
+ // turn params into querystring
+
+ RestRequest request = mRestClient.makeRequest(Method.GET, RestClient.getAbsoluteURL(path, params), null, listener, errorListener);
+ if(retryPolicy == null) {
+ retryPolicy = new DefaultRetryPolicy(REST_TIMEOUT_MS, REST_MAX_RETRIES_GET, REST_BACKOFF_MULT);
+ }
+ request.setRetryPolicy(retryPolicy);
+ Request authCheck = new Request(request, errorListener);
+ authCheck.send();
+ }
+ /**
+ * Make POST request
+ */
+ public void post(String path, Listener listener, ErrorListener errorListener){
+ post(path, null, null, listener, errorListener);
+ }
+ /**
+ * Make POST request with params
+ */
+ public void post(final String path, Map<String, String> params, RetryPolicy retryPolicy, Listener listener, ErrorListener errorListener){
+ final RestRequest request = mRestClient.makeRequest(Method.POST, RestClient.getAbsoluteURL(path), params, listener, errorListener);
+ if(retryPolicy == null) {
+ retryPolicy = new DefaultRetryPolicy(REST_TIMEOUT_MS, REST_MAX_RETRIES_POST, REST_BACKOFF_MULT); //Do not retry on failure
+ }
+ request.setRetryPolicy(retryPolicy);
+ Request authCheck = new Request(request, errorListener);
+ authCheck.send();
+ }
+ /**
+ * Interface that provides a method that should perform the necessary task to make sure
+ * the provided Request will be authenticated.
+ *
+ * The Authenticator must call Request.send() when it has completed its operations. For
+ * convenience the Request class provides Request.setAccessToken so the Authenticator can
+ * easily update the access token.
+ */
+ public interface Authenticator {
+ void authenticate(Request request);
+ }
+
+ /**
+ * Encapsulates the behaviour for asking the Authenticator for an access token. This
+ * allows the request maker to disregard the authentication state when making requests.
+ */
+ public class Request {
+
+ static public final String SITE_PREFIX = "https://public-api.wordpress.com/rest/v1/sites/";
+ RestRequest mRequest;
+ RestRequest.ErrorListener mListener;
+
+ protected Request(RestRequest request, ErrorListener listener){
+ mRequest = request;
+ mListener = listener;
+ }
+
+ public String getSiteId() {
+ // parse out the site id from the url
+ String url = mRequest.getUrl();
+
+ if (url.startsWith(SITE_PREFIX) && !SITE_PREFIX.equals(url)) {
+ int marker = SITE_PREFIX.length();
+ if (url.indexOf("/", marker) < marker)
+ return null;
+ return url.substring(marker, url.indexOf("/", marker));
+ }
+ // not a sites/$siteId request
+ return null;
+ }
+
+ /**
+ * Attempt to send the request, checks to see if we have an access token and if not
+ * asks the Authenticator to authenticate the request.
+ *
+ * If no Authenticator is provided the request is always sent.
+ */
+ protected void send(){
+ if (mAuthenticator == null) {
+ mRestClient.send(mRequest);
+ } else {
+ mAuthenticator.authenticate(this);
+ }
+ }
+
+ public void sendWithAccessToken(String token){
+ mRequest.setAccessToken(token.toString());
+ mRestClient.send(mRequest);
+ }
+
+ public void sendWithAccessToken(Oauth.Token token){
+ sendWithAccessToken(token.toString());
+ }
+
+ /**
+ * If an access token cannot be obtained the request can be aborted and the
+ * handler's onFailure method is called
+ */
+ public void abort(VolleyError error){
+ if (mListener != null) {
+ mListener.onErrorResponse(error);
+ }
+ }
+
+ }
+
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/WPUnderlineSpan.java b/WordPress/src/main/java/org/wordpress/android/util/WPUnderlineSpan.java
new file mode 100644
index 000000000..ac8faacd9
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/WPUnderlineSpan.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2006 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.wordpress.android.util;
+
+import android.os.Parcel;
+import android.text.ParcelableSpan;
+import android.text.TextPaint;
+import android.text.style.CharacterStyle;
+import android.text.style.UpdateAppearance;
+
+public class WPUnderlineSpan extends CharacterStyle
+ implements UpdateAppearance, ParcelableSpan {
+ public WPUnderlineSpan() {
+ }
+
+ public WPUnderlineSpan(Parcel src) {
+ }
+
+ public int getSpanTypeId() {
+ return 6;
+ }
+
+ public int describeContents() {
+ return 0;
+ }
+
+ public void writeToParcel(Parcel dest, int flags) {
+ }
+
+ @Override
+ public void updateDrawState(TextPaint ds) {
+ ds.setUnderlineText(true);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/WPViewPager.java b/WordPress/src/main/java/org/wordpress/android/util/WPViewPager.java
new file mode 100644
index 000000000..ec01a0489
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/WPViewPager.java
@@ -0,0 +1,49 @@
+package org.wordpress.android.util;
+
+import android.content.Context;
+import android.support.v4.view.ViewPager;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+
+public class WPViewPager extends ViewPager {
+ private boolean enabled;
+ private int mPreviousPage;
+
+ public WPViewPager(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ this.enabled = false;
+ mPreviousPage = 0;
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ if (this.enabled) {
+ return super.onTouchEvent(event);
+ }
+
+ return false;
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(MotionEvent event) {
+ if (this.enabled) {
+ return super.onInterceptTouchEvent(event);
+ }
+
+ return false;
+ }
+
+ public void setPagingEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ @Override
+ public void setCurrentItem(int currentItem) {
+ mPreviousPage = getCurrentItem();
+ super.setCurrentItem(currentItem);
+ }
+
+ public int getPreviousPage() {
+ return mPreviousPage;
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/WPWebChromeClient.java b/WordPress/src/main/java/org/wordpress/android/util/WPWebChromeClient.java
new file mode 100644
index 000000000..6a40c6f38
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/WPWebChromeClient.java
@@ -0,0 +1,29 @@
+package org.wordpress.android.util;
+
+import android.app.Activity;
+import android.view.View;
+import android.webkit.WebChromeClient;
+import android.webkit.WebView;
+import android.widget.ProgressBar;
+
+public class WPWebChromeClient extends WebChromeClient {
+ private ProgressBar mProgressBar;
+ private Activity mActivity;
+
+ public WPWebChromeClient(Activity activity, ProgressBar progressBar) {
+ mProgressBar = progressBar;
+ mActivity = activity;
+ }
+
+ public void onProgressChanged(WebView webView, int progress) {
+ if (!mActivity.isFinishing()) {
+ mActivity.setTitle(webView.getTitle());
+ }
+ if (progress == 100) {
+ mProgressBar.setVisibility(View.GONE);
+ } else {
+ mProgressBar.setVisibility(View.VISIBLE);
+ mProgressBar.setProgress(progress);
+ }
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/wordpress/android/util/WPWebViewClient.java b/WordPress/src/main/java/org/wordpress/android/util/WPWebViewClient.java
new file mode 100644
index 000000000..d62fc4d22
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/WPWebViewClient.java
@@ -0,0 +1,83 @@
+package org.wordpress.android.util;
+
+import android.graphics.Bitmap;
+import android.net.http.SslError;
+import android.webkit.HttpAuthHandler;
+import android.webkit.SslErrorHandler;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.networking.SelfSignedSSLCertsManager;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+
+/**
+ * WebViewClient that is capable of handling HTTP authentication requests using the HTTP
+ * username and password of the blog configured for this activity.
+ */
+public class WPWebViewClient extends WebViewClient {
+ private final Blog mBlog;
+ private String mCurrentUrl;
+
+ public WPWebViewClient(Blog blog) {
+ super();
+ this.mBlog = blog;
+ }
+
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, String url) {
+ // Found a bug on some pages where there is an incorrect
+ // auto-redirect to file:///android_asset/webkit/.
+ if (!url.equals("file:///android_asset/webkit/")) {
+ view.loadUrl(url);
+ }
+ return true;
+ }
+
+ @Override
+ public void onPageFinished(WebView view, String url) {
+ }
+
+ @Override
+ public void onPageStarted(WebView view, String url, Bitmap favicon) {
+ super.onPageStarted(view, url, favicon);
+ mCurrentUrl = url;
+ }
+
+ @Override
+ public void onReceivedHttpAuthRequest(WebView view, HttpAuthHandler handler, String host, String realm) {
+ if (mBlog != null && mBlog.hasValidHTTPAuthCredentials()) {
+ // Check that the HTTP AUth protected domain is the same of the blog. Do not send current blog's HTTP
+ // AUTH credentials to external site.
+ // NOTE: There is still a small security hole here, since the realm is not considered when getting
+ // the password. Unfortunately the real is not stored when setting up the blog, and we cannot compare it
+ // at this point.
+ String domainFromHttpAuthRequest = UrlUtils.getDomainFromUrl(UrlUtils.addUrlSchemeIfNeeded(host, false));
+ String currentBlogDomain = UrlUtils.getDomainFromUrl(mBlog.getUrl());
+ if (domainFromHttpAuthRequest.equals(currentBlogDomain)) {
+ handler.proceed(mBlog.getHttpuser(), mBlog.getHttppassword());
+ return;
+ }
+ }
+ // TODO: If there is no match show the HTTP Auth dialog here. Like a normal browser usually does...
+ super.onReceivedHttpAuthRequest(view, handler, host, realm);
+ }
+
+ @Override
+ public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
+ try {
+ if (SelfSignedSSLCertsManager.getInstance(view.getContext()).isCertificateTrusted(error.getCertificate())) {
+ handler.proceed();
+ return;
+ }
+ } catch (GeneralSecurityException e) {
+ // Do nothing
+ } catch (IOException e) {
+ // Do nothing
+ }
+
+ super.onReceivedSslError(view, handler, error);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/stats/AnalyticsTracker.java b/WordPress/src/main/java/org/wordpress/android/util/stats/AnalyticsTracker.java
new file mode 100644
index 000000000..b01c385e6
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/stats/AnalyticsTracker.java
@@ -0,0 +1,145 @@
+package org.wordpress.android.util.stats;
+
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+
+import org.wordpress.android.WordPress;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+public final class AnalyticsTracker {
+ private static boolean mHasUserOptedOut;
+
+ public enum Stat {
+ APPLICATION_OPENED,
+ APPLICATION_CLOSED,
+ THEMES_ACCESSED_THEMES_BROWSER,
+ THEMES_CHANGED_THEME,
+ READER_ACCESSED,
+ READER_OPENED_ARTICLE,
+ READER_LIKED_ARTICLE,
+ READER_REBLOGGED_ARTICLE,
+ READER_INFINITE_SCROLL,
+ READER_FOLLOWED_READER_TAG,
+ READER_UNFOLLOWED_READER_TAG,
+ READER_FOLLOWED_SITE,
+ READER_LOADED_TAG,
+ READER_LOADED_FRESHLY_PRESSED,
+ READER_COMMENTED_ON_ARTICLE,
+ STATS_ACCESSED,
+ EDITOR_CREATED_POST,
+ EDITOR_ADDED_PHOTO_VIA_LOCAL_LIBRARY,
+ EDITOR_ADDED_PHOTO_VIA_WP_MEDIA_LIBRARY,
+ EDITOR_UPDATED_POST,
+ EDITOR_SCHEDULED_POST,
+ EDITOR_PUBLISHED_POST,
+ EDITOR_PUBLISHED_POST_WITH_PHOTO,
+ EDITOR_PUBLISHED_POST_WITH_VIDEO,
+ EDITOR_PUBLISHED_POST_WITH_CATEGORIES,
+ EDITOR_PUBLISHED_POST_WITH_TAGS,
+ NOTIFICATIONS_ACCESSED,
+ NOTIFICATIONS_OPENED_NOTIFICATION_DETAILS,
+ NOTIFICATION_PERFORMED_ACTION,
+ NOTIFICATION_REPLIED_TO,
+ NOTIFICATION_APPROVED,
+ NOTIFICATION_TRASHED,
+ NOTIFICATION_FLAGGED_AS_SPAM,
+ OPENED_POSTS,
+ OPENED_PAGES,
+ OPENED_COMMENTS,
+ OPENED_VIEW_SITE,
+ OPENED_VIEW_ADMIN,
+ OPENED_MEDIA_LIBRARY,
+ OPENED_SETTINGS,
+ CREATED_ACCOUNT,
+ CREATED_SITE,
+ SHARED_ITEM,
+ ADDED_SELF_HOSTED_SITE,
+ SIGNED_INTO_JETPACK,
+ PERFORMED_JETPACK_SIGN_IN_FROM_STATS_SCREEN,
+ STATS_SELECTED_INSTALL_JETPACK,
+ }
+
+ public interface Tracker {
+ void track(Stat stat);
+ void track(Stat stat, Map<String, ?> properties);
+ void beginSession();
+ void endSession();
+ void clearAllData();
+ }
+
+ private static final List<Tracker> TRACKERS = new ArrayList<Tracker>();
+
+ private AnalyticsTracker() {
+ }
+
+ public static void init() {
+ loadPrefHasUserOptedOut(false);
+ }
+
+ public static void loadPrefHasUserOptedOut(boolean manageSession) {
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(WordPress.getContext());
+
+ boolean hasUserOptedOut = !prefs.getBoolean("wp_pref_send_usage_stats", true);
+ if (hasUserOptedOut != mHasUserOptedOut && manageSession) {
+ mHasUserOptedOut = hasUserOptedOut;
+ if (mHasUserOptedOut) {
+ endSession(true);
+ clearAllData();
+ } else {
+ beginSession();
+ }
+ }
+ }
+
+ public static void registerTracker(Tracker tracker) {
+ if (tracker != null) {
+ TRACKERS.add(tracker);
+ }
+ }
+
+ public static void track(Stat stat) {
+ if (mHasUserOptedOut) {
+ return;
+ }
+ for (Tracker tracker : TRACKERS) {
+ tracker.track(stat);
+ }
+ }
+
+ public static void track(Stat stat, Map<String, ?> properties) {
+ if (mHasUserOptedOut) {
+ return;
+ }
+ for (Tracker tracker : TRACKERS) {
+ tracker.track(stat, properties);
+ }
+ }
+
+ public static void beginSession() {
+ if (mHasUserOptedOut) {
+ return;
+ }
+ for (Tracker tracker : TRACKERS) {
+ tracker.beginSession();
+ }
+ }
+
+ public static void endSession(boolean force) {
+ if (mHasUserOptedOut && !force) {
+ return;
+ }
+ for (Tracker tracker : TRACKERS) {
+ tracker.endSession();
+ }
+ }
+
+ public static void clearAllData() {
+ for (Tracker tracker : TRACKERS) {
+ tracker.clearAllData();
+ }
+ }
+}
+
diff --git a/WordPress/src/main/java/org/wordpress/android/util/stats/AnalyticsTrackerMixpanel.java b/WordPress/src/main/java/org/wordpress/android/util/stats/AnalyticsTrackerMixpanel.java
new file mode 100644
index 000000000..274df3b4a
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/stats/AnalyticsTrackerMixpanel.java
@@ -0,0 +1,488 @@
+package org.wordpress.android.util.stats;
+
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+
+import com.mixpanel.android.mpmetrics.MixpanelAPI;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.BuildConfig;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.AppLog;
+
+import java.util.EnumMap;
+import java.util.Iterator;
+import java.util.Map;
+
+public class AnalyticsTrackerMixpanel implements AnalyticsTracker.Tracker {
+ private MixpanelAPI mMixpanel;
+ private EnumMap<AnalyticsTracker.Stat, JSONObject> mAggregatedProperties;
+ private static final String SESSION_COUNT = "sessionCount";
+ private static final String MIXPANEL_PLATFORM = "platform";
+ private static final String MIXPANEL_SESSION_COUNT = "session_count";
+ private static final String DOTCOM_USER = "dotcom_user";
+ private static final String JETPACK_USER = "jetpack_user";
+ private static final String MIXPANEL_NUMBER_OF_BLOGS = "number_of_blogs";
+
+ public AnalyticsTrackerMixpanel() {
+ mAggregatedProperties = new EnumMap<AnalyticsTracker.Stat, JSONObject>(AnalyticsTracker.Stat.class);
+ mMixpanel = MixpanelAPI.getInstance(WordPress.getContext(), BuildConfig.MIXPANEL_TOKEN);
+ }
+
+ @Override
+ public void track(AnalyticsTracker.Stat stat) {
+ track(stat, null);
+ }
+
+ @Override
+ public void track(AnalyticsTracker.Stat stat, Map<String, ?> properties) {
+ AnalyticsTrackerMixpanelInstructionsForStat instructions = instructionsForStat(stat);
+
+ if (instructions == null) {
+ return;
+ }
+
+ trackMixpanelDataForInstructions(instructions, properties);
+ }
+
+ private void trackMixpanelDataForInstructions(AnalyticsTrackerMixpanelInstructionsForStat instructions,
+ Map<String, ?> properties) {
+ if (instructions.getDisableForSelfHosted()) {
+ return;
+ }
+
+ trackMixpanelEventForInstructions(instructions, properties);
+ trackMixpanelPropertiesForInstructions(instructions);
+ }
+
+ private void trackMixpanelPropertiesForInstructions(AnalyticsTrackerMixpanelInstructionsForStat instructions) {
+ if (instructions.getPeoplePropertyToIncrement() != null
+ && !instructions.getPeoplePropertyToIncrement().isEmpty()) {
+ incrementPeopleProperty(instructions.getPeoplePropertyToIncrement());
+ }
+
+ if (instructions.getSuperPropertyToIncrement() != null
+ && !instructions.getSuperPropertyToIncrement().isEmpty()) {
+ incrementSuperProperty(instructions.getSuperPropertyToIncrement());
+ }
+
+ if (instructions.getPropertyToIncrement() != null && !instructions.getPropertyToIncrement().isEmpty()) {
+ incrementProperty(instructions.getPropertyToIncrement(), instructions.getStatToAttachProperty());
+ }
+
+ if (instructions.getSuperPropertiesToFlag() != null && instructions.getSuperPropertiesToFlag().size() > 0) {
+ for (String superPropertyToFlag : instructions.getSuperPropertiesToFlag()) {
+ flagSuperProperty(superPropertyToFlag);
+ }
+ }
+ }
+
+ private void trackMixpanelEventForInstructions(AnalyticsTrackerMixpanelInstructionsForStat instructions,
+ Map<String, ?> properties) {
+ String eventName = instructions.getMixpanelEventName();
+ if (eventName != null && !eventName.isEmpty()) {
+ JSONObject savedPropertiesForStat = propertiesForStat(instructions.getStat());
+ if (savedPropertiesForStat == null) {
+ savedPropertiesForStat = new JSONObject();
+ }
+
+ // Retrieve properties user has already passed in and combine them with the saved properties
+ if (properties != null) {
+ Iterator iter = properties.entrySet().iterator();
+ while (iter.hasNext()) {
+ Map.Entry pairs = (Map.Entry) iter.next();
+ String key = (String) pairs.getKey();
+ try {
+ Object value = pairs.getValue();
+ savedPropertiesForStat.put(key, value);
+ } catch (JSONException e) {
+ AppLog.e(AppLog.T.UTILS, e);
+ }
+ }
+ }
+ mMixpanel.track(eventName, savedPropertiesForStat);
+ removePropertiesForStat(instructions.getStat());
+ }
+ }
+
+ @Override
+ public void beginSession() {
+ // Tracking session count will help us isolate users who just installed the app
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(WordPress.getContext());
+ int sessionCount = preferences.getInt(SESSION_COUNT, 0);
+ sessionCount++;
+ SharedPreferences.Editor editor = preferences.edit();
+ editor.putInt(SESSION_COUNT, sessionCount);
+ editor.commit();
+
+ // Register super properties
+ boolean connected = WordPress.hasValidWPComCredentials(WordPress.getContext());
+ boolean jetpackUser = WordPress.wpDB.hasAnyJetpackBlogs();
+ int numBlogs = WordPress.wpDB.getVisibleAccounts().size();
+ try {
+ JSONObject properties = new JSONObject();
+ properties.put(MIXPANEL_PLATFORM, "Android");
+ properties.put(MIXPANEL_SESSION_COUNT, sessionCount);
+ properties.put(DOTCOM_USER, connected);
+ properties.put(JETPACK_USER, jetpackUser);
+ properties.put(MIXPANEL_NUMBER_OF_BLOGS, numBlogs);
+ mMixpanel.registerSuperProperties(properties);
+ } catch (JSONException e) {
+ AppLog.e(AppLog.T.UTILS, e);
+ }
+
+ // Application opened and start.
+ if (connected) {
+ String username = preferences.getString(WordPress.WPCOM_USERNAME_PREFERENCE, null);
+ mMixpanel.identify(username);
+ mMixpanel.getPeople().identify(username);
+
+ try {
+ JSONObject jsonObj = new JSONObject();
+ jsonObj.put("$username", username);
+ jsonObj.put("$first_name", username);
+ mMixpanel.getPeople().set(jsonObj);
+ } catch (JSONException e) {
+ AppLog.e(AppLog.T.UTILS, e);
+ }
+ }
+ }
+
+ @Override
+ public void endSession() {
+ mAggregatedProperties.clear();
+ mMixpanel.flush();
+ }
+
+ @Override
+ public void clearAllData() {
+ mMixpanel.clearSuperProperties();
+ mMixpanel.getPeople().clearPushRegistrationId();
+ }
+
+ private AnalyticsTrackerMixpanelInstructionsForStat instructionsForStat(AnalyticsTracker.Stat stat) {
+ AnalyticsTrackerMixpanelInstructionsForStat instructions = null;
+ switch (stat) {
+ case APPLICATION_OPENED:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Application Opened");
+ instructions.setSuperPropertyToIncrement("Application Opened");
+ break;
+ case APPLICATION_CLOSED:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Application Closed");
+ break;
+ case THEMES_ACCESSED_THEMES_BROWSER:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Themes - Accessed Theme Browser");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_accessed_theme_browser");
+ break;
+ case THEMES_CHANGED_THEME:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Themes - Changed Theme");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_changed_theme");
+ break;
+ case READER_ACCESSED:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Reader - Accessed");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_accessed_reader");
+ break;
+ case READER_OPENED_ARTICLE:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Reader - Opened Article");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_opened_article");
+ break;
+ case READER_LIKED_ARTICLE:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Reader - Liked Article");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_liked_article");
+ break;
+ case READER_REBLOGGED_ARTICLE:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Reader - Reblogged Article");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_reblogged_article");
+ break;
+ case READER_INFINITE_SCROLL:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Reader - Infinite Scroll");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement(
+ "number_of_times_reader_performed_infinite_scroll");
+ break;
+ case READER_FOLLOWED_READER_TAG:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Reader - Followed Reader Tag");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_followed_reader_tag");
+ break;
+ case READER_UNFOLLOWED_READER_TAG:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Reader - Unfollowed Reader Tag");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_unfollowed_reader_tag");
+ break;
+ case READER_LOADED_TAG:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Reader - Loaded Tag");
+ break;
+ case READER_LOADED_FRESHLY_PRESSED:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Reader - Loaded Freshly Pressed");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_loaded_freshly_pressed");
+ break;
+ case READER_COMMENTED_ON_ARTICLE:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Reader - Commented on Article");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_commented_on_article");
+ break;
+ case READER_FOLLOWED_SITE:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Reader - Followed Site");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_followed_site");
+ break;
+ case EDITOR_CREATED_POST:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Editor - Created Post");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_editor_created_post");
+ break;
+ case EDITOR_ADDED_PHOTO_VIA_LOCAL_LIBRARY:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Editor - Added Photo via Local Library");
+ instructions.
+ setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_added_photo_via_local_library");
+ break;
+ case EDITOR_ADDED_PHOTO_VIA_WP_MEDIA_LIBRARY:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Editor - Added Photo via WP Media Library");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement(
+ "number_of_times_added_photo_via_wp_media_library");
+ break;
+ case EDITOR_PUBLISHED_POST:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Editor - Published Post");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_editor_published_post");
+ break;
+ case EDITOR_UPDATED_POST:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Editor - Updated Post");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_editor_updated_post");
+ break;
+ case EDITOR_SCHEDULED_POST:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Editor - Scheduled Post");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_editor_scheduled_post");
+ break;
+ case EDITOR_PUBLISHED_POST_WITH_PHOTO:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsWithSuperPropertyAndPeoplePropertyIncrementor(
+ "number_of_published_posts_with_photos");
+ break;
+ case EDITOR_PUBLISHED_POST_WITH_VIDEO:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsWithSuperPropertyAndPeoplePropertyIncrementor(
+ "number_of_published_posts_with_videos");
+ break;
+ case EDITOR_PUBLISHED_POST_WITH_CATEGORIES:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsWithSuperPropertyAndPeoplePropertyIncrementor(
+ "number_of_published_posts_with_categories");
+ break;
+ case EDITOR_PUBLISHED_POST_WITH_TAGS:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsWithSuperPropertyAndPeoplePropertyIncrementor(
+ "number_of_published_posts_with_tags");
+ break;
+ case NOTIFICATIONS_ACCESSED:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Notifications - Accessed");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_accessed_notifications");
+ break;
+ case NOTIFICATIONS_OPENED_NOTIFICATION_DETAILS:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Notifications - Opened Notification Details");
+ instructions.
+ setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_opened_notification_details");
+ break;
+ case NOTIFICATION_PERFORMED_ACTION:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsWithSuperPropertyAndPeoplePropertyIncrementor(
+ "number_of_times_notifications_performed_action_against");
+ break;
+ case NOTIFICATION_APPROVED:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsWithSuperPropertyAndPeoplePropertyIncrementor(
+ "number_of_times_notifications_approved");
+ break;
+ case NOTIFICATION_REPLIED_TO:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsWithSuperPropertyAndPeoplePropertyIncrementor(
+ "number_of_times_notifications_replied_to");
+ break;
+ case NOTIFICATION_TRASHED:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsWithSuperPropertyAndPeoplePropertyIncrementor(
+ "number_of_times_notifications_trashed");
+ break;
+ case NOTIFICATION_FLAGGED_AS_SPAM:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsWithSuperPropertyAndPeoplePropertyIncrementor(
+ "number_of_times_notifications_flagged_as_spam");
+ break;
+ case OPENED_POSTS:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsWithPropertyIncrementor(
+ "number_of_times_opened_posts", AnalyticsTracker.Stat.APPLICATION_CLOSED);
+ break;
+ case OPENED_PAGES:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsWithPropertyIncrementor(
+ "number_of_times_opened_pages", AnalyticsTracker.Stat.APPLICATION_CLOSED);
+ break;
+ case OPENED_COMMENTS:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsWithPropertyIncrementor(
+ "number_of_times_opened_comments", AnalyticsTracker.Stat.APPLICATION_CLOSED);
+ break;
+ case OPENED_VIEW_SITE:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsWithPropertyIncrementor(
+ "number_of_times_opened_view_site", AnalyticsTracker.Stat.APPLICATION_CLOSED);
+ break;
+ case OPENED_VIEW_ADMIN:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsWithPropertyIncrementor(
+ "number_of_times_opened_view_admin", AnalyticsTracker.Stat.APPLICATION_CLOSED);
+ instructions.
+ setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_opened_view_admin");
+ break;
+ case OPENED_MEDIA_LIBRARY:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsWithPropertyIncrementor(
+ "number_of_times_opened_media_library", AnalyticsTracker.Stat.APPLICATION_CLOSED);
+ break;
+ case OPENED_SETTINGS:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsWithPropertyIncrementor(
+ "number_of_times_opened_settings", AnalyticsTracker.Stat.APPLICATION_CLOSED);
+ break;
+ case CREATED_ACCOUNT:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Created Account");
+ break;
+ case CREATED_SITE:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Created Site");
+ break;
+ case SHARED_ITEM:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsWithSuperPropertyAndPeoplePropertyIncrementor("number_of_items_share");
+ break;
+ case ADDED_SELF_HOSTED_SITE:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Added Self Hosted Site");
+ break;
+ case SIGNED_INTO_JETPACK:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Signed into Jetpack");
+ instructions.addSuperPropertyToFlag("jetpack_user");
+ instructions.addSuperPropertyToFlag("dotcom_user");
+ break;
+ case PERFORMED_JETPACK_SIGN_IN_FROM_STATS_SCREEN:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Signed into Jetpack from Stats Screen");
+ break;
+ case STATS_ACCESSED:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Stats - Accessed");
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement("number_of_times_accessed_stats");
+ break;
+ case STATS_SELECTED_INSTALL_JETPACK:
+ instructions = AnalyticsTrackerMixpanelInstructionsForStat.
+ mixpanelInstructionsForEventName("Selected Install Jetpack");
+ break;
+ default:
+ instructions = null;
+ break;
+ }
+ return instructions;
+ }
+
+ private void incrementPeopleProperty(String property) {
+ mMixpanel.getPeople().increment(property, 1);
+ }
+
+ private void incrementSuperProperty(String property) {
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(WordPress.getContext());
+ int propertyCount = preferences.getInt(property, 0);
+ propertyCount++;
+ SharedPreferences.Editor editor = preferences.edit();
+ editor.putInt(property, propertyCount);
+ editor.commit();
+
+ try {
+ JSONObject superProperty = new JSONObject();
+ superProperty.put(property, propertyCount);
+ mMixpanel.registerSuperProperties(superProperty);
+ } catch (JSONException e) {
+ AppLog.e(AppLog.T.UTILS, e);
+ }
+ }
+
+ private void flagSuperProperty(String property) {
+ try {
+ JSONObject superProperty = new JSONObject();
+ superProperty.put(property, true);
+ mMixpanel.registerSuperProperties(superProperty);
+ } catch (JSONException e) {
+ AppLog.e(AppLog.T.UTILS, e);
+ }
+ }
+
+ private void savePropertyValueForStat(String property, Object value, AnalyticsTracker.Stat stat) {
+ JSONObject properties = mAggregatedProperties.get(stat);
+ if (properties == null) {
+ properties = new JSONObject();
+ mAggregatedProperties.put(stat, properties);
+ }
+
+ try {
+ properties.put(property, value);
+ } catch (JSONException e) {
+ AppLog.e(AppLog.T.UTILS, e);
+ }
+ }
+
+ private JSONObject propertiesForStat(AnalyticsTracker.Stat stat) {
+ return mAggregatedProperties.get(stat);
+ }
+
+ private void removePropertiesForStat(AnalyticsTracker.Stat stat) {
+ mAggregatedProperties.remove(stat);
+ }
+
+ private Object propertyForStat(String property, AnalyticsTracker.Stat stat) {
+ JSONObject properties = mAggregatedProperties.get(stat);
+ if (properties == null) {
+ return null;
+ }
+
+ try {
+ Object valueForProperty = properties.get(property);
+ return valueForProperty;
+ } catch (JSONException e) {
+ // We are okay with swallowing this exception as the next line will just return a null value
+ }
+
+ return null;
+ }
+
+ private void incrementProperty(String property, AnalyticsTracker.Stat stat) {
+ Object currentValueObj = propertyForStat(property, stat);
+ int currentValue = 1;
+ if (currentValueObj != null) {
+ currentValue = Integer.valueOf(currentValueObj.toString());
+ currentValue++;
+ }
+
+ savePropertyValueForStat(property, Integer.toString(currentValue), stat);
+ }
+}
+
diff --git a/WordPress/src/main/java/org/wordpress/android/util/stats/AnalyticsTrackerMixpanelInstructionsForStat.java b/WordPress/src/main/java/org/wordpress/android/util/stats/AnalyticsTrackerMixpanelInstructionsForStat.java
new file mode 100644
index 000000000..e0dc381d8
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/stats/AnalyticsTrackerMixpanelInstructionsForStat.java
@@ -0,0 +1,109 @@
+package org.wordpress.android.util.stats;
+
+import java.util.ArrayList;
+
+public class AnalyticsTrackerMixpanelInstructionsForStat {
+ private String mMixpanelEventName;
+ private String mSuperPropertyToIncrement;
+ private String mPeoplePropertyToIncrement;
+ private ArrayList<String> mSuperPropertiesToFlag;
+ private AnalyticsTracker.Stat mStatToAttachProperty;
+ private AnalyticsTracker.Stat mStat;
+ private String mPropertyToIncrement;
+ private boolean mDisableForSelfHosted;
+
+ public static AnalyticsTrackerMixpanelInstructionsForStat mixpanelInstructionsForEventName(String eventName) {
+ AnalyticsTrackerMixpanelInstructionsForStat instructions = new AnalyticsTrackerMixpanelInstructionsForStat();
+ instructions.setMixpanelEventName(eventName);
+ return instructions;
+ }
+
+ public static AnalyticsTrackerMixpanelInstructionsForStat
+ mixpanelInstructionsWithSuperPropertyAndPeoplePropertyIncrementor(String property) {
+ AnalyticsTrackerMixpanelInstructionsForStat instructions = new AnalyticsTrackerMixpanelInstructionsForStat();
+ instructions.setSuperPropertyAndPeoplePropertyToIncrement(property);
+ return instructions;
+ }
+
+ public static AnalyticsTrackerMixpanelInstructionsForStat mixpanelInstructionsWithPropertyIncrementor(
+ String property, AnalyticsTracker.Stat stat) {
+ AnalyticsTrackerMixpanelInstructionsForStat instructions = new AnalyticsTrackerMixpanelInstructionsForStat();
+ instructions.setStatToAttachProperty(stat);
+ instructions.setPropertyToIncrement(property);
+ return instructions;
+ }
+
+ public String getMixpanelEventName() {
+ return mMixpanelEventName;
+ }
+
+ public void setMixpanelEventName(String mixpanelEventName) {
+ this.mMixpanelEventName = mixpanelEventName;
+ }
+
+ public String getSuperPropertyToIncrement() {
+ return mSuperPropertyToIncrement;
+ }
+
+ public void setSuperPropertyToIncrement(String superPropertyToIncrement) {
+ this.mSuperPropertyToIncrement = superPropertyToIncrement;
+ }
+
+ public String getPeoplePropertyToIncrement() {
+ return mPeoplePropertyToIncrement;
+ }
+
+ public void setPeoplePropertyToIncrement(String peoplePropertyToIncrement) {
+ this.mPeoplePropertyToIncrement = peoplePropertyToIncrement;
+ }
+
+ public void setSuperPropertyAndPeoplePropertyToIncrement(String property) {
+ setSuperPropertyToIncrement(property);
+ setPeoplePropertyToIncrement(property);
+ }
+
+ public AnalyticsTracker.Stat getStatToAttachProperty() {
+ return mStatToAttachProperty;
+ }
+
+ public void setStatToAttachProperty(AnalyticsTracker.Stat statToAttachProperty) {
+ this.mStatToAttachProperty = statToAttachProperty;
+ }
+
+ public String getPropertyToIncrement() {
+ return mPropertyToIncrement;
+ }
+
+ public void setPropertyToIncrement(String propertyToIncrement) {
+ this.mPropertyToIncrement = propertyToIncrement;
+ }
+
+ public boolean getDisableForSelfHosted() {
+ return mDisableForSelfHosted;
+ }
+
+ public void setDisableForSelfHosted(boolean disableForSelfHosted) {
+ this.mDisableForSelfHosted = disableForSelfHosted;
+ }
+
+ public AnalyticsTracker.Stat getStat() {
+ return mStat;
+ }
+
+ public void setStat(AnalyticsTracker.Stat stat) {
+ this.mStat = stat;
+ }
+
+ public ArrayList<String> getSuperPropertiesToFlag() {
+ return mSuperPropertiesToFlag;
+ }
+
+ public void addSuperPropertyToFlag(String superPropertyToFlag) {
+ if( mSuperPropertiesToFlag == null) {
+ mSuperPropertiesToFlag = new ArrayList<String>();
+ }
+ if (!mSuperPropertiesToFlag.contains(superPropertyToFlag)) {
+ mSuperPropertiesToFlag.add(superPropertyToFlag);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/util/stats/AnalyticsTrackerWPCom.java b/WordPress/src/main/java/org/wordpress/android/util/stats/AnalyticsTrackerWPCom.java
new file mode 100644
index 000000000..1d709050f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/stats/AnalyticsTrackerWPCom.java
@@ -0,0 +1,71 @@
+package org.wordpress.android.util.stats;
+
+import com.android.volley.Request;
+import com.android.volley.Response;
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.StringRequest;
+
+import org.wordpress.android.Constants;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.AppLog;
+
+import java.util.Map;
+
+public class AnalyticsTrackerWPCom implements AnalyticsTracker.Tracker {
+ @Override
+ public void track(AnalyticsTracker.Stat stat) {
+ track(stat, null);
+ }
+
+ @Override
+ public void track(AnalyticsTracker.Stat stat, Map<String, ?> properties) {
+ switch (stat) {
+ case READER_LOADED_FRESHLY_PRESSED:
+ pingWPComStatsEndpoint("freshly");
+ break;
+ case READER_OPENED_ARTICLE:
+ pingWPComStatsEndpoint("details_page");
+ break;
+ case READER_ACCESSED:
+ pingWPComStatsEndpoint("home_page");
+ break;
+ default:
+ // Do nothing
+ }
+ }
+
+ @Override
+ public void beginSession() {
+ // No-op
+ }
+
+ @Override
+ public void endSession() {
+ // No-op
+ }
+
+ @Override
+ public void clearAllData() {
+ // No-op
+ }
+
+ private void pingWPComStatsEndpoint(String statName) {
+ Response.Listener<String> listener = new Response.Listener<String>() {
+ public void onResponse(String response) {
+ }
+ };
+ Response.ErrorListener errorListener = new Response.ErrorListener() {
+ @Override
+ public void onErrorResponse(VolleyError volleyError) {
+ String errMsg = String.format("Error pinging WPCom Stats: %s", volleyError.getMessage());
+ AppLog.w(AppLog.T.STATS, errMsg);
+ }
+ };
+
+ int rnd = (int) (Math.random() * 100000);
+ String statsURL = String.format("%s%s%s%s%d", Constants.readerURL_v3,
+ "&template=stats&stats_name=", statName, "&rnd=", rnd);
+ StringRequest req = new StringRequest(Request.Method.GET, statsURL, listener, errorListener);
+ WordPress.requestQueue.add(req);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/widgets/OpenSansButton.java b/WordPress/src/main/java/org/wordpress/android/widgets/OpenSansButton.java
new file mode 100644
index 000000000..9fa6dd26f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/widgets/OpenSansButton.java
@@ -0,0 +1,22 @@
+package org.wordpress.android.widgets;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.Button;
+
+public class OpenSansButton extends Button {
+ public OpenSansButton(Context context) {
+ super(context);
+ TypefaceCache.setCustomTypeface(context, this, null);
+ }
+
+ public OpenSansButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ TypefaceCache.setCustomTypeface(context, this, attrs);
+ }
+
+ public OpenSansButton(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ TypefaceCache.setCustomTypeface(context, this, attrs);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/widgets/OpenSansEditText.java b/WordPress/src/main/java/org/wordpress/android/widgets/OpenSansEditText.java
new file mode 100644
index 000000000..42d913836
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/widgets/OpenSansEditText.java
@@ -0,0 +1,22 @@
+package org.wordpress.android.widgets;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.EditText;
+
+public class OpenSansEditText extends EditText {
+ public OpenSansEditText(Context context) {
+ super(context);
+ TypefaceCache.setCustomTypeface(context, this, null);
+ }
+
+ public OpenSansEditText(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ TypefaceCache.setCustomTypeface(context, this, attrs);
+ }
+
+ public OpenSansEditText(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ TypefaceCache.setCustomTypeface(context, this, attrs);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/widgets/TypefaceCache.java b/WordPress/src/main/java/org/wordpress/android/widgets/TypefaceCache.java
new file mode 100644
index 000000000..c4bfedf0b
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/widgets/TypefaceCache.java
@@ -0,0 +1,103 @@
+package org.wordpress.android.widgets;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Typeface;
+import android.util.AttributeSet;
+import android.widget.TextView;
+
+import org.wordpress.android.R;
+
+import java.util.Hashtable;
+
+public class TypefaceCache {
+ protected static final int VARIATION_NORMAL = 0;
+ protected static final int VARIATION_LIGHT = 1;
+
+ private static final Hashtable<String, Typeface> mTypefaceCache = new Hashtable<String, Typeface>();
+
+ protected static Typeface getTypeface(Context context) {
+ return getTypeface(context, Typeface.NORMAL, VARIATION_NORMAL);
+ }
+ protected static Typeface getTypeface(Context context, int fontStyle, int variation) {
+ if (context == null)
+ return null;
+
+ // note that the "light" variation doesn't support bold or bold-italic
+ final String typefaceName;
+ switch (fontStyle) {
+ case Typeface.BOLD:
+ typefaceName = "OpenSans-Bold.ttf";
+ break;
+ case Typeface.ITALIC:
+ typefaceName = (variation == VARIATION_LIGHT ? "OpenSans-LightItalic.ttf" : "OpenSans-Italic.ttf");
+ break;
+ case Typeface.BOLD_ITALIC:
+ typefaceName = "OpenSans-BoldItalic.ttf";
+ break;
+ default:
+ typefaceName = (variation == VARIATION_LIGHT ? "OpenSans-Light.ttf" : "OpenSans-Regular.ttf");
+ break;
+ }
+
+ if (!mTypefaceCache.containsKey(typefaceName)) {
+ Typeface typeface = Typeface.createFromAsset(context.getApplicationContext().getAssets(), "fonts/"
+ + typefaceName);
+ if (typeface != null) {
+ mTypefaceCache.put(typefaceName, typeface);
+ }
+ }
+
+ return mTypefaceCache.get(typefaceName);
+ }
+
+ /*
+ * sets the typeface for a TextView (or TextView descendant such as EditText or Button) based on
+ * the passed attributes, defaults to normal typeface
+ */
+ protected static void setCustomTypeface(Context context, TextView view, AttributeSet attrs) {
+ if (context == null || view == null)
+ return;
+
+ // skip at design-time
+ if (view.isInEditMode())
+ return;
+
+ // read custom fontVariation from attributes, default to normal
+ int variation = TypefaceCache.VARIATION_NORMAL;
+ if (attrs != null) {
+ TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.WPTypeface, 0, 0);
+
+ if (a != null) {
+ try {
+ variation = a.getInteger(R.styleable.WPTypeface_fontVariation, TypefaceCache.VARIATION_NORMAL);
+ } finally {
+ a.recycle();
+ }
+ }
+ }
+
+ // determine the font style from the existing typeface
+ final int fontStyle;
+ if (view.getTypeface() != null) {
+ boolean isBold = view.getTypeface().isBold();
+ boolean isItalic = view.getTypeface().isItalic();
+ if (isBold && isItalic) {
+ fontStyle = Typeface.BOLD_ITALIC;
+ } else if (isBold) {
+ fontStyle = Typeface.BOLD;
+ } else if (isItalic) {
+ fontStyle = Typeface.ITALIC;
+ } else {
+ fontStyle = Typeface.NORMAL;
+ }
+ } else {
+ fontStyle = Typeface.NORMAL;
+ }
+
+ Typeface typeface = getTypeface(context, fontStyle, variation);
+ if (typeface != null) {
+ view.setTypeface(typeface);
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/widgets/WPLinearLayoutSizeBound.java b/WordPress/src/main/java/org/wordpress/android/widgets/WPLinearLayoutSizeBound.java
new file mode 100644
index 000000000..3c1d36822
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/widgets/WPLinearLayoutSizeBound.java
@@ -0,0 +1,45 @@
+package org.wordpress.android.widgets;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.widget.LinearLayout;
+
+import org.wordpress.android.R;
+
+
+public class WPLinearLayoutSizeBound extends LinearLayout {
+ private final int mMaxWidth;
+ private final int mMaxHeight;
+
+ public WPLinearLayoutSizeBound(Context context) {
+ super(context);
+ mMaxWidth = 0;
+ mMaxHeight = 0;
+ }
+
+ public WPLinearLayoutSizeBound(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ TypedArray a = getContext().obtainStyledAttributes(attrs,
+ R.styleable.WPLinearLayoutSizeBound);
+ mMaxWidth = a.getDimensionPixelSize(R.styleable.WPLinearLayoutSizeBound_maxWidth,
+ Integer.MAX_VALUE);
+ mMaxHeight = a.getDimensionPixelSize(R.styleable.WPLinearLayoutSizeBound_maxHeight,
+ Integer.MAX_VALUE);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int measuredWidth = MeasureSpec.getSize(widthMeasureSpec);
+ if (mMaxWidth > 0 && mMaxWidth < measuredWidth) {
+ int measureMode = MeasureSpec.getMode(widthMeasureSpec);
+ widthMeasureSpec = MeasureSpec.makeMeasureSpec(mMaxWidth, measureMode);
+ }
+ int measuredHeight = MeasureSpec.getSize(heightMeasureSpec);
+ if (mMaxHeight > 0 && mMaxHeight < measuredHeight) {
+ int measureMode = MeasureSpec.getMode(heightMeasureSpec);
+ heightMeasureSpec = MeasureSpec.makeMeasureSpec(mMaxHeight, measureMode);
+ }
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/widgets/WPListView.java b/WordPress/src/main/java/org/wordpress/android/widgets/WPListView.java
new file mode 100644
index 000000000..21b9eece7
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/widgets/WPListView.java
@@ -0,0 +1,106 @@
+package org.wordpress.android.widgets;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.ViewTreeObserver;
+import android.widget.ListView;
+
+/**
+ * ListView which reports scroll changes and offers additional properties
+ */
+public class WPListView extends ListView {
+ private float mLastMotionY;
+ private boolean mIsMoving;
+
+ // use this listener to detect when list is scrolled, even during a fling - note that
+ // this may fire very frequently, so make sure code inside listener is optimized
+ private ViewTreeObserver.OnScrollChangedListener mScrollChangedListener;
+
+ // use this listener to detect simple up/down scrolling
+ public interface OnScrollDirectionListener {
+ public void onScrollUp();
+ public void onScrollDown();
+ }
+ private OnScrollDirectionListener mOnScrollDirectionListener;
+
+ public WPListView(Context context) {
+ super(context);
+ }
+
+ public WPListView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public WPListView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ /*
+ * returns the vertical scroll position
+ */
+ public int getVerticalScrollOffset() {
+ return super.computeVerticalScrollOffset();
+ }
+
+ public void setOnScrollDirectionListener(OnScrollDirectionListener listener) {
+ mOnScrollDirectionListener = listener;
+ }
+
+ public void setOnScrollChangedListener(ViewTreeObserver.OnScrollChangedListener listener) {
+ mScrollChangedListener = listener;
+ }
+
+ @Override
+ protected void onScrollChanged(int l, int t, int oldl, int oldt) {
+ super.onScrollChanged(l, t, oldl, oldt);
+ if (mScrollChangedListener != null) {
+ mScrollChangedListener.onScrollChanged();
+ }
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ // detect when scrolling up/down if a direction listener is assigned
+ if (mOnScrollDirectionListener != null) {
+ int action = event.getAction() & MotionEvent.ACTION_MASK;
+
+ switch (action) {
+ case MotionEvent.ACTION_MOVE :
+ if (mIsMoving) {
+ int yDiff = (int) (event.getY() - mLastMotionY);
+ if (yDiff < 0) {
+ mOnScrollDirectionListener.onScrollDown();
+ } else if (yDiff > 0) {
+ mOnScrollDirectionListener.onScrollUp();
+ }
+ mLastMotionY = event.getY();
+ } else {
+ mIsMoving = true;
+ mLastMotionY = event.getY();
+ }
+ break;
+
+ default :
+ mIsMoving = false;
+ break;
+ }
+ }
+
+ return super.onTouchEvent(event);
+ }
+
+ public boolean isScrolledToTop() {
+ return (getChildCount() == 0 || getVerticalScrollOffset() == 0);
+ }
+
+ /*
+ * returns true if the listView can scroll up/down vertically
+ */
+ public boolean canScrollUp() {
+ return canScrollVertically(-1);
+ }
+ public boolean canScrollDown() {
+ return canScrollVertically(1);
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/widgets/WPNetworkImageView.java b/WordPress/src/main/java/org/wordpress/android/widgets/WPNetworkImageView.java
new file mode 100644
index 000000000..8052b8451
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/widgets/WPNetworkImageView.java
@@ -0,0 +1,376 @@
+package org.wordpress.android.widgets;
+
+import android.animation.ObjectAnimator;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.graphics.drawable.ColorDrawable;
+import android.os.Handler;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup.LayoutParams;
+import android.widget.ImageView;
+
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.ImageLoader;
+
+import org.wordpress.android.R;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.ReaderThumbnailTable;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.DisplayUtils;
+import org.wordpress.android.util.ReaderVideoUtils;
+import org.wordpress.android.util.VolleyUtils;
+
+/**
+ * most of the code below is from Volley's NetworkImageView, but it's modified to support:
+ * (1) fading in downloaded images
+ * (2) manipulating images before display
+ * (3) automatically retrieving the thumbnail for YouTube & Vimeo videos
+ * (4) adding a listener to determine when image request has completed or failed
+ * (5) automatically retrying mshot requests that return a 307
+ */
+public class WPNetworkImageView extends ImageView {
+ public static enum ImageType {NONE,
+ PHOTO,
+ PHOTO_FULL,
+ MSHOT,
+ VIDEO,
+ AVATAR}
+
+ private ImageType mImageType = ImageType.NONE;
+ private String mUrl;
+ private ImageLoader.ImageContainer mImageContainer;
+
+ private int mRetryCnt;
+ private static final int MAX_RETRIES = 3;
+ private static final long RETRY_DELAY = 2500;
+
+ public interface ImageListener {
+ public void onImageLoaded(boolean succeeded);
+ }
+ private ImageListener mImageListener;
+
+ public WPNetworkImageView(Context context) {
+ super(context);
+ }
+ public WPNetworkImageView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public WPNetworkImageView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ public String getUrl() {
+ return mUrl;
+ }
+
+ public void setImageUrl(String url, ImageType imageType) {
+ setImageUrl(url, imageType, null);
+ }
+ public void setImageUrl(String url, ImageType imageType, ImageListener imageListener) {
+ mUrl = url;
+ mImageType = imageType;
+ mImageListener = imageListener;
+ mRetryCnt = 0;
+
+ // The URL has potentially changed. See if we need to load it.
+ loadImageIfNecessary(false);
+ }
+
+ /*
+ * retrieves and displays the thumbnail for the passed video
+ */
+ public void setVideoUrl(final long postId, final String videoUrl) {
+ mImageType = ImageType.VIDEO;
+
+ if (TextUtils.isEmpty(videoUrl)) {
+ showDefaultImage();
+ return;
+ }
+
+ // if we already have a cached thumbnail for this video, show it immediately
+ String cachedThumbnail = ReaderThumbnailTable.getThumbnailUrl(videoUrl);
+ if (!TextUtils.isEmpty(cachedThumbnail)) {
+ setImageUrl(cachedThumbnail, ImageType.VIDEO);
+ return;
+ }
+
+ showDefaultImage();
+
+ // vimeo videos require network request to get thumbnail
+ if (ReaderVideoUtils.isVimeoLink(videoUrl)) {
+ ReaderVideoUtils.requestVimeoThumbnail(videoUrl, new ReaderVideoUtils.VideoThumbnailListener() {
+ @Override
+ public void onResponse(boolean successful, String thumbnailUrl) {
+ if (successful) {
+ ReaderThumbnailTable.addThumbnail(postId, videoUrl, thumbnailUrl);
+ setImageUrl(thumbnailUrl, ImageType.VIDEO);
+ }
+ }
+ });
+ }
+ }
+
+ /*
+ * retry the current image request after a brief delay
+ */
+ private void retry(final boolean isInLayoutPass) {
+ new Handler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ AppLog.d(AppLog.T.READER, String.format("retrying image request (%d)", mRetryCnt));
+ if (mImageContainer != null) {
+ mImageContainer.cancelRequest();
+ mImageContainer = null;
+ }
+ loadImageIfNecessary(isInLayoutPass);
+ }
+ }, RETRY_DELAY);
+ }
+
+ /**
+ * Loads the image for the view if it isn't already loaded.
+ * @param isInLayoutPass True if this was invoked from a layout pass, false otherwise.
+ */
+ private void loadImageIfNecessary(final boolean isInLayoutPass) {
+ // do nothing if image type hasn't been set yet
+ if (mImageType == ImageType.NONE) {
+ return;
+ }
+
+ int width = getWidth();
+ int height = getHeight();
+
+ boolean isFullyWrapContent = getLayoutParams() != null
+ && getLayoutParams().height == LayoutParams.WRAP_CONTENT
+ && getLayoutParams().width == LayoutParams.WRAP_CONTENT;
+ // if the view's bounds aren't known yet, and this is not a wrap-content/wrap-content
+ // view, hold off on loading the image.
+ if (width == 0 && height == 0 && !isFullyWrapContent) {
+ return;
+ }
+
+ // if the URL to be loaded in this view is empty, cancel any old requests and clear the
+ // currently loaded image.
+ if (TextUtils.isEmpty(mUrl)) {
+ if (mImageContainer != null) {
+ mImageContainer.cancelRequest();
+ mImageContainer = null;
+ }
+ showErrorImage();
+ return;
+ }
+
+ // if there was an old request in this view, check if it needs to be canceled.
+ if (mImageContainer != null && mImageContainer.getRequestUrl() != null) {
+ if (mImageContainer.getRequestUrl().equals(mUrl)) {
+ // if the request is from the same URL, return.
+ return;
+ } else {
+ // if there is a pre-existing request, cancel it if it's fetching a different URL.
+ mImageContainer.cancelRequest();
+ showDefaultImage();
+ }
+ }
+
+ // enforce a max size to reduce memory usage
+ Point pt = DisplayUtils.getDisplayPixelSize(this.getContext());
+ int maxSize = Math.max(pt.x, pt.y);
+
+ // The pre-existing content of this view didn't match the current URL. Load the new image
+ // from the network.
+ ImageLoader.ImageContainer newContainer = WordPress.imageLoader.get(mUrl,
+ new ImageLoader.ImageListener() {
+ @Override
+ public void onErrorResponse(VolleyError error) {
+ // mshot requests return a 307 if the mshot has never been requested,
+ // handle this by retrying request after a short delay to give time
+ // for server to generate the image
+ if (mImageType == ImageType.MSHOT
+ && mRetryCnt < MAX_RETRIES
+ && VolleyUtils.statusCodeFromVolleyError(error) == 307)
+ {
+ mRetryCnt++;
+ retry(isInLayoutPass);
+ } else {
+ showErrorImage();
+ if (mImageListener != null) {
+ mImageListener.onImageLoaded(false);
+ }
+ }
+ }
+
+ @Override
+ public void onResponse(final ImageLoader.ImageContainer response, boolean isImmediate) {
+ // If this was an immediate response that was delivered inside of a layout
+ // pass do not set the image immediately as it will trigger a requestLayout
+ // inside of a layout. Instead, defer setting the image by posting back to
+ // the main thread.
+ if (isImmediate && isInLayoutPass) {
+ post(new Runnable() {
+ @Override
+ public void run() {
+ // don't fade in the image since we know it's cached
+ handleResponse(response, true, false);
+ }
+ });
+ } else {
+ handleResponse(response, isImmediate, true);
+ }
+ }
+ }, maxSize, maxSize);
+
+ // update the ImageContainer to be the new bitmap container.
+ mImageContainer = newContainer;
+ }
+
+ private static boolean canFadeInImageType(ImageType imageType) {
+ return imageType == ImageType.PHOTO
+ || imageType == ImageType.VIDEO
+ || imageType == ImageType.MSHOT;
+ }
+
+ private void handleResponse(ImageLoader.ImageContainer response,
+ boolean isCached,
+ boolean allowFadeIn) {
+ if (response.getBitmap() != null) {
+ setImageBitmap(response.getBitmap());
+
+ // fade in photos/videos if not cached (not used for other image types since animation can be expensive)
+ if (!isCached && allowFadeIn && canFadeInImageType(mImageType))
+ fadeIn();
+
+ if (mImageListener != null) {
+ mImageListener.onImageLoaded(true);
+ }
+ } else {
+ showDefaultImage();
+ }
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ if (isInEditMode()) {
+ // draw light blue box during design-time
+ if (getDrawable() == null) {
+ setImageDrawable(new ColorDrawable(getColorRes(R.color.blue_extra_light)));
+ }
+ } else {
+ loadImageIfNecessary(true);
+ }
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ if (mImageContainer != null) {
+ // If the view was bound to an image request, cancel it and clear
+ // out the image from the view.
+ mImageContainer.cancelRequest();
+ setImageDrawable(null);
+ // also clear out the container so we can reload the image if necessary.
+ mImageContainer = null;
+ }
+ super.onDetachedFromWindow();
+ }
+
+ @Override
+ protected void drawableStateChanged() {
+ super.drawableStateChanged();
+ invalidate();
+ }
+
+ private int getColorRes(int resId) {
+ return getContext().getResources().getColor(resId);
+ }
+
+ private void showDefaultImage() {
+ switch (mImageType) {
+ case NONE:
+ // do nothing
+ break;
+ case PHOTO_FULL:
+ // null default for full-screen photos
+ setImageDrawable(null);
+ break;
+ case MSHOT:
+ // null default for mshots
+ setImageDrawable(null);
+ break;
+ default :
+ // light grey box for all others
+ setImageDrawable(new ColorDrawable(getColorRes(R.color.grey_light)));
+ break;
+ }
+ }
+
+ public void showErrorImage() {
+ switch (mImageType) {
+ case NONE:
+ // do nothing
+ break;
+ case PHOTO_FULL:
+ // null default for full-screen photos
+ setImageDrawable(null);
+ break;
+ case AVATAR:
+ // "mystery man" for failed avatars
+ setImageResource(R.drawable.placeholder);
+ break;
+ default :
+ // medium grey box for all others
+ setImageDrawable(new ColorDrawable(getColorRes(R.color.grey_medium)));
+ break;
+ }
+ }
+
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+ if (mImageType == ImageType.VIDEO) {
+ drawVideoOverlay(canvas);
+ }
+ }
+
+ private void drawVideoOverlay(Canvas canvas) {
+ if (canvas == null) {
+ return;
+ }
+
+ Bitmap overlay = BitmapFactory.decodeResource(getContext().getResources(), R.drawable.ic_reader_video_overlay, null);
+ int overlaySize = getContext().getResources().getDimensionPixelSize(R.dimen.reader_video_overlay_size);
+
+ // use the size of the view rather than the canvas
+ int srcWidth = this.getWidth();
+ int srcHeight = this.getHeight();
+
+ // skip if overlay is larger than source image
+ if (overlaySize > srcWidth || overlaySize > srcHeight) {
+ return;
+ }
+
+ final int left = (srcWidth / 2) - (overlaySize / 2);
+ final int top = (srcHeight / 2) - (overlaySize / 2);
+ final Rect rcDst = new Rect(left, top, left + overlaySize, top + overlaySize);
+
+ canvas.drawBitmap(overlay, null, rcDst, new Paint(Paint.FILTER_BITMAP_FLAG));
+
+ overlay.recycle();
+ }
+
+ // --------------------------------------------------------------------------------------------------
+
+
+ private static final int FADE_TRANSITION = 250;
+
+ private void fadeIn() {
+ ObjectAnimator alpha = ObjectAnimator.ofFloat(this, View.ALPHA, 0.25f, 1f);
+ alpha.setDuration(FADE_TRANSITION);
+ alpha.start();
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/widgets/WPTextView.java b/WordPress/src/main/java/org/wordpress/android/widgets/WPTextView.java
new file mode 100644
index 000000000..852e67434
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/widgets/WPTextView.java
@@ -0,0 +1,26 @@
+package org.wordpress.android.widgets;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.TextView;
+
+/**
+ * custom TextView used in layouts - enables keeping custom typeface handling in one place (so we
+ * avoid having to set the typeface for every single TextView in every single activity)
+ */
+public class WPTextView extends TextView {
+ public WPTextView(Context context) {
+ super(context);
+ TypefaceCache.setCustomTypeface(context, this, null);
+ }
+
+ public WPTextView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ TypefaceCache.setCustomTypeface(context, this, attrs);
+ }
+
+ public WPTextView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ TypefaceCache.setCustomTypeface(context, this, attrs);
+ }
+}
diff --git a/WordPress/src/main/java/org/xmlrpc/android/ApiHelper.java b/WordPress/src/main/java/org/xmlrpc/android/ApiHelper.java
new file mode 100644
index 000000000..68b008804
--- /dev/null
+++ b/WordPress/src/main/java/org/xmlrpc/android/ApiHelper.java
@@ -0,0 +1,1176 @@
+package org.xmlrpc.android;
+
+import android.content.Context;
+import android.os.AsyncTask;
+import android.util.Xml;
+
+import com.android.volley.NetworkResponse;
+import com.android.volley.Request;
+import com.android.volley.ServerError;
+import com.android.volley.toolbox.RequestFuture;
+import com.android.volley.toolbox.StringRequest;
+import com.google.gson.Gson;
+
+import org.wordpress.android.WordPress;
+import org.wordpress.android.datasets.CommentTable;
+import org.wordpress.android.models.Blog;
+import org.wordpress.android.models.BlogIdentifier;
+import org.wordpress.android.models.Comment;
+import org.wordpress.android.models.CommentList;
+import org.wordpress.android.models.FeatureSet;
+import org.wordpress.android.models.MediaFile;
+import org.wordpress.android.ui.media.MediaGridFragment.Filter;
+import org.wordpress.android.ui.posts.PostsListFragment;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.DateTimeUtils;
+import org.wordpress.android.util.MapUtils;
+import org.wordpress.android.util.NetworkUtils;
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.StringReader;
+import java.net.HttpURLConnection;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Vector;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.net.ssl.SSLHandshakeException;
+
+public class ApiHelper {
+ public enum ErrorType {
+ NO_ERROR, INVALID_CURRENT_BLOG, NETWORK_XMLRPC, INVALID_CONTEXT,
+ INVALID_RESULT, NO_UPLOAD_FILES_CAP, CAST_EXCEPTION, TASK_CANCELLED}
+
+ public static final Map<String, String> blogOptionsXMLRPCParameters = new HashMap<String, String>();
+
+ static {
+ blogOptionsXMLRPCParameters.put("software_version", "software_version");
+ blogOptionsXMLRPCParameters.put("post_thumbnail", "post_thumbnail");
+ blogOptionsXMLRPCParameters.put("jetpack_client_id", "jetpack_client_id");
+ blogOptionsXMLRPCParameters.put("blog_public", "blog_public");
+ blogOptionsXMLRPCParameters.put("home_url", "home_url");
+ blogOptionsXMLRPCParameters.put("admin_url", "admin_url");
+ blogOptionsXMLRPCParameters.put("login_url", "login_url");
+ blogOptionsXMLRPCParameters.put("blog_title", "blog_title");
+ }
+
+ public static abstract class HelperAsyncTask<Params, Progress, Result> extends AsyncTask<Params, Progress, Result> {
+ protected String mErrorMessage;
+ protected ErrorType mErrorType = ErrorType.NO_ERROR;
+ protected Throwable mThrowable;
+
+ protected void setError(ErrorType errorType, String errorMessage) {
+ mErrorMessage = errorMessage;
+ mErrorType = errorType;
+ AppLog.e(T.API, mErrorType.name() + " - " + mErrorMessage);
+ }
+
+ protected void setError(ErrorType errorType, String errorMessage, Throwable throwable) {
+ mErrorMessage = errorMessage;
+ mErrorType = errorType;
+ mThrowable = throwable;
+ AppLog.e(T.API, mErrorType.name() + " - " + mErrorMessage, throwable);
+ }
+ }
+
+ public interface GenericErrorCallback {
+ public void onFailure(ErrorType errorType, String errorMessage, Throwable throwable);
+ }
+
+ public interface GenericCallback extends GenericErrorCallback {
+ public void onSuccess();
+ }
+
+ public static class GetPostFormatsTask extends HelperAsyncTask<java.util.List<?>, Void, Object> {
+ private Blog mBlog;
+
+ @Override
+ protected Object doInBackground(List<?>... args) {
+ List<?> arguments = args[0];
+ mBlog = (Blog) arguments.get(0);
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(mBlog.getUri(), mBlog.getHttpuser(),
+ mBlog.getHttppassword());
+ Object result = null;
+ Object[] params = { mBlog.getRemoteBlogId(), mBlog.getUsername(),
+ mBlog.getPassword(), "show-supported" };
+ try {
+ result = client.call("wp.getPostFormats", params);
+ } catch (ClassCastException cce) {
+ setError(ErrorType.INVALID_RESULT, cce.getMessage(), cce);
+ } catch (XMLRPCException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ } catch (IOException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ } catch (XmlPullParserException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ }
+ return result;
+ }
+
+ protected void onPostExecute(Object result) {
+ if (result != null && result instanceof HashMap) {
+ Map<?, ?> postFormats = (HashMap<?, ?>) result;
+ if (postFormats.size() > 0) {
+ Gson gson = new Gson();
+ String postFormatsJson = gson.toJson(postFormats);
+ if (postFormatsJson != null) {
+ if (mBlog.bsetPostFormats(postFormatsJson)) {
+ WordPress.wpDB.saveBlog(mBlog);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ public static synchronized void updateBlogOptions(Blog currentBlog, Map<?, ?> blogOptions) {
+ boolean isModified = false;
+ Gson gson = new Gson();
+ String blogOptionsJson = gson.toJson(blogOptions);
+ if (blogOptionsJson != null) {
+ isModified |= currentBlog.bsetBlogOptions(blogOptionsJson);
+ }
+
+ // Software version
+ if (!currentBlog.isDotcomFlag()) {
+ Map<?, ?> sv = (HashMap<?, ?>) blogOptions.get("software_version");
+ String wpVersion = MapUtils.getMapStr(sv, "value");
+ if (wpVersion.length() > 0) {
+ isModified |= currentBlog.bsetWpVersion(wpVersion);
+ }
+ }
+
+ // Featured image support
+ Map<?, ?> featuredImageHash = (HashMap<?, ?>) blogOptions.get("post_thumbnail");
+ if (featuredImageHash != null) {
+ boolean featuredImageCapable = MapUtils.getMapBool(featuredImageHash, "value");
+ isModified |= currentBlog.bsetFeaturedImageCapable(featuredImageCapable);
+ } else {
+ isModified |= currentBlog.bsetFeaturedImageCapable(false);
+ }
+
+ // Blog name
+ Map<?, ?> blogNameHash = (HashMap<?, ?>) blogOptions.get("blog_title");
+ if (blogNameHash != null) {
+ String blogName = MapUtils.getMapStr(blogNameHash, "value");
+ if (blogName != null && !blogName.equals(currentBlog.getBlogName())) {
+ currentBlog.setBlogName(blogName);
+ isModified = true;
+ }
+ }
+
+ if (isModified) {
+ WordPress.wpDB.saveBlog(currentBlog);
+ }
+ }
+
+ /**
+ * Task to refresh blog level information (WP version number) and stuff
+ * related to the active theme (available post types, recent comments, etc).
+ */
+ public static class RefreshBlogContentTask extends HelperAsyncTask<Boolean, Void, Boolean> {
+ private static HashSet<BlogIdentifier> refreshedBlogs = new HashSet<BlogIdentifier>();
+ private Blog mBlog;
+ private Context mContext;
+ private BlogIdentifier mBlogIdentifier;
+ private GenericCallback mCallback;
+
+ public RefreshBlogContentTask(Context context, Blog blog, GenericCallback callback) {
+ if (context == null || blog == null) {
+ cancel(true);
+ return;
+ }
+
+ if (!NetworkUtils.isNetworkAvailable(context)) {
+ cancel(true);
+ return;
+ }
+
+ mBlogIdentifier = new BlogIdentifier(blog.getUrl(), blog.getRemoteBlogId());
+ if (refreshedBlogs.contains(mBlogIdentifier)) {
+ cancel(true);
+ } else {
+ refreshedBlogs.add(mBlogIdentifier);
+ }
+
+ mBlog = blog;
+ mContext = context;
+ mCallback = callback;
+ }
+
+ private void updateBlogAdmin(Map<String, Object> userInfos) {
+ if (userInfos.containsKey("roles") && ( userInfos.get("roles") instanceof Object[])) {
+ boolean isAdmin = false;
+ Object[] userRoles = (Object[])userInfos.get("roles");
+ for (int i = 0; i < userRoles.length; i++) {
+ if (userRoles[i].toString().equals("administrator")) {
+ isAdmin = true;
+ break;
+ }
+ }
+ if (mBlog.bsetAdmin(isAdmin)) {
+ WordPress.wpDB.saveBlog(mBlog);
+ }
+ }
+ }
+
+ @Override
+ protected Boolean doInBackground(Boolean... params) {
+ boolean commentsOnly = params[0];
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(mBlog.getUri(), mBlog.getHttpuser(),
+ mBlog.getHttppassword());
+
+ if (!commentsOnly) {
+ // check the WP number if self-hosted
+ Map<String, String> hPost = ApiHelper.blogOptionsXMLRPCParameters;
+
+ Object[] vParams = {mBlog.getRemoteBlogId(),
+ mBlog.getUsername(),
+ mBlog.getPassword(),
+ hPost};
+ Object versionResult = null;
+ try {
+ versionResult = client.call("wp.getOptions", vParams);
+ } catch (ClassCastException cce) {
+ setError(ErrorType.INVALID_RESULT, cce.getMessage(), cce);
+ return false;
+ } catch (Exception e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ return false;
+ }
+
+ if (versionResult != null) {
+ Map<?, ?> blogOptions = (HashMap<?, ?>) versionResult;
+ ApiHelper.updateBlogOptions(mBlog, blogOptions);
+ }
+
+ // get theme post formats
+ List<Object> args = new Vector<Object>();
+ args.add(mBlog);
+ args.add(mContext);
+ new GetPostFormatsTask().execute(args);
+ }
+
+ // Check if user is an admin
+ Object[] userParams = {mBlog.getRemoteBlogId(), mBlog.getUsername(), mBlog.getPassword()};
+ try {
+ Map<String, Object> userInfos = (HashMap<String, Object>) client.call("wp.getProfile", userParams);
+ updateBlogAdmin(userInfos);
+ } catch (ClassCastException cce) {
+ setError(ErrorType.INVALID_RESULT, cce.getMessage(), cce);
+ return false;
+ } catch (XMLRPCException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ } catch (IOException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ } catch (XmlPullParserException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ }
+
+ // refresh the comments
+ Map<String, Object> hPost = new HashMap<String, Object>();
+ hPost.put("number", 30);
+ Object[] commentParams = {mBlog.getRemoteBlogId(), mBlog.getUsername(),
+ mBlog.getPassword(), hPost};
+ try {
+ ApiHelper.refreshComments(mContext, mBlog, commentParams);
+ } catch (Exception e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ return false;
+ }
+
+ return true;
+ }
+
+ @Override
+ protected void onPostExecute(Boolean success) {
+ if (mCallback != null) {
+ if (success) {
+ mCallback.onSuccess();
+ } else {
+ mCallback.onFailure(mErrorType, mErrorMessage, mThrowable);
+ }
+ }
+ refreshedBlogs.remove(mBlogIdentifier);
+ }
+ }
+
+ /**
+ * request deleted comments for passed blog and remove them from local db
+ * @param blog blog to check
+ * @return count of comments that were removed from db
+ */
+ public static int removeDeletedComments(Blog blog) {
+ if (blog == null) {
+ return 0;
+ }
+
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(
+ blog.getUri(),
+ blog.getHttpuser(),
+ blog.getHttppassword());
+
+ Map<String, Object> hPost = new HashMap<String, Object>();
+ hPost.put("status", "trash");
+
+ Object[] params = { blog.getRemoteBlogId(),
+ blog.getUsername(),
+ blog.getPassword(),
+ hPost };
+
+ int numDeleted = 0;
+ try {
+ Object[] result = (Object[]) client.call("wp.getComments", params);
+ if (result == null || result.length == 0) {
+ return 0;
+ }
+ Map<?, ?> contentHash;
+ for (Object aComment : result) {
+ contentHash = (Map<?, ?>) aComment;
+ long commentId = Long.parseLong(contentHash.get("comment_id").toString());
+ if (CommentTable.deleteComment(blog.getLocalTableBlogId(), commentId))
+ numDeleted++;
+ }
+ if (numDeleted > 0) {
+ AppLog.d(T.COMMENTS, String.format("removed %d deleted comments", numDeleted));
+ }
+ } catch (XMLRPCException e) {
+ AppLog.e(T.COMMENTS, e);
+ } catch (IOException e) {
+ AppLog.e(T.COMMENTS, e);
+ } catch (XmlPullParserException e) {
+ AppLog.e(T.COMMENTS, e);
+ }
+
+ return numDeleted;
+ }
+
+ public static CommentList refreshComments(Context context, Blog blog, Object[] commentParams)
+ throws XMLRPCException, IOException, XmlPullParserException {
+ if (blog == null) {
+ return null;
+ }
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+ Object[] result;
+ result = (Object[]) client.call("wp.getComments", commentParams);
+
+ if (result.length == 0) {
+ return null;
+ }
+
+ Map<?, ?> contentHash;
+ long commentID, postID;
+ String authorName, content, status, authorEmail, authorURL, postTitle, pubDate;
+ java.util.Date date;
+ CommentList comments = new CommentList();
+
+ for (int ctr = 0; ctr < result.length; ctr++) {
+ contentHash = (Map<?, ?>) result[ctr];
+ content = contentHash.get("content").toString();
+ status = contentHash.get("status").toString();
+ postID = Long.parseLong(contentHash.get("post_id").toString());
+ commentID = Long.parseLong(contentHash.get("comment_id").toString());
+ authorName = contentHash.get("author").toString();
+ authorURL = contentHash.get("author_url").toString();
+ authorEmail = contentHash.get("author_email").toString();
+ postTitle = contentHash.get("post_title").toString();
+ date = (java.util.Date) contentHash.get("date_created_gmt");
+ pubDate = DateTimeUtils.javaDateToIso8601(date);
+
+ Comment comment = new Comment(
+ postID,
+ commentID,
+ authorName,
+ pubDate,
+ content,
+ status,
+ postTitle,
+ authorURL,
+ authorEmail,
+ null);
+
+ comments.add(comment);
+ }
+
+ int localBlogId = blog.getLocalTableBlogId();
+ CommentTable.saveComments(localBlogId, comments);
+
+ return comments;
+ }
+
+ public static class FetchPostsTask extends HelperAsyncTask<java.util.List<?>, Boolean, Boolean> {
+ public interface Callback extends GenericErrorCallback {
+ public void onSuccess(int postCount);
+ }
+
+ private Callback mCallback;
+ private String mErrorMessage;
+ private int mPostCount;
+
+ public FetchPostsTask(Callback callback) {
+ mCallback = callback;
+ }
+
+ @Override
+ protected Boolean doInBackground(List<?>... params) {
+ List<?> arguments = params[0];
+
+ Blog blog = (Blog) arguments.get(0);
+ if (blog == null)
+ return false;
+
+ boolean isPage = (Boolean) arguments.get(1);
+ int recordCount = (Integer) arguments.get(2);
+ boolean loadMore = (Boolean) arguments.get(3);
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+
+ Object[] result;
+ Object[] xmlrpcParams = { blog.getRemoteBlogId(),
+ blog.getUsername(),
+ blog.getPassword(), recordCount };
+ try {
+ result = (Object[]) client.call((isPage) ? "wp.getPages"
+ : "metaWeblog.getRecentPosts", xmlrpcParams);
+ if (result != null && result.length > 0) {
+ mPostCount = result.length;
+ List<Map<?, ?>> postsList = new ArrayList<Map<?, ?>>();
+
+ if (!loadMore) {
+ WordPress.wpDB.deleteUploadedPosts(
+ blog.getLocalTableBlogId(), isPage);
+ }
+
+ // If we're loading more posts, only save the posts at the end of the array.
+ // NOTE: Switching to wp.getPosts wouldn't require janky solutions like this
+ // since it allows for an offset parameter.
+ int startPosition = 0;
+ if (loadMore && result.length > PostsListFragment.POSTS_REQUEST_COUNT) {
+ startPosition = result.length - PostsListFragment.POSTS_REQUEST_COUNT;
+ }
+
+ for (int ctr = startPosition; ctr < result.length; ctr++) {
+ Map<?, ?> postMap = (Map<?, ?>) result[ctr];
+ postsList.add(postMap);
+ }
+
+ WordPress.wpDB.savePosts(postsList, blog.getLocalTableBlogId(), isPage, !loadMore);
+ }
+ return true;
+ } catch (XMLRPCException e) {
+ mErrorMessage = e.getMessage();
+ } catch (IOException e) {
+ mErrorMessage = e.getMessage();
+ } catch (XmlPullParserException e) {
+ mErrorMessage = e.getMessage();
+ }
+
+ return false;
+ }
+
+ @Override
+ protected void onCancelled() {
+ super.onCancelled();
+ mCallback.onFailure(ErrorType.TASK_CANCELLED, mErrorMessage, mThrowable);
+ }
+
+ @Override
+ protected void onPostExecute(Boolean success) {
+ if (mCallback != null) {
+ if (success) {
+ mCallback.onSuccess(mPostCount);
+ } else {
+ mCallback.onFailure(mErrorType, mErrorMessage, mThrowable);
+ }
+ }
+ }
+ }
+
+ /**
+ * Fetch a single post or page from the XML-RPC API and save/update it in the DB
+ */
+ public static class FetchSinglePostTask extends HelperAsyncTask<java.util.List<?>, Boolean, Boolean> {
+ public interface Callback extends GenericErrorCallback {
+ public void onSuccess();
+ }
+
+ private Callback mCallback;
+ private String mErrorMessage;
+
+ public FetchSinglePostTask(Callback callback) {
+ mCallback = callback;
+ }
+
+ @Override
+ protected Boolean doInBackground(List<?>... params) {
+ List<?> arguments = params[0];
+
+ Blog blog = (Blog) arguments.get(0);
+ if (blog == null)
+ return false;
+
+ String postId = (String) arguments.get(1);
+ boolean isPage = (Boolean) arguments.get(2);
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+
+ Object[] apiParams;
+ if (isPage) {
+ apiParams = new Object[]{
+ blog.getRemoteBlogId(),
+ postId,
+ blog.getUsername(),
+ blog.getPassword()
+ };
+ } else {
+ apiParams = new Object[]{
+ postId,
+ blog.getUsername(),
+ blog.getPassword()
+ };
+ }
+
+ try {
+ Object result = client.call((isPage) ? "wp.getPage" : "metaWeblog.getPost", apiParams);
+ if (result != null && result instanceof Map) {
+ Map postMap = (HashMap) result;
+ List<Map<?, ?>> postsList = new ArrayList<Map<?, ?>>();
+ postsList.add(postMap);
+
+ WordPress.wpDB.savePosts(postsList, blog.getLocalTableBlogId(), isPage, true);
+ }
+
+ return true;
+ } catch (XMLRPCException e) {
+ mErrorMessage = e.getMessage();
+ } catch (IOException e) {
+ mErrorMessage = e.getMessage();
+ } catch (XmlPullParserException e) {
+ mErrorMessage = e.getMessage();
+ }
+
+ return false;
+ }
+
+ @Override
+ protected void onPostExecute(Boolean success) {
+ if (mCallback != null) {
+ if (success) {
+ mCallback.onSuccess();
+ } else {
+ mCallback.onFailure(mErrorType, mErrorMessage, mThrowable);
+ }
+ }
+ }
+ }
+
+ public static class SyncMediaLibraryTask extends HelperAsyncTask<java.util.List<?>, Void, Integer> {
+ public interface Callback extends GenericErrorCallback {
+ public void onSuccess(int results);
+ }
+
+ private Callback mCallback;
+ private int mOffset;
+ private Filter mFilter;
+
+ public SyncMediaLibraryTask(int offset, Filter filter, Callback callback) {
+ mOffset = offset;
+ mCallback = callback;
+ mFilter = filter;
+ }
+
+ @Override
+ protected Integer doInBackground(List<?>... params) {
+ List<?> arguments = params[0];
+ WordPress.currentBlog = (Blog) arguments.get(0);
+ Blog blog = WordPress.currentBlog;
+ if (blog == null) {
+ setError(ErrorType.INVALID_CURRENT_BLOG, "ApiHelper - current blog is null");
+ return 0;
+ }
+
+ String blogId = String.valueOf(blog.getLocalTableBlogId());
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+ Map<String, Object> filter = new HashMap<String, Object>();
+ filter.put("number", 50);
+ filter.put("offset", mOffset);
+
+ if (mFilter == Filter.IMAGES) {
+ filter.put("mime_type","image/*");
+ } else if(mFilter == Filter.UNATTACHED) {
+ filter.put("parent_id", 0);
+ }
+
+ Object[] apiParams = {blog.getRemoteBlogId(), blog.getUsername(), blog.getPassword(),
+ filter};
+
+ Object[] results = null;
+ try {
+ results = (Object[]) client.call("wp.getMediaLibrary", apiParams);
+ } catch (ClassCastException cce) {
+ setError(ErrorType.INVALID_RESULT, cce.getMessage(), cce);
+ return 0;
+ } catch (XMLRPCException e) {
+ prepareErrorMessage(e);
+ return 0;
+ } catch (IOException e) {
+ prepareErrorMessage(e);
+ return 0;
+ } catch (XmlPullParserException e) {
+ prepareErrorMessage(e);
+ return 0;
+ }
+
+ if (blogId == null) {
+ setError(ErrorType.INVALID_CURRENT_BLOG, "Invalid blogId");
+ return 0;
+ }
+
+ if (results == null) {
+ setError(ErrorType.INVALID_RESULT, "Invalid blogId");
+ return 0;
+ }
+
+ Map<?, ?> resultMap;
+ // results returned, so mark everything existing to deleted
+ // since offset is 0, we are doing a full refresh
+ if (mOffset == 0) {
+ WordPress.wpDB.setMediaFilesMarkedForDeleted(blogId);
+ }
+ for (Object result : results) {
+ resultMap = (Map<?, ?>) result;
+ MediaFile mediaFile = new MediaFile(blogId, resultMap);
+ WordPress.wpDB.saveMediaFile(mediaFile);
+ }
+ WordPress.wpDB.deleteFilesMarkedForDeleted(blogId);
+ return results.length;
+ }
+
+ private void prepareErrorMessage(Exception e) {
+ // user does not have permission to view media gallery
+ if (e.getMessage() != null && e.getMessage().contains("401")) {
+ setError(ErrorType.NO_UPLOAD_FILES_CAP, e.getMessage(), e);
+ } else {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ }
+ }
+
+ @Override
+ protected void onPostExecute(Integer result) {
+ if (mCallback != null) {
+ if (mErrorType == ErrorType.NO_ERROR) {
+ mCallback.onSuccess(result);
+ } else {
+ mCallback.onFailure(mErrorType, mErrorMessage, mThrowable);
+ }
+ }
+ }
+ }
+
+ public static class EditMediaItemTask extends HelperAsyncTask<List<?>, Void, Boolean> {
+ private GenericCallback mCallback;
+ private String mMediaId;
+ private String mTitle;
+ private String mDescription;
+ private String mCaption;
+
+ public EditMediaItemTask(String mediaId, String title, String description, String caption,
+ GenericCallback callback) {
+ mMediaId = mediaId;
+ mCallback = callback;
+ mTitle = title;
+ mCaption = caption;
+ mDescription = description;
+ }
+ @Override
+ protected Boolean doInBackground(List<?>... params) {
+ List<?> arguments = params[0];
+ WordPress.currentBlog = (Blog) arguments.get(0);
+ Blog blog = WordPress.currentBlog;
+
+ if (blog == null) {
+ setError(ErrorType.INVALID_CURRENT_BLOG, "ApiHelper - current blog is null");
+ return null;
+ }
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+ Map<String, Object> contentStruct = new HashMap<String, Object>();
+ contentStruct.put("post_title", mTitle);
+ contentStruct.put("post_content", mDescription);
+ contentStruct.put("post_excerpt", mCaption);
+
+ Object[] apiParams = {
+ blog.getRemoteBlogId(),
+ blog.getUsername(),
+ blog.getPassword(),
+ mMediaId,
+ contentStruct
+ };
+
+ Boolean result = null;
+ try {
+ result = (Boolean) client.call("wp.editPost", apiParams);
+ } catch (ClassCastException cce) {
+ setError(ErrorType.INVALID_RESULT, cce.getMessage(), cce);
+ } catch (XMLRPCException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ } catch (IOException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ } catch (XmlPullParserException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ }
+
+ return result;
+ }
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ if (mCallback != null) {
+ if (mErrorType == ErrorType.NO_ERROR) {
+ mCallback.onSuccess();
+ } else {
+ mCallback.onFailure(mErrorType, mErrorMessage, mThrowable);
+ }
+ }
+ }
+ }
+
+ public static class GetMediaItemTask extends HelperAsyncTask<List<?>, Void, MediaFile> {
+ public interface Callback extends GenericErrorCallback {
+ public void onSuccess(MediaFile results);
+ }
+ private Callback mCallback;
+ private int mMediaId;
+
+ public GetMediaItemTask(int mediaId, Callback callback) {
+ mMediaId = mediaId;
+ mCallback = callback;
+ }
+
+ @Override
+ protected MediaFile doInBackground(List<?>... params) {
+ List<?> arguments = params[0];
+ WordPress.currentBlog = (Blog) arguments.get(0);
+ Blog blog = WordPress.currentBlog;
+ if (blog == null) {
+ setError(ErrorType.INVALID_CURRENT_BLOG, "ApiHelper - current blog is null");
+ return null;
+ }
+
+ String blogId = String.valueOf(blog.getLocalTableBlogId());
+
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+ Object[] apiParams = {
+ blog.getRemoteBlogId(),
+ blog.getUsername(),
+ blog.getPassword(),
+ mMediaId
+ };
+ Map<?, ?> results = null;
+ try {
+ results = (Map<?, ?>) client.call("wp.getMediaItem", apiParams);
+ } catch (ClassCastException cce) {
+ setError(ErrorType.INVALID_RESULT, cce.getMessage(), cce);
+ } catch (XMLRPCException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ } catch (IOException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ } catch (XmlPullParserException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ }
+
+ if (results != null && blogId != null) {
+ MediaFile mediaFile = new MediaFile(blogId, results);
+ mediaFile.save();
+ return mediaFile;
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ protected void onPostExecute(MediaFile result) {
+ if (mCallback != null) {
+ if (result != null) {
+ mCallback.onSuccess(result);
+ } else {
+ mCallback.onFailure(mErrorType, mErrorMessage, mThrowable);
+ }
+ }
+ }
+ }
+
+ public static class UploadMediaTask extends HelperAsyncTask<List<?>, Void, String> {
+ public interface Callback extends GenericErrorCallback {
+ public void onSuccess(String id);
+ }
+ private Callback mCallback;
+ private Context mContext;
+ private MediaFile mMediaFile;
+
+ public UploadMediaTask(Context applicationContext, MediaFile mediaFile,
+ Callback callback) {
+ mContext = applicationContext;
+ mMediaFile = mediaFile;
+ mCallback = callback;
+ }
+
+ @Override
+ protected String doInBackground(List<?>... params) {
+ List<?> arguments = params[0];
+ WordPress.currentBlog = (Blog) arguments.get(0);
+ Blog blog = WordPress.currentBlog;
+
+ if (blog == null) {
+ setError(ErrorType.INVALID_CURRENT_BLOG, "current blog is null");
+ return null;
+ }
+
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+
+ Map<String, Object> data = new HashMap<String, Object>();
+ data.put("name", mMediaFile.getFileName());
+ data.put("type", mMediaFile.getMimeType());
+ data.put("bits", mMediaFile);
+ data.put("overwrite", true);
+
+ Object[] apiParams = {
+ blog.getRemoteBlogId(),
+ blog.getUsername(),
+ blog.getPassword(),
+ data
+ };
+
+ if (mContext == null) {
+ return null;
+ }
+
+ Map<?, ?> resultMap;
+ try {
+ resultMap = (HashMap<?, ?>) client.call("wp.uploadFile", apiParams, getTempFile(mContext));
+ } catch (ClassCastException cce) {
+ setError(ErrorType.INVALID_RESULT, cce.getMessage(), cce);
+ return null;
+ } catch (XMLRPCException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ return null;
+ } catch (IOException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ return null;
+ } catch (XmlPullParserException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ return null;
+ }
+
+ if (resultMap != null && resultMap.containsKey("id")) {
+ return (String) resultMap.get("id");
+ } else {
+ setError(ErrorType.INVALID_RESULT, "Invalid result");
+ }
+
+ return null;
+ }
+
+ // Create a temp file for media upload
+ private File getTempFile(Context context) {
+ String tempFileName = "wp-" + System.currentTimeMillis();
+ try {
+ context.openFileOutput(tempFileName, Context.MODE_PRIVATE);
+ } catch (FileNotFoundException e) {
+ return null;
+ }
+ return context.getFileStreamPath(tempFileName);
+ }
+
+ @Override
+ protected void onPostExecute(String result) {
+ if (mCallback != null) {
+ if (result != null) {
+ mCallback.onSuccess(result);
+ } else {
+ mCallback.onFailure(mErrorType, mErrorMessage, mThrowable);
+ }
+ }
+ }
+ }
+
+ public static class DeleteMediaTask extends HelperAsyncTask<List<?>, Void, Void> {
+ private GenericCallback mCallback;
+ private String mMediaId;
+
+ public DeleteMediaTask(String mediaId, GenericCallback callback) {
+ mMediaId = mediaId;
+ mCallback = callback;
+ }
+
+ @Override
+ protected Void doInBackground(List<?>... params) {
+ List<?> arguments = params[0];
+ Blog blog = (Blog) arguments.get(0);
+
+ if (blog == null) {
+ setError(ErrorType.INVALID_CONTEXT, "ApiHelper - invalid blog");
+ return null;
+ }
+
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+ Object[] apiParams = new Object[]{blog.getRemoteBlogId(), blog.getUsername(),
+ blog.getPassword(), mMediaId};
+
+ try {
+ if (client != null) {
+ Boolean result = (Boolean) client.call("wp.deletePost", apiParams);
+ if (!result) {
+ setError(ErrorType.INVALID_RESULT, "wp.deletePost returned false");
+ }
+ }
+ } catch (ClassCastException cce) {
+ setError(ErrorType.INVALID_RESULT, cce.getMessage(), cce);
+ } catch (XMLRPCException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ } catch (IOException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ } catch (XmlPullParserException e) {
+ setError(ErrorType.NETWORK_XMLRPC, e.getMessage(), e);
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void v) {
+ if (mCallback != null) {
+ if (mErrorType == ErrorType.NO_ERROR) {
+ mCallback.onSuccess();
+ } else {
+ mCallback.onFailure(mErrorType, mErrorMessage, mThrowable);
+ }
+ }
+ }
+ }
+
+ public static class GetFeatures extends AsyncTask<List<?>, Void, FeatureSet> {
+ public interface Callback {
+ void onResult(FeatureSet featureSet);
+ }
+
+ private Callback mCallback;
+
+ public GetFeatures() {
+ }
+
+ public GetFeatures(Callback callback) {
+ mCallback = callback;
+ }
+
+ public FeatureSet doSynchronously(List<?>... params) {
+ return doInBackground(params);
+ }
+
+ @Override
+ protected FeatureSet doInBackground(List<?>... params) {
+ List<?> arguments = params[0];
+ Blog blog = (Blog) arguments.get(0);
+
+ if (blog == null)
+ return null;
+
+ XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(),
+ blog.getHttppassword());
+
+ Object[] apiParams = new Object[] {
+ blog.getRemoteBlogId(),
+ blog.getUsername(),
+ blog.getPassword(),
+ };
+
+ Map<?, ?> resultMap = null;
+ try {
+ resultMap = (HashMap<?, ?>) client.call("wpcom.getFeatures", apiParams);
+ } catch (ClassCastException cce) {
+ AppLog.e(T.API, "wpcom.getFeatures error", cce);
+ } catch (XMLRPCException e) {
+ AppLog.e(T.API, "wpcom.getFeatures error", e);
+ } catch (IOException e) {
+ AppLog.e(T.API, "wpcom.getFeatures error", e);
+ } catch (XmlPullParserException e) {
+ AppLog.e(T.API, "wpcom.getFeatures error", e);
+ }
+
+ if (resultMap != null) {
+ return new FeatureSet(blog.getRemoteBlogId(), resultMap);
+ }
+
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(FeatureSet result) {
+ if (mCallback != null)
+ mCallback.onResult(result);
+ }
+
+ }
+
+ /**
+ * Discover the XML-RPC endpoint for the WordPress API associated with the specified blog URL.
+ *
+ * @param urlString URL of the blog to get the XML-RPC endpoint for.
+ * @return XML-RPC endpoint for the specified blog, or null if unable to discover endpoint.
+ */
+ public static String getXMLRPCUrl(String urlString) throws SSLHandshakeException {
+ Pattern xmlrpcLink = Pattern.compile("<api\\s*?name=\"WordPress\".*?apiLink=\"(.*?)\"",
+ Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
+
+ String html = getResponse(urlString);
+ if (html != null) {
+ Matcher matcher = xmlrpcLink.matcher(html);
+ if (matcher.find()) {
+ return matcher.group(1);
+ }
+ }
+ return null; // never found the rsd tag
+ }
+
+ /**
+ * Synchronous method to fetch the String content at the specified URL.
+ *
+ * @param url URL to fetch contents for.
+ * @return content of the resource, or null if URL was invalid or resource could not be retrieved.
+ */
+ public static String getResponse(final String url) throws SSLHandshakeException {
+ return getResponse(url, 3);
+ }
+
+ private static String getResponse(final String url, int maxRedirection)
+ throws SSLHandshakeException {
+ RequestFuture<String> requestFuture = RequestFuture.newFuture();
+ StringRequest stringRequest = new StringRequest(Request.Method.GET, url, requestFuture, requestFuture);
+ WordPress.requestQueue.add(stringRequest);
+ try {
+ // Wait for the response
+ return requestFuture.get(30, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ AppLog.e(T.API, e);
+ return null;
+ } catch (ExecutionException e) {
+ if (e.getCause() instanceof ServerError) {
+ NetworkResponse networkResponse = ((ServerError) e.getCause()).networkResponse;
+ if ((networkResponse != null) && (networkResponse.statusCode == HttpURLConnection.HTTP_MOVED_PERM ||
+ networkResponse.statusCode == HttpURLConnection.HTTP_MOVED_TEMP)) {
+ String newUrl = networkResponse.headers.get("Location");
+ if (maxRedirection > 0) {
+ return getResponse(newUrl, maxRedirection - 1);
+ }
+ }
+ }
+ if (e.getCause() != null && e.getCause().getCause() instanceof SSLHandshakeException) {
+ throw (SSLHandshakeException) e.getCause().getCause();
+ }
+ AppLog.e(T.API, e);
+ return null;
+ } catch (TimeoutException e) {
+ AppLog.e(T.API, e);
+ return null;
+ }
+ }
+
+ /**
+ * Regex pattern for matching the RSD link found in most WordPress sites.
+ */
+ private static final Pattern rsdLink = Pattern.compile(
+ "<link\\s*?rel=\"EditURI\"\\s*?type=\"application/rsd\\+xml\"\\s*?title=\"RSD\"\\s*?href=\"(.*?)\"",
+ Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
+
+ /**
+ * Returns RSD URL based on regex match
+ * @param urlString
+ * @return String RSD url
+ */
+ public static String getRSDMetaTagHrefRegEx(String urlString)
+ throws SSLHandshakeException {
+ String html = ApiHelper.getResponse(urlString);
+ if (html != null) {
+ Matcher matcher = rsdLink.matcher(html);
+ if (matcher.find()) {
+ String href = matcher.group(1);
+ return href;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns RSD URL based on html tag search
+ * @param urlString
+ * @return String RSD url
+ */
+ public static String getRSDMetaTagHref(String urlString)
+ throws SSLHandshakeException {
+ // get the html code
+ String data = ApiHelper.getResponse(urlString);
+
+ // parse the html and get the attribute for xmlrpc endpoint
+ if (data != null) {
+ StringReader stringReader = new StringReader(data);
+ XmlPullParser parser = Xml.newPullParser();
+ try {
+ // auto-detect the encoding from the stream
+ parser.setInput(stringReader);
+ int eventType = parser.getEventType();
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ String name = null;
+ String rel = "";
+ String type = "";
+ String href = "";
+ switch (eventType) {
+ case XmlPullParser.START_TAG:
+ name = parser.getName();
+ if (name.equalsIgnoreCase("link")) {
+ for (int i = 0; i < parser.getAttributeCount(); i++) {
+ String attrName = parser.getAttributeName(i);
+ String attrValue = parser.getAttributeValue(i);
+ if (attrName.equals("rel")) {
+ rel = attrValue;
+ } else if (attrName.equals("type"))
+ type = attrValue;
+ else if (attrName.equals("href"))
+ href = attrValue;
+ }
+
+ if (rel.equals("EditURI") && type.equals("application/rsd+xml")) {
+ return href;
+ }
+ // currentMessage.setLink(parser.nextText());
+ }
+ break;
+ }
+ eventType = parser.next();
+ }
+ } catch (XmlPullParserException e) {
+ AppLog.e(T.API, e);
+ return null;
+ } catch (IOException e) {
+ AppLog.e(T.API, e);
+ return null;
+ }
+ }
+ return null; // never found the rsd tag
+ }
+}
diff --git a/WordPress/src/main/java/org/xmlrpc/android/LoggedInputStream.java b/WordPress/src/main/java/org/xmlrpc/android/LoggedInputStream.java
new file mode 100644
index 000000000..1f18c9681
--- /dev/null
+++ b/WordPress/src/main/java/org/xmlrpc/android/LoggedInputStream.java
@@ -0,0 +1,118 @@
+package org.xmlrpc.android;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+
+/**
+ * A LoggedInputStream adds logging functionality to another input stream.
+ * <p>Note that calls on a LoggedInputStream are passed "as-is" to the underlying stream.</p>
+ *
+ *
+ * <p>We're using a LoggedInputStream in {@code XMLRPClient.java} to log the XML-RPC response document in case of parser errors.<br />
+ *
+ * There are plenty of other ways to log the response, but a {@code XmlPullParser} wants an InputStream as input parameter, and
+ * a LoggedInputStream seems the most reliable solution, with the smallest memory footprint.<br />
+ * Below are other examples of logging we tried:</p>
+ * <ul>
+ * <li>Read the first 1000 characters from the original input stream, then create a new SequenceInputStream with both the characters just read (a new ByteArrayInputStream),
+ * and the original input stream.</li>
+ * <li>Read the whole content in a String and log it, then create an StringInputStream over the string, and pass the new stream to the parser.</li>
+ * </ul>
+ */
+
+
+public final class LoggedInputStream extends InputStream {
+ private final InputStream inputStream;
+
+ private final static int MAX_LOG_SIZE = 1000;
+ private final byte[] loggedString = new byte[MAX_LOG_SIZE];
+ private int loggedStringSize = 0;
+
+ public LoggedInputStream(InputStream input) {
+ this.inputStream = input;
+ }
+
+
+ @Override
+ public int available() throws IOException {
+ return inputStream.available();
+ }
+
+
+ @Override
+ public void close() throws IOException {
+ inputStream.close();
+ }
+
+
+ @Override
+ public void mark(int readlimit) {
+ inputStream.mark(readlimit);
+ }
+
+
+ @Override
+ public boolean markSupported() {
+ return inputStream.markSupported();
+ }
+
+
+ @Override
+ public int read(byte[] buffer, int byteOffset, int byteCount) throws IOException {
+ int bytesRead = inputStream.read(buffer, byteOffset, byteCount);
+ if (bytesRead != -1 ) {
+ log(buffer, byteOffset, bytesRead);
+ }
+ return bytesRead;
+ }
+
+
+ @Override
+ public int read(byte[] buffer) throws IOException {
+ return this.read(buffer, 0, buffer.length);
+ }
+
+ @Override
+ public int read() throws IOException {
+ int characterRead = inputStream.read();
+ if (characterRead != -1) {
+ log(characterRead);
+ }
+ return characterRead;
+ }
+
+ @Override
+ public synchronized void reset() throws IOException {
+ inputStream.reset();
+ }
+
+
+ @Override
+ public long skip(long byteCount) throws IOException {
+ return inputStream.skip(byteCount);
+ }
+
+ private void log(byte[] inputArray, int byteOffset, int byteCount) {
+ int availableSpace = MAX_LOG_SIZE-loggedStringSize;
+ if (availableSpace <= 0)
+ return;
+ int bytesLength = Math.min(availableSpace, byteCount);
+ int startingPosition = MAX_LOG_SIZE - availableSpace;
+ System.arraycopy(inputArray, byteOffset, loggedString, startingPosition, bytesLength);
+ loggedStringSize += bytesLength;
+ }
+
+ private void log(int inputChar) {
+ byte[] logThis = {(byte) inputChar};
+ log(logThis, 0, 1);
+ }
+
+ public String getResponseDocument() {
+ if (loggedStringSize==0) {
+ return "";
+ } else {
+ return new String(loggedString, 0, loggedStringSize);
+ }
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/xmlrpc/android/NullOutputStream.java b/WordPress/src/main/java/org/xmlrpc/android/NullOutputStream.java
new file mode 100644
index 000000000..533311224
--- /dev/null
+++ b/WordPress/src/main/java/org/xmlrpc/android/NullOutputStream.java
@@ -0,0 +1,11 @@
+package org.xmlrpc.android;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**Writes to nowhere*/
+final class NullOutputStream extends OutputStream {
+ @Override
+ public void write(int b) throws IOException {
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/xmlrpc/android/TrustUserSSLCertsSocketFactory.java b/WordPress/src/main/java/org/xmlrpc/android/TrustUserSSLCertsSocketFactory.java
new file mode 100644
index 000000000..007b218cc
--- /dev/null
+++ b/WordPress/src/main/java/org/xmlrpc/android/TrustUserSSLCertsSocketFactory.java
@@ -0,0 +1,162 @@
+package org.xmlrpc.android;
+
+import android.annotation.SuppressLint;
+import android.net.SSLCertificateSocketFactory;
+import android.os.Build;
+
+import org.apache.http.conn.scheme.SocketFactory;
+import org.apache.http.conn.ssl.BrowserCompatHostnameVerifier;
+import org.apache.http.conn.ssl.SSLSocketFactory;
+
+import org.wordpress.android.networking.SelfSignedSSLCertsManager;
+import org.wordpress.android.networking.WPTrustManager;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.Socket;
+import java.security.GeneralSecurityException;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+
+
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLSession;
+import javax.net.ssl.SSLSocket;
+import javax.net.ssl.TrustManager;
+
+
+/**
+ * Custom SSLSocketFactory that adds Self-Signed (trusted) certificates,
+ * and SNI support.
+ *
+ * Loosely based on: http://blog.dev001.net/post/67082904181/android-using-sni-and-tlsv1-2-with-apache-httpclient
+ *
+ * Ref: https://github.com/wordpress-mobile/WordPress-Android/issues/1288
+ *
+ */
+public class TrustUserSSLCertsSocketFactory extends SSLSocketFactory {
+ private SSLCertificateSocketFactory mFactory;
+ private static final BrowserCompatHostnameVerifier mHostnameVerifier = new BrowserCompatHostnameVerifier();
+
+ public TrustUserSSLCertsSocketFactory() throws IOException, GeneralSecurityException {
+ super(null);
+ // No handshake timeout used
+ mFactory = (SSLCertificateSocketFactory) SSLCertificateSocketFactory.getDefault(0);
+ TrustManager[] trustAllowedCerts;
+ try {
+ trustAllowedCerts = new TrustManager[]{
+ new WPTrustManager(SelfSignedSSLCertsManager.getInstance(null).getLocalKeyStore())
+ };
+ mFactory.setTrustManagers(trustAllowedCerts);
+ } catch (GeneralSecurityException e1) {
+ AppLog.e(T.API, "Cannot set TrustAllSSLSocketFactory on our factory. Proceding without it...", e1);
+ }
+ }
+
+ public static SocketFactory getDefault() throws IOException, GeneralSecurityException {
+ return new TrustUserSSLCertsSocketFactory();
+ }
+
+ public Socket createSocket() throws IOException {
+ return mFactory.createSocket();
+ }
+
+ @Override
+ public Socket createSocket(Socket plainSocket, String host, int port, boolean autoClose) throws IOException {
+ if (autoClose) {
+ // we don't need the plainSocket
+ plainSocket.close();
+ }
+
+ SSLSocket ssl = (SSLSocket) mFactory.createSocket(InetAddress.getByName(host), port);
+ return enableSNI(ssl, host);
+ }
+
+ @SuppressLint("NewApi")
+ private Socket enableSNI(SSLSocket ssl, String host) throws SSLPeerUnverifiedException {
+ // enable TLSv1.1/1.2 if available
+ // (see https://github.com/rfc2822/davdroid/issues/229)
+ ssl.setEnabledProtocols(ssl.getSupportedProtocols());
+
+ // set up SNI before the handshake
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ AppLog.i(T.API, "Setting SNI hostname");
+ mFactory.setHostname(ssl, host);
+ } else {
+ AppLog.i(T.API, "No documented SNI support on Android <4.2, trying with reflection");
+ try {
+ java.lang.reflect.Method setHostnameMethod = ssl.getClass().getMethod("setHostname", String.class);
+ setHostnameMethod.invoke(ssl, host);
+ } catch (Exception e) {
+ AppLog.e(T.API, "SNI not useable", e);
+ }
+ }
+
+ // verify hostname and certificate
+ SSLSession session = ssl.getSession();
+ if (!mHostnameVerifier.verify(host, session)) {
+ // the verify failed. We need to check if the current certificate is in Trusted Store.
+ try {
+ Certificate[] errorChain = ssl.getSession().getPeerCertificates();
+ X509Certificate[] X509CertificateChain = new X509Certificate[errorChain.length];
+ for (int i = 0; i < errorChain.length; i++) {
+ X509Certificate x509Certificate = (X509Certificate) errorChain[0];
+ X509CertificateChain[i] = x509Certificate;
+ }
+
+ if (X509CertificateChain.length == 0) {
+ throw new SSLPeerUnverifiedException("Cannot verify hostname: " + host);
+ }
+
+ if (!SelfSignedSSLCertsManager.getInstance(null).isCertificateTrusted(X509CertificateChain[0])) {
+ SelfSignedSSLCertsManager.getInstance(null).setLastFailureChain(X509CertificateChain);
+ throw new SSLPeerUnverifiedException("Cannot verify hostname: " + host);
+ }
+ } catch (GeneralSecurityException e) {
+ AppLog.e(T.API, "GeneralSecurityException occurred when trying to verify a certificate that has failed" +
+ " the host name verifier check", e);
+ throw new SSLPeerUnverifiedException("Cannot verify hostname: " + host);
+ } catch (IOException e) {
+ AppLog.e(T.API, "IOException occurred when trying to verify a certificate that has failed" +
+ " the host name verifier check", e);
+ throw new SSLPeerUnverifiedException("Cannot verify hostname: " + host);
+ } catch (Exception e) {
+ // We don't want crash the app here for an unexpected error
+ AppLog.e(T.API, "An Exception occurred when trying to verify a certificate that has failed" +
+ " the host name verifier check", e);
+ throw new SSLPeerUnverifiedException("Cannot verify hostname: " + host);
+ }
+ }
+
+ AppLog.i(T.API, "Established " + session.getProtocol()
+ + " connection with " + session.getPeerHost()
+ + " using " + session.getCipherSuite());
+
+ return ssl;
+ }
+
+ public Socket createSocket(InetAddress inaddr, int i, InetAddress inaddr1, int j) throws IOException {
+ SSLSocket ssl = (SSLSocket) mFactory.createSocket(inaddr, i, inaddr1, j);
+ return enableSNI(ssl, inaddr.getHostName());
+ }
+
+ public Socket createSocket(InetAddress inaddr, int i) throws IOException {
+ SSLSocket ssl = (SSLSocket) mFactory.createSocket(inaddr, i);
+ return enableSNI(ssl, inaddr.getHostName());
+ }
+
+ public Socket createSocket(String s, int i, InetAddress inaddr, int j) throws IOException {
+ SSLSocket ssl = (SSLSocket) mFactory.createSocket(s, i, inaddr, j);
+ return enableSNI(ssl, inaddr.getHostName());
+ }
+
+ public Socket createSocket(String s, int i) throws IOException {
+ SSLSocket ssl = (SSLSocket) mFactory.createSocket(s, i);
+ return enableSNI(ssl, s);
+ }
+
+ public String[] getDefaultCipherSuites() { return mFactory.getDefaultCipherSuites(); }
+ public String[] getSupportedCipherSuites() { return mFactory.getSupportedCipherSuites(); }
+}
diff --git a/WordPress/src/main/java/org/xmlrpc/android/XMLRPCCallback.java b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCCallback.java
new file mode 100644
index 000000000..45471f8dc
--- /dev/null
+++ b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCCallback.java
@@ -0,0 +1,26 @@
+package org.xmlrpc.android;
+
+/**
+ * The XMLRPCCallback interface must be implemented by a listener for an
+ * asynchronous call to a server method.
+ * When the server responds, the corresponding method on the listener is called.
+ *
+ * @author Tim Roes
+ */
+public interface XMLRPCCallback {
+ /**
+ * This callback is called whenever the server successfully responds.
+ *
+ * @param id The id as returned by the XMLRPCClient.asyncCall(..) method for this request.
+ * @param result The Object returned from the server.
+ */
+ public void onSuccess(long id, Object result);
+
+ /**
+ * This callback is called whenever an error occurs during the method call.
+ *
+ * @param id The id as returned by the XMLRPCClient.asyncCall(..) method for this request.
+ * @param error The error occured.
+ */
+ public void onFailure(long id, Exception error);
+}
diff --git a/WordPress/src/main/java/org/xmlrpc/android/XMLRPCClient.java b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCClient.java
new file mode 100644
index 000000000..2aad1c099
--- /dev/null
+++ b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCClient.java
@@ -0,0 +1,603 @@
+package org.xmlrpc.android;
+
+import android.content.Intent;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.TextUtils;
+import android.util.Xml;
+
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpResponse;
+import org.apache.http.HttpStatus;
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.conn.scheme.Scheme;
+import org.apache.http.entity.FileEntity;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.BasicCredentialsProvider;
+import org.apache.http.impl.client.DefaultHttpClient;
+import org.apache.http.params.CoreConnectionPNames;
+import org.apache.http.params.HttpConnectionParams;
+import org.apache.http.params.HttpParams;
+import org.apache.http.params.HttpProtocolParams;
+import org.apache.http.util.EntityUtils;
+import org.wordpress.android.WordPress;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.StringUtils;
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlpull.v1.XmlPullParserFactory;
+import org.xmlpull.v1.XmlSerializer;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.SequenceInputStream;
+import java.io.StringWriter;
+import java.net.URI;
+import java.net.URL;
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.net.ssl.SSLHandshakeException;
+import javax.net.ssl.SSLPeerUnverifiedException;
+
+/**
+ * A WordPress XMLRPC Client.
+ * Based on android-xmlrpc: code.google.com/p/android-xmlrpc/
+ * Async support based on aXMLRPC: https://github.com/timroes/aXMLRPC
+ */
+
+public class XMLRPCClient implements XMLRPCClientInterface {
+ private static final String TAG_METHOD_CALL = "methodCall";
+ private static final String TAG_METHOD_NAME = "methodName";
+ private static final String TAG_METHOD_RESPONSE = "methodResponse";
+ private static final String TAG_PARAMS = "params";
+ private static final String TAG_PARAM = "param";
+ private static final String TAG_FAULT = "fault";
+ private static final String TAG_FAULT_CODE = "faultCode";
+ private static final String TAG_FAULT_STRING = "faultString";
+ private static final int DEFAULT_CONNECTION_TIMEOUT = 30000;
+ private static final int DEFAULT_SOCKET_TIMEOUT = 60000;
+
+ private Map<Long,Caller> backgroundCalls = new HashMap<Long, Caller>();
+
+ private DefaultHttpClient mClient;
+ private HttpPost mPostMethod;
+ private XmlSerializer mSerializer;
+ private HttpParams mHttpParams;
+ private boolean mIsWpcom;
+
+ /**
+ * XMLRPCClient constructor. Creates new instance based on server URI
+ * @param XMLRPC server URI
+ */
+ public XMLRPCClient(URI uri, String httpuser, String httppasswd) {
+ mPostMethod = new HttpPost(uri);
+ mPostMethod.addHeader("Content-Type", "text/xml");
+ mPostMethod.addHeader("charset", "UTF-8");
+ mPostMethod.addHeader("User-Agent", WordPress.getUserAgent());
+
+ mHttpParams = mPostMethod.getParams();
+ HttpProtocolParams.setUseExpectContinue(mHttpParams, false);
+
+ UsernamePasswordCredentials credentials = null;
+ if (!TextUtils.isEmpty(httpuser) && !TextUtils.isEmpty(httppasswd)) {
+ credentials = new UsernamePasswordCredentials(httpuser, httppasswd);
+ }
+
+ mClient = instantiateClientForUri(uri, credentials);
+ mSerializer = Xml.newSerializer();
+ }
+
+ private class ConnectionClient extends DefaultHttpClient {
+ public ConnectionClient(int port) throws IOException, GeneralSecurityException {
+ super();
+ TrustUserSSLCertsSocketFactory tasslf = new TrustUserSSLCertsSocketFactory();
+ Scheme scheme = new Scheme("https", tasslf, port);
+ getConnectionManager().getSchemeRegistry().register(scheme);
+ }
+ }
+
+ private DefaultHttpClient instantiateClientForUri(URI uri, UsernamePasswordCredentials usernamePasswordCredentials) {
+ DefaultHttpClient client = null;
+ if (uri != null && uri.getHost() != null && uri.getHost().endsWith("wordpress.com")) {
+ mIsWpcom = true;
+ }
+ if (mIsWpcom || (uri == null || uri.getScheme() == null || uri.getScheme().equals("http"))) {
+ //wpcom blog or self-hosted blog on plain HTTP
+ client = new DefaultHttpClient();
+ } else {
+ int port = uri.getPort();
+ if (port == -1) {
+ port = 443;
+ }
+
+ try {
+ client = new ConnectionClient(port);
+ } catch (GeneralSecurityException e) {
+ AppLog.e(T.API, "Cannot create the DefaultHttpClient object with our TrustAllSSLSocketFactory", e);
+ client = null;
+ } catch (IOException e) {
+ AppLog.e(T.API, "Cannot create the DefaultHttpClient object with our TrustAllSSLSocketFactory", e);
+ client = null;
+ }
+
+ if (client == null) {
+ client = new DefaultHttpClient();
+ }
+ }
+
+ // This is probably superfluous, since we're setting the timeouts in the method parameters. See preparePostMethod
+ HttpConnectionParams.setConnectionTimeout(client.getParams(), DEFAULT_CONNECTION_TIMEOUT);
+ HttpConnectionParams.setSoTimeout(client.getParams(), DEFAULT_SOCKET_TIMEOUT);
+
+ //Setup HTTP Basic Auth if necessary
+ if (usernamePasswordCredentials != null) {
+ BasicCredentialsProvider cP = new BasicCredentialsProvider();
+ cP.setCredentials(AuthScope.ANY, usernamePasswordCredentials);
+ client.setCredentialsProvider(cP);
+ }
+
+ return client;
+ }
+
+ public void addQuickPostHeader(String type) {
+ mPostMethod.addHeader("WP-QUICK-POST", type);
+ }
+
+ /**
+ * Convenience constructor. Creates new instance based on server String address
+ * @param url server url
+ */
+ public XMLRPCClient(String url, String httpuser, String httppasswd) {
+ this(URI.create(url), httpuser, httppasswd);
+ }
+
+ /**
+ * Convenience XMLRPCClient constructor. Creates new instance based on server URL
+ * @param url server URL
+ */
+ public XMLRPCClient(URL url, String httpuser, String httppasswd) {
+ this(URI.create(url.toExternalForm()), httpuser, httppasswd);
+ }
+
+ /**
+ * Set WP.com auth header
+ * @param authToken authorization token
+ */
+ public void setAuthorizationHeader(String authToken) {
+ if( authToken != null)
+ mPostMethod.addHeader("Authorization", String.format("Bearer %s", authToken));
+ else
+ mPostMethod.removeHeaders("Authorization");
+ }
+
+ /**
+ * Call method with optional parameters. This is general method.
+ * If you want to call your method with 0-8 parameters, you can use more
+ * convenience call methods
+ *
+ * @param method name of method to call
+ * @param params parameters to pass to method (may be null if method has no parameters)
+ * @return deserialized method return value
+ * @throws XMLRPCException
+ */
+ public Object call(String method, Object[] params) throws XMLRPCException, IOException, XmlPullParserException {
+ return call(method, params, null);
+ }
+
+ /**
+ * Convenience method call with no parameters
+ *
+ * @param method name of method to call
+ * @return deserialized method return value
+ * @throws XMLRPCException
+ */
+ public Object call(String method) throws XMLRPCException, IOException, XmlPullParserException {
+ return call(method, null, null);
+ }
+
+
+ public Object call(String method, Object[] params, File tempFile) throws XMLRPCException, IOException, XmlPullParserException {
+ return new Caller().callXMLRPC(method, params, tempFile);
+ }
+
+ /**
+ * Convenience call for callAsync with two paramaters
+ *
+ * @param XMLRPCCallback listener, XMLRPC methodName, XMLRPC parameters
+ * @return unique id of this async call
+ * @throws XMLRPCException
+ */
+ public long callAsync(XMLRPCCallback listener, String methodName, Object[] params) {
+ return callAsync(listener, methodName, params, null);
+ }
+
+ /**
+ * Asynchronous XMLRPC call
+ *
+ * @param XMLRPCCallback listener, XMLRPC methodName, XMLRPC parameters, File for large uploads
+ * @return unique id of this async call
+ * @throws XMLRPCException
+ */
+ public long callAsync(XMLRPCCallback listener, String methodName, Object[] params, File tempFile) {
+ long id = System.currentTimeMillis();
+ new Caller(listener, id, methodName, params, tempFile).start();
+ return id;
+ }
+
+ @SuppressWarnings("unchecked")
+ public static Object parseXMLRPCResponse(InputStream is, HttpEntity entity)
+ throws XMLRPCException, IOException, XmlPullParserException, NumberFormatException {
+ // setup pull parser
+ XmlPullParser pullParser = XmlPullParserFactory.newInstance().newPullParser();
+
+ // Many WordPress configs can output junk before the xml response (php warnings for example), this cleans it.
+ int bomCheck = -1;
+ int stopper = 0;
+ while ((bomCheck = is.read()) != -1 && stopper <= 5000) {
+ stopper++;
+ String snippet = "";
+ // 60 == '<' character
+ if (bomCheck == 60) {
+ for (int i = 0; i < 4; i++) {
+ byte[] chunk = new byte[1];
+ is.read(chunk);
+ snippet += new String(chunk, "UTF-8");
+ }
+ if (snippet.equals("?xml")) {
+ // it's all good, add xml tag back and start parsing
+ String start = "<" + snippet;
+ List<InputStream> streams = Arrays.asList(new ByteArrayInputStream(start.getBytes()), is);
+ is = new SequenceInputStream(Collections.enumeration(streams));
+ break;
+ } else {
+ // keep searching...
+ List<InputStream> streams = Arrays.asList(new ByteArrayInputStream(snippet.getBytes()), is);
+ is = new SequenceInputStream(Collections.enumeration(streams));
+ }
+ }
+ }
+
+ pullParser.setInput(is, "UTF-8");
+
+ // lets start pulling...
+ pullParser.nextTag();
+ pullParser.require(XmlPullParser.START_TAG, null, TAG_METHOD_RESPONSE);
+
+ pullParser.nextTag(); // either TAG_PARAMS (<params>) or TAG_FAULT (<fault>)
+ String tag = pullParser.getName();
+ if (tag.equals(TAG_PARAMS)) {
+ // normal response
+ pullParser.nextTag(); // TAG_PARAM (<param>)
+ pullParser.require(XmlPullParser.START_TAG, null, TAG_PARAM);
+ pullParser.nextTag(); // TAG_VALUE (<value>)
+ // no parser.require() here since its called in XMLRPCSerializer.deserialize() below
+ // deserialize result
+ Object obj = XMLRPCSerializer.deserialize(pullParser);
+ if (entity != null) {
+ entity.consumeContent();
+ }
+ return obj;
+ } else if (tag.equals(TAG_FAULT)) {
+ // fault response
+ pullParser.nextTag(); // TAG_VALUE (<value>)
+ // no parser.require() here since its called in XMLRPCSerializer.deserialize() below
+ // deserialize fault result
+ Map<String, Object> map = (Map<String, Object>) XMLRPCSerializer.deserialize(pullParser);
+ String faultString = (String) map.get(TAG_FAULT_STRING);
+ int faultCode = (Integer) map.get(TAG_FAULT_CODE);
+ if (entity != null) {
+ entity.consumeContent();
+ }
+ throw new XMLRPCFault(faultString, faultCode);
+ } else {
+ if (entity != null) {
+ entity.consumeContent();
+ }
+ throw new XMLRPCException("Bad tag <" + tag + "> in XMLRPC response - neither <params> nor <fault>");
+ }
+ }
+
+ public void preparePostMethod(String method, Object[] params, File tempFile) throws IOException, XMLRPCException, IllegalArgumentException, IllegalStateException {
+ // prepare POST body
+ if (method.equals("wp.uploadFile")) {
+ if (!tempFile.exists() && !tempFile.mkdirs()) {
+ throw new XMLRPCException("Path to file could not be created.");
+ }
+
+ FileWriter fileWriter = new FileWriter(tempFile);
+ mSerializer.setOutput(fileWriter);
+
+ mSerializer.startDocument(null, null);
+ mSerializer.startTag(null, TAG_METHOD_CALL);
+ // set method name
+ mSerializer.startTag(null, TAG_METHOD_NAME).text(method).endTag(null, TAG_METHOD_NAME);
+ if (params != null && params.length != 0) {
+ // set method params
+ mSerializer.startTag(null, TAG_PARAMS);
+ for (int i = 0; i < params.length; i++) {
+ mSerializer.startTag(null, TAG_PARAM).startTag(null, XMLRPCSerializer.TAG_VALUE);
+ XMLRPCSerializer.serialize(mSerializer, params[i]);
+ mSerializer.endTag(null, XMLRPCSerializer.TAG_VALUE).endTag(null, TAG_PARAM);
+ }
+ mSerializer.endTag(null, TAG_PARAMS);
+ }
+ mSerializer.endTag(null, TAG_METHOD_CALL);
+ mSerializer.endDocument();
+
+ fileWriter.flush();
+ fileWriter.close();
+
+ FileEntity fEntity = new FileEntity(tempFile, "text/xml; charset=\"UTF-8\"");
+ fEntity.setContentType("text/xml");
+ //fEntity.setChunked(true);
+ mPostMethod.setEntity(fEntity);
+ } else {
+ StringWriter bodyWriter = new StringWriter();
+ mSerializer.setOutput(bodyWriter);
+
+ mSerializer.startDocument(null, null);
+ mSerializer.startTag(null, TAG_METHOD_CALL);
+ // set method name
+ mSerializer.startTag(null, TAG_METHOD_NAME).text(method).endTag(null, TAG_METHOD_NAME);
+ if (params != null && params.length != 0) {
+ // set method params
+ mSerializer.startTag(null, TAG_PARAMS);
+ for (int i = 0; i < params.length; i++) {
+ mSerializer.startTag(null, TAG_PARAM).startTag(null, XMLRPCSerializer.TAG_VALUE);
+ if (method.equals("metaWeblog.editPost") || method.equals("metaWeblog.newPost")) {
+ XMLRPCSerializer.serialize(mSerializer, params[i]);
+ } else {
+ XMLRPCSerializer.serialize(mSerializer, params[i]);
+ }
+ mSerializer.endTag(null, XMLRPCSerializer.TAG_VALUE).endTag(null, TAG_PARAM);
+ }
+ mSerializer.endTag(null, TAG_PARAMS);
+ }
+ mSerializer.endTag(null, TAG_METHOD_CALL);
+ mSerializer.endDocument();
+
+ HttpEntity entity = new StringEntity(bodyWriter.toString());
+ //Log.i("WordPress", bodyWriter.toString());
+ mPostMethod.setEntity(entity);
+ }
+
+ //set timeout to 30 seconds, does it need to be set for both mClient and method?
+ mClient.getParams().setParameter(CoreConnectionPNames.CONNECTION_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT);
+ mClient.getParams().setParameter(CoreConnectionPNames.SO_TIMEOUT, DEFAULT_SOCKET_TIMEOUT);
+ mPostMethod.getParams().setParameter(CoreConnectionPNames.CONNECTION_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT);
+ mPostMethod.getParams().setParameter(CoreConnectionPNames.SO_TIMEOUT, DEFAULT_SOCKET_TIMEOUT);
+ }
+
+ /**
+ * The Caller class is used to make asynchronous calls to the server.
+ * For synchronous calls the Thread function of this class isn't used.
+ */
+ private class Caller extends Thread {
+ private XMLRPCCallback listener;
+ private long threadId;
+ private String methodName;
+ private Object[] params;
+ private File tempFile;
+
+ /**
+ * Create a new Caller for asynchronous use.
+ *
+ * @param listener The listener to notice about the response or an error.
+ * @param threadId An id that will be send to the listener.
+ * @param methodName The method name to call.
+ * @param params The parameters of the call or null.
+ */
+ public Caller(XMLRPCCallback listener, long threadId, String methodName, Object[] params, File tempFile) {
+ this.listener = listener;
+ this.threadId = threadId;
+ this.methodName = methodName;
+ this.params = params;
+ this.tempFile = tempFile;
+ }
+
+ /**
+ * Create a new Caller for synchronous use.
+ * If the caller has been created with this constructor you cannot use the
+ * start method to start it as a thread. But you can call the call method
+ * on it for synchronous use.
+ */
+ public Caller() { }
+
+ /**
+ * The run method is invoked when the thread gets started.
+ * This will only work, if the Caller has been created with parameters.
+ * It execute the call method and notify the listener about the result.
+ */
+ @Override
+ public void run() {
+ if(listener == null)
+ return;
+
+ try {
+ backgroundCalls.put(threadId, this);
+ Object o = this.callXMLRPC(methodName, params, tempFile);
+ listener.onSuccess(threadId, o);
+ } catch(CancelException ex) {
+ // Don't notify the listener, if the call has been canceled.
+ } catch (Exception ex) {
+ listener.onFailure(threadId, ex);
+ } finally {
+ backgroundCalls.remove(threadId);
+ }
+
+ }
+
+ /**
+ * Call method with optional parameters
+ *
+ * @param method name of method to call
+ * @param params parameters to pass to method (may be null if method has no parameters)
+ * @return deserialized method return value
+ * @throws XMLRPCException
+ */
+ private Object callXMLRPC(String method, Object[] params, File tempFile)
+ throws XMLRPCException, IOException, XmlPullParserException {
+ LoggedInputStream loggedInputStream = null;
+ try {
+ preparePostMethod(method, params, tempFile);
+
+ // execute HTTP POST request
+ HttpResponse response = mClient.execute(mPostMethod);
+
+ if (response.getStatusLine() == null) // StatusLine is null. We can't read the response code.
+ throw new XMLRPCException( "HTTP Status code is missing!" );
+
+ int statusCode = response.getStatusLine().getStatusCode();
+ HttpEntity entity = response.getEntity();
+
+ if (entity == null) {
+ //This is an error since the parser will fail here.
+ throw new XMLRPCException( "HTTP status code: " + statusCode + " was returned AND no response from the server." );
+ }
+
+ if (statusCode == HttpStatus.SC_OK) {
+ loggedInputStream = new LoggedInputStream(entity.getContent());
+ return XMLRPCClient.parseXMLRPCResponse(loggedInputStream, entity);
+ }
+
+ String statusLineReasonPhrase = StringUtils.notNullStr(response.getStatusLine().getReasonPhrase());
+ try {
+ String responseString = EntityUtils.toString(entity, "UTF-8");
+ if (TextUtils.isEmpty(responseString)) {
+ AppLog.e(T.API, "No HTTP error document document from the server");
+ } else {
+ AppLog.e(T.API, "HTTP error document received from the server: " + responseString);
+ }
+
+ if (statusCode == HttpStatus.SC_INTERNAL_SERVER_ERROR) {
+ //Try to intercept out of memory error here and show a better error message.
+ if (!TextUtils.isEmpty(responseString) && responseString.contains("php fatal error") &&
+ responseString.contains("bytes exhausted")) {
+ String newErrorMsg;
+ if (method.equals("wp.uploadFile")) {
+ newErrorMsg =
+ "The server doesn't have enough memory to upload this file. You may need to increase the PHP memory limit on your site.";
+ } else {
+ newErrorMsg =
+ "The server doesn't have enough memory to fulfill the request. You may need to increase the PHP memory limit on your site.";
+ }
+ throw new XMLRPCException( statusLineReasonPhrase + ".\n\n" + newErrorMsg);
+ }
+ }
+
+ } catch (Exception e) {
+ // eat all the exceptions here, we dont want to crash the app when trying to show a
+ // better error message.
+ }
+ throw new XMLRPCException( "HTTP status code: " + statusCode + " was returned. " + statusLineReasonPhrase);
+ } catch (XMLRPCFault e) {
+ if (loggedInputStream!=null) {
+ AppLog.w(T.API, "Response document received from the server: " + loggedInputStream.getResponseDocument());
+ }
+ // Detect login issues and broadcast a message if the error is known
+ switch (e.getFaultCode()) {
+ case 403:
+ broadcastAction(WordPress.BROADCAST_ACTION_XMLRPC_INVALID_CREDENTIALS);
+ break;
+ case 425:
+ broadcastAction(WordPress.BROADCAST_ACTION_XMLRPC_TWO_FA_AUTH);
+ break;
+ //TODO: Check the login limit here
+ default:
+ break;
+ }
+ throw e;
+ } catch (XmlPullParserException e) {
+ AppLog.e(T.API, "Error while parsing the XML-RPC response document received from the server.", e);
+ if (loggedInputStream!=null) {
+ AppLog.e(T.API, "Response document received from the server: " + loggedInputStream.getResponseDocument());
+ }
+ checkXMLRPCErrorMessage(e);
+ throw e;
+ } catch (NumberFormatException e) {
+ //we can catch NumberFormatException here and re-throw an XMLRPCException.
+ //The response document is not a valid XML-RPC document after all.
+ AppLog.e(T.API, "Error while parsing the XML-RPC response document received from the server.", e);
+ if (loggedInputStream!=null) {
+ AppLog.e(T.API, "Response document received from the server: " + loggedInputStream.getResponseDocument());
+ }
+ throw new XMLRPCException("The response received contains an invalid number. " + e.getMessage());
+ } catch (XMLRPCException e) {
+ if (loggedInputStream!=null) {
+ AppLog.e(T.API, "Response document received from the server: " + loggedInputStream.getResponseDocument());
+ }
+ checkXMLRPCErrorMessage(e);
+ throw e;
+ } catch (SSLHandshakeException e) {
+ if (mIsWpcom) {
+ AppLog.e(T.NUX, "SSLHandshakeException failed. Erroneous SSL certificate detected on wordpress.com");
+ } else {
+ AppLog.w(T.NUX, "SSLHandshakeException failed. Erroneous SSL certificate detected.");
+ broadcastAction(WordPress.BROADCAST_ACTION_XMLRPC_INVALID_SSL_CERTIFICATE);
+ }
+ throw e;
+ } catch (SSLPeerUnverifiedException e) {
+ if (mIsWpcom) {
+ AppLog.e(T.NUX, "SSLPeerUnverifiedException failed. Erroneous SSL certificate detected on wordpress.com");
+ } else {
+ AppLog.w(T.NUX, "SSLPeerUnverifiedException failed. Erroneous SSL certificate detected.");
+ broadcastAction(WordPress.BROADCAST_ACTION_XMLRPC_INVALID_SSL_CERTIFICATE);
+ }
+ throw e;
+ } finally {
+ deleteTempFile(method, tempFile);
+ try {
+ if (loggedInputStream!=null) {
+ loggedInputStream.close();
+ }
+ } catch (Exception e) {
+ }
+ }
+ }
+ }
+
+ /**
+ * Detect login issues and broadcast a message if the error is known, App Activities should listen to these
+ * broadcasted events and present user action to take
+ *
+ * @return true if error is known and event broadcasted, false else
+ */
+ private boolean checkXMLRPCErrorMessage(Exception exception) {
+ String errorMessage = exception.getMessage().toLowerCase();
+ if ((errorMessage.contains("code: 503") || errorMessage.contains("code 503"))//TODO Not sure 503 is the correct error code returned by wpcom
+ &&
+ (errorMessage.contains("limit reached") || errorMessage.contains("login limit")))
+ {
+ broadcastAction(WordPress.BROADCAST_ACTION_XMLRPC_LOGIN_LIMIT);
+ return true;
+ }
+ return false;
+ }
+
+ private void broadcastAction(String action) {
+ WordPress.sendLocalBroadcast(WordPress.getContext(), action);
+ }
+
+ private void deleteTempFile(String method, File tempFile) {
+ if (tempFile != null) {
+ if ((method.equals("wp.uploadFile"))){ //get rid of the temp file
+ tempFile.delete();
+ }
+ }
+
+ }
+
+ private class CancelException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+ }
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/xmlrpc/android/XMLRPCClientInterface.java b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCClientInterface.java
new file mode 100644
index 000000000..6b9cf9a10
--- /dev/null
+++ b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCClientInterface.java
@@ -0,0 +1,16 @@
+package org.xmlrpc.android;
+
+import java.io.File;
+import java.io.IOException;
+
+import org.xmlpull.v1.XmlPullParserException;
+
+public interface XMLRPCClientInterface {
+ public void addQuickPostHeader(String type);
+ public void setAuthorizationHeader(String authToken);
+ public Object call(String method, Object[] params) throws XMLRPCException, IOException, XmlPullParserException;
+ public Object call(String method) throws XMLRPCException, IOException, XmlPullParserException;
+ public Object call(String method, Object[] params, File tempFile) throws XMLRPCException, IOException, XmlPullParserException;
+ public long callAsync(XMLRPCCallback listener, String methodName, Object[] params);
+ public long callAsync(XMLRPCCallback listener, String methodName, Object[] params, File tempFile);
+}
diff --git a/WordPress/src/main/java/org/xmlrpc/android/XMLRPCException.java b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCException.java
new file mode 100644
index 000000000..6d41ec741
--- /dev/null
+++ b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCException.java
@@ -0,0 +1,16 @@
+package org.xmlrpc.android;
+
+public class XMLRPCException extends Exception {
+ /**
+ *
+ */
+ private static final long serialVersionUID = 7499675036625522379L;
+
+ public XMLRPCException(Exception e) {
+ super(e);
+ }
+
+ public XMLRPCException(String string) {
+ super(string);
+ }
+}
diff --git a/WordPress/src/main/java/org/xmlrpc/android/XMLRPCFactory.java b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCFactory.java
new file mode 100644
index 000000000..7652902a2
--- /dev/null
+++ b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCFactory.java
@@ -0,0 +1,18 @@
+package org.xmlrpc.android;
+
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+
+import java.net.URI;
+
+public class XMLRPCFactory {
+ public static XMLRPCFactoryAbstract sFactory;
+
+ public static XMLRPCClientInterface instantiate(URI uri, String httpUser, String httpPassword) {
+ if (sFactory == null) {
+ sFactory = new XMLRPCFactoryDefault();
+ }
+ AppLog.v(T.UTILS, "instantiate XMLRPCClient using sFactory: " + sFactory.getClass());
+ return sFactory.make(uri, httpUser, httpPassword);
+ }
+}
diff --git a/WordPress/src/main/java/org/xmlrpc/android/XMLRPCFactoryAbstract.java b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCFactoryAbstract.java
new file mode 100644
index 000000000..f9640c4a9
--- /dev/null
+++ b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCFactoryAbstract.java
@@ -0,0 +1,7 @@
+package org.xmlrpc.android;
+
+import java.net.URI;
+
+public interface XMLRPCFactoryAbstract {
+ public XMLRPCClientInterface make(URI uri, String httpUser, String httpPassword);
+} \ No newline at end of file
diff --git a/WordPress/src/main/java/org/xmlrpc/android/XMLRPCFactoryDefault.java b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCFactoryDefault.java
new file mode 100644
index 000000000..16a3b7ead
--- /dev/null
+++ b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCFactoryDefault.java
@@ -0,0 +1,9 @@
+package org.xmlrpc.android;
+
+import java.net.URI;
+
+public class XMLRPCFactoryDefault implements XMLRPCFactoryAbstract {
+ public XMLRPCClientInterface make(URI uri, String httpUser, String httpPassword) {
+ return new XMLRPCClient(uri, httpUser, httpPassword);
+ }
+}
diff --git a/WordPress/src/main/java/org/xmlrpc/android/XMLRPCFault.java b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCFault.java
new file mode 100644
index 000000000..ab594e838
--- /dev/null
+++ b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCFault.java
@@ -0,0 +1,28 @@
+package org.xmlrpc.android;
+
+public class XMLRPCFault extends XMLRPCException {
+ /**
+ *
+ */
+ private static final long serialVersionUID = 5676562456612956519L;
+ private String faultString;
+ private int faultCode;
+
+ public XMLRPCFault(String faultString, int faultCode) {
+ super(faultString);
+ this.faultString = faultString;
+ this.faultCode = faultCode;
+ }
+
+ public String getFaultString() {
+ return faultString;
+ }
+
+ public int getFaultCode() {
+ return faultCode;
+ }
+
+ public String getMessage() {
+ return super.getMessage() + " [Code: "+this.faultCode+"]";
+ }
+}
diff --git a/WordPress/src/main/java/org/xmlrpc/android/XMLRPCSerializer.java b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCSerializer.java
new file mode 100644
index 000000000..8a8f42c05
--- /dev/null
+++ b/WordPress/src/main/java/org/xmlrpc/android/XMLRPCSerializer.java
@@ -0,0 +1,298 @@
+package org.xmlrpc.android;
+
+import android.text.TextUtils;
+import android.util.Base64;
+import android.util.Xml;
+
+import org.wordpress.android.models.MediaFile;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.StringUtils;
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlpull.v1.XmlSerializer;
+
+import java.io.BufferedReader;
+import java.io.DataInputStream;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StringReader;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.SimpleTimeZone;
+
+class XMLRPCSerializer {
+ static final String TAG_NAME = "name";
+ static final String TAG_MEMBER = "member";
+ static final String TAG_VALUE = "value";
+ static final String TAG_DATA = "data";
+
+ static final String TYPE_INT = "int";
+ static final String TYPE_I4 = "i4";
+ static final String TYPE_I8 = "i8";
+ static final String TYPE_DOUBLE = "double";
+ static final String TYPE_BOOLEAN = "boolean";
+ static final String TYPE_STRING = "string";
+ static final String TYPE_DATE_TIME_ISO8601 = "dateTime.iso8601";
+ static final String TYPE_BASE64 = "base64";
+ static final String TYPE_ARRAY = "array";
+ static final String TYPE_STRUCT = "struct";
+
+ static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd'T'HH:mm:ss");
+ static Calendar cal = Calendar.getInstance(new SimpleTimeZone(0, "GMT"));
+
+ private static final XmlSerializer serializeTester;
+
+ static {
+ serializeTester = Xml.newSerializer();
+ try {
+ serializeTester.setOutput(new NullOutputStream(), "UTF-8");
+ } catch (IllegalArgumentException e) {
+ AppLog.e(AppLog.T.EDITOR, "IllegalArgumentException setting test serializer output stream", e );
+ } catch (IllegalStateException e) {
+ AppLog.e(AppLog.T.EDITOR, "IllegalStateException setting test serializer output stream", e );
+ } catch (IOException e) {
+ AppLog.e(AppLog.T.EDITOR, "IOException setting test serializer output stream", e );
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ static void serialize(XmlSerializer serializer, Object object) throws IOException {
+ // check for scalar types:
+ if (object instanceof Integer || object instanceof Short || object instanceof Byte) {
+ serializer.startTag(null, TYPE_I4).text(object.toString()).endTag(null, TYPE_I4);
+ } else
+ if (object instanceof Long) {
+ // Note Long should be represented by a TYPE_I8 but the WordPress end point doesn't support <i8> tag
+ // Long usually represents IDs, so we convert them to string
+ serializer.startTag(null, TYPE_STRING).text(object.toString()).endTag(null, TYPE_STRING);
+ AppLog.w(T.API, "long type could be misinterpreted when sent to the WordPress XMLRPC end point");
+ } else
+ if (object instanceof Double || object instanceof Float) {
+ serializer.startTag(null, TYPE_DOUBLE).text(object.toString()).endTag(null, TYPE_DOUBLE);
+ } else
+ if (object instanceof Boolean) {
+ Boolean bool = (Boolean) object;
+ String boolStr = bool.booleanValue() ? "1" : "0";
+ serializer.startTag(null, TYPE_BOOLEAN).text(boolStr).endTag(null, TYPE_BOOLEAN);
+ } else
+ if (object instanceof String) {
+ serializer.startTag(null, TYPE_STRING).text(makeValidInputString((String) object)).endTag(null, TYPE_STRING);
+ } else
+ if (object instanceof Date || object instanceof Calendar) {
+ Date date = (Date) object;
+ SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd'T'HH:mm:ss");
+ dateFormat.setCalendar(cal);
+ String sDate = dateFormat.format(date);
+ serializer.startTag(null, TYPE_DATE_TIME_ISO8601).text(sDate).endTag(null, TYPE_DATE_TIME_ISO8601);
+ } else
+ if (object instanceof byte[] ){
+ String value;
+ try {
+ value = Base64.encodeToString((byte[])object, Base64.DEFAULT);
+ serializer.startTag(null, TYPE_BASE64).text(value).endTag(null, TYPE_BASE64);
+ } catch (OutOfMemoryError e) {
+ throw new IOException("Out of memory");
+ }
+ }
+ else if( object instanceof MediaFile ) {
+ //convert media file binary to base64
+ serializer.startTag( null, "base64" );
+ MediaFile mediaFile = (MediaFile) object;
+ InputStream inStream = new DataInputStream(new FileInputStream(mediaFile.getFilePath()));
+ byte[] buffer = new byte[3600];//you must use a 24bit multiple
+ int length = -1;
+ String chunk = null;
+ while ((length = inStream.read(buffer)) > 0) {
+ chunk = Base64.encodeToString(buffer, 0, length, Base64.DEFAULT);
+ serializer.text(chunk);
+ }
+ inStream.close();
+ serializer.endTag(null, "base64");
+ }else
+ if (object instanceof List<?>) {
+ serializer.startTag(null, TYPE_ARRAY).startTag(null, TAG_DATA);
+ List<Object> list = (List<Object>) object;
+ Iterator<Object> iter = list.iterator();
+ while (iter.hasNext()) {
+ Object o = iter.next();
+ serializer.startTag(null, TAG_VALUE);
+ serialize(serializer, o);
+ serializer.endTag(null, TAG_VALUE);
+ }
+ serializer.endTag(null, TAG_DATA).endTag(null, TYPE_ARRAY);
+ } else
+ if (object instanceof Object[]) {
+ serializer.startTag(null, TYPE_ARRAY).startTag(null, TAG_DATA);
+ Object[] objects = (Object[]) object;
+ for (int i=0; i<objects.length; i++) {
+ Object o = objects[i];
+ serializer.startTag(null, TAG_VALUE);
+ serialize(serializer, o);
+ serializer.endTag(null, TAG_VALUE);
+ }
+ serializer.endTag(null, TAG_DATA).endTag(null, TYPE_ARRAY);
+ } else
+ if (object instanceof Map) {
+ serializer.startTag(null, TYPE_STRUCT);
+ Map<String, Object> map = (Map<String, Object>) object;
+ Iterator<Entry<String, Object>> iter = map.entrySet().iterator();
+ while (iter.hasNext()) {
+ Entry<String, Object> entry = iter.next();
+ String key = entry.getKey();
+ Object value = entry.getValue();
+
+ serializer.startTag(null, TAG_MEMBER);
+ serializer.startTag(null, TAG_NAME).text(key).endTag(null, TAG_NAME);
+ serializer.startTag(null, TAG_VALUE);
+ serialize(serializer, value);
+ serializer.endTag(null, TAG_VALUE);
+ serializer.endTag(null, TAG_MEMBER);
+ }
+ serializer.endTag(null, TYPE_STRUCT);
+ } else {
+ throw new IOException("Cannot serialize " + object);
+ }
+ }
+
+ private static final String makeValidInputString(final String input) throws IOException {
+ if (TextUtils.isEmpty(input))
+ return "";
+
+ if (serializeTester == null)
+ return input;
+
+ try {
+ // try to encode the string as-is, 99.9% of the time it's OK
+ serializeTester.text(input);
+ return input;
+ } catch (IllegalArgumentException e) {
+ // There are characters outside the XML unicode charset as specified by the XML 1.0 standard
+ // See http://www.w3.org/TR/2000/REC-xml-20001006#NT-Char
+ AppLog.e(AppLog.T.EDITOR, "There are characters outside the XML unicode charset as specified by the XML 1.0 standard", e );
+ }
+
+ // We need to do the following things:
+ // 1. Replace surrogates with HTML Entity.
+ // 2. Replace emoji with their textual versions (if available on WP)
+ // 3. Try to serialize the resulting string.
+ // 4. If it fails again, strip characters that are not allowed in XML 1.0
+ final String noEmojiString = StringUtils.replaceUnicodeSurrogateBlocksWithHTMLEntities(input);
+ try {
+ serializeTester.text(noEmojiString);
+ return noEmojiString;
+ } catch (IllegalArgumentException e) {
+ AppLog.e(AppLog.T.EDITOR, "noEmojiString still contains characters outside the XML unicode charset as specified by the XML 1.0 standard", e );
+ return StringUtils.stripNonValidXMLCharacters(noEmojiString);
+ }
+ }
+
+ static Object deserialize(XmlPullParser parser) throws XmlPullParserException, IOException, NumberFormatException {
+ parser.require(XmlPullParser.START_TAG, null, TAG_VALUE);
+
+ parser.nextTag();
+ String typeNodeName = parser.getName();
+
+ Object obj;
+ if (typeNodeName.equals(TYPE_INT) || typeNodeName.equals(TYPE_I4)) {
+ String value = parser.nextText();
+ try {
+ obj = Integer.parseInt(value);
+ } catch (NumberFormatException nfe) {
+ AppLog.w(T.API, "Server replied with an invalid 4 bytes int value, trying to parse it as 8 bytes long");
+ obj = Long.parseLong(value);
+ }
+ } else
+ if (typeNodeName.equals(TYPE_I8)) {
+ String value = parser.nextText();
+ obj = Long.parseLong(value);
+ } else
+ if (typeNodeName.equals(TYPE_DOUBLE)) {
+ String value = parser.nextText();
+ obj = Double.parseDouble(value);
+ } else
+ if (typeNodeName.equals(TYPE_BOOLEAN)) {
+ String value = parser.nextText();
+ obj = value.equals("1") ? Boolean.TRUE : Boolean.FALSE;
+ } else
+ if (typeNodeName.equals(TYPE_STRING)) {
+ obj = parser.nextText();
+ } else
+ if (typeNodeName.equals(TYPE_DATE_TIME_ISO8601)) {
+ dateFormat.setCalendar(cal);
+ String value = parser.nextText();
+ try {
+ obj = dateFormat.parseObject(value);
+ } catch (ParseException e) {
+ AppLog.e(T.API, e);
+ obj = value;
+ }
+ } else
+ if (typeNodeName.equals(TYPE_BASE64)) {
+ String value = parser.nextText();
+ BufferedReader reader = new BufferedReader(new StringReader(value));
+ String line;
+ StringBuffer sb = new StringBuffer();
+ while ((line = reader.readLine()) != null) {
+ sb.append(line);
+ }
+ obj = Base64.decode(sb.toString(), Base64.DEFAULT);
+ } else
+ if (typeNodeName.equals(TYPE_ARRAY)) {
+ parser.nextTag(); // TAG_DATA (<data>)
+ parser.require(XmlPullParser.START_TAG, null, TAG_DATA);
+
+ parser.nextTag();
+ List<Object> list = new ArrayList<Object>();
+ while (parser.getName().equals(TAG_VALUE)) {
+ list.add(deserialize(parser));
+ parser.nextTag();
+ }
+ parser.require(XmlPullParser.END_TAG, null, TAG_DATA);
+ parser.nextTag(); // TAG_ARRAY (</array>)
+ parser.require(XmlPullParser.END_TAG, null, TYPE_ARRAY);
+ obj = list.toArray();
+ } else
+ if (typeNodeName.equals(TYPE_STRUCT)) {
+ parser.nextTag();
+ Map<String, Object> map = new HashMap<String, Object>();
+ while (parser.getName().equals(TAG_MEMBER)) {
+ String memberName = null;
+ Object memberValue = null;
+ while (true) {
+ parser.nextTag();
+ String name = parser.getName();
+ if (name.equals(TAG_NAME)) {
+ memberName = parser.nextText();
+ } else
+ if (name.equals(TAG_VALUE)) {
+ memberValue = deserialize(parser);
+ } else {
+ break;
+ }
+ }
+ if (memberName != null && memberValue != null) {
+ map.put(memberName, memberValue);
+ }
+ parser.require(XmlPullParser.END_TAG, null, TAG_MEMBER);
+ parser.nextTag();
+ }
+ parser.require(XmlPullParser.END_TAG, null, TYPE_STRUCT);
+ obj = map;
+ } else {
+ throw new IOException("Cannot deserialize " + parser.getName());
+ }
+ parser.nextTag(); // TAG_VALUE (</value>)
+ parser.require(XmlPullParser.END_TAG, null, TAG_VALUE);
+ return obj;
+ }
+}