Skip to content

feat: migrate from XML Views to Jetpack Compose + Navigation Compose#758

Open
aibot505 wants to merge 2 commits into
masterfrom
feature/compose-migration
Open

feat: migrate from XML Views to Jetpack Compose + Navigation Compose#758
aibot505 wants to merge 2 commits into
masterfrom
feature/compose-migration

Conversation

@aibot505

@aibot505 aibot505 commented Jun 9, 2026

Copy link
Copy Markdown

Summary

Complete migration of the entire Android UI layer from XML Views + View Binding + Navigation XML to Jetpack Compose + Navigation Compose.

Architecture changes

Before After
AppCompatActivity ComponentActivity with setContent
14 Fragments @composable destinations (0 Fragments)
Navigation XML Navigation Compose NavHost (11 routes)
View Binding Removed entirely
ChatKit Native Compose LazyColumn + ClickableText
ImageView.load() Coil 3.4.0 AsyncImage
'next' build type Removed

New files (17 composables in src/main/.../ui/)

  • Theme.kt: Material3 theme matching Juick web CSS design tokens
  • MainScreen.kt: Scaffold with TopAppBar, NavigationBar, FAB
  • AppNavigation.kt: NavHost with all 11 routes
  • PostCard.kt: full post card with entity-based AnnotatedString formatting
  • MessageFormatter.kt: entity-based text formatter (mirrors iOS algorithm)
  • ChatScreen.kt + ChatsListScreen.kt: native Compose PM
  • FeedScreen.kt, ThreadScreen.kt, NewPostScreen.kt, TagsScreen.kt, NoAuthScreen.kt
  • SearchScreen.kt, SignInScreen.kt, SignUpScreen.kt, CropSheet.kt, ProfileHeader.kt

Deleted (37 files)

17 XML layouts + 2 menu XMLs + navigation.xml + 14 Fragments + FeedAdapter + CropBottomSheet + ImageHelper + src/next/ directory

Message formatting

  • Uses API-provided entities with exact positions (links, quotes, bold, italic, underline)
  • URL shortening via entity.text (hostname display, full URL on click)
  • Block-level quote rendering with pink accent border + background
  • Newline collapsing to eliminate extra vertical space

Design system

  • Colors match Juick web app style.css design tokens exactly
  • Post cards: white surface, subtle shadow, sharp corners, 20dp padding
  • Bar borders: 0.5dp outline dividers
  • Quote blocks: 3dp tertiary left border, background fill, dimmed text
  • Bottom nav: onSurface selected, tertiary indicator, dimmed inactive

Tests

  • UITest.kt: restored and migrated for Compose (MainActivity display, notification, spans)
  • FormatPostTextTest.kt: 8 entity-based formatting tests
  • LinkClickTest.kt: 5 link rendering + URL position tests
  • MainScreenTest.kt, SignInScreenTest.kt

CI changes

  • check job: assembleDebug (builds all 3 flavors)
  • Removed concurrency groups to prevent cancelled jobs

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Full app UI redesigned with Jetpack Compose: new main screen, bottom navigation, FAB, feed, post, thread, chat, tags, search, sign-in/sign-up and profile screens.
    • Improved image loading with HTTP caching.
  • Bug Fixes

    • Google Sign-In mapping corrected.
  • Refactor

    • Moved from legacy fragment/layout navigation to Compose-based navigation and unified deep-link handling.
    • Updated post formatting and link handling.

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR migrates the Juick Android app from Fragment/XML-based navigation to a Compose-first architecture. It adds Material3 theming, Compose navigation and screens, updates build scripts and version catalog for Compose/Coil, adapts models to drop Chatkit, implements Coil image loading with OkHttp, and converts instrumentation tests to Compose testing.

Changes

Compose migration and supporting refactors

Layer / File(s) Summary
Gradle, catalog, CI, and properties
build.gradle, gradle/libs.versions.toml, gradle.properties, .github/workflows/android.yml, .github/workflows/schedule.yml
Build scripts add Compose/Coil/lifecycle dependencies, enforce a dependency version override, fix org.gradle.parallel, update the version catalog with Coil and Compose test aliases, remove the next flavor and viewBinding, and change CI build targets and concurrency blocks.
Model contracts and runtime image loading
src/main/java/com/juick/api/model/Chat.kt, src/main/java/com/juick/api/model/Post.kt, src/main/java/com/juick/api/model/User.kt, src/main/java/com/juick/App.kt, src/main/java/com/juick/android/widget/util/ImageUtil.kt, src/main/java/com/juick/android/NotificationSender.kt
Chat/Post/User stop implementing Chatkit interfaces; Post gains an Entity type and entities list; App configures Coil with an OkHttp client (User-Agent, optional Authorization, cache) and registers SingletonImageLoader; ImageUtil provides suspend image download/decoding; NotificationSender uses Post.getText().
Activity entrypoints and listener refactor
src/main/java/com/juick/android/MainActivity.kt, src/main/java/com/juick/android/SignInActivity.kt, src/main/java/com/juick/android/SignUpActivity.kt, src/main/java/com/juick/android/JuickMessageMenuListener.kt, src/main/java/com/juick/android/OnItemClickListener.kt, src/google/java/com/juick/android/GoogleSignInProvider.kt
MainActivity converted to ComponentActivity and Compose setContent with rememberNavController and a public processUri router; SignIn/SignUp activities now host Compose screens; JuickMessageMenuListener uses lifecycleOwner.lifecycleScope and an onDeletePostNavigate callback; OnItemClickListener is a new top-level interface; GoogleSignIn nick bundle uses data.user.uname.
Compose theme, scaffold, and navigation
src/main/java/com/juick/android/ui/Theme.kt, src/main/java/com/juick/android/ui/MainScreen.kt, src/main/java/com/juick/android/ui/navigation/AppNavigation.kt
AppTheme defines light/dark Material3 ColorSchemes and updates status bar appearance; MainScreen implements Scaffold with top bar, conditional bottom navigation and FAB; AppNavigation registers routes and dialog destinations for feed, chats, blog, search, discussions, auth, thread, tags, and new-post flows.
Feed rendering, text formatting, and post cards
src/main/java/com/juick/android/ui/screens/feed/FeedScreen.kt, src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt, src/main/java/com/juick/android/ui/screens/feed/PostCard.kt, src/main/java/com/juick/android/ui/screens/feed/ProfileHeader.kt
FeedScreen implements pagination and pull-to-refresh; MessageFormatter normalizes post bodies into AnnotatedString/TextBlock with UrlPosition lists derived from entities; PostCard renders block content with ClickableText, photo preview, and like/reply actions; ProfileHeader displays avatar and blog label.
Compose screens and widgets
src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt, src/main/java/com/juick/android/ui/screens/chats/ChatsListScreen.kt, src/main/java/com/juick/android/ui/screens/thread/ThreadScreen.kt, src/main/java/com/juick/android/ui/screens/noauth/NoAuthScreen.kt, src/main/java/com/juick/android/ui/screens/search/SearchScreen.kt, src/main/java/com/juick/android/ui/screens/tags/TagsScreen.kt, src/main/java/com/juick/android/ui/screens/post/NewPostScreen.kt, src/main/java/com/juick/android/ui/signin/SignInScreen.kt, src/main/java/com/juick/android/ui/signup/SignUpScreen.kt, src/main/java/com/juick/android/ui/widget/CropSheet.kt
New Compose screens implement chats, chat list, thread view, no-auth prompt, search input, tags grid, new post composer, sign-in/sign-up UIs, and CropSheet (replaces fragment-based crop bottom sheet). ChatScreen and ChatBubble handle clickable URL offsets and posting PMs.
Instrumentation tests for Compose UI and formatting
src/androidTest/java/com/juick/android/testing/FormatPostTextTest.kt, src/androidTest/java/com/juick/android/testing/LinkClickTest.kt, src/androidTest/java/com/juick/android/testing/MainScreenTest.kt, src/androidTest/java/com/juick/android/testing/SignInScreenTest.kt, src/androidTest/java/com/juick/android/testing/UITest.kt, src/androidTest/AndroidManifest.xml
Tests adopt Compose test rules (createAndroidComposeRule), validate formatPostText output and spanStyles for tags/links/quotes, verify PostCard link rendering and UrlPosition extraction, and assert top-level MainScreen and SignIn screen UI elements.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

A rabbit hops through Compose-lit streams,
Replacing fragments with Material3 dreams,
Models trimmed, images cached and bright,
Navigation sings in a Scaffold of light. 🐇✨

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/compose-migration

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (19)
src/main/java/com/juick/android/MainActivity.kt-203-210 (1)

203-210: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Silently swallowed exception in like handler.

The empty catch block hides API errors from the user. Consider showing feedback on failure.

🐛 Proposed fix
                     onLikeClick = { post ->
                         lifecycleScope.launch {
                             try {
                                 App.instance.api.like(post.mid)
                                 account.refresh()
-                            } catch (_: Exception) { }
+                            } catch (e: Exception) {
+                                Log.w("MainActivity", "Like failed", e)
+                                // Optionally show a toast
+                            }
                         }
                     },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/MainActivity.kt` around lines 203 - 210, The
onLikeClick handler currently swallows all exceptions in the empty catch block,
hiding API failures; update the lifecycleScope.launch block that calls
App.instance.api.like(post.mid) and account.refresh() to catch the exception as
a variable (e.g., catch (e: Exception)), log the error (using Android Log or
your app logger) and show user-facing feedback (Toast or Snackbar) indicating
the like failed, optionally including a concise error message; ensure you still
handle success path as before.
src/main/java/com/juick/android/widget/util/ImageUtil.kt-24-31 (1)

24-31: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add logging for failed image loads.

The exception is silently swallowed, making it difficult to diagnose image loading failures in production. While returning null is appropriate for graceful degradation (e.g., notification icons), logging the error would aid debugging.

🐛 Proposed fix to add logging
+import android.util.Log
+
 suspend fun loadImage(url: String): Bitmap? {
     return try {
         val responseBody = App.instance.api.download(url)
         responseBody.byteStream().use { BitmapFactory.decodeStream(it) }
     } catch (e: Exception) {
+        Log.w("ImageUtil", "Failed to load image: $url", e)
         null
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/widget/util/ImageUtil.kt` around lines 24 -
31, The loadImage function currently swallows exceptions; modify the catch block
in suspend fun loadImage(url: String): Bitmap? to log the failure before
returning null — e.g., use Android logging (Log.e or Timber) with a clear
message that includes the URL and the exception object (reference
App.instance.api.download and loadImage to find the code), ensuring you still
return null for graceful degradation; add or reuse a TAG (e.g.,
ImageUtil::class.java.simpleName) if needed.

Source: Linters/SAST tools

src/main/java/com/juick/android/SignUpActivity.kt-43-43 (1)

43-43: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Potential null authCode passed to API.

authCode can be null if the intent extra is missing. This will likely cause an API error. Consider validating before calling the API or showing an appropriate error.

