From 0957a4b96e93c3995aac94b957a6a90d481e5d81 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 1 Dec 2025 14:52:03 -0500 Subject: [PATCH 1/2] feat: Navigate to Reader post detail view deep links Improve the deep link experience by navigating to an individual post detail view rather than the top-level Reader view. --- .../android/ui/ActivityLauncher.java | 10 +++- .../android/ui/deeplinks/DeepLinkNavigator.kt | 8 ++- .../deeplinks/handlers/ReaderLinkHandler.kt | 51 +++++++++++++++- .../handlers/ReaderLinkHandlerTest.kt | 60 ++++++++++++++++++- 4 files changed, 121 insertions(+), 8 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/ActivityLauncher.java b/WordPress/src/main/java/org/wordpress/android/ui/ActivityLauncher.java index 084adad57ae0..5fb24a925c6d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/ActivityLauncher.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/ActivityLauncher.java @@ -348,12 +348,18 @@ public static void viewPostDeeplinkInNewStack(Context context, Uri uri) { .startActivities(); } - public static void viewReaderPostDetailInNewStack(Context context, long blogId, long postId, Uri uri) { + public static void viewReaderPostDetailInNewStack( + Context context, + long blogId, + long postId, + boolean isFeed, + Uri uri + ) { Intent mainActivityIntent = getMainActivityInNewStack(context) .putExtra(WPMainActivity.ARG_OPEN_PAGE, WPMainActivity.ARG_READER); Intent viewPostIntent = ReaderActivityLauncher.buildReaderPostDetailIntent( context, - false, + isFeed, blogId, postId, null, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/deeplinks/DeepLinkNavigator.kt b/WordPress/src/main/java/org/wordpress/android/ui/deeplinks/DeepLinkNavigator.kt index 8613afa59d1d..f0e2ec298079 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/deeplinks/DeepLinkNavigator.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/deeplinks/DeepLinkNavigator.kt @@ -83,6 +83,7 @@ class DeepLinkNavigator activity, navigateAction.blogId, navigateAction.postId, + navigateAction.isFeed, navigateAction.uri.uri ) OpenNotifications -> ActivityLauncher.viewNotificationsInNewStack(activity) @@ -124,7 +125,12 @@ class DeepLinkNavigator data class OpenEditorForSite(val site: SiteModel) : NavigateAction() object OpenReader : NavigateAction() data class OpenInReader(val uri: UriWrapper) : NavigateAction() - data class ViewPostInReader(val blogId: Long, val postId: Long, val uri: UriWrapper) : NavigateAction() + data class ViewPostInReader( + val blogId: Long, + val postId: Long, + val isFeed: Boolean, + val uri: UriWrapper + ) : NavigateAction() object OpenEditor : NavigateAction() data class OpenStatsForSiteAndTimeframe(val site: SiteModel, val statsTimeframe: StatsTimeframe) : NavigateAction() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/deeplinks/handlers/ReaderLinkHandler.kt b/WordPress/src/main/java/org/wordpress/android/ui/deeplinks/handlers/ReaderLinkHandler.kt index d7058c070bcb..c289b42b9c81 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/deeplinks/handlers/ReaderLinkHandler.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/deeplinks/handlers/ReaderLinkHandler.kt @@ -44,13 +44,14 @@ class ReaderLinkHandler override fun buildNavigateAction(uri: UriWrapper): NavigateAction { return when (uri.host) { - DEEP_LINK_HOST_READ -> OpenReader + DEEP_LINK_HOST_READ -> buildReadNavigateAction(uri) DEEP_LINK_HOST_VIEWPOST -> { val blogId = uri.getQueryParameter(BLOG_ID)?.toLongOrNull() val postId = uri.getQueryParameter(POST_ID)?.toLongOrNull() if (blogId != null && postId != null) { analyticsUtilsWrapper.trackWithBlogPostDetails(READER_VIEWPOST_INTERCEPTED, blogId, postId) - ViewPostInReader(blogId, postId, uri) + // viewpost deep links always use blog IDs, not feed IDs + ViewPostInReader(blogId, postId, isFeed = false, uri) } else { _toast.value = Event(R.string.error_generic) OpenReader @@ -60,9 +61,50 @@ class ReaderLinkHandler } } + /** + * Builds the navigate action for URIs with "read" host. + * Handles paths like /blogs/{blogId}/posts/{postId} or /feeds/{feedId}/posts/{feedItemId} + */ + private fun buildReadNavigateAction(uri: UriWrapper): NavigateAction { + val segments = uri.pathSegments + // Check for path: /blogs/{blogId}/posts/{postId} or /feeds/{feedId}/posts/{feedItemId} + if (segments.size >= 4 && + (segments[0] == BLOGS_PATH || segments[0] == FEEDS_PATH) && + segments[2] == POSTS_PATH + ) { + val blogId = segments[1].toLongOrNull() + val postId = segments[3].toLongOrNull() + val isFeed = segments[0] == FEEDS_PATH + if (blogId != null && postId != null) { + analyticsUtilsWrapper.trackWithBlogPostDetails(READER_VIEWPOST_INTERCEPTED, blogId, postId) + return ViewPostInReader(blogId, postId, isFeed, uri) + } + } + return OpenReader + } + + /** + * Builds a stripped URL for analytics from URIs with "read" host. + * Replaces actual IDs with placeholders. + */ + private fun buildStrippedReadUrl(uri: UriWrapper): String { + val segments = uri.pathSegments + return buildString { + append("$APPLINK_SCHEME$DEEP_LINK_HOST_READ") + if (segments.size >= 4 && + (segments[0] == BLOGS_PATH || segments[0] == FEEDS_PATH) && + segments[2] == POSTS_PATH + ) { + append("/${segments[0]}/$BLOG_ID/$POSTS_PATH/$POST_ID") + } + } + } + /** * URLs handled here * `wordpress://read` + * `wordpress://read/blogs/{blogId}/posts/{postId}` + * `wordpress://read/feeds/{feedId}/posts/{feedItemId}` * `wordpress://viewpost?blogId={blogId}&postId={postId}` * wordpress.com/read/feeds/feedId/posts/feedItemId * wordpress.com/read/blogs/feedId/posts/feedItemId @@ -72,7 +114,7 @@ class ReaderLinkHandler */ override fun stripUrl(uri: UriWrapper): String { return when (uri.host) { - DEEP_LINK_HOST_READ -> "$APPLINK_SCHEME$DEEP_LINK_HOST_READ" + DEEP_LINK_HOST_READ -> buildStrippedReadUrl(uri) DEEP_LINK_HOST_VIEWPOST -> { val hasBlogId = uri.getQueryParameter(BLOG_ID) != null val hasPostId = uri.getQueryParameter(POST_ID) != null @@ -142,6 +184,9 @@ class ReaderLinkHandler private const val BLOG_ID = "blogId" private const val POST_ID = "postId" private const val FEED_ID = "feedId" + private const val BLOGS_PATH = "blogs" + private const val FEEDS_PATH = "feeds" + private const val POSTS_PATH = "posts" private const val CUSTOM_DOMAIN_POSITION = 3 private const val BLOGS_FEEDS_PATH_POSITION = 1 private const val POSTS_PATH_POSITION = 3 diff --git a/WordPress/src/test/java/org/wordpress/android/ui/deeplinks/handlers/ReaderLinkHandlerTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/deeplinks/handlers/ReaderLinkHandlerTest.kt index 5159b253d636..eadb99760ece 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/deeplinks/handlers/ReaderLinkHandlerTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/deeplinks/handlers/ReaderLinkHandlerTest.kt @@ -81,6 +81,44 @@ class ReaderLinkHandlerTest : BaseUnitTest() { assertThat(navigateAction).isEqualTo(OpenReader) } + @Test + fun `URI with read host and blogs path opens post in reader with isFeed false`() { + val uri = buildUri("read", "blogs", blogId.toString(), "posts", postId.toString()) + + val navigateAction = readerLinkHandler.buildNavigateAction(uri) + + assertThat(navigateAction).isEqualTo(ViewPostInReader(blogId, postId, isFeed = false, uri)) + verify(analyticsUtilsWrapper).trackWithBlogPostDetails(READER_VIEWPOST_INTERCEPTED, blogId, postId) + } + + @Test + fun `URI with read host and feeds path opens post in reader with isFeed true`() { + val uri = buildUri("read", "feeds", feedId.toString(), "posts", postId.toString()) + + val navigateAction = readerLinkHandler.buildNavigateAction(uri) + + assertThat(navigateAction).isEqualTo(ViewPostInReader(feedId, postId, isFeed = true, uri)) + verify(analyticsUtilsWrapper).trackWithBlogPostDetails(READER_VIEWPOST_INTERCEPTED, feedId, postId) + } + + @Test + fun `URI with read host and invalid blog ID opens reader`() { + val uri = buildUri("read", "blogs", "invalid", "posts", postId.toString()) + + val navigateAction = readerLinkHandler.buildNavigateAction(uri) + + assertThat(navigateAction).isEqualTo(OpenReader) + } + + @Test + fun `URI with read host and incomplete path opens reader`() { + val uri = buildUri("read", "blogs", blogId.toString()) + + val navigateAction = readerLinkHandler.buildNavigateAction(uri) + + assertThat(navigateAction).isEqualTo(OpenReader) + } + @Test fun `URI with viewpost host without query params opens reader`() { val uri = buildUri(host = "viewpost") @@ -104,7 +142,7 @@ class ReaderLinkHandlerTest : BaseUnitTest() { } @Test - fun `URI with viewpost host with query params opens post in reader`() { + fun `URI with viewpost host with query params opens post in reader with isFeed false`() { val uri = buildUri( host = "viewpost", queryParam1 = "blogId" to blogId.toString(), @@ -113,7 +151,7 @@ class ReaderLinkHandlerTest : BaseUnitTest() { val navigateAction = readerLinkHandler.buildNavigateAction(uri) - assertThat(navigateAction).isEqualTo(ViewPostInReader(blogId, postId, uri)) + assertThat(navigateAction).isEqualTo(ViewPostInReader(blogId, postId, isFeed = false, uri)) verify(analyticsUtilsWrapper).trackWithBlogPostDetails(READER_VIEWPOST_INTERCEPTED, blogId, postId) } @@ -135,6 +173,24 @@ class ReaderLinkHandlerTest : BaseUnitTest() { assertThat(strippedUrl).isEqualTo("wordpress://read") } + @Test + fun `correctly strips READ applink with blogs path`() { + val uri = buildUri("read", "blogs", blogId.toString(), "posts", postId.toString()) + + val strippedUrl = readerLinkHandler.stripUrl(uri) + + assertThat(strippedUrl).isEqualTo("wordpress://read/blogs/blogId/posts/postId") + } + + @Test + fun `correctly strips READ applink with feeds path`() { + val uri = buildUri("read", "feeds", feedId.toString(), "posts", postId.toString()) + + val strippedUrl = readerLinkHandler.stripUrl(uri) + + assertThat(strippedUrl).isEqualTo("wordpress://read/feeds/blogId/posts/postId") + } + @Test fun `correctly strips VIEWPOST applink with all params`() { val uri = buildUri( From 08f50812bc5f2b231a7911cbbd29681695fdb497 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 1 Dec 2025 15:30:21 -0500 Subject: [PATCH 2/2] refactor: Address lint warnings --- .../deeplinks/handlers/ReaderLinkHandler.kt | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/deeplinks/handlers/ReaderLinkHandler.kt b/WordPress/src/main/java/org/wordpress/android/ui/deeplinks/handlers/ReaderLinkHandler.kt index c289b42b9c81..91ca94749bc9 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/deeplinks/handlers/ReaderLinkHandler.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/deeplinks/handlers/ReaderLinkHandler.kt @@ -67,20 +67,29 @@ class ReaderLinkHandler */ private fun buildReadNavigateAction(uri: UriWrapper): NavigateAction { val segments = uri.pathSegments - // Check for path: /blogs/{blogId}/posts/{postId} or /feeds/{feedId}/posts/{feedItemId} - if (segments.size >= 4 && - (segments[0] == BLOGS_PATH || segments[0] == FEEDS_PATH) && - segments[2] == POSTS_PATH - ) { - val blogId = segments[1].toLongOrNull() - val postId = segments[3].toLongOrNull() - val isFeed = segments[0] == FEEDS_PATH - if (blogId != null && postId != null) { - analyticsUtilsWrapper.trackWithBlogPostDetails(READER_VIEWPOST_INTERCEPTED, blogId, postId) - return ViewPostInReader(blogId, postId, isFeed, uri) - } + if (!isValidReadPath(segments)) { + return OpenReader + } + val blogId = segments[BLOG_ID_PATH_POSITION].toLongOrNull() + val postId = segments[POST_ID_PATH_POSITION].toLongOrNull() + val isFeed = segments[0] == FEEDS_PATH + return if (blogId != null && postId != null) { + analyticsUtilsWrapper.trackWithBlogPostDetails(READER_VIEWPOST_INTERCEPTED, blogId, postId) + ViewPostInReader(blogId, postId, isFeed, uri) + } else { + OpenReader } - return OpenReader + } + + /** + * Checks if the path segments represent a valid read path. + * Valid paths: /blogs/{blogId}/posts/{postId} or /feeds/{feedId}/posts/{feedItemId} + */ + private fun isValidReadPath(segments: List): Boolean { + if (segments.size < MIN_READ_PATH_SEGMENTS) return false + val isBlogsOrFeeds = segments[0] == BLOGS_PATH || segments[0] == FEEDS_PATH + val hasPostsSegment = segments[POSTS_SEGMENT_POSITION] == POSTS_PATH + return isBlogsOrFeeds && hasPostsSegment } /** @@ -91,10 +100,7 @@ class ReaderLinkHandler val segments = uri.pathSegments return buildString { append("$APPLINK_SCHEME$DEEP_LINK_HOST_READ") - if (segments.size >= 4 && - (segments[0] == BLOGS_PATH || segments[0] == FEEDS_PATH) && - segments[2] == POSTS_PATH - ) { + if (isValidReadPath(segments)) { append("/${segments[0]}/$BLOG_ID/$POSTS_PATH/$POST_ID") } } @@ -191,5 +197,10 @@ class ReaderLinkHandler private const val BLOGS_FEEDS_PATH_POSITION = 1 private const val POSTS_PATH_POSITION = 3 private const val DATE_URL_SEGMENTS = 3 + // Path segment positions for read deep links: /blogs/{blogId}/posts/{postId} + private const val MIN_READ_PATH_SEGMENTS = 4 + private const val BLOG_ID_PATH_POSITION = 1 + private const val POSTS_SEGMENT_POSITION = 2 + private const val POST_ID_PATH_POSITION = 3 } }