🐛 Proposed fix
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         val authCode = intent.getStringExtra("authCode")
+        if (authCode.isNullOrEmpty()) {
+            Toast.makeText(this, R.string.Error, Toast.LENGTH_SHORT).show()
+            finish()
+            return
+        }

         setContent {
             AppTheme {
                 SignUpScreen(
                     onSignUp = { nick ->
                         lifecycleScope.launch(Dispatchers.IO) {
                             try {
-                                val user = App.instance.api.signup(nick, authCode)
+                                val user = App.instance.api.signup(nick, authCode!!)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/SignUpActivity.kt` at line 43, The signup
call in SignUpActivity is passing a potentially null authCode
(App.instance.api.signup(nick, authCode)); validate that authCode is non-null
before calling the API and handle the null case explicitly: if authCode is
missing, show an error to the user (toast/dialog) or navigate back and do not
call api.signup, or retrieve/compute a fallback authCode if appropriate; update
the code around the signup invocation in SignUpActivity so the API is only
called with a non-null authCode and add a clear user-facing error path when
authCode is absent.
src/main/java/com/juick/android/SignUpActivity.kt-51-57 (1)

51-57: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Hardcoded error string and swallowed exception.

The error message should use a string resource for i18n, and logging the exception would help debug signup failures.

🐛 Proposed fix
+import android.util.Log
+
                             } catch (e: Exception) {
+                                Log.w("SignUpActivity", "Signup failed", e)
                                 withContext(Dispatchers.Main) {
                                     Toast.makeText(
                                         this@SignUpActivity,
-                                        "Username is not correct (already taken?)", Toast.LENGTH_LONG
+                                        R.string.username_taken_or_invalid, Toast.LENGTH_LONG
                                     ).show()
                                 }
                             }

Add to strings.xml:

<string name="username_taken_or_invalid">Username is not correct (already taken?)</string>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/SignUpActivity.kt` around lines 51 - 57,
Replace the hardcoded toast and swallowed exception in SignUpActivity's signup
catch block by using a string resource and logging the exception: add a string
resource named username_taken_or_invalid to strings.xml, change the
Toast.makeText call in SignUpActivity (inside the catch and
withContext(Dispatchers.Main)) to use
getString(R.string.username_taken_or_invalid), and log the caught Exception (e)
with Android logging (e.g., Log.e or your app logger) including a clear message
so the exception isn't swallowed.

Source: Linters/SAST tools

src/main/java/com/juick/android/JuickMessageMenuListener.kt-189-191 (1)

189-191: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Link clicks silently fail when activity is not MainActivity.

If activity is not a MainActivity instance, the link click is ignored without feedback. Consider either enforcing the type constraint in the constructor or handling the fallback explicitly.

🔧 Proposed fix to handle the fallback explicitly
 override fun onLinkClick(url: String) {
-    (activity as? MainActivity)?.processUri(url.toUri())
+    val mainActivity = activity as? MainActivity
+    if (mainActivity != null) {
+        mainActivity.processUri(url.toUri())
+    } else {
+        // Fallback: open in external browser
+        val intent = Intent(Intent.ACTION_VIEW, url.toUri())
+        activity.startActivity(intent)
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/JuickMessageMenuListener.kt` around lines 189
- 191, onLinkClick in JuickMessageMenuListener currently ignores clicks when
activity isn't a MainActivity; update onLinkClick to attempt a safe cast to
MainActivity and call (activity as? MainActivity)?.processUri(url.toUri()), but
add an explicit fallback when the cast fails: use activity?.let { val intent =
Intent(Intent.ACTION_VIEW, url.toUri()); it.startActivity(intent) } and/or show
a brief Toast and log the event so the click doesn't silently fail; ensure you
import Intent/Toast and keep processUri call as the primary path.
src/androidTest/java/com/juick/android/testing/LinkClickTest.kt-84-112 (1)

84-112: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Test does not actually verify the click callback.

The test is named postCard_linkClick_triggersCallback but never performs a click action on the link. It only verifies that the URL annotation exists in the formatted text. The clickedUrl variable is never updated because onLinkClick is never invoked.

💚 Proposed fix to add click interaction

Note: Clicking annotated text links in Compose requires using ClickableText or manually handling pointer input. Since PostCard uses a plain Text composable, it may not currently support link clicking via the test API. You may need to either:

  1. Add ClickableText support to PostCard
  2. Verify the callback contract in a lower-level unit test instead of a UI test

If PostCard already uses ClickableText, you can add:

 `@Test`
 fun postCard_linkClick_triggersCallback() {
     var clickedUrl: String? = null
     val post = Post(User(0, "test")).apply {
         setBody("Click https://juick.com/m/12345 now")
         mid = 2
     }

     composeTestRule.setContent {
         PostCard(
             post = post,
             onPostClick = {},
             onUserClick = {},
             onMenuClick = {},
             onLikeClick = {},
             onLinkClick = { url -> clickedUrl = url },
         )
     }

-    // The URL text is embedded in the AnnotatedString — click the text node
-    composeTestRule.onNodeWithText(
-        "Click https://juick.com/m/12345 now"
-    ).assertIsDisplayed()
+    // Click the link text
+    composeTestRule.onNodeWithText(
+        "Click https://juick.com/m/12345 now",
+        useUnmergedTree = true
+    ).performClick()
+    
+    // Verify callback was invoked with correct URL
+    assertThat(clickedUrl).isEqualTo("https://juick.com/m/12345")

-    // Verify the URL annotation exists in the formatted text
-    val annotated = formatPostText(post, primary, dimmed, onSurface)
-    val urls = annotated.getStringAnnotations("URL", 0, annotated.text.length)
-    assertThat(urls.map { it.item }).contains("https://juick.com/m/12345")
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/androidTest/java/com/juick/android/testing/LinkClickTest.kt` around lines
84 - 112, The test never triggers the link callback; add an interaction or make
the UI expose clickable links: either (A) update the test to perform a click on
the displayed text (e.g. call composeTestRule.onNodeWithText("Click
https://juick.com/m/12345 now").performClick()) and then assert clickedUrl ==
"https://juick.com/m/12345", or (B) if PostCard currently uses plain Text,
change PostCard to render the body with ClickableText and invoke onLinkClick
when the URL annotation is clicked (ensure the ClickableText logic maps the
clicked offset to the URL from formatPostText), then keep the test's
performClick + assert on clickedUrl; reference symbols: PostCard, onLinkClick,
formatPostText, clickedUrl, and composeTestRule.onNodeWithText.
src/androidTest/java/com/juick/android/testing/UITest.kt-50-53 (1)

50-53: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Strengthen the main screen assertion to a stable UI contract.

onRoot().assertExists() is too broad and can pass even when the intended Main screen content regresses. Assert a deterministic node (e.g., top app bar title, bottom-nav item text/contentDescription, or testTag) so this test actually protects behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/androidTest/java/com/juick/android/testing/UITest.kt` around lines 50 -
53, The test isDisplayed_MainActivity uses
composeTestRule.onRoot().assertExists(), which is too broad; update the
isDisplayed_MainActivity test to target a deterministic UI element instead
(e.g., the top app bar title text, a bottom-nav item text/contentDescription, or
a testTag) by replacing the root assertion with a specific node lookup
(composeTestRule.onNodeWithText / onNodeWithContentDescription / onNodeWithTag)
and assertIsDisplayed (or assertExists/assertIsDisplayed) on that node so the
test verifies the intended Main screen contract.
src/main/java/com/juick/android/ui/screens/feed/PostCard.kt-119-135 (1)

119-135: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard against empty photo URLs to prevent invalid navigation.

If both photo.url and photoMedium.url are null, photoUrl becomes "" and the image click handler calls onLinkClick(""). The downstream openUri(Uri.parse("")) in MainActivity could crash or produce an error when attempting to open an empty URI.

🛡️ Proposed fix to make clickable conditional on valid URL
         val photo = post.photo
         val photoMedium = photo?.medium
         if (photoMedium != null) {
             Spacer(Modifier.height(4.dp))
             val photoUrl = photoMedium.url ?: ""
             val shouldBlur = BuildConfig.HIDE_NSFW && MessageUtils.haveNSFWContent(post)
+            val validUrl = photo.url ?: photoUrl
             AsyncImage(
                 model = photoUrl,
                 contentDescription = null,
                 modifier = Modifier
                     .fillMaxWidth()
                     .height(200.dp)
-                    .clickable { onLinkClick(photo.url ?: photoUrl) },
+                    .then(
+                        if (validUrl.isNotEmpty()) {
+                            Modifier.clickable { onLinkClick(validUrl) }
+                        } else {
+                            Modifier
+                        }
+                    ),
                 contentScale = ContentScale.FillWidth,
             )
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/feed/PostCard.kt` around lines 119
- 135, The click handler currently passes an empty string when both photo.url
and photoMedium.url are null (see PostCard.kt variables photo, photoMedium and
photoUrl), so change the logic to resolve a non-empty URL first (e.g.,
resolvedUrl = photo.url ?: photoMedium?.url) and only add the Modifier.clickable
{ onLinkClick(resolvedUrl) } when resolvedUrl is non-null and not blank;
otherwise leave the image non-clickable or call a safe no-op. Update the
AsyncImage modifier construction to conditionally include clickable based on
that validated resolvedUrl.
src/main/java/com/juick/android/ui/screens/feed/FeedScreen.kt-130-134 (1)

130-134: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Lambda referential equality check will always be false.

The condition if (profileHeader !== {}) attempts to check whether a non-default profile header was provided, but it compares the passed lambda against a new empty lambda instance using referential equality (!==). In Kotlin, each lambda literal creates a new instance, so this condition will always evaluate to false—even when the caller passes the default {}.

As a result, the profile header item is always added to the LazyColumn, though it renders nothing when the default empty lambda is used. This creates an unnecessary item in the list and doesn't match the intended logic.

♻️ Proposed fix using nullable lambda
 `@Composable`
 fun FeedScreen(
     initialUrl: Uri,
     onPostClick: (Post) -> Unit,
     onUserClick: (String) -> Unit,
     onMenuClick: (Post) -> Unit,
     onLikeClick: (Post) -> Unit,
     onLinkClick: (String) -> Unit,
-    profileHeader: `@Composable` () -> Unit = {},
+    profileHeader: (`@Composable` () -> Unit)? = null,
     modifier: Modifier = Modifier,
     vm: FeedViewModel = viewModel(),
 ) {
     // ...
                     LazyColumn(state = listState) {
-                        if (profileHeader !== {}) {
+                        if (profileHeader != null) {
                             item(key = "profile_header") {
-                                profileHeader()
+                                profileHeader.invoke()
                             }
                         }
                         items(

Then update the call site in AppNavigation.kt:

         composable("blog/{uname}",
             // ...
         ) { entry ->
             val uname = entry.arguments?.getString("uname") ?: ""
             FeedScreen(
                 initialUrl = Uris.getUserPostsByName(uname),
                 // ...
-                profileHeader = {
+                profileHeader = {
                     ProfileHeader(uname = uname)
                 },
             )
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/feed/FeedScreen.kt` around lines
130 - 134, The check against a new empty lambda is always false; change the
profileHeader parameter (in FeedScreen.kt) to be a nullable lambda with default
null (e.g., profileHeader: (() -> Unit)? = null) and update the rendering branch
to only call item(key = "profile_header") { profileHeader?.invoke() } when
profileHeader != null; also update any call sites (e.g., in AppNavigation.kt) to
pass null or a real lambda instead of relying on an empty `{}` default.
src/main/java/com/juick/android/ui/screens/thread/ThreadScreen.kt-45-53 (1)

45-53: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Provide user feedback when thread load fails.

Line 48 catches and ignores thread loading exceptions. If the API call fails, isLoading is set to false and an empty list is displayed, giving users no indication that an error occurred. Show an error state (e.g., a Text with error styling) so users understand the failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/thread/ThreadScreen.kt` around
lines 45 - 53, The thread loader currently swallows exceptions in the
LaunchedEffect(mid) block causing silent failures; modify the catch to record an
error state (e.g., set a new loadError: String? or isError: Boolean) and capture
the exception message, ensure isLoading is set false in the finally path, and
update the composable UI to display an error Text with appropriate styling when
loadError/isError is set instead of showing an empty list; refer to
LaunchedEffect(mid), posts, isLoading, scrollToEnd, and
listState.animateScrollToItem to locate and update the load logic and the UI
rendering branch.
src/main/java/com/juick/android/ui/signin/SignInScreen.kt-92-98 (1)

92-98: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add password visual transformation.

The password OutlinedTextField currently displays text in plain format. Add visualTransformation = PasswordVisualTransformation() to mask password input for security.

🔒 Proposed fix to mask password input
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+
 OutlinedTextField(
     value = password,
     onValueChange = { password = it },
     label = { Text(stringResource(R.string.Password)) },
+    visualTransformation = PasswordVisualTransformation(),
     singleLine = true,
     modifier = Modifier.fillMaxWidth(),
 )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/signin/SignInScreen.kt` around lines 92 -
98, The password field in SignInScreen uses OutlinedTextField and currently
shows plain text; update the OutlinedTextField instance that binds to the
password state (value = password, onValueChange = { password = it }) to include
visualTransformation = PasswordVisualTransformation() so the input is masked;
locate the OutlinedTextField in SignInScreen (the one with label = {
Text(stringResource(R.string.Password)) }) and add the visualTransformation
property.
src/main/java/com/juick/android/ui/screens/chats/ChatsListScreen.kt-38-44 (1)

38-44: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make authentication check reactive to state changes.

LaunchedEffect(Unit) on Line 38 runs only on initial composition. If the user navigates away and returns after authentication state changes, the effect won't re-run. Change the key to App.instance.isAuthenticated so the effect responds to authentication changes.

🔄 Proposed fix to react to auth state changes
-LaunchedEffect(Unit) {
+LaunchedEffect(App.instance.isAuthenticated) {
     if (App.instance.isAuthenticated) {
         vm.loadChats()
     } else {
         onNavigateToAuth()
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/chats/ChatsListScreen.kt` around
lines 38 - 44, Change the LaunchedEffect key so the authentication check re-runs
on auth state changes: replace LaunchedEffect(Unit) with
LaunchedEffect(App.instance.isAuthenticated) so when
App.instance.isAuthenticated toggles the effect will re-evaluate and call
vm.loadChats() or onNavigateToAuth() accordingly; keep the existing branches
that call vm.loadChats() when authenticated and onNavigateToAuth() when not.
src/main/java/com/juick/android/ui/screens/thread/ThreadScreen.kt-84-87 (1)

84-87: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use string-based key to prevent collisions.

Line 86 computes the item key as it.mid.toLong() * 10000 + it.rid. This arithmetic can overflow for large mid values or produce collisions when rid varies. Use a string key like "${it.mid}-${it.rid}" for guaranteed uniqueness.

🐛 Proposed fix for key collision
 items(
     items = posts,
-    key = { it.mid.toLong() * 10000 + it.rid },
+    key = { "${it.mid}-${it.rid}" },
 ) { post ->
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/thread/ThreadScreen.kt` around
lines 84 - 87, The current items key in ThreadScreen's composable uses numeric
arithmetic (it.mid.toLong() * 10000 + it.rid) which can overflow or collide;
change the key to a stable string composite like "${it.mid}-${it.rid}" in the
items(...) call so each item key is unique and collision-free (update the key
lambda in the items invocation that iterates over posts).
src/main/java/com/juick/android/ui/signin/SignInScreen.kt-115-125 (1)

115-125: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Simplify AndroidView factory to avoid side effects.

The factory lambda detaches googleSignInButton from its parent on Line 118, which is a side effect that modifies external state. If the googleSignInButton instance changes or the composable recomposes with a different view, the detachment logic won't re-run correctly. Consider moving parent detachment to an update block or performing it before passing the view to the composable.

♻️ Move detachment to update block
 AndroidView(
     factory = { context ->
-        val parent = googleSignInButton.parent as? ViewGroup
-        parent?.removeView(googleSignInButton)
         googleSignInButton.apply {
             layoutParams = ViewGroup.LayoutParams(
                 ViewGroup.LayoutParams.WRAP_CONTENT,
                 ViewGroup.LayoutParams.WRAP_CONTENT,
             )
         }
     },
+    update = { view ->
+        val parent = view.parent as? ViewGroup
+        parent?.removeView(view)
+    },
     modifier = Modifier
         .width(200.dp)
         .height(48.dp),
 )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/signin/SignInScreen.kt` around lines 115 -
125, The factory lambda in the AndroidView is performing a side-effect by
removing googleSignInButton from its parent; move that parent detachment out of
the factory and into the AndroidView's update block (or perform it before
passing the view into the composable) so view removal runs on
updates/recompositions instead of only on initial creation; locate the
AndroidView usage and the factory lambda around googleSignInButton and implement
the parent?.removeView(googleSignInButton) call inside the update parameter (or
prior to rendering) while keeping layoutParams setup in the factory.
src/main/java/com/juick/android/ui/signup/SignUpScreen.kt-70-79 (1)

70-79: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add client-side validation and disable button for empty nickname.

The "Create" button invokes onSignUp(nick) without validating that nick is non-empty. While the server may reject invalid nicknames, providing immediate client feedback improves UX. Disable the button when nick.isBlank() and optionally show a helper text.

🛡️ Proposed fix to disable button when nickname is empty
+val isNickValid = nick.isNotBlank()
+
 Button(
     onClick = { onSignUp(nick) },
+    enabled = isNickValid,
     modifier = Modifier.fillMaxWidth(),
     shape = RoundedCornerShape(0.dp),
     colors = ButtonDefaults.buttonColors(
         containerColor = MaterialTheme.colorScheme.tertiary,
     ),
 ) {
     Text(stringResource(R.string.Create))
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/signup/SignUpScreen.kt` around lines 70 -
79, The "Create" Button currently calls onSignUp(nick) without client-side
validation; update the Button composable that uses onSignUp and the nick state
to set enabled = !nick.isBlank() so the button is disabled for empty/blank
nicknames, and add a small helper Text below the input (e.g., using
nick.isBlank() to conditionally show an error/helper message with error color)
so users get immediate feedback before submitting.
src/main/java/com/juick/android/ui/screens/thread/ThreadScreen.kt-55-62 (1)

55-62: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Deduplicate incoming SSE messages.

Line 60 appends relevant messages directly to posts without checking for duplicates. If the SSE stream emits the same message twice, it will appear multiple times in the UI. Filter out messages already present in posts by checking mid and rid before appending.

🛡️ Proposed fix to deduplicate messages
 LaunchedEffect(newMessages) {
     val relevant = newMessages.filter { it.mid == mid }
     if (relevant.isNotEmpty()) {
-        posts = posts + relevant
+        val existingKeys = posts.map { "${it.mid}-${it.rid}" }.toSet()
+        val newPosts = relevant.filter { "${it.mid}-${it.rid}" !in existingKeys }
+        posts = posts + newPosts
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/thread/ThreadScreen.kt` around
lines 55 - 62, The SSE handler in the LaunchedEffect currently appends all
relevant messages from newMessages to posts without deduplication; update the
LaunchedEffect that watches newMessages to first build a set of existing
identifiers from posts (using mid and rid), then filter relevant =
newMessages.filter { it.mid == mid } to only include items whose (mid,rid) pair
is not already in posts before doing posts = posts + filtered; reference the
variables and symbols posts, newMessages, LaunchedEffect and the message fields
mid and rid when making the change.
src/main/java/com/juick/android/ui/screens/thread/ThreadScreen.kt-115-128 (1)

115-128: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Wait for send success before clearing reply text.

Line 121 clears replyText immediately after calling sendMessage, before the response is received. If the send fails, the user's input is lost. The receiver flow created on Line 119 is never collected, so success/failure is not observed. Collect the receiver flow and clear replyText only on success.

🔄 Proposed fix to clear text only on success
 IconButton(onClick = {
     if (replyText.isNotBlank()) {
+        val currentReply = replyText
         scope.launch {
             try {
                 val receiver = MutableStateFlow<Result<PostResponse>?>(null)
-                App.instance.sendMessage(scope, receiver, replyText)
-                replyText = ""
+                App.instance.sendMessage(scope, receiver, currentReply)
+                receiver.collect { result ->
+                    if (result != null) {
+                        result.onSuccess { replyText = "" }
+                        // Optionally show error on failure
+                    }
+                }
             } catch (_: Exception) { }
         }
     }
 }) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/thread/ThreadScreen.kt` around
lines 115 - 128, The click handler currently launches a coroutine, creates a
MutableStateFlow<Result<PostResponse>?>(null) named receiver, calls
App.instance.sendMessage(scope, receiver, replyText) and immediately clears
replyText; instead collect the receiver flow and only clear replyText when the
result indicates success. Concretely: in the IconButton onClick scope.launch
block, after calling App.instance.sendMessage(scope, receiver, replyText)
suspend until receiver emits a non-null Result (e.g., receiver.first { it !=
null }), check the Result (use isSuccess / isFailure or getOrNull()), clear
replyText only on success, and handle/log failures without clearing so the
user’s input is preserved; keep the existing try/catch around the whole
sequence.
src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt-56-56 (1)

56-56: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use string-based key to prevent collisions.

Line 56 computes the item key as it.mid.toLong() * 10000 + it.rid. This arithmetic can overflow for large mid values or produce collisions when rid varies. Use a string key like "${it.mid}-${it.rid}" for guaranteed uniqueness.

🐛 Proposed fix for key collision
-items(messages, key = { it.mid.toLong() * 10000 + it.rid }) { post ->
+items(messages, key = { "${it.mid}-${it.rid}" }) { post ->
     ChatBubble(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt` at line 56,
The current Compose lazy list key computation inside the items(...) call uses
arithmetic (it.mid.toLong() * 10000 + it.rid) which can overflow or collide;
change the key to a stable string-based key such as "${it.mid}-${it.rid}" (i.e.
use string concatenation of it.mid and it.rid) in the items(..., key = { ... })
lambda so each item has a unique, collision-free identifier; update the key
lambda where items(messages, key = { ... }) is defined to return the string
instead of a numeric expression.
src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt-81-93 (1)

81-93: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Provide user feedback when message send fails.

Line 87 catches and silently ignores all exceptions during postPm. Users receive no indication that their message failed to send, leading to a poor experience. Display a Toast or Snackbar on error so users know to retry.

🛡️ Proposed fix to show error feedback

If you have access to a Context or SnackbarHostState, show an error message:

+import android.widget.Toast
+import androidx.compose.ui.platform.LocalContext
+
+val context = LocalContext.current
+
 IconButton(onClick = {
     if (inputText.isNotBlank()) {
         scope.launch {
             try {
                 App.instance.api.postPm(uname, inputText)
                 inputText = ""
-            } catch (_: Exception) { }
+            } catch (e: Exception) {
+                Toast.makeText(context, "Failed to send: ${e.localizedMessage}", Toast.LENGTH_SHORT).show()
+            }
         }
     }
 }) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt` around lines
81 - 93, The click handler in ChatScreen.kt currently swallows exceptions from
App.instance.api.postPm, giving no user feedback; update the IconButton onClick
coroutine around App.instance.api.postPm (where inputText is cleared) to catch
the exception as a named variable and surface an error to the user (e.g., show a
Toast via a provided Context or display a Snackbar using a SnackbarHostState)
and avoid clearing inputText on failure so the user can retry; ensure you
reference the coroutine scope.launch block and App.instance.api.postPm when
implementing the feedback.
🧹 Nitpick comments (9)
build.gradle (1)

100-101: 💤 Low value

Consider enabling these Compose lint rules post-migration.

Disabling CoroutineCreationDuringComposition and StateFlowValueCalledInComposition globally can mask legitimate issues. These rules catch common Compose antipatterns (launching coroutines during composition, reading .value instead of collectAsState()). Consider addressing the underlying issues and re-enabling these checks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@build.gradle` around lines 100 - 101, Currently the build.gradle disables the
Compose lint rules "CoroutineCreationDuringComposition" and
"StateFlowValueCalledInComposition"; instead re-enable those rules and fix any
violations: search for usages of CoroutineScope.launch or coroutine creation
inside composable functions (symbols to find: explicit CoroutineScope.launch,
GlobalScope, or creating new coroutines inside `@Composable` functions) and move
that work into LaunchedEffect, rememberCoroutineScope, or viewModel scope; also
search for direct StateFlow.value reads inside composables (symbol: .value on
StateFlow/MutableStateFlow) and replace them with
collectAsState()/collectAsStateWithLifecycle() or observeAsState equivalents so
composition observes flows correctly; finally remove the two disable lines so
the lints run again and the codebase is validated going forward.
src/main/java/com/juick/App.kt (1)

119-143: ⚡ Quick win

Consider extracting shared interceptor logic to reduce duplication.

The User-Agent and Authorization header interceptor logic (lines 120-131) is duplicated from the main API client (lines 65-74). This creates maintenance risk if the header logic needs to change.

The coilHttpClient also omits the read timeout and logging interceptor present in the main client. While this may be intentional for image loading, consider whether timeouts should be applied consistently.

♻️ Proposed refactor: Extract shared interceptor
// Add a shared function at class level
private fun createAuthInterceptor(): Interceptor = Interceptor { chain ->
    val request = chain.request().newBuilder()
        .header(
            "User-Agent",
            "${getString(R.string.Juick)}/${BuildConfig.VERSION_CODE} " +
                    "okhttp/${OkHttp.VERSION} Android/${Build.VERSION.SDK_INT}"
        )
        .apply {
            if (accountData.isNotEmpty()) {
                addHeader("Authorization", "Juick $accountData")
            }
        }
        .build()
    chain.proceed(request)
}

// Then use in both clients:
// val coilHttpClient = OkHttpClient.Builder()
//     .addInterceptor(createAuthInterceptor())
//     .cache(Cache(cacheDir, cacheSize))
//     .build()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/App.kt` around lines 119 - 143, Extract the
duplicated header-building interceptor into a shared private function (e.g.,
createAuthInterceptor(): Interceptor) and replace the inline lambda in
coilHttpClient and the main API client with
addInterceptor(createAuthInterceptor()); ensure the shared function builds the
same User-Agent and conditional Authorization header using
getString(R.string.Juick), BuildConfig.VERSION_CODE, OkHttp.VERSION and
Build.VERSION.SDK_INT so both ImageLoader.Builder (OkHttpNetworkFetcherFactory /
coilHttpClient) and the main client use the same logic; also review
coilHttpClient setup (readTimeout and logging interceptor) and, if consistent
timeouts/logging are required, add the same timeout and logging configuration as
used by the main client to coilHttpClient.
src/androidTest/java/com/juick/android/testing/SignInScreenTest.kt (2)

20-22: 💤 Low value

Remove unused imports.

The imports assertIsEnabled and assertIsNotEnabled are not used in any test.

♻️ Proposed cleanup
 import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.assertIsEnabled
-import androidx.compose.ui.test.assertIsNotEnabled
 import androidx.compose.ui.test.junit4.createAndroidComposeRule
 import androidx.compose.ui.test.onNodeWithText
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/androidTest/java/com/juick/android/testing/SignInScreenTest.kt` around
lines 20 - 22, Remove the unused imports `assertIsEnabled` and
`assertIsNotEnabled` from SignInScreenTest.kt: locate the import block in the
SignInScreenTest class (where `import
androidx.compose.ui.test.assertIsDisplayed` appears) and delete the two unused
import lines, then save/organize imports so only `assertIsDisplayed` remains;
ensure the file still compiles and no references to those symbols exist in any
tests.

45-50: 💤 Low value

Test name suggests checking enabled state but only checks display.

The test is named signInScreen_showsNicknameField_enabled but only calls assertIsDisplayed(), not assertIsEnabled(). Either rename the test or add the enabled assertion.

♻️ Option 1: Rename the test
 `@Test`
-fun signInScreen_showsNicknameField_enabled() {
+fun signInScreen_showsNicknameField() {
     composeTestRule.onNodeWithText(
         composeTestRule.activity.getString(R.string.your_nickname)
     ).assertIsDisplayed()
 }
♻️ Option 2: Add the enabled assertion
 `@Test`
 fun signInScreen_showsNicknameField_enabled() {
     composeTestRule.onNodeWithText(
         composeTestRule.activity.getString(R.string.your_nickname)
-    ).assertIsDisplayed()
+    ).assertIsDisplayed()
+        .assertIsEnabled()
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/androidTest/java/com/juick/android/testing/SignInScreenTest.kt` around
lines 45 - 50, The test signInScreen_showsNicknameField_enabled currently only
asserts visibility; update the test (function
signInScreen_showsNicknameField_enabled) to also assert enabled state by calling
assertIsEnabled() on the same node returned by
composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.your_nickname))
(i.e., chain or add a separate assertion after assertIsDisplayed()), or
alternatively rename the test to reflect only "showsNicknameField" if you prefer
not to assert enabled—prefer adding assertIsEnabled() to satisfy the test name.
src/androidTest/java/com/juick/android/testing/FormatPostTextTest.kt (1)

108-115: ⚡ Quick win

Strengthen the quote color assertion.

The test is named formatPostText_withQuote_usesDimmedColor but only asserts that the result is non-empty. It doesn't verify that the dimmed color is actually applied to the quote text spans.

♻️ Proposed enhancement to verify dimmed color
 `@Test`
 fun formatPostText_withQuote_usesDimmedColor() {
     val post = Post(User(0, "test")).apply {
         setBody("<blockquote>quoted text</blockquote>")
     }
     val result = formatPostText(post, primary, dimmed, onSurface)
-    assertThat(result.text).isNotEmpty()
+    assertThat(result.text).contains("quoted text")
+    
+    // Verify dimmed color is applied to the quote
+    val quoteStart = result.text.indexOf("quoted text")
+    val quoteEnd = quoteStart + "quoted text".length
+    val spans = result.spanStyles
+    val hasDimmedColoring = spans.any { span ->
+        span.start <= quoteStart && span.end >= quoteEnd &&
+                span.item.color == dimmed
+    }
+    assertThat(hasDimmedColoring).isTrue()
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/androidTest/java/com/juick/android/testing/FormatPostTextTest.kt` around
lines 108 - 115, The test formatPostText_withQuote_usesDimmedColor currently
only checks non-empty text; update it to locate the quote range in the returned
Spannable (from result.text) and assert that a ForegroundColorSpan (or
appropriate CharacterStyle used by formatPostText) is applied to that range with
the expected dimmed color value (the dimmed parameter passed into
formatPostText). Use result.text.getSpans(...) and verify at least one span
covers the quoted substring and its color equals dimmed. Ensure you reference
formatPostText, the test method formatPostText_withQuote_usesDimmedColor, and
use result.text to find spans.
src/main/java/com/juick/android/ui/navigation/AppNavigation.kt (1)

108-108: ⚡ Quick win

Centralize the API endpoint to avoid duplication.

The search route hardcodes API_ENDPOINT while other routes use Uris methods. This creates duplication and inconsistency. If the API endpoint needs to change (e.g., for dev/staging environments or build variants), multiple places would require updates.

♻️ Refactor to centralize URL construction

Add a method to the Uris class:

// In Uris.kt
fun getSearchUrl(query: String): Uri {
    return Uri.parse("${BASE_URL}search/$query")
}

Then update the search route:

-            initialUrl = Uri.parse("${API_ENDPOINT}search/$query"),
+            initialUrl = Uris.getSearchUrl(query),

And remove the private constant:

-private const val API_ENDPOINT = "https://api.juick.com/"

Also applies to: 190-190

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/navigation/AppNavigation.kt` at line 108,
Replace the hardcoded use of API_ENDPOINT in the search route by adding a
centralized URL builder in Uris (e.g., add fun getSearchUrl(query: String): Uri)
and update AppNavigation's search route to call Uris.getSearchUrl(query) instead
of Uri.parse("${API_ENDPOINT}search/$query"); also remove the now-redundant
private API_ENDPOINT constant so all routes use the Uris helpers (verify other
occurrences such as the one mentioned at the other location and replace them
too).
src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt (1)

39-43: ⚡ Quick win

Remove dead code collecting SSE messages.

Lines 39–43 collect App.instance.messages but perform no action. The comment suggests the ViewModel already handles SSE updates, making this LaunchedEffect unnecessary and a potential source of confusion.

🗑️ Proposed fix to remove unused SSE collection
-// SSE real-time updates
-val sseMessages by App.instance.messages.collectAsStateWithLifecycle()
-LaunchedEffect(sseMessages) {
-    // handled via ViewModel flow
-}
-
 LaunchedEffect(Unit) {
     vm.loadMessages()
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt` around lines
39 - 43, Remove the unused SSE collection: delete the val sseMessages by
App.instance.messages.collectAsStateWithLifecycle() and the empty
LaunchedEffect(sseMessages) block in ChatScreen; the ViewModel already handles
SSE updates, so removing these unused references (sseMessages,
App.instance.messages, and the LaunchedEffect) will eliminate dead code and
confusion.
src/main/java/com/juick/android/ui/screens/tags/TagsScreen.kt (1)

60-73: 💤 Low value

Replace !! with safer idiom.

Line 60 uses the !! operator after the null check on Line 53. While this is safe here, !! is generally discouraged in Kotlin. Refactor to use let or restructure the when to avoid the assertion.

♻️ Proposed refactor using let
-val result = tagsResult!!
-if (result.isSuccess) {
+tagsResult.let { result ->
+  if (result.isSuccess) {
     TagsGrid(
         tags = result.getOrThrow(),
         onTagClick = onTagSelected,
     )
-} else {
+  } else {
     Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
         Text(
             text = stringResource(R.string.network_error),
             color = MaterialTheme.colorScheme.error,
         )
     }
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/tags/TagsScreen.kt` around lines
60 - 73, The code currently uses the unsafe non-null assertion tagsResult!!
before inspecting its success; replace this with a safe idiom such as
tagsResult?.let { result -> ... } so you avoid !!: call tagsResult?.let { result
-> if (result.isSuccess) { TagsGrid(tags = result.getOrThrow(), onTagClick =
onTagSelected) } else { /* show error Box as before */ } } ?: /* handle null
case (e.g. show loading or error) */; update the block that renders TagsGrid and
the error Box to live inside that let so all null/success branches are handled
without the !! operator.
src/main/java/com/juick/android/ui/signin/SignInScreen.kt (1)

63-63: ⚡ Quick win

Replace magic number with named constant.

Line 63 compares currentAction != 1 but 1 represents ACTION_PASSWORD_UPDATE as shown in the context. Define a companion object constant or accept a boolean parameter to improve readability.

♻️ Refactor to use a named constant
+companion object {
+    const val ACTION_PASSWORD_UPDATE = 1
+}
+
 `@Composable`
 fun SignInScreen(
     currentAction: Int,
     initialNick: String,
     googleSignInButton: View?,
     onSignIn: (nick: String, password: String) -> Unit,
     modifier: Modifier = Modifier,
 ) {
     var nick by remember { mutableStateOf(initialNick) }
     var password by remember { mutableStateOf("") }
-    val nickEnabled = currentAction != 1 // ACTION_PASSWORD_UPDATE = 1
+    val nickEnabled = currentAction != ACTION_PASSWORD_UPDATE
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/signin/SignInScreen.kt` at line 63, The
code uses a magic number when computing nickEnabled; replace the literal 1 with
a named constant (e.g., ACTION_PASSWORD_UPDATE) and update the comparison to use
it: change val nickEnabled = currentAction != 1 to val nickEnabled =
currentAction != ACTION_PASSWORD_UPDATE. If ACTION_PASSWORD_UPDATE already
exists elsewhere, import/reference it; otherwise declare it (e.g., inside the
SignInScreen companion object) as const val ACTION_PASSWORD_UPDATE = 1 so the
intent is clear.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 52a4dc31-781d-4818-a5a5-e9817c997304

📥 Commits

Reviewing files that changed from the base of the PR and between 8ff57f2 and 9962f10.

📒 Files selected for processing (79)
  • build.gradle
  • gradle/libs.versions.toml
  • src/androidTest/AndroidManifest.xml
  • src/androidTest/java/com/juick/android/testing/ChatLinkClickTest.kt
  • src/androidTest/java/com/juick/android/testing/FormatPostTextTest.kt
  • src/androidTest/java/com/juick/android/testing/LinkClickTest.kt
  • src/androidTest/java/com/juick/android/testing/MainScreenTest.kt
  • src/androidTest/java/com/juick/android/testing/SignInScreenTest.kt
  • src/androidTest/java/com/juick/android/testing/UITest.kt
  • src/google/java/com/juick/android/GoogleSignInProvider.kt
  • src/main/AndroidManifest.xml
  • src/main/java/com/juick/App.kt
  • src/main/java/com/juick/android/JuickMessageMenuListener.kt
  • src/main/java/com/juick/android/MainActivity.kt
  • src/main/java/com/juick/android/NotificationSender.kt
  • src/main/java/com/juick/android/OnItemClickListener.kt
  • src/main/java/com/juick/android/SignInActivity.kt
  • src/main/java/com/juick/android/SignUpActivity.kt
  • src/main/java/com/juick/android/fragment/ThreadFragment.kt
  • src/main/java/com/juick/android/screens/FeedAdapter.kt
  • src/main/java/com/juick/android/screens/FeedFragment.kt
  • src/main/java/com/juick/android/screens/blog/BlogFragment.kt
  • src/main/java/com/juick/android/screens/chat/ChatFragment.kt
  • src/main/java/com/juick/android/screens/chats/ChatsFragment.kt
  • src/main/java/com/juick/android/screens/chats/NoAuthFragment.kt
  • src/main/java/com/juick/android/screens/discussions/DiscussionsFragment.kt
  • src/main/java/com/juick/android/screens/home/HomeFragment.kt
  • src/main/java/com/juick/android/screens/post/NewPostFragment.kt
  • src/main/java/com/juick/android/screens/post/TagsFragment.kt
  • src/main/java/com/juick/android/screens/search/SearchFragment.kt
  • src/main/java/com/juick/android/ui/MainScreen.kt
  • src/main/java/com/juick/android/ui/Theme.kt
  • src/main/java/com/juick/android/ui/navigation/AppNavigation.kt
  • src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt
  • src/main/java/com/juick/android/ui/screens/chats/ChatsListScreen.kt
  • src/main/java/com/juick/android/ui/screens/feed/FeedScreen.kt
  • src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt
  • src/main/java/com/juick/android/ui/screens/feed/PostCard.kt
  • src/main/java/com/juick/android/ui/screens/feed/ProfileHeader.kt
  • src/main/java/com/juick/android/ui/screens/noauth/NoAuthScreen.kt
  • src/main/java/com/juick/android/ui/screens/post/NewPostScreen.kt
  • src/main/java/com/juick/android/ui/screens/tags/TagsScreen.kt
  • src/main/java/com/juick/android/ui/screens/thread/ThreadScreen.kt
  • src/main/java/com/juick/android/ui/signin/SignInScreen.kt
  • src/main/java/com/juick/android/ui/signup/SignUpScreen.kt
  • src/main/java/com/juick/android/ui/widget/CropSheet.kt
  • src/main/java/com/juick/android/widget/CropBottomSheet.kt
  • src/main/java/com/juick/android/widget/util/ImageHelper.kt
  • src/main/java/com/juick/android/widget/util/ImageUtil.kt
  • src/main/java/com/juick/api/model/Chat.kt
  • src/main/java/com/juick/api/model/Post.kt
  • src/main/java/com/juick/api/model/User.kt
  • src/main/res/layout/activity_login.xml
  • src/main/res/layout/activity_main.xml
  • src/main/res/layout/activity_signup.xml
  • src/main/res/layout/content_main.xml
  • src/main/res/layout/dialog_crop.xml
  • src/main/res/layout/fragment_chat.xml
  • src/main/res/layout/fragment_dialog_list.xml
  • src/main/res/layout/fragment_me.xml
  • src/main/res/layout/fragment_new_post.xml
  • src/main/res/layout/fragment_no_auth.xml
  • src/main/res/layout/fragment_posts_page.xml
  • src/main/res/layout/fragment_posts_viewpager.xml
  • src/main/res/layout/fragment_tags_list.xml
  • src/main/res/layout/fragment_thread.xml
  • src/main/res/layout/item_post.xml
  • src/main/res/layout/item_tag.xml
  • src/main/res/layout/item_thread_reply.xml
  • src/main/res/layout/menu_layout_discussions.xml
  • src/main/res/layout/menu_layout_profile.xml
  • src/main/res/menu/bottom_navigation.xml
  • src/main/res/menu/toolbar.xml
  • src/main/res/navigation/navigation.xml
  • src/next/google/google-services.json
  • src/next/java/com/juick/android/NextSignInActivity.kt
  • src/next/java/com/juick/android/ui/Theme.kt
  • src/release/google/google-services.json
  • src/release/huawei/agconnect-services.json
💤 Files with no reviewable changes (40)
  • src/main/res/navigation/navigation.xml
  • src/main/res/layout/fragment_no_auth.xml
  • src/main/res/layout/fragment_new_post.xml
  • src/main/res/layout/menu_layout_profile.xml
  • src/main/res/layout/fragment_dialog_list.xml
  • src/main/res/layout/item_thread_reply.xml
  • src/main/res/layout/fragment_me.xml
  • src/main/res/layout/fragment_posts_viewpager.xml
  • src/main/java/com/juick/android/screens/chat/ChatFragment.kt
  • src/main/res/menu/bottom_navigation.xml
  • src/main/res/layout/fragment_posts_page.xml
  • src/main/res/layout/item_post.xml
  • src/main/res/layout/activity_signup.xml
  • src/main/java/com/juick/android/screens/search/SearchFragment.kt
  • src/main/java/com/juick/android/screens/chats/NoAuthFragment.kt
  • src/main/res/layout/activity_main.xml
  • src/main/res/layout/content_main.xml
  • src/main/res/menu/toolbar.xml
  • src/main/java/com/juick/android/screens/post/NewPostFragment.kt
  • src/main/res/layout/fragment_tags_list.xml
  • src/next/java/com/juick/android/ui/Theme.kt
  • src/main/java/com/juick/android/screens/post/TagsFragment.kt
  • src/main/res/layout/dialog_crop.xml
  • src/next/google/google-services.json
  • src/main/res/layout/menu_layout_discussions.xml
  • src/main/res/layout/fragment_thread.xml
  • src/main/java/com/juick/android/screens/chats/ChatsFragment.kt
  • src/main/java/com/juick/android/widget/CropBottomSheet.kt
  • src/main/res/layout/item_tag.xml
  • src/main/java/com/juick/android/screens/FeedFragment.kt
  • src/main/java/com/juick/android/screens/discussions/DiscussionsFragment.kt
  • src/main/java/com/juick/android/widget/util/ImageHelper.kt
  • src/main/res/layout/activity_login.xml
  • src/main/res/layout/fragment_chat.xml
  • src/androidTest/java/com/juick/android/testing/ChatLinkClickTest.kt
  • src/main/java/com/juick/android/screens/blog/BlogFragment.kt
  • src/main/java/com/juick/android/fragment/ThreadFragment.kt
  • src/main/java/com/juick/android/screens/FeedAdapter.kt
  • src/main/java/com/juick/android/screens/home/HomeFragment.kt
  • src/next/java/com/juick/android/NextSignInActivity.kt

Comment thread src/main/java/com/juick/android/MainActivity.kt
Comment thread src/main/java/com/juick/android/MainActivity.kt
Comment on lines +103 to +148
NavigationBarItem(
selected = currentRoute == "home",
onClick = {
navController.navigate("home") {
popUpTo("home") { inclusive = true }
}
},
icon = {
Icon(
painterResource(R.drawable.ic_ei_clock),
contentDescription = null,
)
},
label = { Text(stringResource(R.string.Subscriptions)) },
)
NavigationBarItem(
selected = currentRoute == "discover",
onClick = {
navController.navigate("discover") {
popUpTo("home") { inclusive = true }
}
},
icon = {
Icon(
painterResource(R.drawable.icon_discover),
contentDescription = null,
)
},
label = { Text(stringResource(R.string.Discover)) },
)
NavigationBarItem(
selected = currentRoute == "chats",
onClick = {
navController.navigate("chats") {
popUpTo("home") { inclusive = true }
}
},
icon = {
Icon(
painterResource(R.drawable.ic_ei_envelope),
contentDescription = null,
)
},
label = { Text(stringResource(R.string.PMs)) },
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for navigation-related tests and documentation

# Check for tests covering back navigation
rg -n "onBackPressed|back press|navigate.*back" --type=kt -g '*Test.kt'

# Check for navigation documentation or comments
rg -n "bottom.*nav|backstack|back.*behavior" --type=kt -C2

Repository: juick/Juick-Android

Length of output: 117


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Re-run searches (fix rg filetype usage)
rg -n "onBackPressed|back press|navigate.*back|BackHandler" . -g '*Test*.kt' || true
rg -n "bottom.*nav|backstack|back.*behavior|saveState|restoreState|popUpTo\\(" . -t kotlin -C2 || true

# 2) Find all occurrences of popUpTo("home") usage
rg -n "popUpTo\\(\"home\"\\)" . -t kotlin || true

# 3) Identify navigation graph / route definitions for home/discover/chats
rg -n "\"home\"|\"discover\"|\"chats\"" src/main/java/com/juick/android/ui/MainScreen.kt -n || true

# 4) Print the surrounding code for MainScreen bottom nav block
sed -n '1,220p' src/main/java/com/juick/android/ui/MainScreen.kt | nl -ba | sed -n '70,170p'

Repository: juick/Juick-Android

Length of output: 2824


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/main/java/com/juick/android/ui/MainScreen.kt"

# 1) Basic stats
echo "=== wc -l MainScreen.kt ==="
wc -l "$FILE"

# 2) Locate NavHost and relevant composable route definitions
echo "=== NavHost/composable route locations ==="
rg -n "NavHost|composable\\(.*\\\"home\\\"|composable\\(.*\\\"discover\\\"|composable\\(.*\\\"chats\\\"" "$FILE" || true

# 3) Locate any back-press handling
echo "=== BackHandler/onBackPressed locations (src/main) ==="
rg -n "BackHandler\\(|onBackPressedDispatcher|onBackPressed|OnBackPressed|BackEvent" src/main/java || true

# 4) Print sections around NavHost (if present)
echo "=== Excerpt around NavHost ==="
LINE=$(rg -n "NavHost" "$FILE" | head -n1 | cut -d: -f1 || true)
if [ -n "${LINE:-}" ]; then
  START=$((LINE-80)); END=$((LINE+160))
  sed -n "${START},${END}p" "$FILE"
else
  echo "NavHost not found in MainScreen.kt"
fi

# 5) Print sections around each route composable definition (if present)
for route in home discover chats; do
  echo "=== Excerpt around composable route \"$route\" ==="
  LINE=$(rg -n "composable\\(.*\\\"$route\\\"" "$FILE" | head -n1 | cut -d: -f1 || true)
  if [ -n "${LINE:-}" ]; then
    START=$((LINE-60)); END=$((LINE+120))
    sed -n "${START},${END}p" "$FILE"
  else
    echo "No direct composable(\"$route\") found in MainScreen.kt"
  fi
done

Repository: juick/Juick-Android

Length of output: 2284


🏁 Script executed:

#!/bin/bash
set -u

FILE="src/main/java/com/juick/android/ui/MainScreen.kt"

echo "=== NavHost occurrences ==="
rg -n "NavHost" "$FILE" || true

echo "=== composable(\"...\" route occurrences near home/discover/chats ==="
for route in home discover chats; do
  echo "--- route token: \"$route\" ---"
  rg -n "\"$route\"" "$FILE" || true
  rg -n "composable\\(" "$FILE" || true
done

echo "=== Back press handling across src/main/java ==="
rg -n "BackHandler\\(|onBackPressedDispatcher|onBackPressed\\b|OnBackPressed" src/main/java || true

echo "=== Excerpt: full MainScreen.kt (178 lines) ==="
cat -n "$FILE"

Repository: juick/Juick-Android

Length of output: 9562


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate AppNavigation source
rg -n "class AppNavigation|fun AppNavigation" src/main/java/com/juick/android/ui/navigation -S || true
fd -a "AppNavigation.*" src/main/java/com/juick/android/ui/navigation || true
fd -a "AppNavigation.*" src/main/java || true

# Search for home/discover/chats route declarations in navigation code
rg -n "\"home\"|\"discover\"|\"chats\"" src/main/java/com/juick/android/ui/navigation -S || true

# If AppNavigation file is found, print it (bounded)
APP_FILE="$(rg -l "AppNavigation" src/main/java/com/juick/android/ui/navigation | head -n1 || true)"
if [ -n "${APP_FILE:-}" ]; then
  echo "=== AppNavigation file: $APP_FILE ==="
  wc -l "$APP_FILE"
  sed -n '1,240p' "$APP_FILE" | cat -n | head -n 240
else
  echo "AppNavigation file not found via search; listing navigation directory:"
  ls -la src/main/java/com/juick/android/ui/navigation || true
fi

Repository: juick/Juick-Android

Length of output: 9098


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search Kotlin tests + main for explicit back navigation / bottom nav behavior docs
rg -n "onBackPressedDispatcher|onBackPressed\\b|BackHandler\\b|back press|Back press|navigate.*back" -S . \
  --glob '*Test*.kt' --glob '*androidTest*' --glob '*test*' || true

rg -n "bottom.*nav|bottom navigation|backstack|popUpTo\\(\"home\"\\)" -S . \
  --glob '*.kt' --glob '*.md' || true

Repository: juick/Juick-Android

Length of output: 435


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "onBackPressed\\b|OnBackPressed\\b|BackHandler\\b|onBackPressedDispatcher" -S . --glob '*.kt' --glob '*.kts' || true

Repository: juick/Juick-Android

Length of output: 45


Bottom nav back press will exit the app (flat back stack) due to popUpTo("home") { inclusive = true }.

All three bottom bar items in MainScreen.kt navigate with popUpTo("home") { inclusive = true }. Since AppNavigation.kt uses a single NavHost with startDestination = "home" and there’s no custom BackHandler/onBackPressed logic, back from "discover"/"chats" will pop the last destination and leave the app instead of returning to Home. Consider popUpTo("home") { inclusive = false } or tab state/backstack management (saveState/restoreState) if returning to Home is the intended UX.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/MainScreen.kt` around lines 103 - 148, The
three NavigationBarItem onClick handlers in MainScreen.kt (the
navController.navigate calls for routes "home", "discover", and "chats")
currently use popUpTo("home") { inclusive = true } which flattens the back stack
and causes back to exit the app; change those navigate blocks to either use
popUpTo("home") { inclusive = false } or remove the inclusive flag, or implement
proper tab backstack handling by enabling saveState/restoreState on navigate
(and pass launchSingleTop where appropriate) so navigating to "discover" or
"chats" does not make the Back button leave the app instead of returning to
Home.

Comment thread src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt
Comment thread src/main/java/com/juick/android/ui/screens/feed/FeedScreen.kt Outdated
Comment thread src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt
Comment thread src/main/java/com/juick/android/ui/screens/post/NewPostScreen.kt
Comment thread src/main/java/com/juick/android/ui/widget/CropSheet.kt
Comment thread src/main/java/com/juick/android/ui/widget/CropSheet.kt
@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
{}

@vitalyster

Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/main/java/com/juick/android/ui/widget/CropSheet.kt (1)

124-137: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add error handling inside saveBitmapToFile.

The function performs I/O operations that can fail but has no internal error handling. If dir.mkdirs() returns false (directory creation failed), FileOutputStream throws (disk full, permission denied), or FileProvider.getUriForFile fails (misconfigured provider), the exception will propagate to the caller. While the caller on line 100-104 catches exceptions, it's better to handle errors at the source with proper validation and error recovery.

🛡️ Proposed fix
 private fun saveBitmapToFile(context: android.content.Context, bitmap: Bitmap?): Uri? {
-    if (bitmap == null) return null
-    val dir = File(context.filesDir, "cropped")
-    dir.mkdirs()
-    val file = File(dir, "crop_${System.currentTimeMillis()}.jpg")
-    FileOutputStream(file).use { out ->
-        bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
+    return try {
+        if (bitmap == null) return null
+        val dir = File(context.filesDir, "cropped")
+        if (!dir.exists() && !dir.mkdirs()) {
+            android.util.Log.e("CropSheet", "Failed to create directory: ${dir.absolutePath}")
+            return null
+        }
+        val file = File(dir, "crop_${System.currentTimeMillis()}.jpg")
+        FileOutputStream(file).use { out ->
+            bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
+        }
+        FileProvider.getUriForFile(
+            context,
+            "${context.packageName}.provider",
+            file,
+        )
+    } catch (e: Exception) {
+        android.util.Log.e("CropSheet", "Error saving bitmap to file", e)
+        null
     }
-    return FileProvider.getUriForFile(
-        context,
-        "${context.packageName}.provider",
-        file,
-    )
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/widget/CropSheet.kt` around lines 124 -
137, The saveBitmapToFile function currently performs filesystem and provider
calls without local error handling; wrap the dir.mkdirs(), FileOutputStream
usage (already using use) and FileProvider.getUriForFile calls in a try/catch
that detects and handles failures (check the boolean return of dir.mkdirs() and
treat false as failure), catch IOException, SecurityException and
IllegalArgumentException from FileOutputStream and FileProvider.getUriForFile,
log or report the error, and return null on failure instead of letting
exceptions propagate; keep the function signature and use the existing bitmap
null guard, but add these guards around dir, stream creation and getUriForFile
to fail gracefully.
src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt (1)

119-141: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

URL annotations in chat messages are not clickable.

formatPostText creates "URL" annotations for links in the message body, and ChatBubble receives an onLinkClick callback, but the Text composable at line 135-139 does not wire link click handling. Users cannot tap links in chat messages.

To make links clickable, replace the Text composable with ClickableText and handle URL annotation clicks, or use a Text with a custom Modifier.pointerInput that detects taps on URL-annotated regions.

🔗 Proposed fix to wire link clicks
-            Text(
-                text = annotatedText,
-                style = MaterialTheme.typography.bodyMedium.copy(color = textColor),
-                modifier = Modifier.padding(12.dp),
-            )
+            ClickableText(
+                text = annotatedText,
+                style = MaterialTheme.typography.bodyMedium.copy(color = textColor),
+                modifier = Modifier.padding(12.dp),
+                onClick = { offset ->
+                    annotatedText.getStringAnnotations("URL", offset, offset)
+                        .firstOrNull()?.let { annotation ->
+                            onLinkClick(annotation.item)
+                        }
+                }
+            )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt` around lines
119 - 141, The Text composable is not handling URL annotations so links are not
clickable; replace the Text usage that displays annotatedText (inside
ChatBubble/Column/Surface) with ClickableText (or a Text + pointerInput) and
wire clicks to the existing onLinkClick callback: use the AnnotatedString
annotations produced by formatPostText to detect annotation ranges (look for
annotation key "URL") in the ClickableText onClick lambda and call
onLinkClick(url) for the tapped annotation; ensure the displayed style still
uses MaterialTheme.typography.bodyMedium with color textColor so visuals remain
unchanged.
🧹 Nitpick comments (3)
src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt (1)

10-10: ⚡ Quick win

Remove unused import.

ClickableText is imported but never used in this file.

🧹 Proposed fix
-import androidx.compose.foundation.text.ClickableText
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt` at line 10,
Remove the unused import of ClickableText from ChatScreen.kt: delete the line
importing androidx.compose.foundation.text.ClickableText (it is not referenced
anywhere in the file, e.g., no usages in ChatScreen or related composables),
leaving only the necessary imports to avoid unused-import warnings.
src/main/java/com/juick/android/ui/widget/CropSheet.kt (1)

100-104: ⚡ Quick win

Log the exception before swallowing it.

The catch block silently discards the exception, losing diagnostic information that would help debug cropping failures. Add logging to capture the error details.

📋 Proposed fix
                             val uri = if (result.isSuccessful) {
                                 try {
                                     saveBitmapToFile(context, result.bitmap)
                                 } catch (e: Exception) {
+                                    android.util.Log.e("CropSheet", "Failed to save cropped image", e)
                                     null
                                 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/widget/CropSheet.kt` around lines 100 -
104, In CropSheet.kt update the try/catch around saveBitmapToFile(context,
result.bitmap) to log the caught Exception instead of silently swallowing it:
inside the catch(e: Exception) block call the app logger (e.g.,
android.util.Log.e or your project's logger) with a clear message like "Failed
to save cropped bitmap" and pass the exception object so stacktrace and message
are recorded; keep the existing control flow after logging. Ensure the log call
is in the catch that surrounds saveBitmapToFile and references the same symbols
(saveBitmapToFile, CropSheet).
src/main/java/com/juick/android/ui/screens/search/SearchScreen.kt (1)

78-87: 💤 Low value

Consider removing or updating the centered placeholder text.

The centered Text at lines 78-87 displays the same R.string.search string that already appears as the OutlinedTextField placeholder on line 53. This duplication provides no additional value to the user. Consider either removing this text entirely or replacing it with a more informative message (e.g., "Enter a search term to find posts").

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/search/SearchScreen.kt` around
lines 78 - 87, The centered placeholder Text inside the Box (Text using
stringResource(R.string.search)) duplicates the OutlinedTextField's placeholder;
remove that Text node or replace its stringResource with a more informative
message (e.g., "Enter a search term to find posts") so the UI isn't
redundant—update the composable containing Box/Text (the Box + Text block) used
alongside the OutlinedTextField to either delete the Text or change its resource
key to a new, descriptive string.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt`:
- Around line 119-141: The Text composable is not handling URL annotations so
links are not clickable; replace the Text usage that displays annotatedText
(inside ChatBubble/Column/Surface) with ClickableText (or a Text + pointerInput)
and wire clicks to the existing onLinkClick callback: use the AnnotatedString
annotations produced by formatPostText to detect annotation ranges (look for
annotation key "URL") in the ClickableText onClick lambda and call
onLinkClick(url) for the tapped annotation; ensure the displayed style still
uses MaterialTheme.typography.bodyMedium with color textColor so visuals remain
unchanged.

In `@src/main/java/com/juick/android/ui/widget/CropSheet.kt`:
- Around line 124-137: The saveBitmapToFile function currently performs
filesystem and provider calls without local error handling; wrap the
dir.mkdirs(), FileOutputStream usage (already using use) and
FileProvider.getUriForFile calls in a try/catch that detects and handles
failures (check the boolean return of dir.mkdirs() and treat false as failure),
catch IOException, SecurityException and IllegalArgumentException from
FileOutputStream and FileProvider.getUriForFile, log or report the error, and
return null on failure instead of letting exceptions propagate; keep the
function signature and use the existing bitmap null guard, but add these guards
around dir, stream creation and getUriForFile to fail gracefully.

---

Nitpick comments:
In `@src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt`:
- Line 10: Remove the unused import of ClickableText from ChatScreen.kt: delete
the line importing androidx.compose.foundation.text.ClickableText (it is not
referenced anywhere in the file, e.g., no usages in ChatScreen or related
composables), leaving only the necessary imports to avoid unused-import
warnings.

In `@src/main/java/com/juick/android/ui/screens/search/SearchScreen.kt`:
- Around line 78-87: The centered placeholder Text inside the Box (Text using
stringResource(R.string.search)) duplicates the OutlinedTextField's placeholder;
remove that Text node or replace its stringResource with a more informative
message (e.g., "Enter a search term to find posts") so the UI isn't
redundant—update the composable containing Box/Text (the Box + Text block) used
alongside the OutlinedTextField to either delete the Text or change its resource
key to a new, descriptive string.

In `@src/main/java/com/juick/android/ui/widget/CropSheet.kt`:
- Around line 100-104: In CropSheet.kt update the try/catch around
saveBitmapToFile(context, result.bitmap) to log the caught Exception instead of
silently swallowing it: inside the catch(e: Exception) block call the app logger
(e.g., android.util.Log.e or your project's logger) with a clear message like
"Failed to save cropped bitmap" and pass the exception object so stacktrace and
message are recorded; keep the existing control flow after logging. Ensure the
log call is in the catch that surrounds saveBitmapToFile and references the same
symbols (saveBitmapToFile, CropSheet).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1e0eb88f-4bb4-4f89-8e09-3db5e45ae0f7

📥 Commits

Reviewing files that changed from the base of the PR and between 9962f10 and 522f2e4.

📒 Files selected for processing (16)
  • .github/workflows/android.yml
  • .github/workflows/schedule.yml
  • src/androidTest/java/com/juick/android/testing/FormatPostTextTest.kt
  • src/androidTest/java/com/juick/android/testing/LinkClickTest.kt
  • src/androidTest/java/com/juick/android/testing/MainScreenTest.kt
  • src/main/java/com/juick/android/MainActivity.kt
  • src/main/java/com/juick/android/ui/MainScreen.kt
  • src/main/java/com/juick/android/ui/navigation/AppNavigation.kt
  • src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt
  • src/main/java/com/juick/android/ui/screens/feed/FeedScreen.kt
  • src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt
  • src/main/java/com/juick/android/ui/screens/feed/PostCard.kt
  • src/main/java/com/juick/android/ui/screens/post/NewPostScreen.kt
  • src/main/java/com/juick/android/ui/screens/search/SearchScreen.kt
  • src/main/java/com/juick/android/ui/screens/thread/ThreadScreen.kt
  • src/main/java/com/juick/android/ui/widget/CropSheet.kt
💤 Files with no reviewable changes (1)
  • .github/workflows/schedule.yml
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/androidTest/java/com/juick/android/testing/LinkClickTest.kt
  • src/main/java/com/juick/android/ui/screens/post/NewPostScreen.kt
  • src/main/java/com/juick/android/ui/MainScreen.kt
  • src/main/java/com/juick/android/ui/screens/thread/ThreadScreen.kt
  • src/main/java/com/juick/android/ui/screens/feed/PostCard.kt
  • src/main/java/com/juick/android/ui/navigation/AppNavigation.kt
  • src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt`:
- Around line 227-229: TextBlock.Quote currently stores a plain String which
loses spans; change its payload from String to AnnotatedString (i.e., data class
Quote(val annotatedString: AnnotatedString, val urlPositions:
List<UrlPosition>)) and update all call sites that used quote.text or
quoteAnnotated.toString() to use quote.annotatedString and pass/consume
AnnotatedString instead (also update any rendering in PostCard and any logic
around URL extraction at the other noted location to read spans from the
AnnotatedString rather than the plain String). Ensure UrlPosition logic still
computes ranges against the AnnotatedString and remove any toString()
conversions that strip annotations.

In `@src/main/java/com/juick/android/ui/screens/feed/PostCard.kt`:
- Around line 192-201: The click handler for the image can call onLinkClick with
an empty string because photo.url ?: (photoMedium.url ?: "") allows "", so
update the image wiring to compute a non-empty target URL first (e.g., val
targetUrl = photo.url?.takeIf { it.isNotBlank() } ?: photoMedium.url?.takeIf {
it.isNotBlank() }) and only attach the clickable modifier or call onLinkClick
when targetUrl is non-null/non-blank; adjust the AsyncImage instance (the
photoMedium/photo variables and onLinkClick usage) so clicks are no-ops if no
valid URL exists and avoid passing empty strings into onLinkClick.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 79d89a9f-0eaf-4732-8830-dfe410516a0e

📥 Commits

Reviewing files that changed from the base of the PR and between 522f2e4 and c0eef01.

📒 Files selected for processing (6)
  • src/main/java/com/juick/android/ui/MainScreen.kt
  • src/main/java/com/juick/android/ui/Theme.kt
  • src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt
  • src/main/java/com/juick/android/ui/screens/feed/FeedScreen.kt
  • src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt
  • src/main/java/com/juick/android/ui/screens/feed/PostCard.kt
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/main/java/com/juick/android/ui/Theme.kt
  • src/main/java/com/juick/android/ui/MainScreen.kt

Comment thread src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt Outdated
Comment thread src/main/java/com/juick/android/ui/screens/feed/PostCard.kt Outdated
@vitalyster vitalyster force-pushed the feature/compose-migration branch 12 times, most recently from 9ce7e13 to 13b876e Compare June 9, 2026 17:33

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

♻️ Duplicate comments (1)
src/main/java/com/juick/android/ui/widget/CropSheet.kt (1)

124-136: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle file I/O and URI creation failures in saveBitmapToFile.

Directory creation, file write, and FileProvider.getUriForFile can fail and currently propagate as crashes.

Suggested fix
 private fun saveBitmapToFile(context: android.content.Context, bitmap: Bitmap?): Uri? {
-    if (bitmap == null) return null
-    val dir = File(context.filesDir, "cropped")
-    dir.mkdirs()
-    val file = File(dir, "crop_${System.currentTimeMillis()}.jpg")
-    FileOutputStream(file).use { out ->
-        bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
-    }
-    return FileProvider.getUriForFile(
-        context,
-        "${context.packageName}.provider",
-        file,
-    )
+    return try {
+        if (bitmap == null) return null
+        val dir = File(context.filesDir, "cropped")
+        if (!dir.exists() && !dir.mkdirs()) return null
+        val file = File(dir, "crop_${System.currentTimeMillis()}.jpg")
+        FileOutputStream(file).use { out ->
+            bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
+        }
+        FileProvider.getUriForFile(
+            context,
+            "${context.packageName}.provider",
+            file,
+        )
+    } catch (_: Exception) {
+        null
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/widget/CropSheet.kt` around lines 124 -
136, In saveBitmapToFile, guard directory creation, file write and URI creation
in a try/catch and return null on failure: check mkdirs() result (and create
parent dir if missing), wrap FileOutputStream/bitmap.compress and
FileProvider.getUriForFile calls in a try block catching IOException,
SecurityException and IllegalArgumentException, log the exception (or use
existing logger) and return null instead of letting exceptions propagate; keep
using the same file naming logic and FileProvider.getUriForFile call but only
after successful write.

Source: Linters/SAST tools

🧹 Nitpick comments (2)
src/androidTest/java/com/juick/android/testing/FormatPostTextTest.kt (1)

108-115: ⚡ Quick win

Strengthen the out-of-range entity test assertion.

This currently allows false positives; it should assert the final text is exactly unchanged, not just that "short" is present.

Suggested tweak
         val result = formatPostText(post, primary, dimmed, onSurface)
-        assertThat(result.text).contains("short")
+        assertThat(result.text).isEqualTo("short")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/androidTest/java/com/juick/android/testing/FormatPostTextTest.kt` around
lines 108 - 115, The test entitiesIgnored_whenPositionsOutsideBody currently
only checks that "short" is contained, which can false-positive; update the
assertion to require the formatted text equals the original body exactly by
replacing the contains check with an equality check against the post body (use
result.text == "short" or assertThat(result.text).isEqualTo(post.body)) to
ensure out-of-range entities produce no changes; locate this in the test
function entitiesIgnored_whenPositionsOutsideBody and adjust the assertion
accordingly for formatPostText's output.
src/androidTest/java/com/juick/android/testing/LinkClickTest.kt (1)

84-96: ⚡ Quick win

Add a regression case for link offsets when a non-link entity comes first.

This suite currently won’t detect URL-range misalignment when entity ordering is mixed (e.g., bold/quote before link).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/androidTest/java/com/juick/android/testing/LinkClickTest.kt` around lines
84 - 96, The test adds a regression case where non-link entities precede a link,
revealing that buildUrlPositions misaligns URL ranges; update buildUrlPositions
to iterate all Post.entities and compute link offsets using each entity's
start/end (use Post.Entity fields and existing e(...) helper) rather than
relying on entity ordering or index; ensure
buildUrlPositions_skipsNonLinkEntities (the new test) passes by filtering for
link-type entities while preserving their original start/end boundaries when
constructing the UrlPosition results.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main/java/com/juick/android/JuickMessageMenuListener.kt`:
- Around line 140-144: The current delete flow calls onDeletePostNavigate
immediately after launching the async processCommand in the
MENU_ACTION_DELETE_POST branch (inside confirmAction), which can make failures
look successful or cancel the request; remove the inline onDeletePostNavigate
call from the confirmAction callback and instead trigger navigation from the
success path that updates receiver (i.e., where the code handles the completed
processCommand result and updates the receiver state), so navigation only occurs
after a successful delete; apply the same change to the other similar delete
site referenced (the block around the second occurrence).

In `@src/main/java/com/juick/android/MainActivity.kt`:
- Around line 84-89: The current guard uses browserClient != null which can miss
the window where the service is bound but onCustomTabsServiceConnected() hasn't
set browserClient; change bindCustomTabService to capture the boolean result of
CustomTabsClient.bindCustomTabsService(context, packageName, browserConnection)
into a new field (e.g., isCustomTabsBound) and set it accordingly, and update
onCustomTabsServiceConnected/onDestroy (and the similar unbind location around
the other bind) to unbind only if isCustomTabsBound is true, then reset
isCustomTabsBound to false when unbinding; continue to set/clear browserClient
inside onCustomTabsServiceConnected/onServiceDisconnected as before.
- Around line 171-172: The onResume() handler currently clears intent.action
unconditionally and can drop a cold-start share before composition sets
this@MainActivity.navController; change the logic so you only consume/clear the
share intent after verifying navigation is ready: check that
this@MainActivity.navController is non-null and that it can navigate to
"new_post" (e.g., navController.currentDestination is available or a canNavigate
predicate) before calling navigate() and clearing intent.action; if
navController is not yet set, defer processing the intent (or re-post the intent
handling to run once composition assigns navController). Apply the same guard to
the other occurrence around lines 246-252.
- Around line 122-125: The single-segment Juick profile branch currently calls
openUri(data) which sends users to an external browser; instead detect Juick
profile deep links (single path segment) and route them to the in-app blog
screen by extracting the username from the path and launching the internal blog
handler (replace the openUri(data) call with a call that navigates to the app's
blog route, e.g., invoke the existing in-app blog navigation method or start the
activity/fragment for "blog/$uname"); apply the same change to the other
identical branch mentioned (the similar case at lines 188-190) so all
single-segment Juick paths open in-app rather than in the browser.

In `@src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt`:
- Line 87: Replace the hard-coded placeholder string in ChatScreen's TextField
(placeholder = { Text("Message") }) with a localized resource: use placeholder =
{ Text(stringResource(R.string.chat_message_placeholder)) }, add a corresponding
translatable entry chat_message_placeholder to your strings.xml, and import
androidx.compose.ui.res.stringResource; update any tests/resources if needed.

In `@src/main/java/com/juick/android/ui/screens/feed/FeedScreen.kt`:
- Around line 119-127: The current scope.launch creates a never-completing
snapshotFlow collector every time (using snapshotFlow { feedState
}.distinctUntilChanged().collectLatest) causing multiple live collectors;
instead, in the refresh handler await a single emission and then stop (e.g. use
snapshotFlow { feedState }.filterNotNull().first() or snapshotFlow { feedState
}.first { it != null }) and set isRefreshing = false after that await; update
the code referencing feedState, isRefreshing, scope.launch, snapshotFlow and
replace collectLatest with a single-terminal operation
(first()/filterNotNull().first()) so a new collector is not left running after
each pull-to-refresh.
- Around line 214-220: ReplyCard currently renders PostCard with a no-op like
handler (onLikeClick = {}), which leaves the visible like control
non-functional; replace that no-op by forwarding ReplyCard's actual like handler
(onLikeClick = onLikeClick) so clicks propagate, or if ReplyCard intentionally
should not support likes, pass null and update PostCard's onLikeClick parameter
to be nullable and hide/disable the like UI when onLikeClick == null. Update the
call in ReplyCard (remove onLikeClick = {} and forward or pass null) and, if
choosing the nullable approach, adjust PostCard's signature and its like-button
rendering logic accordingly.

In `@src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt`:
- Around line 149-170: The quote blocks drop link click data and the URL
extraction for non-quote blocks uses rText.indexOf(e.text) which mis-maps
repeated link text; fix by computing UrlPosition from entity character offsets
relative to the block slice instead of searching for text. In
MessageFormatter.kt use the existing entity list (e.g., 'all' or 'sorted'
entries with their start/end) to build the UrlPosition ranges for each block
(both regular blocks built from rBuilder/rText and quote blocks created via
TextBlock.Quote) by subtracting the block's start offset from entity.start/end
so repeated link text maps correctly and quote blocks get their url list instead
of emptyList().
- Around line 50-58: In MessageFormatter (the loop over sorted entities),
validate each entity's bounds before injecting e.text or recording offsets: skip
any entity where e.start >= body.length, e.end <= e.start, or the computed end
(e.end.coerceAtMost(body.length)) <= e.start; only append intervening body
chars, add eStart/eEnd/eType and set bp when the entity is valid. Ensure bp
advancement uses the validated end and do not append e.text for skipped/invalid
entities so offsets remain correct.
- Around line 195-200: buildUrlPositions currently advances the sorted-entity
pointer (si) for every index i, which misaligns URLs when p.entityType[i] isn't
a link; change the mapping so you only attempt to consume/advance si when
p.entityType[i] == "a": inside buildUrlPositions, for each i check if
p.entityType[i] != "a" then return null (do not touch si), otherwise
loop/advance si until you find sorted[si].type == "a", verify e.url != null and
then create UrlPosition(p.entityStart[i], p.entityEnd[i], e.url); this ensures
si stays in sync with link entries and preserves correct click ranges.

In `@src/main/java/com/juick/android/ui/screens/thread/ThreadScreen.kt`:
- Around line 79-86: ThreadScreen is rendering PostCard with an empty
onLikeClick callback so likes are ignored; replace the empty lambda in the
items(posts, ...) block with a real handler that forwards the post (or its id)
to the screen's like handler (e.g., call the existing onLikeClick parameter of
ThreadScreen or implement a local handleLike(post) that invokes the
repository/update and state update), i.e., update the PostCard invocation to
pass onLikeClick = { post -> onLikeClick(post) } (or equivalent) so the
clickable heart triggers the real like logic.

In `@src/main/java/com/juick/android/ui/widget/CropSheet.kt`:
- Around line 95-111: Guard against cropImageView being null before mutating
isCropping: in the TextButton click handler check cropImageView (and isCropping)
first and return early if cropImageView is null so you never set isCropping =
true when there’s no view to produce a callback; only set isCropping, attach the
onCropImageCompleteListener on cropImageView, and call
cropImageView.croppedImageAsync() after confirming cropImageView is non-null
(references: isCropping, cropImageView, setOnCropImageCompleteListener,
croppedImageAsync, onCropResult).

In `@src/main/java/com/juick/android/widget/util/ImageUtil.kt`:
- Around line 24-29: The loadImage suspend function currently swallows
CancellationException by catching Exception; update loadImage so it rethrows
coroutine cancellations: in the catch block for exceptions from
App.instance.api.download/BitmapFactory.decodeStream, detect
CancellationException (or catch CancellationException first) and rethrow it, and
only convert non-cancellation exceptions to null. Reference the loadImage
function and the caller NotificationSender (which uses runBlocking) when making
the change.

In `@src/main/java/com/juick/api/model/Post.kt`:
- Around line 56-65: The Parcelize generation fails because Post is annotated
with `@Parcelize` but its nested data class Entity is only `@Serializable` and not
Parcelable; either make Entity implement Parcelable (annotate Entity with
`@Parcelize` and implement android.os.Parcelable) or exclude entities from
parceling (annotate the entities property with `@IgnoredOnParcel` and provide a
custom serialization/transfer strategy), then rebuild — update the Entity class
declaration (Entity) or the Post.entities property accordingly so all types used
by Post are parcelable or explicitly ignored for parceling.

---

Duplicate comments:
In `@src/main/java/com/juick/android/ui/widget/CropSheet.kt`:
- Around line 124-136: In saveBitmapToFile, guard directory creation, file write
and URI creation in a try/catch and return null on failure: check mkdirs()
result (and create parent dir if missing), wrap FileOutputStream/bitmap.compress
and FileProvider.getUriForFile calls in a try block catching IOException,
SecurityException and IllegalArgumentException, log the exception (or use
existing logger) and return null instead of letting exceptions propagate; keep
using the same file naming logic and FileProvider.getUriForFile call but only
after successful write.

---

Nitpick comments:
In `@src/androidTest/java/com/juick/android/testing/FormatPostTextTest.kt`:
- Around line 108-115: The test entitiesIgnored_whenPositionsOutsideBody
currently only checks that "short" is contained, which can false-positive;
update the assertion to require the formatted text equals the original body
exactly by replacing the contains check with an equality check against the post
body (use result.text == "short" or
assertThat(result.text).isEqualTo(post.body)) to ensure out-of-range entities
produce no changes; locate this in the test function
entitiesIgnored_whenPositionsOutsideBody and adjust the assertion accordingly
for formatPostText's output.

In `@src/androidTest/java/com/juick/android/testing/LinkClickTest.kt`:
- Around line 84-96: The test adds a regression case where non-link entities
precede a link, revealing that buildUrlPositions misaligns URL ranges; update
buildUrlPositions to iterate all Post.entities and compute link offsets using
each entity's start/end (use Post.Entity fields and existing e(...) helper)
rather than relying on entity ordering or index; ensure
buildUrlPositions_skipsNonLinkEntities (the new test) passes by filtering for
link-type entities while preserving their original start/end boundaries when
constructing the UrlPosition results.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a17b91ff-4cf7-4572-b23d-d8765824ae6c

📥 Commits

Reviewing files that changed from the base of the PR and between c0eef01 and 2b36896.

📒 Files selected for processing (80)
  • .github/workflows/android.yml
  • .github/workflows/schedule.yml
  • build.gradle
  • gradle.properties
  • gradle/libs.versions.toml
  • src/androidTest/AndroidManifest.xml
  • src/androidTest/java/com/juick/android/testing/ChatLinkClickTest.kt
  • src/androidTest/java/com/juick/android/testing/FormatPostTextTest.kt
  • src/androidTest/java/com/juick/android/testing/LinkClickTest.kt
  • src/androidTest/java/com/juick/android/testing/MainScreenTest.kt
  • src/androidTest/java/com/juick/android/testing/SignInScreenTest.kt
  • src/androidTest/java/com/juick/android/testing/UITest.kt
  • src/google/java/com/juick/android/GoogleSignInProvider.kt
  • src/main/AndroidManifest.xml
  • src/main/java/com/juick/App.kt
  • src/main/java/com/juick/android/JuickMessageMenuListener.kt
  • src/main/java/com/juick/android/MainActivity.kt
  • src/main/java/com/juick/android/NotificationSender.kt
  • src/main/java/com/juick/android/OnItemClickListener.kt
  • src/main/java/com/juick/android/SignInActivity.kt
  • src/main/java/com/juick/android/SignUpActivity.kt
  • src/main/java/com/juick/android/fragment/ThreadFragment.kt
  • src/main/java/com/juick/android/screens/FeedAdapter.kt
  • src/main/java/com/juick/android/screens/FeedFragment.kt
  • src/main/java/com/juick/android/screens/blog/BlogFragment.kt
  • src/main/java/com/juick/android/screens/chat/ChatFragment.kt
  • src/main/java/com/juick/android/screens/chats/ChatsFragment.kt
  • src/main/java/com/juick/android/screens/chats/NoAuthFragment.kt
  • src/main/java/com/juick/android/screens/home/HomeFragment.kt
  • src/main/java/com/juick/android/screens/post/NewPostFragment.kt
  • src/main/java/com/juick/android/screens/post/TagsFragment.kt
  • src/main/java/com/juick/android/screens/search/SearchFragment.kt
  • src/main/java/com/juick/android/ui/MainScreen.kt
  • src/main/java/com/juick/android/ui/Theme.kt
  • src/main/java/com/juick/android/ui/navigation/AppNavigation.kt
  • src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt
  • src/main/java/com/juick/android/ui/screens/chats/ChatsListScreen.kt
  • src/main/java/com/juick/android/ui/screens/feed/FeedScreen.kt
  • src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt
  • src/main/java/com/juick/android/ui/screens/feed/PostCard.kt
  • src/main/java/com/juick/android/ui/screens/feed/ProfileHeader.kt
  • src/main/java/com/juick/android/ui/screens/noauth/NoAuthScreen.kt
  • src/main/java/com/juick/android/ui/screens/post/NewPostScreen.kt
  • src/main/java/com/juick/android/ui/screens/search/SearchScreen.kt
  • src/main/java/com/juick/android/ui/screens/tags/TagsScreen.kt
  • src/main/java/com/juick/android/ui/screens/thread/ThreadScreen.kt
  • src/main/java/com/juick/android/ui/signin/SignInScreen.kt
  • src/main/java/com/juick/android/ui/signup/SignUpScreen.kt
  • src/main/java/com/juick/android/ui/widget/CropSheet.kt
  • src/main/java/com/juick/android/widget/CropBottomSheet.kt
  • src/main/java/com/juick/android/widget/util/ImageHelper.kt
  • src/main/java/com/juick/android/widget/util/ImageUtil.kt
  • src/main/java/com/juick/api/model/Chat.kt
  • src/main/java/com/juick/api/model/Post.kt
  • src/main/java/com/juick/api/model/User.kt
  • src/main/res/layout/activity_login.xml
  • src/main/res/layout/activity_main.xml
  • src/main/res/layout/activity_signup.xml
  • src/main/res/layout/content_main.xml
  • src/main/res/layout/dialog_crop.xml
  • src/main/res/layout/fragment_chat.xml
  • src/main/res/layout/fragment_dialog_list.xml
  • src/main/res/layout/fragment_me.xml
  • src/main/res/layout/fragment_new_post.xml
  • src/main/res/layout/fragment_no_auth.xml
  • src/main/res/layout/fragment_posts_page.xml
  • src/main/res/layout/fragment_posts_viewpager.xml
  • src/main/res/layout/fragment_tags_list.xml
  • src/main/res/layout/fragment_thread.xml
  • src/main/res/layout/item_post.xml
  • src/main/res/layout/item_tag.xml
  • src/main/res/layout/item_thread_reply.xml
  • src/main/res/layout/menu_layout_discussions.xml
  • src/main/res/layout/menu_layout_profile.xml
  • src/main/res/menu/bottom_navigation.xml
  • src/main/res/menu/toolbar.xml
  • src/main/res/navigation/navigation.xml
  • src/next/google/google-services.json
  • src/next/java/com/juick/android/NextSignInActivity.kt
  • src/next/java/com/juick/android/ui/Theme.kt
💤 Files with no reviewable changes (40)
  • src/main/res/layout/menu_layout_profile.xml
  • src/main/res/layout/item_tag.xml
  • src/main/res/layout/fragment_no_auth.xml
  • src/main/res/layout/menu_layout_discussions.xml
  • src/main/res/layout/item_thread_reply.xml
  • src/main/java/com/juick/android/screens/chats/ChatsFragment.kt
  • src/main/java/com/juick/android/screens/home/HomeFragment.kt
  • src/main/res/menu/toolbar.xml
  • src/main/java/com/juick/android/screens/FeedAdapter.kt
  • src/main/res/layout/dialog_crop.xml
  • src/main/res/layout/fragment_posts_viewpager.xml
  • src/next/google/google-services.json
  • src/main/res/menu/bottom_navigation.xml
  • src/main/java/com/juick/android/screens/blog/BlogFragment.kt
  • src/main/java/com/juick/android/screens/FeedFragment.kt
  • src/main/res/layout/fragment_posts_page.xml
  • .github/workflows/schedule.yml
  • src/main/res/layout/fragment_tags_list.xml
  • src/main/res/layout/activity_login.xml
  • src/main/res/layout/fragment_dialog_list.xml
  • src/main/res/layout/activity_main.xml
  • src/main/res/layout/activity_signup.xml
  • src/main/java/com/juick/android/screens/chats/NoAuthFragment.kt
  • src/main/java/com/juick/android/screens/chat/ChatFragment.kt
  • src/main/res/layout/item_post.xml
  • src/main/res/layout/fragment_me.xml
  • src/main/res/layout/fragment_chat.xml
  • src/main/res/layout/content_main.xml
  • src/main/java/com/juick/android/fragment/ThreadFragment.kt
  • src/main/java/com/juick/android/widget/util/ImageHelper.kt
  • src/next/java/com/juick/android/NextSignInActivity.kt
  • src/main/res/layout/fragment_new_post.xml
  • src/main/java/com/juick/android/screens/search/SearchFragment.kt
  • src/next/java/com/juick/android/ui/Theme.kt
  • src/main/java/com/juick/android/screens/post/NewPostFragment.kt
  • src/androidTest/java/com/juick/android/testing/ChatLinkClickTest.kt
  • src/main/java/com/juick/android/widget/CropBottomSheet.kt
  • src/main/res/navigation/navigation.xml
  • src/main/res/layout/fragment_thread.xml
  • src/main/java/com/juick/android/screens/post/TagsFragment.kt
✅ Files skipped from review due to trivial changes (2)
  • gradle.properties
  • src/androidTest/AndroidManifest.xml
🚧 Files skipped from review as they are similar to previous changes (24)
  • src/main/java/com/juick/android/ui/signup/SignUpScreen.kt
  • src/main/java/com/juick/android/ui/screens/noauth/NoAuthScreen.kt
  • src/main/java/com/juick/android/ui/screens/tags/TagsScreen.kt
  • src/androidTest/java/com/juick/android/testing/SignInScreenTest.kt
  • src/main/AndroidManifest.xml
  • src/main/java/com/juick/android/ui/screens/search/SearchScreen.kt
  • src/google/java/com/juick/android/GoogleSignInProvider.kt
  • src/main/java/com/juick/android/NotificationSender.kt
  • src/main/java/com/juick/android/ui/Theme.kt
  • src/main/java/com/juick/App.kt
  • src/androidTest/java/com/juick/android/testing/MainScreenTest.kt
  • .github/workflows/android.yml
  • src/main/java/com/juick/android/ui/screens/feed/ProfileHeader.kt
  • src/main/java/com/juick/api/model/Chat.kt
  • src/main/java/com/juick/android/ui/screens/post/NewPostScreen.kt
  • src/main/java/com/juick/android/ui/screens/chats/ChatsListScreen.kt
  • build.gradle
  • src/main/java/com/juick/android/ui/MainScreen.kt
  • src/main/java/com/juick/android/SignInActivity.kt
  • src/main/java/com/juick/android/ui/signin/SignInScreen.kt
  • src/main/java/com/juick/android/OnItemClickListener.kt
  • src/androidTest/java/com/juick/android/testing/UITest.kt
  • gradle/libs.versions.toml
  • src/main/java/com/juick/android/ui/navigation/AppNavigation.kt

Comment on lines 140 to 144
MENU_ACTION_DELETE_POST -> confirmAction(activity, R.string.Are_you_sure_delete) {
processCommand("D #" +
if (rid == 0) "$mid" else "$mid/$rid")
val navController = findNavController(fragment)
navController.popBackStack(R.id.home, false)
if (rid > 0) {
val args = Bundle()
args.putInt("mid", mid)
args.putBoolean("scrollToEnd", true)
navController.navigate(R.id.thread, args)
} else {
navController.navigate(R.id.home)
}
onDeletePostNavigate()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Navigate away only after the delete command succeeds.

processCommand() is asynchronous, so calling onDeletePostNavigate() immediately makes failed deletes look successful. If this LifecycleOwner is screen-scoped, the navigation can also cancel the request that was just launched. Trigger the navigation from the success path that updates receiver, not inline here.

Also applies to: 193-195

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/JuickMessageMenuListener.kt` around lines 140
- 144, The current delete flow calls onDeletePostNavigate immediately after
launching the async processCommand in the MENU_ACTION_DELETE_POST branch (inside
confirmAction), which can make failures look successful or cancel the request;
remove the inline onDeletePostNavigate call from the confirmAction callback and
instead trigger navigation from the success path that updates receiver (i.e.,
where the code handles the completed processCommand result and updates the
receiver state), so navigation only occurs after a successful delete; apply the
same change to the other similar delete site referenced (the block around the
second occurrence).

Comment on lines 84 to 89
private fun bindCustomTabService(context: Context) {
// Check for an existing connection
if (browserClient != null) {
// Do nothing if there is an existing service connection
return
}
// Get the default browser package name, this will be null if
// the default browser does not provide a CustomTabsService
if (browserClient != null) return
val packageName = CustomTabsClient.getPackageName(context, null)
packageName?.let {
CustomTabsClient.bindCustomTabsService(context, it, browserConnection)
} ?: run {
browserSessionSupported.value = false
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Track the Custom Tabs bound state explicitly.

Using browserClient != null as the bind/unbind guard is not sufficient. The service can already be bound before onCustomTabsServiceConnected() runs, and in that window onDestroy() skips unbindService(). Track the boolean result of bindCustomTabsService() and unbind from that instead.

Suggested fix
+    private var customTabsBound = false
+
     private fun bindCustomTabService(context: Context) {
-        if (browserClient != null) return
+        if (customTabsBound) return
         val packageName = CustomTabsClient.getPackageName(context, null)
         packageName?.let {
-            CustomTabsClient.bindCustomTabsService(context, it, browserConnection)
+            customTabsBound = CustomTabsClient.bindCustomTabsService(context, it, browserConnection)
         }
     }
 ...
     override fun onDestroy() {
-        browserClient?.let {
+        if (customTabsBound) {
             unbindService(browserConnection)
+            customTabsBound = false
         }
         browserClient = null
         browserSession = null
         super.onDestroy()
     }

Also applies to: 257-260

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/MainActivity.kt` around lines 84 - 89, The
current guard uses browserClient != null which can miss the window where the
service is bound but onCustomTabsServiceConnected() hasn't set browserClient;
change bindCustomTabService to capture the boolean result of
CustomTabsClient.bindCustomTabsService(context, packageName, browserConnection)
into a new field (e.g., isCustomTabsBound) and set it accordingly, and update
onCustomTabsServiceConnected/onDestroy (and the similar unbind location around
the other bind) to unbind only if isCustomTabsBound is true, then reset
isCustomTabsBound to false when unbinding; continue to set/clear browserClient
inside onCustomTabsServiceConnected/onServiceDisconnected as before.

Comment thread src/main/java/com/juick/android/MainActivity.kt
Comment thread src/main/java/com/juick/android/MainActivity.kt
Comment thread src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt Outdated
Comment on lines +195 to +200
return p.entityStart.indices.mapNotNull { i ->
while (si < sorted.size && sorted[si].type != "a") si++
if (si >= sorted.size) return@mapNotNull null
val e = sorted[si++]
if (e.url == null) return@mapNotNull null
UrlPosition(p.entityStart[i], p.entityEnd[i], e.url)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

buildUrlPositions misaligns URLs when non-link entities appear earlier.

The loop maps the next "a" entity URL onto the current processed index i even if p.entityType[i] is not a link, which shifts click ranges.

Suggested fix
 fun buildUrlPositions(post: Post): List<UrlPosition> {
     val p = processBody(post)
-    val sorted = post.entities.sortedBy { it.start }
-    var si = 0
+    val links = post.entities
+        .sortedBy { it.start }
+        .filter { it.type == "a" && it.url != null }
+    var li = 0
     return p.entityStart.indices.mapNotNull { i ->
-        while (si < sorted.size && sorted[si].type != "a") si++
-        if (si >= sorted.size) return@mapNotNull null
-        val e = sorted[si++]
-        if (e.url == null) return@mapNotNull null
-        UrlPosition(p.entityStart[i], p.entityEnd[i], e.url)
+        if (p.entityType[i] != "a") return@mapNotNull null
+        val e = links.getOrNull(li++) ?: return@mapNotNull null
+        UrlPosition(p.entityStart[i], p.entityEnd[i], e.url!!)
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt` around
lines 195 - 200, buildUrlPositions currently advances the sorted-entity pointer
(si) for every index i, which misaligns URLs when p.entityType[i] isn't a link;
change the mapping so you only attempt to consume/advance si when
p.entityType[i] == "a": inside buildUrlPositions, for each i check if
p.entityType[i] != "a" then return null (do not touch si), otherwise
loop/advance si until you find sorted[si].type == "a", verify e.url != null and
then create UrlPosition(p.entityStart[i], p.entityEnd[i], e.url); this ensures
si stays in sync with link entries and preserves correct click ranges.

Comment thread src/main/java/com/juick/android/ui/screens/thread/ThreadScreen.kt
Comment on lines +95 to +111
if (isCropping) return@TextButton
isCropping = true
cropImageView?.setOnCropImageCompleteListener { _, result ->
isCropping = false
val uri = if (result.isSuccessful) {
try {
saveBitmapToFile(context, result.bitmap)
} catch (e: Exception) {
null
}
} else {
null
}
onCropResult(uri)
}
cropImageView?.croppedImageAsync()
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard cropImageView before toggling isCropping.

If the button is tapped before AndroidView initializes, isCropping becomes true and never resets because no async crop callback runs.

Suggested fix
                 TextButton(
                     onClick = {
                         if (isCropping) return@TextButton
-                        isCropping = true
-                        cropImageView?.setOnCropImageCompleteListener { _, result ->
+                        val view = cropImageView ?: return@TextButton
+                        isCropping = true
+                        view.setOnCropImageCompleteListener { _, result ->
                             isCropping = false
@@
-                        cropImageView?.croppedImageAsync()
+                        view.croppedImageAsync()
                     },
🧰 Tools
🪛 detekt (1.23.8)

[warning] 102-102: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/widget/CropSheet.kt` around lines 95 -
111, Guard against cropImageView being null before mutating isCropping: in the
TextButton click handler check cropImageView (and isCropping) first and return
early if cropImageView is null so you never set isCropping = true when there’s
no view to produce a callback; only set isCropping, attach the
onCropImageCompleteListener on cropImageView, and call
cropImageView.croppedImageAsync() after confirming cropImageView is non-null
(references: isCropping, cropImageView, setOnCropImageCompleteListener,
croppedImageAsync, onCropResult).

Comment on lines +24 to +29
suspend fun loadImage(url: String): Bitmap? {
return try {
val responseBody = App.instance.api.download(url)
responseBody.byteStream().use { BitmapFactory.decodeStream(it) }
} catch (e: Exception) {
null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Locate the file and inspect around the referenced lines
ls -la
fd -t f "ImageUtil.kt" -a src/main/java/com/juick/android/widget/util || true
# Use sed to print around lines 1-120 to capture context; file likely <200 lines.
FILE="src/main/java/com/juick/android/widget/util/ImageUtil.kt"
if [ -f "$FILE" ]; then
  echo "---- $FILE ----"
  nl -ba "$FILE" | sed -n '1,160p'
else
  echo "File not found: $FILE"
  exit 1
fi
# Search for other uses of loadImage or similar cancellation handling
rg -n "loadImage\\(" -S src/main/java || true
rg -n "CancellationException" -S src/main/java || true

Repository: juick/Juick-Android

Length of output: 1529


🏁 Script executed:

#!/bin/bash
set -euo pipefail
ls -la
fd -t f "ImageUtil.kt" -a src/main/java/com/juick/android/widget/util || true
FILE="src/main/java/com/juick/android/widget/util/ImageUtil.kt"
if [ -f "$FILE" ]; then
  echo "---- $FILE ----"
  nl -ba "$FILE" | sed -n '1,200p'
else
  echo "File not found: $FILE"
  exit 1
fi
rg -n "suspend fun loadImage" -S src/main/java || true
rg -n "loadImage\\(" -S src/main/java || true
rg -n "CancellationException" -S src/main/java || true

Repository: juick/Juick-Android

Length of output: 1528


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/main/java/com/juick/android/widget/util/ImageUtil.kt"
if [ ! -f "$FILE" ]; then
  echo "Missing $FILE"
  exit 1
fi
nl -ba "$FILE" | sed -n '1,200p'

echo "---- usages of loadImage ----"
rg -n "loadImage\\(" -S src/main/java || true

echo "---- other cancellation handling ----"
rg -n "CancellationException" -S src/main/java || true

echo "---- coroutines exception handling patterns ----"
rg -n "catch \\(Exception\\)" -S src/main/java/com/juick/android/widget/util || true

Repository: juick/Juick-Android

Length of output: 106


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/main/java/com/juick/android/widget/util/ImageUtil.kt"
if [ ! -f "$FILE" ]; then
  echo "Missing $FILE"
  exit 1
fi

nl -ba "$FILE" | sed -n '1,220p'

echo "---- usages of loadImage ----"
rg -n "loadImage\\(" -S src/main/java || true

echo "---- other cancellation handling ----"
rg -n "CancellationException" -S src/main/java || true

echo "---- coroutines exception handling patterns (catch Exception) in util package ----"
rg -n "catch \\(.*Exception\\)" -S src/main/java/com/juick/android/widget/util || true

Repository: juick/Juick-Android

Length of output: 107


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/main/java/com/juick/android/widget/util/ImageUtil.kt"
if [ ! -f "$FILE" ]; then
  echo "Missing $FILE"
  exit 1
fi

echo "---- file: $FILE (lines 1-120) ----"
awk '{printf "%d\t%s\n", NR, $0}' "$FILE" | sed -n '1,120p'

echo "---- usages of loadImage ----"
rg -n "suspend fun loadImage\\(" -S src/main/java || true
rg -n "loadImage\\(" -S src/main/java || true

echo "---- other cancellation handling (CancellationException) ----"
rg -n "CancellationException" -S src/main/java || true

echo "---- coroutines exception handling patterns (catch (Exception)) ----"
rg -n "catch \\(.*Exception\\)" -S src/main/java/com/juick/android/widget/util || true

Repository: juick/Juick-Android

Length of output: 1905


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/main/java/com/juick/android/NotificationSender.kt"
if [ ! -f "$FILE" ]; then
  echo "Missing $FILE"
  exit 1
fi

echo "---- excerpt: $FILE (lines 90-160) ----"
awk 'NR>=90 && NR<=160 {printf "%d\t%s\n", NR, $0}' "$FILE"

echo "---- coroutine-related usages in $FILE ----"
rg -n "(launch\\(|async\\(|withContext\\(|coroutineScope\\(|supervisorScope\\(|runBlocking\\(|job\\.join\\()" "$FILE" || true

echo "---- any try/catch in $FILE ----"
rg -n "catch \\(.*Exception\\)" "$FILE" || true

Repository: juick/Juick-Android

Length of output: 3524


Rethrow coroutine cancellation in loadImage
loadImage catches Exception, which also catches CancellationException, turning cancellation into null. loadImage(...) is called inside runBlocking in NotificationSender, so cancellation of that coroutine can be ignored.

Suggested fix
+import kotlinx.coroutines.CancellationException
+
 suspend fun loadImage(url: String): Bitmap? {
     return try {
         val responseBody = App.instance.api.download(url)
         responseBody.byteStream().use { BitmapFactory.decodeStream(it) }
+    } catch (e: CancellationException) {
+        throw e
     } catch (e: Exception) {
         null
     }
 }
🧰 Tools
🪛 detekt (1.23.8)

[warning] 28-28: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/widget/util/ImageUtil.kt` around lines 24 -
29, The loadImage suspend function currently swallows CancellationException by
catching Exception; update loadImage so it rethrows coroutine cancellations: in
the catch block for exceptions from
App.instance.api.download/BitmapFactory.decodeStream, detect
CancellationException (or catch CancellationException first) and rethrow it, and
only convert non-cancellation exceptions to null. Reference the loadImage
function and the caller NotificationSender (which uses runBlocking) when making
the change.

Source: Linters/SAST tools

Comment thread src/main/java/com/juick/api/model/Post.kt
@vitalyster vitalyster force-pushed the feature/compose-migration branch 2 times, most recently from cd18acc to a03f745 Compare June 9, 2026 19:17

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (8)
src/main/java/com/juick/android/widget/util/ImageUtil.kt (1)

24-30: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Rethrow coroutine cancellation in loadImage.

Line 28 catches all exceptions, including CancellationException, and converts cancellation into a null result.

Suggested fix
+import kotlinx.coroutines.CancellationException
+
 suspend fun loadImage(url: String): Bitmap? {
     return try {
         val responseBody = App.instance.api.download(url)
         responseBody.byteStream().use { BitmapFactory.decodeStream(it) }
+    } catch (e: CancellationException) {
+        throw e
     } catch (e: Exception) {
         null
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/widget/util/ImageUtil.kt` around lines 24 -
30, In loadImage, don't swallow coroutine cancellations: modify the exception
handling in the suspend function loadImage so that CancellationException is
rethrown (or allowed to propagate) while other exceptions return null;
specifically, in the try/catch around App.instance.api.download(...) and
BitmapFactory.decodeStream(...), add a catch for CancellationException that
rethrows, then a general catch(Exception) that returns null, ensuring coroutine
cancellation is preserved.

Source: Linters/SAST tools

src/main/java/com/juick/android/MainActivity.kt (3)

122-125: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Route single-segment profile deep links in-app.

Line 124 always opens browser, but this screen already navigates to blog/{uname} (Line 189), so profile app-links bypass in-app navigation.

Suggested fix
             1 -> {
-                // user profile → open in browser
-                openUri(data)
+                val uname = segments[0]
+                navController?.navigate("blog/${Uri.encode(uname)}") ?: openUri(data)
                 return true
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/MainActivity.kt` around lines 122 - 125, The
deep-link handler in MainActivity.kt currently always calls openUri(data) for
the single-segment case (the 1 -> branch), which forces the browser instead of
using the app's internal profile route; change the logic in that case to parse
the single path segment as uname and call the app navigation for the profile
(the same route used elsewhere: navigateTo("blog/{uname}" or the app's profile
navigation method) instead of openUri, falling back to openUri only if parsing
fails. Target the 1 -> branch in MainActivity.kt and replace the openUri(data)
call with the in-app navigation to blog/{uname} using the existing navigation
helper.

249-252: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Consume share intent only after navigation is available.

Line 249 clears the action before confirming navigation can run. If navController is still null, the shared text is dropped.

Suggested fix
         if (Intent.ACTION_SEND == intent.action) {
             val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: ""
             if (text.isNotEmpty()) {
-                intent.action = null // consume to prevent re-processing
-                navController?.navigate(
+                val nav = navController ?: return
+                nav.navigate(
                     "new_post?text=${Uri.encode(text)}"
                 )
+                intent.action = null // consume only after successful handoff
             }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/MainActivity.kt` around lines 249 - 252, The
share intent's action is being cleared before ensuring navigation can occur,
which can drop the shared text if navController is null; update the logic in
MainActivity so you only call intent.action = null after confirming
navController is non-null and navigation was invoked (i.e., check navController
!= null before calling
navController.navigate("new_post?text=${Uri.encode(text)}"), or attempt
navigation and only then set intent.action = null), referencing the intent
variable, navController instance, and the navigate(...) call to locate the
change.

85-89: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Track Custom Tabs bind state explicitly.

Line 85/Line 258 use browserClient as the bind/unbind signal, which misses the period where service is bound but callback hasn’t set browserClient yet.

Suggested fix
+    private var customTabsBound = false
+
     private fun bindCustomTabService(context: Context) {
-        if (browserClient != null) return
+        if (customTabsBound) return
         val packageName = CustomTabsClient.getPackageName(context, null)
         packageName?.let {
-            CustomTabsClient.bindCustomTabsService(context, it, browserConnection)
+            customTabsBound = CustomTabsClient.bindCustomTabsService(context, it, browserConnection)
         }
     }
@@
     override fun onDestroy() {
-        browserClient?.let {
+        if (customTabsBound) {
             unbindService(browserConnection)
+            customTabsBound = false
         }
         browserClient = null
         browserSession = null
         super.onDestroy()
     }

Also applies to: 257-260

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/MainActivity.kt` around lines 85 - 89, The
code uses browserClient as the signal for whether the Custom Tabs service is
bound, which misses the window where the service is bound but browserClient is
not yet set; add an explicit boolean flag (e.g. isBrowserServiceBound) as a
class property, set it to true in browserConnection.onServiceConnected and false
in browserConnection.onServiceDisconnected, and replace checks that currently
use browserClient (e.g. the early return before calling
CustomTabsClient.bindCustomTabsService and the corresponding unbind logic) to
use this boolean so bind/unbind calls reflect the actual bound state regardless
of browserClient being null temporarily.
src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt (3)

195-200: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Only consume link entities for link-typed processed spans.

Line 195 iterates all processed entity slots, but Lines 196–200 always consume the next link entity, shifting URL ranges when non-link entities appear.

Suggested fix
 fun buildUrlPositions(post: Post): List<UrlPosition> {
     val p = processBody(post)
     val sorted = post.entities.sortedBy { it.start }
     var si = 0
     return p.entityStart.indices.mapNotNull { i ->
+        if (p.entityType[i] != "a") return@mapNotNull null
         while (si < sorted.size && sorted[si].type != "a") si++
         if (si >= sorted.size) return@mapNotNull null
         val e = sorted[si++]
         if (e.url == null) return@mapNotNull null
         UrlPosition(p.entityStart[i], p.entityEnd[i], e.url)
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt` around
lines 195 - 200, The code currently advances the shared link pointer si for
every processed entity index, which shifts link consumption when the processed
span at that index is not a link; update the loop in the mapping over
p.entityStart/p.entityEnd so you first check whether the processed span at index
i is a link-type (e.g. its span/type is "a") and only then search for and
consume the next link entity from sorted (advancing si); if the processed span
is not a link, return null for that index without moving si. Reference
variables: p.entityStart, p.entityEnd, sorted, si, and UrlPosition.

149-170: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Use offset-based URL mapping per block (including quotes).

Line 149 drops quote URL positions, and Line 168 uses indexOf(e.text), which mis-maps repeated link text and unrelated links.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt` around
lines 149 - 170, The block builder for non-quote and quote blocks (rBuilder /
TextBlock.Quote) is dropping or mis-mapping URL positions by using
rText.indexOf(e.text) and ignoring quote offsets; fix by computing URL offsets
relative to each block using the entity start/end positions from the parsed
entities list (e.g., use the same 'all'/'sorted' entries you iterate when
building the block) instead of indexOf; for each UrlPosition, calculate start =
entity.start - blockStart and end = entity.end - blockStart (and include quote
blocks as well) so UrlPosition ranges align with the block's text and handle
repeated link text correctly.

50-58: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate entity bounds before injecting entity text.

Line 50–58 still allows out-of-range/invalid entities to append e.text, which corrupts processed offsets.

Suggested fix
     for (e in sorted) {
-        if (e.start < bp) continue
-        val end = e.end.coerceAtMost(body.length)
-        while (bp < body.length && bp < e.start) sb.appendCollapsing(body[bp++])
+        val start = e.start.coerceIn(0, body.length)
+        val end = e.end.coerceIn(start, body.length)
+        if (start < bp) continue
+        if (start >= body.length || end <= start) continue
+        while (bp < body.length && bp < start) sb.appendCollapsing(body[bp++])
         eStart.add(sb.length)
         for (c in e.text) sb.appendCollapsing(c)
         eEnd.add(sb.length)
         eType.add(e.type)
         bp = end
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt` around
lines 50 - 58, Validate entity bounds before injecting e.text: in the loop over
sorted entities (variables: e, bp, body, sb, eStart, eEnd, eType) ensure e.start
and e.end are within [0, body.length] and that e.end > e.start (or clamp end =
e.end.coerceAtMost(body.length) and skip if end <= e.start) before appending
e.text and recording offsets; if invalid, skip the entity (do not append e.text
or update eStart/eEnd/eType and do not move bp) so processed offsets remain
consistent; also ensure bp is advanced only to the validated/clamped end.
src/main/java/com/juick/android/ui/widget/CropSheet.kt (1)

95-110: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard cropImageView before mutating isCropping.

If Crop is tapped before cropImageView is ready, isCropping is set to true and never reset because no async callback is registered.

💡 Suggested patch
                 TextButton(
                     onClick = {
                         if (isCropping) return@TextButton
+                        val view = cropImageView ?: return@TextButton
                         isCropping = true
-                        cropImageView?.setOnCropImageCompleteListener { _, result ->
+                        view.setOnCropImageCompleteListener { _, result ->
                             isCropping = false
                             val uri = if (result.isSuccessful) {
                                 try {
                                     saveBitmapToFile(context, result.bitmap)
                                 } catch (e: Exception) {
                                     null
                                 }
                             } else {
                                 null
                             }
                             onCropResult(uri)
                         }
-                        cropImageView?.croppedImageAsync()
+                        view.croppedImageAsync()
                     },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/widget/CropSheet.kt` around lines 95 -
110, The bug is that isCropping is set true before verifying cropImageView is
non-null, which can leave isCropping stuck if cropImageView isn't ready; update
the click/trigger handler to first check cropImageView != null (or obtain a
non-null reference) and only then set isCropping = true, call
cropImageView.setOnCropImageCompleteListener(...), and start
cropImageView.croppedImageAsync(); if cropImageView is null, return early (and
do not change isCropping), and ensure the listener body still resets isCropping
and calls onCropResult with the saved URI from saveBitmapToFile(context,
result.bitmap).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/androidTest/java/com/juick/android/testing/SignInScreenTest.kt`:
- Around line 46-50: The test signInScreen_showsNicknameField_enabled currently
only asserts visibility; update it to also assert enabled state by chaining
assertIsEnabled() on the node returned by composeTestRule.onNodeWithText(...)
(e.g., after the existing assertIsDisplayed() call), so the test both checks
display and enabled state; alternatively, if you prefer not to assert enabled,
rename the test to remove "_enabled" from
signInScreen_showsNicknameField_enabled to match the current behavior.

In `@src/main/java/com/juick/android/SignUpActivity.kt`:
- Around line 51-57: In SignUpActivity's coroutine catch block that currently
does "catch (e: Exception)" (the block that shows the "Username is not
correct..." Toast), ensure you don't treat coroutine cancellation as a signup
failure by rethrowing CancellationException: check if the caught exception is a
kotlin.coroutines.cancellation.CancellationException (or use "if (e is
CancellationException) throw e") before handling other exceptions and showing
the Toast; keep the existing UI error handling for non-cancellation exceptions
only.

In `@src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt`:
- Line 62: The code trims the string when constructing Processed(...) which
invalidates previously recorded entity offsets (eStart/eEnd); either perform
trimming before you compute/record entity offsets or adjust eStart/eEnd to
account for removed leading/trailing characters. Concretely, ensure the string
(sb.toString()) is trimmed first (or compute leadingTrimCount/trailingTrimCount
and subtract leadingTrimCount from eStart/eEnd and clamp eEnd) so that
Processed.text and the entity offsets (eStart, eEnd) remain consistent with each
other.

In `@src/main/java/com/juick/android/ui/screens/feed/PostCard.kt`:
- Around line 125-130: The media block currently checks only for medium != null
so a null/blank medium.url still renders an empty 200dp area and passes an empty
model to AsyncImage; update the conditional to require a non-blank URL (e.g.,
medium?.url.isNullOrBlank() == false) before showing Spacer and calling
AsyncImage (references: post.photo, medium, AsyncImage) so the entire media UI
is skipped when medium.url is null or blank.
- Around line 86-87: The menu, like, and comment icons lack contentDescription
and have undersized touch targets; update Icon usages in PostCard so interactive
icons use IconButton (or apply
Modifier.size(48.dp)/minimumInteractiveComponentSize()) instead of small fixed
sizes, move click handlers onto IconButton (e.g., onMenuClick for the menu, the
like click handler, and the comment click handler), and supply meaningful
contentDescription strings like "More options", "Like post", and "Comment" for
the respective Icon calls to restore accessibility and meet touch-target
minimums.

In `@src/main/java/com/juick/android/ui/Theme.kt`:
- Around line 89-91: Replace the unsafe cast in the SideEffect where you do
(view.context as Activity).window by resolving the Activity safely: obtain the
context from LocalView.current (view.context), attempt a safe cast (as?), and if
that fails walk ContextWrapper parents (or call a helper like
findActivityFromContext) to get the Activity; if no Activity is found return
early from the SideEffect, otherwise set activity.window.statusBarColor =
colorScheme.background.toArgb(). Update the SideEffect block (referencing
SideEffect, view, LocalView.current, Activity, window.statusBarColor,
colorScheme.background.toArgb()) to use this safe-null-checked approach.

---

Duplicate comments:
In `@src/main/java/com/juick/android/MainActivity.kt`:
- Around line 122-125: The deep-link handler in MainActivity.kt currently always
calls openUri(data) for the single-segment case (the 1 -> branch), which forces
the browser instead of using the app's internal profile route; change the logic
in that case to parse the single path segment as uname and call the app
navigation for the profile (the same route used elsewhere:
navigateTo("blog/{uname}" or the app's profile navigation method) instead of
openUri, falling back to openUri only if parsing fails. Target the 1 -> branch
in MainActivity.kt and replace the openUri(data) call with the in-app navigation
to blog/{uname} using the existing navigation helper.
- Around line 249-252: The share intent's action is being cleared before
ensuring navigation can occur, which can drop the shared text if navController
is null; update the logic in MainActivity so you only call intent.action = null
after confirming navController is non-null and navigation was invoked (i.e.,
check navController != null before calling
navController.navigate("new_post?text=${Uri.encode(text)}"), or attempt
navigation and only then set intent.action = null), referencing the intent
variable, navController instance, and the navigate(...) call to locate the
change.
- Around line 85-89: The code uses browserClient as the signal for whether the
Custom Tabs service is bound, which misses the window where the service is bound
but browserClient is not yet set; add an explicit boolean flag (e.g.
isBrowserServiceBound) as a class property, set it to true in
browserConnection.onServiceConnected and false in
browserConnection.onServiceDisconnected, and replace checks that currently use
browserClient (e.g. the early return before calling
CustomTabsClient.bindCustomTabsService and the corresponding unbind logic) to
use this boolean so bind/unbind calls reflect the actual bound state regardless
of browserClient being null temporarily.

In `@src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt`:
- Around line 195-200: The code currently advances the shared link pointer si
for every processed entity index, which shifts link consumption when the
processed span at that index is not a link; update the loop in the mapping over
p.entityStart/p.entityEnd so you first check whether the processed span at index
i is a link-type (e.g. its span/type is "a") and only then search for and
consume the next link entity from sorted (advancing si); if the processed span
is not a link, return null for that index without moving si. Reference
variables: p.entityStart, p.entityEnd, sorted, si, and UrlPosition.
- Around line 149-170: The block builder for non-quote and quote blocks
(rBuilder / TextBlock.Quote) is dropping or mis-mapping URL positions by using
rText.indexOf(e.text) and ignoring quote offsets; fix by computing URL offsets
relative to each block using the entity start/end positions from the parsed
entities list (e.g., use the same 'all'/'sorted' entries you iterate when
building the block) instead of indexOf; for each UrlPosition, calculate start =
entity.start - blockStart and end = entity.end - blockStart (and include quote
blocks as well) so UrlPosition ranges align with the block's text and handle
repeated link text correctly.
- Around line 50-58: Validate entity bounds before injecting e.text: in the loop
over sorted entities (variables: e, bp, body, sb, eStart, eEnd, eType) ensure
e.start and e.end are within [0, body.length] and that e.end > e.start (or clamp
end = e.end.coerceAtMost(body.length) and skip if end <= e.start) before
appending e.text and recording offsets; if invalid, skip the entity (do not
append e.text or update eStart/eEnd/eType and do not move bp) so processed
offsets remain consistent; also ensure bp is advanced only to the
validated/clamped end.

In `@src/main/java/com/juick/android/ui/widget/CropSheet.kt`:
- Around line 95-110: The bug is that isCropping is set true before verifying
cropImageView is non-null, which can leave isCropping stuck if cropImageView
isn't ready; update the click/trigger handler to first check cropImageView !=
null (or obtain a non-null reference) and only then set isCropping = true, call
cropImageView.setOnCropImageCompleteListener(...), and start
cropImageView.croppedImageAsync(); if cropImageView is null, return early (and
do not change isCropping), and ensure the listener body still resets isCropping
and calls onCropResult with the saved URI from saveBitmapToFile(context,
result.bitmap).

In `@src/main/java/com/juick/android/widget/util/ImageUtil.kt`:
- Around line 24-30: In loadImage, don't swallow coroutine cancellations: modify
the exception handling in the suspend function loadImage so that
CancellationException is rethrown (or allowed to propagate) while other
exceptions return null; specifically, in the try/catch around
App.instance.api.download(...) and BitmapFactory.decodeStream(...), add a catch
for CancellationException that rethrows, then a general catch(Exception) that
returns null, ensuring coroutine cancellation is preserved.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1a0f5b87-7bfe-48d1-bb7a-1a3b8d29dab5

📥 Commits

Reviewing files that changed from the base of the PR and between 2b36896 and cd18acc.

📒 Files selected for processing (80)
  • .github/workflows/android.yml
  • .github/workflows/schedule.yml
  • build.gradle
  • gradle.properties
  • gradle/libs.versions.toml
  • src/androidTest/AndroidManifest.xml
  • src/androidTest/java/com/juick/android/testing/ChatLinkClickTest.kt
  • src/androidTest/java/com/juick/android/testing/FormatPostTextTest.kt
  • src/androidTest/java/com/juick/android/testing/LinkClickTest.kt
  • src/androidTest/java/com/juick/android/testing/MainScreenTest.kt
  • src/androidTest/java/com/juick/android/testing/SignInScreenTest.kt
  • src/androidTest/java/com/juick/android/testing/UITest.kt
  • src/google/java/com/juick/android/GoogleSignInProvider.kt
  • src/main/AndroidManifest.xml
  • src/main/java/com/juick/App.kt
  • src/main/java/com/juick/android/JuickMessageMenuListener.kt
  • src/main/java/com/juick/android/MainActivity.kt
  • src/main/java/com/juick/android/NotificationSender.kt
  • src/main/java/com/juick/android/OnItemClickListener.kt
  • src/main/java/com/juick/android/SignInActivity.kt
  • src/main/java/com/juick/android/SignUpActivity.kt
  • src/main/java/com/juick/android/fragment/ThreadFragment.kt
  • src/main/java/com/juick/android/screens/FeedAdapter.kt
  • src/main/java/com/juick/android/screens/FeedFragment.kt
  • src/main/java/com/juick/android/screens/blog/BlogFragment.kt
  • src/main/java/com/juick/android/screens/chat/ChatFragment.kt
  • src/main/java/com/juick/android/screens/chats/ChatsFragment.kt
  • src/main/java/com/juick/android/screens/chats/NoAuthFragment.kt
  • src/main/java/com/juick/android/screens/home/HomeFragment.kt
  • src/main/java/com/juick/android/screens/post/NewPostFragment.kt
  • src/main/java/com/juick/android/screens/post/TagsFragment.kt
  • src/main/java/com/juick/android/screens/search/SearchFragment.kt
  • src/main/java/com/juick/android/ui/MainScreen.kt
  • src/main/java/com/juick/android/ui/Theme.kt
  • src/main/java/com/juick/android/ui/navigation/AppNavigation.kt
  • src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt
  • src/main/java/com/juick/android/ui/screens/chats/ChatsListScreen.kt
  • src/main/java/com/juick/android/ui/screens/feed/FeedScreen.kt
  • src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt
  • src/main/java/com/juick/android/ui/screens/feed/PostCard.kt
  • src/main/java/com/juick/android/ui/screens/feed/ProfileHeader.kt
  • src/main/java/com/juick/android/ui/screens/noauth/NoAuthScreen.kt
  • src/main/java/com/juick/android/ui/screens/post/NewPostScreen.kt
  • src/main/java/com/juick/android/ui/screens/search/SearchScreen.kt
  • src/main/java/com/juick/android/ui/screens/tags/TagsScreen.kt
  • src/main/java/com/juick/android/ui/screens/thread/ThreadScreen.kt
  • src/main/java/com/juick/android/ui/signin/SignInScreen.kt
  • src/main/java/com/juick/android/ui/signup/SignUpScreen.kt
  • src/main/java/com/juick/android/ui/widget/CropSheet.kt
  • src/main/java/com/juick/android/widget/CropBottomSheet.kt
  • src/main/java/com/juick/android/widget/util/ImageHelper.kt
  • src/main/java/com/juick/android/widget/util/ImageUtil.kt
  • src/main/java/com/juick/api/model/Chat.kt
  • src/main/java/com/juick/api/model/Post.kt
  • src/main/java/com/juick/api/model/User.kt
  • src/main/res/layout/activity_login.xml
  • src/main/res/layout/activity_main.xml
  • src/main/res/layout/activity_signup.xml
  • src/main/res/layout/content_main.xml
  • src/main/res/layout/dialog_crop.xml
  • src/main/res/layout/fragment_chat.xml
  • src/main/res/layout/fragment_dialog_list.xml
  • src/main/res/layout/fragment_me.xml
  • src/main/res/layout/fragment_new_post.xml
  • src/main/res/layout/fragment_no_auth.xml
  • src/main/res/layout/fragment_posts_page.xml
  • src/main/res/layout/fragment_posts_viewpager.xml
  • src/main/res/layout/fragment_tags_list.xml
  • src/main/res/layout/fragment_thread.xml
  • src/main/res/layout/item_post.xml
  • src/main/res/layout/item_tag.xml
  • src/main/res/layout/item_thread_reply.xml
  • src/main/res/layout/menu_layout_discussions.xml
  • src/main/res/layout/menu_layout_profile.xml
  • src/main/res/menu/bottom_navigation.xml
  • src/main/res/menu/toolbar.xml
  • src/main/res/navigation/navigation.xml
  • src/next/google/google-services.json
  • src/next/java/com/juick/android/NextSignInActivity.kt
  • src/next/java/com/juick/android/ui/Theme.kt
💤 Files with no reviewable changes (40)
  • src/main/res/layout/menu_layout_profile.xml
  • src/main/res/layout/menu_layout_discussions.xml
  • src/main/res/layout/fragment_new_post.xml
  • src/main/res/layout/fragment_posts_page.xml
  • src/main/res/layout/activity_signup.xml
  • .github/workflows/schedule.yml
  • src/main/res/layout/activity_login.xml
  • src/main/java/com/juick/android/screens/chats/NoAuthFragment.kt
  • src/main/res/layout/item_tag.xml
  • src/main/res/layout/fragment_me.xml
  • src/main/java/com/juick/android/screens/FeedFragment.kt
  • src/next/google/google-services.json
  • src/main/res/layout/item_post.xml
  • src/main/java/com/juick/android/screens/post/TagsFragment.kt
  • src/main/res/layout/fragment_posts_viewpager.xml
  • src/main/res/layout/fragment_chat.xml
  • src/main/res/layout/item_thread_reply.xml
  • src/main/res/menu/bottom_navigation.xml
  • src/main/res/layout/fragment_no_auth.xml
  • src/androidTest/java/com/juick/android/testing/ChatLinkClickTest.kt
  • src/main/res/layout/content_main.xml
  • src/main/java/com/juick/android/screens/search/SearchFragment.kt
  • src/main/res/layout/fragment_thread.xml
  • src/main/java/com/juick/android/screens/home/HomeFragment.kt
  • src/next/java/com/juick/android/ui/Theme.kt
  • src/main/java/com/juick/android/screens/chat/ChatFragment.kt
  • src/main/res/layout/dialog_crop.xml
  • src/next/java/com/juick/android/NextSignInActivity.kt
  • src/main/res/layout/fragment_dialog_list.xml
  • src/main/java/com/juick/android/fragment/ThreadFragment.kt
  • src/main/res/navigation/navigation.xml
  • src/main/res/menu/toolbar.xml
  • src/main/java/com/juick/android/widget/util/ImageHelper.kt
  • src/main/res/layout/fragment_tags_list.xml
  • src/main/java/com/juick/android/screens/chats/ChatsFragment.kt
  • src/main/res/layout/activity_main.xml
  • src/main/java/com/juick/android/screens/blog/BlogFragment.kt
  • src/main/java/com/juick/android/screens/FeedAdapter.kt
  • src/main/java/com/juick/android/screens/post/NewPostFragment.kt
  • src/main/java/com/juick/android/widget/CropBottomSheet.kt
✅ Files skipped from review due to trivial changes (4)
  • src/androidTest/AndroidManifest.xml
  • src/main/java/com/juick/android/NotificationSender.kt
  • src/main/AndroidManifest.xml
  • src/google/java/com/juick/android/GoogleSignInProvider.kt
🚧 Files skipped from review as they are similar to previous changes (25)
  • src/main/java/com/juick/android/ui/screens/noauth/NoAuthScreen.kt
  • src/main/java/com/juick/android/ui/screens/feed/ProfileHeader.kt
  • src/main/java/com/juick/android/ui/signup/SignUpScreen.kt
  • src/main/java/com/juick/android/ui/screens/tags/TagsScreen.kt
  • src/androidTest/java/com/juick/android/testing/UITest.kt
  • src/androidTest/java/com/juick/android/testing/MainScreenTest.kt
  • src/main/java/com/juick/App.kt
  • src/androidTest/java/com/juick/android/testing/FormatPostTextTest.kt
  • src/main/java/com/juick/android/ui/screens/post/NewPostScreen.kt
  • src/main/java/com/juick/android/ui/screens/chats/ChatsListScreen.kt
  • src/main/java/com/juick/api/model/User.kt
  • src/main/java/com/juick/android/ui/screens/thread/ThreadScreen.kt
  • src/main/java/com/juick/android/ui/screens/search/SearchScreen.kt
  • src/main/java/com/juick/android/JuickMessageMenuListener.kt
  • src/main/java/com/juick/android/ui/navigation/AppNavigation.kt
  • src/main/java/com/juick/android/ui/screens/chat/ChatScreen.kt
  • src/main/java/com/juick/api/model/Chat.kt
  • src/main/java/com/juick/android/ui/signin/SignInScreen.kt
  • src/androidTest/java/com/juick/android/testing/LinkClickTest.kt
  • src/main/java/com/juick/android/OnItemClickListener.kt
  • src/main/java/com/juick/android/SignInActivity.kt
  • build.gradle
  • src/main/java/com/juick/android/ui/screens/feed/FeedScreen.kt
  • src/main/java/com/juick/api/model/Post.kt
  • src/main/java/com/juick/android/ui/MainScreen.kt

Comment on lines +46 to +50
fun signInScreen_showsNicknameField_enabled() {
composeTestRule.onNodeWithText(
composeTestRule.activity.getString(R.string.your_nickname)
).assertIsDisplayed()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Test name says “enabled” but no enabled-state assertion is made.

This currently validates visibility only. Either add assertIsEnabled() or rename the test to match the actual assertion.

💡 Suggested patch
     `@Test`
     fun signInScreen_showsNicknameField_enabled() {
         composeTestRule.onNodeWithText(
             composeTestRule.activity.getString(R.string.your_nickname)
-        ).assertIsDisplayed()
+        ).assertIsDisplayed()
+            .assertIsEnabled()
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/androidTest/java/com/juick/android/testing/SignInScreenTest.kt` around
lines 46 - 50, The test signInScreen_showsNicknameField_enabled currently only
asserts visibility; update it to also assert enabled state by chaining
assertIsEnabled() on the node returned by composeTestRule.onNodeWithText(...)
(e.g., after the existing assertIsDisplayed() call), so the test both checks
display and enabled state; alternatively, if you prefer not to assert enabled,
rename the test to remove "_enabled" from
signInScreen_showsNicknameField_enabled to match the current behavior.

Comment on lines +51 to +57
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(
this@SignUpActivity,
"Username is not correct (already taken?)", Toast.LENGTH_LONG
).show()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ls -R
echo "----"
sed -n '1,120p' src/main/java/com/juick/android/SignUpActivity.kt
echo "----"
# show around line 40-80 if file shorter
nl -ba src/main/java/com/juick/android/SignUpActivity.kt | sed -n '35,90p'

Repository: juick/Juick-Android

Length of output: 9921


🏁 Script executed:

#!/bin/bash
set -euo pipefail
fd "SignUpActivity\.kt" .
echo "----"
# locate if multiple
paths="$(fd -a "SignUpActivity\.kt" .)"
echo "$paths"

Repository: juick/Juick-Android

Length of output: 189


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="$(fd -a "SignUpActivity\.kt" . | head -n 1)"
echo "Using: $file"
nl -ba "$file" | sed -n '1,140p'

Repository: juick/Juick-Android

Length of output: 200


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="$(fd -a "SignUpActivity\.kt" . | head -n 1)"
echo "Using: $file"
nl -ba "$file" | sed -n '35,100p'

Repository: juick/Juick-Android

Length of output: 200


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="$(fd -a "SignUpActivity\.kt" . | head -n 1)"
echo "----file----"
echo "$file"
echo "----lines 40-80----"
nl -ba "$file" | sed -n '40,85p'
echo "----catch blocks around----"
rg -n "catch \\(" "$file"

Repository: juick/Juick-Android

Length of output: 226


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="$(fd -a "SignUpActivity\.kt" . | head -n 1)"
echo "----file----"
echo "$file"
echo "----lines 40-85----"
nl -ba "$file" | sed -n '40,85p'
echo "----catch blocks----"
rg -n "catch \\(" "$file"
echo "----CancellationException usage----"
rg -n "CancellationException" "$file" || true

Repository: juick/Juick-Android

Length of output: 226


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="$(fd -a "SignUpActivity\.kt" . | head -n 1)"
echo "Using: $file"
echo "----lines 40-90----"
awk 'NR>=40 && NR<=90 {printf "%5d  %s\n", NR, $0}' "$file"

echo "----all catch blocks in file----"
rg -n "catch\s*\(" "$file"

echo "----CancellationException usage----"
rg -n "CancellationException" "$file" || true

Repository: juick/Juick-Android

Length of output: 1658


Don’t treat coroutine cancellation as a signup failure

catch (e: Exception) will also catch CancellationException, so cancelling the coroutine can incorrectly show the “Username is not correct (already taken?)” toast.

Suggested fix
+import kotlinx.coroutines.CancellationException
@@
-                            } catch (e: Exception) {
+                            } catch (e: CancellationException) {
+                                throw e
+                            } catch (e: Exception) {
                                 withContext(Dispatchers.Main) {
                                     Toast.makeText(
                                         this@SignUpActivity,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(
this@SignUpActivity,
"Username is not correct (already taken?)", Toast.LENGTH_LONG
).show()
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(
this@SignUpActivity,
"Username is not correct (already taken?)", Toast.LENGTH_LONG
).show()
}
🧰 Tools
🪛 detekt (1.23.8)

[warning] 51-51: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/SignUpActivity.kt` around lines 51 - 57, In
SignUpActivity's coroutine catch block that currently does "catch (e:
Exception)" (the block that shows the "Username is not correct..." Toast),
ensure you don't treat coroutine cancellation as a signup failure by rethrowing
CancellationException: check if the caught exception is a
kotlin.coroutines.cancellation.CancellationException (or use "if (e is
CancellationException) throw e") before handling other exceptions and showing
the Toast; keep the existing UI error handling for non-cancellation exceptions
only.

Source: Linters/SAST tools

Comment thread src/main/java/com/juick/android/ui/screens/feed/MessageFormatter.kt Outdated
Comment on lines +86 to +87
IconButton(onClick = onMenuClick, modifier = Modifier.size(24.dp)) {
Icon(Icons.Default.MoreVert, null, Modifier.size(16.dp), tint = colors.onSurfaceVariant)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/main/java/com/juick/android/ui/screens/feed/PostCard.kt"

# Show the referenced sections with line numbers
nl -ba "$FILE" | sed -n '60,110p'
echo "----"
nl -ba "$FILE" | sed -n '120,170p'

# Look for contentDescription=null and small icon/tap sizing
rg -n --fixed-string "contentDescription" "$FILE" || true
rg -n --fixed-string "null" "$FILE" || true
rg -n "Modifier\\.size\\((1[0-9]|18)\\.dp\\)" "$FILE" || true
rg -n "IconButton\\(" "$FILE" || true
rg -n "MoreVert|Favorite|Like|Thumb|menu|like" "$FILE" || true

Repository: juick/Juick-Android

Length of output: 106


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/main/java/com/juick/android/ui/screens/feed/PostCard.kt"

echo "== Lines 60-110 =="
awk 'NR>=60 && NR<=110 { printf "%5d:%s\n", NR, $0 }' "$FILE" || true
echo "== Lines 120-170 =="
awk 'NR>=120 && NR<=170 { printf "%5d:%s\n", NR, NR>=120 && NR<=170 ? $0 : $0 }' "$FILE" || true

echo "== Search: contentDescription =="
rg -n "contentDescription" "$FILE" || true

echo "== Search: Modifier.size(18.dp) or Modifier.size(24.dp) =="
rg -n "Modifier\\.size\\((18|24)\\.dp\\)" "$FILE" || true

echo "== Search: IconButton and Icons.Default.MoreVert/Favorite/Like =="
rg -n "IconButton\\(" "$FILE" || true
rg -n "Icons\\.Default\\.(MoreVert|Favorite|FavoriteBorder|Thumb|ThumbUp|ThumbDown|More|Menu)" "$FILE" || true

echo "== Search: like/menu identifiers around snippet context =="
rg -n "(onMenuClick|onLikeClick|like|menu)" "$FILE" || true

Repository: juick/Juick-Android

Length of output: 5663


Fix accessibility labels and minimum touch targets for action icons in PostCard

  • Menu icon: IconButton(..., modifier = Modifier.size(24.dp)) contains Icon(..., contentDescription = null, ...), leaving the action unlabeled and constraining the touch target.
  • Like icon: Icon(..., contentDescription = null, modifier = Modifier.size(18.dp).clickable { ... }) makes the clickable area ~18dp.
  • Comment icon: also uses Icon(..., contentDescription = null, ...) (line 139).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/feed/PostCard.kt` around lines 86
- 87, The menu, like, and comment icons lack contentDescription and have
undersized touch targets; update Icon usages in PostCard so interactive icons
use IconButton (or apply Modifier.size(48.dp)/minimumInteractiveComponentSize())
instead of small fixed sizes, move click handlers onto IconButton (e.g.,
onMenuClick for the menu, the like click handler, and the comment click
handler), and supply meaningful contentDescription strings like "More options",
"Like post", and "Comment" for the respective Icon calls to restore
accessibility and meet touch-target minimums.

Comment on lines +125 to +130
val photo = post.photo
val medium = photo?.medium
if (medium != null) {
Spacer(Modifier.height(12.dp))
AsyncImage(medium.url ?: "", null, Modifier.fillMaxWidth().height(200.dp), contentScale = ContentScale.FillWidth)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Skip rendering the media block when the URL is blank.

medium != null still allows a null/blank medium.url, which renders a fixed 200dp empty area and passes an empty model to AsyncImage.

💡 Suggested patch
             val photo = post.photo
             val medium = photo?.medium
-            if (medium != null) {
+            val mediumUrl = medium?.url?.takeIf { it.isNotBlank() }
+            if (mediumUrl != null) {
                 Spacer(Modifier.height(12.dp))
-                AsyncImage(medium.url ?: "", null, Modifier.fillMaxWidth().height(200.dp), contentScale = ContentScale.FillWidth)
+                AsyncImage(
+                    model = mediumUrl,
+                    contentDescription = null,
+                    modifier = Modifier.fillMaxWidth().height(200.dp),
+                    contentScale = ContentScale.FillWidth,
+                )
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val photo = post.photo
val medium = photo?.medium
if (medium != null) {
Spacer(Modifier.height(12.dp))
AsyncImage(medium.url ?: "", null, Modifier.fillMaxWidth().height(200.dp), contentScale = ContentScale.FillWidth)
}
val photo = post.photo
val medium = photo?.medium
val mediumUrl = medium?.url?.takeIf { it.isNotBlank() }
if (mediumUrl != null) {
Spacer(Modifier.height(12.dp))
AsyncImage(
model = mediumUrl,
contentDescription = null,
modifier = Modifier.fillMaxWidth().height(200.dp),
contentScale = ContentScale.FillWidth,
)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/screens/feed/PostCard.kt` around lines 125
- 130, The media block currently checks only for medium != null so a null/blank
medium.url still renders an empty 200dp area and passes an empty model to
AsyncImage; update the conditional to require a non-blank URL (e.g.,
medium?.url.isNullOrBlank() == false) before showing Spacer and calling
AsyncImage (references: post.photo, medium, AsyncImage) so the entire media UI
is skipped when medium.url is null or blank.

Comment on lines +89 to +91
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.background.toArgb()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid unsafe Activity cast in theme side effect.

Line 90 can throw ClassCastException when LocalView.current.context is not a direct Activity.

Suggested fix
         SideEffect {
-            val window = (view.context as Activity).window
+            val window = (view.context as? Activity)?.window ?: return@SideEffect
             window.statusBarColor = colorScheme.background.toArgb()
             WindowCompat
                 .getInsetsController(window, view)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.background.toArgb()
SideEffect {
val window = (view.context as? Activity)?.window ?: return@SideEffect
window.statusBarColor = colorScheme.background.toArgb()
WindowCompat
.getInsetsController(window, view)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/juick/android/ui/Theme.kt` around lines 89 - 91, Replace
the unsafe cast in the SideEffect where you do (view.context as Activity).window
by resolving the Activity safely: obtain the context from LocalView.current
(view.context), attempt a safe cast (as?), and if that fails walk ContextWrapper
parents (or call a helper like findActivityFromContext) to get the Activity; if
no Activity is found return early from the SideEffect, otherwise set
activity.window.statusBarColor = colorScheme.background.toArgb(). Update the
SideEffect block (referencing SideEffect, view, LocalView.current, Activity,
window.statusBarColor, colorScheme.background.toArgb()) to use this
safe-null-checked approach.

Complete migration of the entire Android UI layer from XML Views +
View Binding + Navigation XML to Jetpack Compose + Navigation Compose.

Architecture:
- Activities: AppCompatActivity → ComponentActivity with setContent
- Fragments: all 14 eliminated, each became a @composable destination
- Navigation: navigation.xml → NavHost composable with 11 routes
- View Binding: removed entirely (no XML layouts remain)
- ChatKit: replaced with native Compose LazyColumn + ClickableText
- Image loading: custom ImageView.load() → Coil 3.4.0 AsyncImage
- Compose deps: ungated from 'next' build type to regular implementation

New composables (17 files in src/main/.../ui/):
- Theme.kt: full Material3 theme matching Juick web CSS design tokens
- MainScreen.kt: Scaffold with TopAppBar, NavigationBar, FAB
- AppNavigation.kt: NavHost with all 11 routes
- FeedScreen.kt: LazyColumn with pull-to-refresh, load-more, ReplyCard
- PostCard.kt: full post card with entity-based formatting, ClickableText
- ProfileHeader.kt: blog profile header
- ChatScreen.kt + ChatBubble: native Compose PM (ChatKit replacement)
- ChatsListScreen.kt: PM dialog list
- ThreadScreen.kt, NewPostScreen.kt, TagsScreen.kt, NoAuthScreen.kt
- SearchScreen.kt, SignInScreen.kt, SignUpScreen.kt, CropSheet.kt
- MessageFormatter.kt: entity-based text formatting (mirrors iOS algorithm)

Message formatting:
- Uses API-provided entities (links, quotes, bold, italic, underline)
  with exact positions and shortened display text
- Block-level quote rendering with pink accent border + background
- URL click handling via position lookup, not annotation boundaries
- Newline collapsing to avoid extra vertical space
- Bold/italic/underline entity types fully supported

Design:
- Colors match Juick web app CSS (style.css design tokens)
- Post cards: white surface, subtle shadow, sharp corners, 20dp pad
- Top/bottom bars: surface background with 0.5dp outline dividers
- Quote blocks: 3dp accent left border, background fill, dimmed text
- NavigationBar: onSurface text selected, tertiary indicator, dimmed inactive
- Both sides of chat bubbles share same surfaceVariant background

Removed:
- 17 XML layouts + 2 menu XMLs + navigation.xml
- 14 Fragments + FeedAdapter + CropBottomSheet
- ChatKit, SwipeRefreshLayout, fragmentviewbindingdelegate dependencies
- 'next' build type (no longer needed)
- android.builtInKotlin=false → restored for CI Java 17 compat

Tests:
- UITest.kt restored and migrated for Compose
- FormatPostTextTest.kt: 8 entity-based formatting tests
- LinkClickTest.kt: 5 link rendering + URL position tests
- MainScreenTest.kt, SignInScreenTest.kt

Model changes:
- Post.Entity: API entity model (start, end, text, type, url)
- Chat/Post/User: removed ChatKit interface implementations

CI:
- check job uses assembleDebug (all 3 flavors)
- Removed concurrency groups to prevent cancelled jobs
- Connected tests use pinned emulator build 13610412

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vitalyster vitalyster force-pushed the feature/compose-migration branch from a03f745 to 2e8f841 Compare June 9, 2026 19:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants