Compare commits

...
473 Commits
Author SHA1 Message Date
GitHub Actions 55678d3914 feat(release): bump version 2026-06-02 10:20:42 +00:00
Harsh ShandilyaandGitHub 2661b82950 Update CHANGELOG.md 2026-06-02 15:45:56 +05:30
ClaudeGitHubanthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com>msfjarvis
4bbf092c2d [WIP] Fix certificate pinning error on app launch (#1188)
Thanks for asking me to work on this. I will get started on it and keep
this PR's description up to date as I form a plan and make progress.

> ----
> 
> *This section details on the original issue you should resolve*
> 
> <issue_title>1.65.0 Certificate Pinning error </issue_title>
> <issue_description>Opening the App just gives the error
> 
> > Certificate pinning failure
> 
> I imagine that this is due to the new certificate on lobster:
> 
> > Not Before Tue, 02 Jun 2026 03:17:13 GMT 
> 
> 
> backtrace:
> ```
> javax.net.ssl.SSLPeerUnverifiedException: Certificate pinning failure!
>   Peer certificate chain:
>     sha256/nmaozNNuQ1MLbF/avDVhKPEu1JiZibqa/rcIhF6rJaU=: CN=lobste.rs
> sha256/brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=: CN=YE1,O=Let's
Encrypt,C=US
> sha256/sCkq5UWXjg+7mKu9lMhhYF5bGLsy7VI/UNW3tccdR7w=: CN=Root
YE,O=ISRG,C=US
> sha256/diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=: CN=ISRG Root
X2,O=Internet Security Research Group,C=US
>   Pinned certificates for lobste.rs:
>     sha256/Bla1TIdpGeHXQS0/CIrA5hhFhOTZd94IIJRS3G3AcIo=
>     sha256/jQJTbIh0grw0/1TkHSumWb+Fs0Ggogr621gT3PvPKG0=
>     sha256/C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=
> at
okhttp3.CertificatePinner.check$okhttp(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:427)
> at
okhttp3.internal.connection.ConnectPlan.connectTls(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:223)
> at
okhttp3.internal.connection.ConnectPlan.connectTlsEtc(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:153)
> at
okhttp3.internal.connection.FastFallbackExchangeFinder.find(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:139)
> at
okhttp3.internal.connection.ConnectInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:30)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
okhttp3.internal.cache.CacheInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:1872)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
okhttp3.internal.cache.CacheInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:666)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
okhttp3.internal.cache.CacheInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:220)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
io.sentry.okhttp.SentryOkHttpInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:461)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
okhttp3.logging.HttpLoggingInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:576)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
okhttp3.internal.cache.CacheInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:773)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
dev.msfjarvis.claw.core.network.UserAgentInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:19)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
dev.msfjarvis.claw.core.network.RetryAfterInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:3)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
okhttp3.internal.connection.RealCall.getResponseWithInterceptorChain$okhttp(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:118)
> at
okhttp3.internal.connection.RealCall$AsyncCall.run(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:42)
> at
java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1154)
> at
java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:652)
> 	at java.lang.Thread.run(Thread.java:1563)
> Suppressed: javax.net.ssl.SSLPeerUnverifiedException: Certificate
pinning failure!
>   Peer certificate chain:
>     sha256/nmaozNNuQ1MLbF/avDVhKPEu1JiZibqa/rcIhF6rJaU=: CN=lobste.rs
> sha256/brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=: CN=YE1,O=Let's
Encrypt,C=US
> sha256/sCkq5UWXjg+7mKu9lMhhYF5bGLsy7VI/UNW3tccdR7w=: CN=Root
YE,O=ISRG,C=US
> sha256/diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=: CN=ISRG Root
X2,O=Internet Security Research Group,C=US
>   Pinned certificates for lobste.rs:
>     sha256/Bla1TIdpGeHXQS0/CIrA5hhFhOTZd94IIJRS3G3AcIo=
>     sha256/jQJTbIh0grw0/1TkHSumWb+Fs0Ggogr621gT3PvPKG0=
>     sha256/C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=
> 		... 27 more
> ```
> 
> 
> Version: 1.65.0
> </issue_description>
> 
> <agent_instructions>Remove the certificate pinning
logic</agent_instructions>
> 
> ## Comments on the Issue (you are @claude[agent] in this section)
> 
> <comments>
> </comments>

---------

Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com>
Co-authored-by: msfjarvis <13348378+msfjarvis@users.noreply.github.com>
2026-06-02 09:27:35 +00:00
renovate[bot]andGitHub 7cbf87080d fix(deps): update dependency org.jetbrains.kotlinx:kotlinx-datetime to v0.8.0-0.6.x-compat 2026-06-02 04:01:12 +00:00
Harsh Shandilya 784bda4105 refactor: drop all uses of java.time 2026-06-02 09:26:43 +05:30
renovate[bot]andGitHub 8adcef03d6 chore(deps): update plugin dependencyanalysis to v3.14.1 2026-06-01 22:09:41 +00:00
Harsh Shandilya 7efa5600d0 fix: give some padding to the collapsed comment count 2026-06-02 00:22:19 +05:30
Harsh Shandilya cb75ef0673 feat: add an indicator for collapsed comment count
Update the tests to account for the new UI affordance
2026-06-02 00:16:29 +05:30
Harsh Shandilya ae8db6d851 chore: update changelog 2026-06-01 00:11:33 +05:30
Harsh Shandilya 265a00e9b1 refactor: match the reply API request shape more closely
Without this we get a failed 404 reply even though the comment goes through just fine
2026-05-31 23:59:35 +05:30
Harsh Shandilya 94aa52b669 feat: add reply screen ui 2026-05-31 23:59:35 +05:30
Harsh Shandilya ca2dbd758c feat: add reply viewmodel 2026-05-31 23:59:35 +05:30
Harsh Shandilya fa22dbe4cf feat: add reply quote insertion helper 2026-05-31 18:57:53 +05:30
Harsh Shandilya 481efd24ab fix: improve comment click UX semantics 2026-05-31 18:43:37 +05:30
Harsh Shandilya 19346101c6 fix: use the correct thread guide color for each level 2026-05-31 18:43:25 +05:30
Harsh Shandilya b19a48fa09 fix: bump to Gradle 9.6.0-rc-1 2026-05-31 12:11:43 +05:30
renovate[bot]andGitHub 5457c204cf fix(deps): update dependency com.slack.lint.compose:compose-lint-checks to v1.5.1 2026-05-31 00:40:05 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
05e0664d14 fix(deps): update screenshot to v0.0.1-alpha15 (#1181)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[com.android.compose.screenshot](https://developer.android.com/studio/build)
([source](https://android.googlesource.com/platform/tools/base)) |
`0.0.1-alpha14` → `0.0.1-alpha15` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.android.compose.screenshot:com.android.compose.screenshot.gradle.plugin/0.0.1-alpha15?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.android.compose.screenshot:com.android.compose.screenshot.gradle.plugin/0.0.1-alpha14/0.0.1-alpha15?slim=true)
|
|
[com.android.tools.screenshot:screenshot-validation-api](http://tools.android.com/)
| `0.0.1-alpha14` → `0.0.1-alpha15` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.android.tools.screenshot:screenshot-validation-api/0.0.1-alpha15?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.android.tools.screenshot:screenshot-validation-api/0.0.1-alpha14/0.0.1-alpha15?slim=true)
|

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMDIuMSIsInVwZGF0ZWRJblZlciI6IjQzLjIwMi4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-30 03:40:01 +00:00
Harsh Shandilya e91338b31e fix: improve comment tree rendering 2026-05-29 23:51:12 +05:30
Harsh Shandilya f77221aeea chore: update changelog 2026-05-29 22:53:32 +05:30
Harsh Shandilya 8ee7d5dc28 Revert "feat: revert comments UI back to current stable for hotfix release"
This reverts commit 6d932c84d5.
2026-05-29 22:45:47 +05:30
GitHub Actions 76cb3376b7 feat(release): start next development iteration 2026-05-29 16:44:51 +00:00
GitHub Actions 9ba9791de8 feat(release): bump version 2026-05-29 16:39:35 +00:00
Harsh Shandilya 43408c2bec chore: update changelog 2026-05-29 21:59:24 +05:30
Harsh Shandilya 7ec580d9c6 fix: update selectors and test assertions 2026-05-29 21:52:59 +05:30
Harsh Shandilya 18b33e550a fix: refresh test fixtures 2026-05-29 21:52:59 +05:30
Harsh Shandilya 6d932c84d5 feat: revert comments UI back to current stable for hotfix release
This will be reverted back after the release

Revert "feat: re-do comment UI to look like Boost for Reddit"

This reverts commit 03e0a13e1d.

Revert "build: add compose screenshot testing support"

This reverts commit 5992a5fa15.

Revert "feat: rework comment entry layout and enable upvote action"

This reverts commit 4904c76220.

Revert "feat: re-do comment upvote UI"

This reverts commit c32ed849d2.
2026-05-29 21:52:59 +05:30
Harsh Shandilya 03e0a13e1d feat: re-do comment UI to look like Boost for Reddit 2026-05-29 19:51:05 +05:30
Harsh Shandilya 5992a5fa15 build: add compose screenshot testing support 2026-05-29 19:44:16 +05:30
renovate[bot]andGitHub 6b84d5277b chore(deps): update dependency com.facebook:ktfmt to v0.63 2026-05-28 21:04:01 +00:00
renovate[bot]andGitHub f41c36c24c fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.9.0 2026-05-28 14:40:29 +00:00
renovate[bot]andGitHub 9b4449c890 chore(deps): update plugin dependencyanalysis to v3.14.0 2026-05-28 06:08:21 +00:00
Harsh Shandilya 4904c76220 feat: rework comment entry layout and enable upvote action 2026-05-28 00:12:13 +05:30
Harsh Shandilya c5a8dea7d2 Merge remote-tracking branches 'origin/renovate/com.diffplug.spotless-spotless-plugin-gradle-8.x' and 'origin/renovate/sentry'
* origin/renovate/com.diffplug.spotless-spotless-plugin-gradle-8.x:
  fix(deps): update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.6.0

* origin/renovate/sentry:
  fix(deps): update sentry to v8.43.0
2026-05-27 23:18:05 +05:30
renovate[bot]andGitHub 8930c0c5dc fix(deps): update sentry to v8.43.0 2026-05-27 17:47:05 +00:00
renovate[bot]andGitHub 5dc4afbf36 fix(deps): update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.6.0 2026-05-27 17:47:01 +00:00
renovate[bot]andHarsh Shandilya c3ec92582f fix(deps): update dependency com.slack.lint.compose:compose-lint-checks to v1.5.0 2026-05-27 23:15:00 +05:30
renovate[bot]andGitHub b8ee16be84 chore(deps): update plugin ksp to v2.3.9 2026-05-26 21:55:32 +00:00
Harsh Shandilya c32ed849d2 feat: re-do comment upvote UI 2026-05-27 00:33:46 +05:30
Harsh Shandilya 997b37ccb0 feat: switch expand/collapse UI to match Lobsters 2026-05-27 00:24:06 +05:30
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
976ed3b31a chore(deps): update plugin com.gradle.develocity to v4.4.2 (#1174)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| com.gradle.develocity | `4.4.1` → `4.4.2` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.gradle.develocity:com.gradle.develocity.gradle.plugin/4.4.2?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.gradle.develocity:com.gradle.develocity.gradle.plugin/4.4.1/4.4.2?slim=true)
|

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xOTQuMCIsInVwZGF0ZWRJblZlciI6IjQzLjE5NC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 15:16:49 +00:00
Harsh Shandilya 75863cdf94 refactor: move CommentEntry composable to CommentEntry.kt 2026-05-26 17:13:04 +05:30
GitHub Actions a3687d0b4c feat(release): start next development iteration 2026-05-26 08:56:03 +00:00
GitHub Actions 2b571be006 feat(release): bump version 2026-05-26 08:49:32 +00:00
renovate[bot]andGitHub 820744409e fix(deps): update aboutlibraries to v14.2.1 2026-05-26 03:45:25 +00:00
Harsh Shandilya 1b6ae68e0d Fix login session detection and comment timestamps
Only persist Lobsters login cookies after the WebView proves the
session is authenticated, and treat anonymous lobster_trap cookies as
logged out state. Also clear the WebView cookie jar on logout so the
embedded login flow cannot silently restore a prior session.

Simplify comment timestamps to match the current site markup by
tracking one timestamp plus an edited flag instead of fabricating a
creation time we no longer receive.
2026-05-23 13:04:38 +05:30
renovate[bot]andGitHub 8c67a874a5 chore(deps): update plugin dependencyanalysis to v3.13.0 2026-05-21 22:43:29 +00:00
renovate[bot]andGitHub 8296f58ad9 fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.8.1 2026-05-21 18:11:24 +00:00
renovate[bot]andGitHub 1cd40a1383 fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.8.0 2026-05-21 13:28:46 +00:00
Harsh Shandilya dce9dc33fe chore: refresh baseline profiles 2026-05-21 18:56:33 +05:30
Harsh Shandilya 12c98aa2c4 chore: fix some dependency health violations 2026-05-21 18:56:33 +05:30
renovate[bot]andGitHub bb05c46e57 chore(deps): update plugin dependencyanalysis to v3.12.2 2026-05-21 01:35:42 +00:00
renovate[bot]andGitHub 12f075a23d fix(deps): update sentry to v8.42.0 2026-05-20 20:58:51 +00:00
renovate[bot]andGitHub 5065889e27 chore(deps): update mindsers/changelog-reader-action action to v2.4.0 2026-05-20 18:48:06 +00:00
renovate[bot]andGitHub 39ea7c9c2e chore(deps): update plugin dependencyanalysis to v3.12.1 2026-05-20 14:31:57 +00:00
renovate[bot]andGitHub 6f972a8099 fix(deps): update dependency org.junit:junit-bom to v6.1.0 2026-05-19 22:17:27 +00:00
renovate[bot]andGitHub 4d8357c774 fix(deps): update androidx 2026-05-19 17:28:52 +00:00
renovate[bot]andGitHub c92be255d2 fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.7.1 2026-05-19 14:40:26 +00:00
renovate[bot]andGitHub 007c303272 fix(deps): update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.5.1 2026-05-16 01:37:53 +00:00
renovate[bot]andGitHub b922dfafee fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.7.0 2026-05-15 14:31:15 +00:00
renovate[bot]andGitHub 9fab85419c fix(deps): update dependency be.digitalia.compose.htmlconverter:htmlconverter to v1.1.1 2026-05-15 14:14:26 +00:00
renovate[bot]andGitHub 09bc8f8ff8 fix(deps): update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.5.0 2026-05-15 01:03:30 +00:00
renovate[bot]andGitHub e248796c49 chore(deps): update plugin dependencyanalysis to v3.12.0 2026-05-14 22:09:07 +00:00
renovate[bot]andGitHub 9297799549 fix(deps): update metro to v1.1.1 2026-05-14 04:47:25 +00:00
renovate[bot]andGitHub 26fdbe0bed chore(deps): update plugin ksp to v2.3.8 2026-05-13 17:04:35 +00:00
renovate[bot]andGitHub 4faf965851 chore(deps): update gradle to v9.5.1 2026-05-12 14:57:06 +00:00
renovate[bot]andGitHub 62016180b9 chore(deps): update plugin dependencyanalysis to v3.11.0 2026-05-12 04:47:11 +00:00
Harsh Shandilya 875b41d0bf fix: default comment count to 1 instead of 0 2026-05-12 08:53:33 +05:30
Harsh Shandilya 052cce6a05 chore: update changelog 2026-05-10 17:12:31 +05:30
Harsh ShandilyaandGitHub 54697ce4ca feat: migrate from unsupported JSON endpoints to parsing HTML (#1162) 2026-05-10 17:01:26 +05:30
Harsh Shandilya 378f140d1a fix(api): remove now unused test dependencies 2026-05-10 15:02:14 +05:30
Harsh Shandilya 00f0e27a0b feat(api): parse tags from html 2026-05-10 15:02:14 +05:30
Harsh Shandilya e21759e6f1 feat(api): parse user profiles from html 2026-05-10 15:01:47 +05:30
Harsh Shandilya 0d88a7cf78 feat(api): parse post details from html 2026-05-10 15:01:45 +05:30
Harsh Shandilya 87fe06a419 feat(api): parse post lists from html 2026-05-10 15:01:35 +05:30
Harsh Shandilya fcdc9db79c feat(api): add kspoon retrofit integration 2026-05-10 14:53:54 +05:30
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
943398bb67 fix(deps): update dependency com.fleeksoft.ksoup:ksoup to v0.2.6 (#1161)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[com.fleeksoft.ksoup:ksoup](https://redirect.github.com/fleeksoft/ksoup)
| `0.2.4` → `0.2.6` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.fleeksoft.ksoup:ksoup/0.2.6?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.fleeksoft.ksoup:ksoup/0.2.4/0.2.6?slim=true)
|

---

### Release Notes

<details>
<summary>fleeksoft/ksoup (com.fleeksoft.ksoup:ksoup)</summary>

###
[`v0.2.6`](https://redirect.github.com/fleeksoft/ksoup/releases/tag/0.2.6)

[Compare
Source](https://redirect.github.com/fleeksoft/ksoup/compare/0.2.5...0.2.6)

- Updated build + CI (Gradle **9.3.1**, workflow refresh, cleaner build
scripts)
- Dependency upgrades: Kotlin **2.3.10**, AGP **8.13.2**, Ktor
**3.4.0**, plus kotlinx/Okio/Dokka updates
- Improved KMP/JS exports (moved to platform-agnostic `@KmpJsExport`)
- Aligned code  with **jsoup 1.22.1**
- Better HTML/XML parsing reliability + expanded fuzz/integration test
coverage

#### New Contributors

- [@&#8203;peacefulprogram](https://redirect.github.com/peacefulprogram)
made their first contribution in
[#&#8203;124](https://redirect.github.com/fleeksoft/ksoup/pull/124)

**Full Changelog**:
<https://github.com/fleeksoft/ksoup/compare/0.2.5...0.2.6>

###
[`v0.2.5`](https://redirect.github.com/fleeksoft/ksoup/releases/tag/0.2.5)

[Compare
Source](https://redirect.github.com/fleeksoft/ksoup/compare/0.2.4...0.2.5)

#### 🛠️ Build & Dependencies

- Kotlin `2.2.0`, Ktor `3.2.2`, Okio `3.16.0`, kotlinx-io `0.8.0`
- Gradle Wrapper updated to `8.14.3`
- Removed deprecated: `kotlin.native.ignoreIncorrectDependencies`

#### 🔬 CI & Compatibility

- GitHub Actions matrix: Java `17`, `21`, `24`
- Expanded test coverage: unit, concurrency, edge cases

#### ⚙️ Enhancements

- Improved DOM performance with `childNodes` cache
- Refactored iterators, attribute, and parser logic
- Build cleanup and code simplification

#### 🐛 Bug Fixes

- Base URI resolution, text node whitespace, stream reader edge cases
- Maven publish ordering
-  Resolved **Bug
[#&#8203;122](https://redirect.github.com/fleeksoft/ksoup/issues/122)**

#### 📚 Documentation

- Deprecated `PseudoTextElement` → use `Element#selectNodes`

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNTkuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE1OS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 18:41:58 +00:00
Harsh Shandilya fc67bda7cb refactor: remove jsoup dependency 2026-05-09 23:56:40 +05:30
Harsh Shandilya 62a71a309b refactor(api): parse search results with ksoup 2026-05-09 23:56:29 +05:30
Harsh Shandilya 3b0a0e3289 refactor(api): parse reply forms with ksoup 2026-05-09 23:56:24 +05:30
Harsh Shandilya 1274897eac refactor(api): parse csrf token with ksoup 2026-05-09 23:56:19 +05:30
Harsh Shandilya 017f970f61 test(api): characterize html converters 2026-05-09 23:55:33 +05:30
Harsh Shandilya bda3c4c26c feat(common): add an ugly as sin test UI for upvote/unvote 2026-05-09 23:55:03 +05:30
Harsh Shandilya c14f529f5e refactor(api): ensure correct ordering for Retrofit converters 2026-05-09 23:55:01 +05:30
Harsh Shandilya 0ff78e5b00 test(api): cover authenticated Lobsters facade 2026-05-09 23:54:50 +05:30
Harsh Shandilya de966de4ec feat(api): add authenticated Lobsters facade 2026-05-09 23:54:43 +05:30
Harsh Shandilya 8a1a64d173 feat(api): add raw authenticated Lobsters endpoints 2026-05-09 18:42:12 +05:30
Harsh Shandilya d1e5df8bc4 feat(api): parse Lobsters reply form 2026-05-09 18:42:12 +05:30
renovate[bot]andGitHub d80ccfe284 chore(deps): update actions/dependency-review-action action to v5 2026-05-09 01:59:35 +00:00
renovate[bot]andGitHub c2cf007b34 fix(deps): update haze to v2.0.0-alpha02 2026-05-08 21:25:45 +00:00
renovate[bot]andGitHub 9cd9d3830a fix(deps): update kotlinx-coroutines monorepo to v1.11.0 2026-05-08 14:01:43 +00:00
renovate[bot]andGitHub 38052b359e fix(deps): update aboutlibraries to v14.2.0 2026-05-07 08:57:12 +00:00
renovate[bot]andGitHub 96151df57f fix(deps): update dependency com.slack.lint.compose:compose-lint-checks to v1.4.3 2026-05-07 01:35:51 +00:00
renovate[bot]andGitHub a141573442 fix(deps): update sentry to v8.41.0 2026-05-06 22:31:41 +00:00
renovate[bot]andGitHub ac537183fb fix(deps): update androidx 2026-05-06 17:16:29 +00:00
renovate[bot]andGitHub 9faac26d57 fix(deps): update agp to v9.2.1 2026-05-05 18:18:26 +00:00
renovate[bot]andGitHub 03baf5edc1 fix(deps): update aboutlibraries to v14.1.0 2026-05-01 19:03:01 +00:00
renovate[bot]andGitHub 2b8a245481 fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.6.0 2026-04-30 20:01:43 +00:00
renovate[bot]andGitHub 2d88ebafa3 chore(deps): update plugin dependencyanalysis to v3.10.0 2026-04-30 04:35:12 +00:00
Harsh Shandilya a1f0c79172 fix: bump Haze to 2.0.0-alpha01 2026-04-30 10:04:00 +05:30
renovate[bot]andGitHub 039c084967 chore(deps): update gradle to v9.5.0 2026-04-28 14:01:48 +00:00
renovate[bot]andGitHub a1ae8af8a8 fix(deps): update metro to v1.0.0 2026-04-28 05:31:30 +00:00
renovate[bot]andGitHub ad269f9edc fix(deps): update kotlinx-coroutines monorepo to v1.11.0-rc02 2026-04-27 17:31:09 +00:00
renovate[bot]andGitHub 7bf21ba23e fix(deps): update dependency org.junit:junit-bom to v6.1.0-rc1 2026-04-25 13:37:29 +00:00
renovate[bot]andGitHub 12226b48e8 chore(deps): update plugin poko to v0.22.1 2026-04-25 05:50:07 +00:00
renovate[bot]andGitHub 1d3ba7c0de fix(deps): update metro to v1.0.0-rc4 2026-04-25 00:46:14 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Harsh Shandilya
6e779cd438 fix(deps): update metro to v1.0.0-rc3 (#1148)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [dev.zacsweers.metro](https://redirect.github.com/ZacSweers/metro) |
`1.0.0-RC2` → `1.0.0-RC3` |
![age](https://developer.mend.io/api/mc/badges/age/maven/dev.zacsweers.metro:dev.zacsweers.metro.gradle.plugin/1.0.0-RC3?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/dev.zacsweers.metro:dev.zacsweers.metro.gradle.plugin/1.0.0-RC2/1.0.0-RC3?slim=true)
|
|
[dev.zacsweers.metro:metrox-viewmodel-compose](https://redirect.github.com/ZacSweers/metro)
| `1.0.0-RC2` → `1.0.0-RC3` |
![age](https://developer.mend.io/api/mc/badges/age/maven/dev.zacsweers.metro:metrox-viewmodel-compose/1.0.0-RC3?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/dev.zacsweers.metro:metrox-viewmodel-compose/1.0.0-RC2/1.0.0-RC3?slim=true)
|
|
[dev.zacsweers.metro:metrox-viewmodel](https://redirect.github.com/ZacSweers/metro)
| `1.0.0-RC2` → `1.0.0-RC3` |
![age](https://developer.mend.io/api/mc/badges/age/maven/dev.zacsweers.metro:metrox-viewmodel/1.0.0-RC3?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/dev.zacsweers.metro:metrox-viewmodel/1.0.0-RC2/1.0.0-RC3?slim=true)
|
|
[dev.zacsweers.metro:metrox-android](https://redirect.github.com/ZacSweers/metro)
| `1.0.0-RC2` → `1.0.0-RC3` |
![age](https://developer.mend.io/api/mc/badges/age/maven/dev.zacsweers.metro:metrox-android/1.0.0-RC3?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/dev.zacsweers.metro:metrox-android/1.0.0-RC2/1.0.0-RC3?slim=true)
|

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/266) for more information.

---

### Release Notes

<details>
<summary>ZacSweers/metro (dev.zacsweers.metro)</summary>

###
[`v1.0.0-RC3`](https://redirect.github.com/ZacSweers/metro/blob/HEAD/CHANGELOG.md#100-RC3)

[Compare
Source](https://redirect.github.com/ZacSweers/metro/compare/1.0.0-RC2...1.0.0-RC3)

*2026-04-23*

This is the third release candidate for Metro 1.0!

This means that its *runtime* APIs (`runtime`, `metrox` artifacts,
Gradle plugin, etc) will be API stable unless annotated with an
experimental annotation.

*Changes since RC2*

##### `enableFunctionProviders` enabled by default

This release promotes `enableFunctionProviders` (i.e. `() -> T` syntax
for providers) to stable, enables it by default, and marks usage of
Metro's native `Provider` as a warning.

This may require some migration in existing codebases. To help with
this, there's a [new section in
docs](https://zacsweers.github.io/metro/latest/adoption/#migrating-providert-to-function-syntax)
with information as well as a helper script: adoption docs guidance here
too.

##### New

- Add a new `desugaredProviderSeverity` option (default: `WARN`) that
reports a diagnostic when `Provider<T>` is used instead of the preferred
`() -> T` form. Set this to `NONE` to disable the warning during
migration, or `ERROR` to enforce the new style. Automatically treated as
`NONE` when `enableFunctionProviders` is disabled.
- **\[FIR]** Add diagnostic checks against providing intrinsic types
(`Provider`, `Lazy`, etc.) from `@Provides` declarations.
- **\[Gradle]** Introduce a new `compilerOptions {}` DSL for free Metro
compiler options and flags.
- **\[Gradle]** Add `IDE_WARN` and `IDE_ERROR` members to
`DiagnosticSeverity` to allow configuring some diagnostics to *only* run
in IDE sessions. Useful for diagnostics you only want to surface to
readers in the IDE without emitting compiler warnings in real (CLI)
compilations.

##### Enhancements

- **\[FIR]** Add a new diagnostic for ambiguous inject constructors.
Namely, cases where a class is annotated with `@Inject`, defines a
secondary constructor but no primary constructor. This is ambiguous,
metro now asks you to pick a lane.
- **\[FIR]** Add a new diagnostic for check against `private`
contributions.
- **\[FIR]** When rendering diagnostics in the IDE, use short names for
classes since they are shown in context already.
- **\[JVM/JS]** Generate `@JvmStatic` and `@JsStatic` annotations onto
static-ish functions for better staticization on those platforms.
- **\[docs]** Migrate doc site to Zensical. Works the same, fresh-ish
coat of paint!

##### Fixes

- **\[FIR]** Fix not recognizing `FirDeclarationOrigin.Precompiled`
origins when checking resolved default binding types. Previously we only
considered `FirDeclarationOrigin.Library`, but incremental compilation
uses `FirDeclarationOrigin.Precompiled` to differentiate. This would
result in misreads of default binding types in some cases during IC.
- **\[FIR]** When merging `@ContributesTo` types to graph supertypes,
add the original interface as well. This ensures they are visible in
ObjC exports, as the metro-generated contribution interfaces are
normally excluded.
- **\[IR]** Fix secondary inject constructors support when
`generateContributionProviders` is enabled.
- **\[IR]** Fix implicit class key lookup for map keys on
source-declared `@Binds` declarations.
- **\[IR]** Fix `implementsProviderType()` check in the compiler to only
exactly match `Function0` types when `enableFunctionProviders` is
enabled.
- **\[IR]** Set `thisGraphInstance` field types as the graph impl type
to avoid a Wasm issue.
- **\[interop]** Fix `@ContributesSubcomponent.Factory` interop with
square/anvil.
- **\[interop]** Fix `@MergeSubcomponent.Factory` interop with
zacsweers/anvil (anvil-ksp).
- **\[docs]** Fix source links in Dokka API docs.
- **\[docs]** Don't publish `**.internal.**` APIs in Dokka API docs.

##### Changes

- `enableFunctionProviders` (i.e. `() -> T` syntax for providers) is now
enabled by default. Previously this required opting in. The
function-syntax form is now the **recommended** way to declare provider
dependencies; `Provider<T>` is still supported but treated as a
desugared alternative and a **warning** by default, similar to if you
were to use `Function0` instead of `() -> T` syntax for functions. See
the [metro-intrinsics](docs/metro-intrinsics.md) docs for more details.
- **\[IR]** Remove deprecated `indexInOldValueParameters` use in IR for
better `2.4.0`+ support.
- **\[Gradle]** Promote `enableFunctionProviders` to stable.
- **\[Gradle]** Remove deprecated `useAssistedParamNamesAsIdentifiers`
property.
- **\[Gradle]** Remove `deduplicateInjectedParams` property.
- **\[Gradle]** Remove `enableKlibParamsCheck` property, use the new
`compilerOptions` API.
- **\[Gradle]** Remove `enableFullBindingGraphValidation` property, use
the new `compilerOptions` API.
- **\[Gradle]** Remove `enableGraphImplClassAsReturnType` property, use
the new `compilerOptions` API.
- **\[Gradle]** Remove `shrinkUnusedBindings` property, use the new
`compilerOptions` API.
- **\[metrox-android]** Change `MetroAppComponentProviders` accessor
multibindings to expose function types instead of `Provider` types.
- **\[metrox-viewmodel]** Change `MetroViewModelFactory` and
`MetroViewModelMultibindings` accessor multibindings to expose function
types instead of `Provider` types.
- Support Kotlin `2.4.0-Beta2`.
- Update embedded androidx.tracing to `2.0.0-alpha06`.

##### Contributors

Special thanks to the following contributors for contributing to this
release!

- [@&#8203;anddani](https://redirect.github.com/anddani)
- [@&#8203;jonamireh](https://redirect.github.com/jonamireh)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMzkuNyIsInVwZGF0ZWRJblZlciI6IjQzLjEzOS43IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
2026-04-23 18:12:19 +00:00
Harsh Shandilya 21ba2c3869 Merge remote-tracking branches 'origin/renovate/sentry' and 'origin/renovate/kotlin-monorepo'
* origin/renovate/sentry:
  fix(deps): update sentry

* origin/renovate/kotlin-monorepo:
  fix(deps): update kotlin monorepo to v2.3.21
2026-04-23 23:26:44 +05:30
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
61d8c4e4e0 fix(deps): update kotlin and compose to v2.3.21 (#1145)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [org.jetbrains.kotlin.plugin.serialization](https://kotlinlang.org/)
([source](https://redirect.github.com/JetBrains/kotlin)) | `2.3.20` →
`2.3.21` |
![age](https://developer.mend.io/api/mc/badges/age/maven/org.jetbrains.kotlin.plugin.serialization:org.jetbrains.kotlin.plugin.serialization.gradle.plugin/2.3.21?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.jetbrains.kotlin.plugin.serialization:org.jetbrains.kotlin.plugin.serialization.gradle.plugin/2.3.20/2.3.21?slim=true)
|
| [org.jetbrains.kotlin.plugin.compose](https://kotlinlang.org/)
([source](https://redirect.github.com/JetBrains/kotlin)) | `2.3.20` →
`2.3.21` |
![age](https://developer.mend.io/api/mc/badges/age/maven/org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin/2.3.21?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin/2.3.20/2.3.21?slim=true)
|
| [org.jetbrains.kotlin:kotlin-gradle-plugin](https://kotlinlang.org/)
([source](https://redirect.github.com/JetBrains/kotlin)) | `2.3.20` →
`2.3.21` |
![age](https://developer.mend.io/api/mc/badges/age/maven/org.jetbrains.kotlin:kotlin-gradle-plugin/2.3.21?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.jetbrains.kotlin:kotlin-gradle-plugin/2.3.20/2.3.21?slim=true)
|

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/266) for more information.

---

### Release Notes

<details>
<summary>JetBrains/kotlin
(org.jetbrains.kotlin.plugin.serialization)</summary>

###
[`v2.3.21`](https://redirect.github.com/JetBrains/kotlin/blob/HEAD/ChangeLog.md#2321)

##### Backend. Wasm

- [`KT-84610`](https://youtrack.jetbrains.com/issue/KT-84610) \[Wasm]
Failed to compile klibs in IC mode

##### Compiler

- [`KT-84566`](https://youtrack.jetbrains.com/issue/KT-84566) Prevent
launching Default dispatcher threads from IJ SDK in kotlin compiler
- [`KT-85358`](https://youtrack.jetbrains.com/issue/KT-85358) Native:
roll back the workaround for KT-84678 once MapLibre has been properly
fixed
- [`KT-85626`](https://youtrack.jetbrains.com/issue/KT-85626)
`@JvmRecord` in commonMain breaks compileCommonMainKotlinMetadata with
"Cannot access 'java.lang.Record'"
- [`KT-85405`](https://youtrack.jetbrains.com/issue/KT-85405)
Postpone/Revert `DontIgnoreUpperBoundViolatedOnImplicitArguments`
- [`KT-84678`](https://youtrack.jetbrains.com/issue/KT-84678) K/N:
Undefined symbol from SPM-added ObjC frameworks when linking iOS target
- [`KT-85021`](https://youtrack.jetbrains.com/issue/KT-85021) False
positive SUBCLASS\_CANT\_CALL\_COMPANION\_PROTECTED\_NON\_STATIC error
in multi-module project

##### JavaScript

- [`KT-82395`](https://youtrack.jetbrains.com/issue/KT-82395) Support
top-level declarations from compiler plugins in JS incremental
compilation
- [`KT-84475`](https://youtrack.jetbrains.com/issue/KT-84475) K/JS:
false-positive exportability warnings in multi-module project
- [`KT-84633`](https://youtrack.jetbrains.com/issue/KT-84633) Kotlin/JS:
"Serializer for class not found" error when IR output granularity is
`whole-program`
- [`KT-85047`](https://youtrack.jetbrains.com/issue/KT-85047) Kotlin/JS:
`@JsStatic` on suspend fun of class companion generates incorrect d.ts
- [`KT-84517`](https://youtrack.jetbrains.com/issue/KT-84517) K/JS: bad
mappings data in outputted Kotlin stdlib source map

##### Libraries

- [`KT-71848`](https://youtrack.jetbrains.com/issue/KT-71848)
Kotlinx.metadata: Add `CompilerPluginData` into Km API

##### Native. C and ObjC Import

- [`KT-85399`](https://youtrack.jetbrains.com/issue/KT-85399)
Kotlin/Native: TypeCastException when casting ObjC Protocol MetaClass
with genericSafeCasts enabled
- [`KT-85508`](https://youtrack.jetbrains.com/issue/KT-85508) K/N:
TypeCastException when using nw\_parameters\_create\_secure\_tcp block
parameter on 2.3.20

##### Tools. Gradle

- [`KT-84729`](https://youtrack.jetbrains.com/issue/KT-84729) Update
Gradle plugin-publish version to enable configuration cache badge on
Gradle plugins portal

##### Tools. Gradle. Compiler plugins

- [`KT-85257`](https://youtrack.jetbrains.com/issue/KT-85257)
AGP/Compose: MergeMappingFileTask clears R8 artifacts due to
`@OutputDirectory` annotation on AGP 9.1+

##### Tools. Scripts

- [`KT-85105`](https://youtrack.jetbrains.com/issue/KT-85105) Scripts:
JVM backend internal error (IR lowering) when scratch file contains
anonymous object
- [`KT-85103`](https://youtrack.jetbrains.com/issue/KT-85103) Exception
while generating code when explain destructuring decls
- [`KT-84842`](https://youtrack.jetbrains.com/issue/KT-84842)
scriptCompilationClasspathFromContext behavior changed from 2.3.10 to
2.3.20
- [`KT-85029`](https://youtrack.jetbrains.com/issue/KT-85029) Kotlin
Scripting: ScriptDiagnostic reports "at null" instead of error location

##### Tools. Statistics (FUS)

- [`KT-85628`](https://youtrack.jetbrains.com/issue/KT-85628) KGP:
composite build FUS metrics fail on access of 'configurationTimeMetrics'

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMzkuNyIsInVwZGF0ZWRJblZlciI6IjQzLjEzOS43IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 17:10:53 +00:00
renovate[bot]andGitHub ccdc75f7a3 fix(deps): update sentry 2026-04-23 16:58:32 +00:00
renovate[bot]andGitHub f1ae3c1a44 fix(deps): update kotlin monorepo to v2.3.21 2026-04-23 16:58:25 +00:00
renovate[bot]andGitHub aa32599a59 chore(deps): update plugin ksp to v2.3.7 2026-04-23 01:55:47 +00:00
renovate[bot]andGitHub a58edda23e fix(deps): update androidx 2026-04-22 20:32:10 +00:00
renovate[bot]andGitHub a833820be3 fix(deps): update agp to v9.2.0 2026-04-21 14:32:06 +00:00
renovate[bot]andGitHub 16cc3097b7 chore(deps): update r0adkll/upload-google-play action to v1.1.5 2026-04-21 02:41:06 +00:00
renovate[bot]andGitHub 49318b2766 chore(deps): update plugin dependencyanalysis to v3.9.0 2026-04-20 21:57:06 +00:00
renovate[bot]andGitHub c9a22df572 chore(deps): update r0adkll/upload-google-play action to v1.1.4 2026-04-20 19:43:40 +00:00
renovate[bot]andGitHub 13d368b86a chore(deps): update plugin com.gradle.develocity to v4.4.1 2026-04-20 13:13:55 +00:00
renovate[bot]andGitHub 4c14890170 fix(deps): update dependency org.jsoup:jsoup to v1.22.2 2026-04-20 08:40:49 +00:00
renovate[bot]andGitHub 43d1b51901 chore(deps): update plugin dependencyanalysis to v3.8.0 2026-04-19 09:09:49 +00:00
Harsh Shandilya 088fcf5a62 fix: bump dependencies 2026-04-18 21:26:19 +05:30
renovate[bot]andGitHub e0bb04ce4c fix(deps): update sentry to v8.39.1 2026-04-17 09:20:42 +00:00
renovate[bot]andGitHub b621fb0480 fix(deps): update sentry to v8.39.0 2026-04-16 20:36:33 +00:00
renovate[bot]andGitHub def7d1a727 fix(deps): update agp to v9.2.0-rc01 2026-04-13 17:03:34 +00:00
renovate[bot]andGitHub 24abd4171a chore(deps): update plugin modulegraphassert to v2.9.1 2026-04-12 14:00:50 +00:00
Harsh Shandilya 31be02c521 refactor: replace if with when 2026-04-12 14:25:49 +05:30
Harsh Shandilya 79f2b5d01c chore: rename ProGuard files to R8 2026-04-12 14:25:49 +05:30
Harsh Shandilya ae047d8a6d fix: remove obsoleted ProGuard rule 2026-04-12 14:10:13 +05:30
Harsh Shandilya 864b023302 refactor: remove useless twiddling of Main dispatcher 2026-04-12 14:01:59 +05:30
Harsh Shandilya 502eb751eb chore: add changelog entry for login 2026-04-12 13:55:59 +05:30
renovate[bot]andGitHub 210bc89346 chore(deps): update plugin dependencyanalysis to v3.7.0 (#1135) 2026-04-11 10:14:32 +05:30
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
9ecb0fa704 chore(deps): update actions/upload-artifact action to v7.0.1 (#1134)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
|
[actions/upload-artifact](https://redirect.github.com/actions/upload-artifact)
| action | patch | `v7.0.0` → `v7.0.1` |

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/266) for more information.

---

### Release Notes

<details>
<summary>actions/upload-artifact (actions/upload-artifact)</summary>

###
[`v7.0.1`](https://redirect.github.com/actions/upload-artifact/compare/v7.0.0...v7.0.1)

[Compare
Source](https://redirect.github.com/actions/upload-artifact/compare/v7.0.0...v7.0.1)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMTAuMiIsInVwZGF0ZWRJblZlciI6IjQzLjExMC4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-11 04:38:22 +00:00
Harsh Shandilya 635e33233b fix: move Lobsters URI creation out of class init
For some reason this is now too early and explodes
2026-04-11 08:17:54 +05:30
Harsh Shandilya 2cf53422bf fix: regenerate baseline profiles 2026-04-11 01:55:54 +05:30
Harsh Shandilya 80980af1c9 fix: move baseline profiles back to the emulators 2026-04-10 22:35:26 +05:30
Harsh Shandilya 3b5c4b3ddb fix(build): remove obsolete source set 2026-04-10 22:35:15 +05:30
Harsh Shandilya 84e36496d3 fix: downgrade Kotlin 2026-04-10 22:26:26 +05:30
Harsh Shandilya 3f714f41c3 fix: upgrade to latest everything 2026-04-10 21:44:31 +05:30
Harsh ShandilyaandGitHub e20c90297a fix: remove now-empty core-ktx library 2026-04-10 00:13:11 +05:30
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
0ac8c91789 fix(deps): update androidx (#1128)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[androidx.lint:lint-gradle](https://developer.android.com/jetpack/androidx/releases/lint#1.0.0-alpha06)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.0.0-alpha05` → `1.0.0-alpha06` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.lint:lint-gradle/1.0.0-alpha06?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.lint:lint-gradle/1.0.0-alpha05/1.0.0-alpha06?slim=true)
|
|
[androidx.sqlite:sqlite](https://developer.android.com/jetpack/androidx/releases/sqlite#2.6.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `2.7.0-alpha02` → `2.7.0-alpha03` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.sqlite:sqlite/2.7.0-alpha03?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.sqlite:sqlite/2.7.0-alpha02/2.7.0-alpha03?slim=true)
|
|
[androidx.compose.runtime:runtime-saveable](https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.runtime:runtime-saveable/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.runtime:runtime-saveable/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.paging:paging-compose](https://developer.android.com/jetpack/androidx/releases/paging#3.4.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `3.5.0-alpha01` → `3.5.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.paging:paging-compose/3.5.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.paging:paging-compose/3.5.0-alpha01/3.5.0-beta01?slim=true)
|
|
[androidx.paging:paging-common](https://developer.android.com/jetpack/androidx/releases/paging#3.4.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `3.5.0-alpha01` → `3.5.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.paging:paging-common/3.5.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.paging:paging-common/3.5.0-alpha01/3.5.0-beta01?slim=true)
|
|
[androidx.compose.material3.adaptive:adaptive-navigation3](https://developer.android.com/jetpack/androidx/releases/compose-material3-adaptive#1.3.0-alpha10)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.3.0-alpha09` → `1.3.0-alpha10` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.material3.adaptive:adaptive-navigation3/1.3.0-alpha10?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.material3.adaptive:adaptive-navigation3/1.3.0-alpha09/1.3.0-alpha10?slim=true)
|
|
[androidx.compose.material3.adaptive:adaptive-layout](https://developer.android.com/jetpack/androidx/releases/compose-material3-adaptive#1.2.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.3.0-alpha09` → `1.3.0-alpha10` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.material3.adaptive:adaptive-layout/1.3.0-alpha10?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.material3.adaptive:adaptive-layout/1.3.0-alpha09/1.3.0-alpha10?slim=true)
|
|
[androidx.compose.material3.adaptive:adaptive](https://developer.android.com/jetpack/androidx/releases/compose-material3-adaptive#1.2.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.3.0-alpha09` → `1.3.0-alpha10` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.material3.adaptive:adaptive/1.3.0-alpha10?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.material3.adaptive:adaptive/1.3.0-alpha09/1.3.0-alpha10?slim=true)
|
|
[androidx.navigation3:navigation3-ui](https://developer.android.com/jetpack/androidx/releases/navigation3#1.1.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.1.0-rc01` → `1.1.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.navigation3:navigation3-ui/1.1.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.navigation3:navigation3-ui/1.1.0-rc01/1.1.0?slim=true)
|
|
[androidx.navigation3:navigation3-runtime](https://developer.android.com/jetpack/androidx/releases/navigation3#1.1.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.1.0-rc01` → `1.1.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.navigation3:navigation3-runtime/1.1.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.navigation3:navigation3-runtime/1.1.0-rc01/1.1.0?slim=true)
|
|
[androidx.compose.ui:ui-unit](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-unit/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-unit/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.ui:ui-tooling-preview](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-tooling-preview/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-tooling-preview/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.ui:ui-tooling](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-tooling/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-tooling/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.ui:ui-text](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-text/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-text/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.ui:ui-graphics](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-graphics/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-graphics/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.ui:ui](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.runtime:runtime-annotation](https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.runtime:runtime-annotation/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.runtime:runtime-annotation/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.runtime:runtime](https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.runtime:runtime/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.runtime:runtime/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.material3:material3-window-size-class](https://developer.android.com/jetpack/androidx/releases/compose-material3#1.4.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.5.0-alpha16` → `1.5.0-alpha17` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.material3:material3-window-size-class/1.5.0-alpha17?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.material3:material3-window-size-class/1.5.0-alpha16/1.5.0-alpha17?slim=true)
|
|
[androidx.compose.material3:material3](https://developer.android.com/jetpack/androidx/releases/compose-material3#1.4.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.5.0-alpha16` → `1.5.0-alpha17` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.material3:material3/1.5.0-alpha17?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.material3:material3/1.5.0-alpha16/1.5.0-alpha17?slim=true)
|
|
[androidx.compose.foundation:foundation-layout](https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.foundation:foundation-layout/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.foundation:foundation-layout/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.foundation:foundation](https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.foundation:foundation/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.foundation:foundation/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.animation:animation-core](https://developer.android.com/jetpack/androidx/releases/compose-animation#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.animation:animation-core/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.animation:animation-core/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.animation:animation](https://developer.android.com/jetpack/androidx/releases/compose-animation#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.animation:animation/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.animation:animation/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.annotation:annotation](https://developer.android.com/jetpack/androidx/releases/annotation#1.10.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.10.0-rc01` → `1.10.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.annotation:annotation/1.10.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.annotation:annotation/1.10.0-rc01/1.10.0?slim=true)
|

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/266) for more information.

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMTAuMiIsInVwZGF0ZWRJblZlciI6IjQzLjExMC4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-09 12:16:38 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>msfjarvisHarsh Shandilya
d74b0c3f40 Centralize Android SDK version constants and wire benchmark through build-logic (#1129)
`benchmark` was hardcoding `compileSdk` because it applies
`com.android.test` + `kotlin-android` — neither of which pulls in
`AndroidCommonPlugin`. The SDK values were also duplicated across two
plugin files with no shared constant.

## Changes

- **New `AndroidConstants.kt`** — single source of truth for
`COMPILE_SDK = 37` and `TARGET_SDK = 37`
- **`AndroidCommonPlugin`** — replace literal `37` with `COMPILE_SDK`
- **`ApplicationPlugin`** — replace literal `37` with `TARGET_SDK`
- **`benchmark/build.gradle.kts`** — apply
`dev.msfjarvis.claw.android-common` so `compileSdk` is set by the
plugin; drop the hardcoded value. `TestExtension` implements
`CommonExtension`, so the plugin's `configure<CommonExtension>` block
applies cleanly.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: msfjarvis <13348378+msfjarvis@users.noreply.github.com>
Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
2026-04-09 12:05:14 +00:00
renovate[bot]andGitHub 7040518fc7 fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.4.0 2026-04-09 11:34:17 +00:00
renovate[bot]andGitHub e27a3582f8 fix(deps): update sentry to v8.38.0 2026-04-08 19:31:23 +00:00
renovate[bot]andGitHub f4b3f936ae fix(deps): update metro to v0.13.2 2026-04-07 01:29:44 +00:00
renovate[bot]andGitHub 199ddc9ca8 fix(deps): update aboutlibraries to v14.0.0 2026-04-06 20:39:38 +00:00
renovate[bot]andGitHub 792b8b343b fix(deps): update metro to v0.13.1 2026-04-06 08:50:46 +00:00
renovate[bot]andGitHub b10c87c33d fix(deps): update metro to v0.13.0 2026-04-05 05:17:12 +00:00
renovate[bot]andGitHub b6ed8e914e chore(deps): update gradle/actions digest to 50e97c2 2026-04-03 16:40:31 +00:00
Harsh Shandilya 3c6145c1f3 chore: refresh Lint baseline 2026-04-03 22:01:41 +05:30
Harsh Shandilya e1ca964185 feat(android): wire Login destination into nav graph and Settings 2026-04-03 21:52:39 +05:30
Harsh Shandilya 2e84ada707 feat(android): add login/logout rows to SettingsScreen 2026-04-03 21:52:39 +05:30
Harsh Shandilya 758f88e02f feat(android): add SettingsViewModel with login state 2026-04-03 21:52:39 +05:30
Harsh Shandilya eef58ab267 feat(android): add LoginScreen WebView composable 2026-04-03 21:52:39 +05:30
Harsh Shandilya 9d34cf6c2e feat(android): add Login nav destination 2026-04-03 21:52:38 +05:30
Harsh Shandilya bfb402d4ae feat(core): add SessionExpiryInterceptor to handle expired sessions 2026-04-03 21:52:38 +05:30
Harsh Shandilya 66c4dc78f4 feat(core): add LobstersCookieJar and wire into OkHttpModule 2026-04-03 21:52:38 +05:30
Harsh Shandilya 34e4836f2d feat(common): implement SessionCookieStore backed by SQLDelight 2026-04-03 21:52:38 +05:30
Harsh Shandilya c77d10942f feat(database): add SessionCookie table and queries 2026-04-03 21:52:38 +05:30
Harsh Shandilya 9c6c5e82cb feat(core): add SessionCookieStore interface 2026-04-03 21:45:52 +05:30
Harsh Shandilya dac23176ff chore: document tag migration removal 2026-04-03 18:15:26 +05:30
Harsh Shandilya 5c8174879f chore: ignore local Git worktrees 2026-04-03 18:15:26 +05:30
Harsh Shandilya 30a5bcc687 refactor(test): remove unnecessary advanceUntilIdle calls 2026-04-03 18:15:26 +05:30
Harsh Shandilya 30293d29f6 fix(build): remove datastore dependencies 2026-04-03 01:06:10 +05:30
Harsh Shandilya 0269a284ef refactor(test): use deterministic tag block dispatchers 2026-04-03 01:06:10 +05:30
Harsh Shandilya 19103c19b9 refactor(test): cover reactive tag block flows 2026-04-03 01:06:10 +05:30
Harsh Shandilya 96f2c78a4e refactor: remove tag datastore migration 2026-04-03 01:06:10 +05:30
GitHub Actions 4e44758c39 feat(release): start next development iteration 2026-04-02 09:29:53 +00:00
GitHub Actions a54940f47a feat(release): bump version 2026-04-02 09:24:42 +00:00
renovate[bot]andGitHub e61bb6bcd8 fix(deps): update metro to v0.12.1 2026-03-30 20:49:51 +00:00
Harsh Shandilya 25909e9949 fix(ci): stop the infinite loop 2026-03-27 23:37:59 +05:30
renovate[bot]andGitHub 6f5eecb694 chore(deps): update msfjarvis/compose-lobsters digest to 92aae74 2026-03-27 05:38:19 +00:00
renovate[bot]andGitHub 92aae743ab fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.3.0 2026-03-26 20:32:30 +00:00
renovate[bot]andGitHub bd1d8dd8a0 chore(deps): pin msfjarvis/compose-lobsters action to f3fb95d 2026-03-26 18:53:01 +00:00
renovate[bot]andGitHub f3fb95dc5f fix(deps): update sentry to v8.37.1 2026-03-26 13:43:43 +00:00
renovate[bot]andGitHub dc98d29fde fix(deps): update konvert to v4.5.0 2026-03-26 04:36:38 +00:00
renovate[bot]andGitHub 4234fda686 chore(deps): update plugin com.gradle.develocity to v4.4.0 2026-03-25 21:37:42 +00:00
renovate[bot]andGitHub f9122cf0b6 fix(deps): update androidx 2026-03-25 18:15:21 +00:00
renovate[bot]andGitHub 4c6c7f4995 fix(deps): update sentry to v8.37.0 2026-03-25 12:58:06 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Harsh Shandilya
ba59ba9015 fix(deps): update metro to v0.12.0 (#1116)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [dev.zacsweers.metro](https://redirect.github.com/ZacSweers/metro) |
`0.11.4` → `0.12.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/dev.zacsweers.metro:dev.zacsweers.metro.gradle.plugin/0.12.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/dev.zacsweers.metro:dev.zacsweers.metro.gradle.plugin/0.11.4/0.12.0?slim=true)
|
|
[dev.zacsweers.metro:metrox-viewmodel-compose](https://redirect.github.com/ZacSweers/metro)
| `0.11.4` → `0.12.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/dev.zacsweers.metro:metrox-viewmodel-compose/0.12.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/dev.zacsweers.metro:metrox-viewmodel-compose/0.11.4/0.12.0?slim=true)
|
|
[dev.zacsweers.metro:metrox-viewmodel](https://redirect.github.com/ZacSweers/metro)
| `0.11.4` → `0.12.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/dev.zacsweers.metro:metrox-viewmodel/0.12.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/dev.zacsweers.metro:metrox-viewmodel/0.11.4/0.12.0?slim=true)
|
|
[dev.zacsweers.metro:metrox-android](https://redirect.github.com/ZacSweers/metro)
| `0.11.4` → `0.12.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/dev.zacsweers.metro:metrox-android/0.12.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/dev.zacsweers.metro:metrox-android/0.11.4/0.12.0?slim=true)
|

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/266) for more information.

---

### Release Notes

<details>
<summary>ZacSweers/metro (dev.zacsweers.metro)</summary>

###
[`v0.12.0`](https://redirect.github.com/ZacSweers/metro/blob/HEAD/CHANGELOG.md#0120)

[Compare
Source](https://redirect.github.com/ZacSweers/metro/compare/0.11.4...0.12.0)

*2026-03-24*

##### New

#####
[**\[MEEP-2014\]**](https://redirect.github.com/ZacSweers/metro/discussions/2014)
Implicit class (map) keys

`MapKey.implicitClassKey` is a new API to allow for class-based map keys
to have their class parameters inferred on classes and `@Binds`
declarations.

This means that instead of redeclaring the annotated class in the key,
for example `@ViewModelKey`, you can now omit it and it will be
inferred.

```kotlin
@&#8203;ViewModelKey // <-- implicitly HomeViewModel::class
@&#8203;ContributesIntoMap(AppScope::class)
class HomeViewModel : ViewModel()
```

For classes, the implicit type is the annotated class. For `@Binds`
declarations, the receiver or single parameter are the implicit type.

You may still specify an explicit type. The compiler will warn you if
you specify a redundant one. If you need to suppress this diagnostic
temporarily to ease migration, you can add
`-Xwarning-level=MAP_KEY_REDUNDANT_IMPLICIT_CLASS_KEY:disabled` to your
compiler arguments.

The compiler will also error if you attempt to do this on `@Provides`
declarations, as those cannot be inferred.

Metro's first-party class-based map keys (like `@ClassKey`,
`@ViewModelKey`, etc.) now support this. Custom map keys can opt-in to
this by setting `MapKey.implicitClassKey` to true. See its doc for more
details.

```kotlin
@&#8203;MapKey(implicitClassKey = true)
annotation class ViewModelKey(val value: KClass<out ViewModel> = Nothing::class)
```

##### Misc

- **\[metrox-viewmodel]** Add `mingwX64` target.

##### Enhancements

- **\[FIR]** Add diagnostic to ensure map key annotations support
`FUNCTION` targets if they have a `@Target` annotation.
- **\[FIR]** Improve annotation argument matching to only use fully
resolved names or none at all. This helps avoid situations in the past
with interop where an argument at the same index and type but different
name could incorrectly be used.

##### Fixes

- **\[IR]** Fix `IllegalArgumentException` thrown when there are
multiple top-level functions with the same name but only one is
annotated with `@Inject`.
- **\[IR]** Only store a given binding container's own provider
factories in metro metadata. This resolves a bug where we could end up
duplicate-processing upstream providers in dynamic factories.
- **\[IR]** Fix a severity conversion compat function call for Kotlin
2.3.20+.
- **\[IR]** Ensure stable sort of output `SuspiciousUnusedMultibinding`
locations.
- **\[IR]** Don't skip dynamic keys inherited from parent graphs when
working with dynamic graphs.
- **\[IR]** Propagate `@OptionalBinding` annotations to generated static
factory creators if present.
- **\[IR]** Preserve nullability when remapping parameters with generic
layers.
- **\[Runtime]** `IntoSet` and `IntoMap` no longer have a `Target` of
`AnnotationTarget.CLASS`

##### Changes

- The Metro compiler now requires JVM 21+. Note that the runtime JVM
artifacts still target 11 unless otherwise documented.
- The Metro Gradle plugin now requires JVM 21+.
- The Metro Gradle plugin now requires Gradle 9+. Note that if you do
not use Kotlin Gradle DSL, it may work on older versions but YMMV.
- The Metro Gradle plugin now targets Kotlin `2.2`.
- `@Assisted.value` is formally deprecated now. See the
[docs](https://zacsweers.github.io/metro/latest/injection-types/#assisted-injection)
on why in case you missed this! TL;DR, Metro matches by parameter names
going forward.
- Metro's main branch now builds with Kotlin `2.3.20` but still targets
Kotlin 2.2 for its runtime artifacts and supports 2.2.20 all to 2.4.0
dev builds in its compiler.
- Remove deprecated `macosX64`, `tvosX64`, and `watchosX64` targets.
- Update Kotlin 2.4 compat support from `2.4.0-dev-539` to
`2.4.0-dev-2124`. This should support the upcoming IntelliJ 2026.1
release as well as the upcoming Kotlin `2.4.0-Beta1`.
- Test IntelliJ `2026.1 RC`.
- Update shaded Wire dependency to `6.1.0`.

##### Contributors

Special thanks to the following contributors for contributing to this
release!

- [@&#8203;Asapha](https://redirect.github.com/Asapha)
-
[@&#8203;ChristianKatzmann](https://redirect.github.com/ChristianKatzmann)
- [@&#8203;grandstaish](https://redirect.github.com/grandstaish)
- [@&#8203;jonamireh](https://redirect.github.com/jonamireh)
- [@&#8203;kevinguitar](https://redirect.github.com/kevinguitar)
- [@&#8203;svenjacobs](https://redirect.github.com/svenjacobs)
- [@&#8203;vRallev](https://redirect.github.com/vRallev)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My42Ni40IiwidXBkYXRlZEluVmVyIjoiNDMuNjYuNCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
2026-03-25 05:58:37 +00:00
renovate[bot]andGitHub 4d4bbb440e chore(deps): update gradle/actions digest to 39e147c 2026-03-24 18:08:31 +00:00
renovate[bot]andGitHub f0ed3871df chore(deps): update gradle/actions action to v6 2026-03-23 18:09:03 +00:00
Harsh Shandilya d5170b201f fixup! fix(comments): refactor state handling to not mark first-visit as unread 2026-03-23 14:26:38 +05:30
Harsh Shandilya 77c635b824 chore: update changelog 2026-03-23 14:23:01 +05:30
Harsh Shandilya 83db9bb8a8 fix(comments): refactor state handling to not mark first-visit as unread 2026-03-23 14:18:26 +05:30
Charles LombardoandHarsh Shandilya 6512567990 LobstersCard: Use M3 button for save and comment
The previous buttons were difficult to hit on a device that used gesture navigation. This adds more horizontal and vertical padding to the card and uses the M3 button with a larger clickable region to make this more accessible.

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
2026-03-22 01:21:19 +05:30
Charles LombardoandHarsh Shandilya f244d35f74 LobstersCard: Increase max title lines to 2
I've frequently had to tap the comments button to read the full title of a post even though it could fit in a card within 2 lines. This prevents that issue in most cases.
2026-03-21 21:50:19 +05:30
renovate[bot]andGitHub 4b948cb7f4 chore(deps): update gradle to v9.4.1 2026-03-19 14:04:37 +00:00
renovate[bot]andGitHub 54455cc4c4 fix(deps): update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.4.0 2026-03-18 18:12:25 +00:00
renovate[bot]andGitHub a4828040a8 fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.2.0 2026-03-18 13:20:53 +00:00
renovate[bot]andHarsh Shandilya 349f1fbc27 chore(deps): update dependency com.facebook:ktfmt to v0.62 2026-03-18 02:41:53 +05:30
renovate[bot]andGitHub 6ac339f56b fix(deps): update sentry to v8.36.0 2026-03-17 20:50:56 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
e8cc694950 fix(deps): update aboutlibraries to v14.0.0-b03 (#1108)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| com.mikepenz.aboutlibraries.plugin.android | `14.0.0-b02` →
`14.0.0-b03` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.mikepenz.aboutlibraries.plugin.android:com.mikepenz.aboutlibraries.plugin.android.gradle.plugin/14.0.0-b03?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.mikepenz.aboutlibraries.plugin.android:com.mikepenz.aboutlibraries.plugin.android.gradle.plugin/14.0.0-b02/14.0.0-b03?slim=true)
|
|
[com.mikepenz:aboutlibraries-compose-m3](https://redirect.github.com/mikepenz/AboutLibraries)
| `14.0.0-b02` → `14.0.0-b03` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.mikepenz:aboutlibraries-compose-m3/14.0.0-b03?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.mikepenz:aboutlibraries-compose-m3/14.0.0-b02/14.0.0-b03?slim=true)
|
|
[com.mikepenz:aboutlibraries-core](https://redirect.github.com/mikepenz/AboutLibraries)
| `14.0.0-b02` → `14.0.0-b03` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.mikepenz:aboutlibraries-core/14.0.0-b03?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.mikepenz:aboutlibraries-core/14.0.0-b02/14.0.0-b03?slim=true)
|
|
[com.mikepenz:aboutlibraries-compose-core](https://redirect.github.com/mikepenz/AboutLibraries)
| `14.0.0-b02` → `14.0.0-b03` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.mikepenz:aboutlibraries-compose-core/14.0.0-b03?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.mikepenz:aboutlibraries-compose-core/14.0.0-b02/14.0.0-b03?slim=true)
|

---

### Release Notes

<details>
<summary>mikepenz/AboutLibraries
(com.mikepenz:aboutlibraries-compose-m3)</summary>

###
[`v14.0.0-b03`](https://redirect.github.com/mikepenz/AboutLibraries/releases/tag/14.0.0-b03)

[Compare
Source](https://redirect.github.com/mikepenz/AboutLibraries/compare/14.0.0-b02...14.0.0-b03)

##### 🚀 Features

- Migrate to ArtifactView API
- PR:
[#&#8203;1297](https://redirect.github.com/mikepenz/AboutLibraries/issues/1297)

##### 🧪 Tests

- Add Paparazzi preview tests and update dependencies
- PR:
[#&#8203;1298](https://redirect.github.com/mikepenz/AboutLibraries/issues/1298)

##### 💬 Other

- docs: add section about AGP 9.0 to README
- PR:
[#&#8203;1295](https://redirect.github.com/mikepenz/AboutLibraries/issues/1295)
- Bump actions/upload-artifact from 6 to 7
- PR:
[#&#8203;1307](https://redirect.github.com/mikepenz/AboutLibraries/issues/1307)
- docs: add description resource strings to keep file guidance
- PR:
[#&#8203;1308](https://redirect.github.com/mikepenz/AboutLibraries/issues/1308)
- Bump mikepenz/action-gh-release from 1 to 2
- PR:
[#&#8203;1309](https://redirect.github.com/mikepenz/AboutLibraries/issues/1309)

##### Contributors:

- [@&#8203;svenjacobs](https://redirect.github.com/svenjacobs),
[@&#8203;mikepenz](https://redirect.github.com/mikepenz),
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot],
[@&#8203;jlee129589](https://redirect.github.com/jlee129589)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My42Ni40IiwidXBkYXRlZEluVmVyIjoiNDMuNjYuNCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 18:32:05 +00:00
Harsh Shandilya e3615795c0 fix: update dependencies to latest alphas 2026-03-17 23:49:23 +05:30
renovate[bot]andGitHub 43a2c2f4b3 chore(deps): update plugin poko to v0.22.0 2026-03-17 05:35:33 +00:00
renovate[bot]andGitHub a9f0569944 fix(deps): update sqldelight to v2.3.2 2026-03-17 00:59:07 +00:00
renovate[bot]andGitHub 5901b22fef fix(deps): update metro to v0.11.3 2026-03-16 22:04:39 +00:00
Harsh Shandilya 36213ad0a9 fixup! fix(deps): update kotlin and compose to v2.3.20 2026-03-16 20:58:12 +05:30
renovate[bot]andGitHub a10fe200e6 fix(deps): update kotlin and compose to v2.3.20 2026-03-16 10:01:50 +00:00
renovate[bot]andGitHub f6bfca6bbb fix(deps): update kotlinresult to v2.3.1 2026-03-14 14:30:41 +00:00
Harsh Shandilya 94b680192a chore: refresh feature graphics 2026-03-13 23:27:02 +05:30
renovate[bot]andGitHub a8dc8d3991 fix(deps): update sqldelight to v2.3.1 2026-03-13 04:49:06 +00:00
renovate[bot]andGitHub 46e55a5c26 fix(deps): update kotlinresult to v2.3.0 2026-03-13 00:42:08 +00:00
renovate[bot]andGitHub 820e422cfe fix(deps): update sentry to v8.35.0 2026-03-12 12:52:13 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
cc4c022e4e fix(deps): update kotlinresult to v2.2.0 (#1100)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[com.michael-bull.kotlin-result:kotlin-result-coroutines](https://redirect.github.com/michaelbull/kotlin-result)
| `2.1.0` → `2.2.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.michael-bull.kotlin-result:kotlin-result-coroutines/2.2.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.michael-bull.kotlin-result:kotlin-result-coroutines/2.1.0/2.2.0?slim=true)
|
|
[com.michael-bull.kotlin-result:kotlin-result](https://redirect.github.com/michaelbull/kotlin-result)
| `2.1.0` → `2.2.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.michael-bull.kotlin-result:kotlin-result/2.2.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.michael-bull.kotlin-result:kotlin-result/2.1.0/2.2.0?slim=true)
|

---

### Release Notes

<details>
<summary>michaelbull/kotlin-result
(com.michael-bull.kotlin-result:kotlin-result-coroutines)</summary>

###
[`v2.2.0`](https://redirect.github.com/michaelbull/kotlin-result/releases/tag/2.2.0)

[Compare
Source](https://redirect.github.com/michaelbull/kotlin-result/compare/2.1.0...2.2.0)

##### Fallible Operations (`try*`)

Fallible collection operations now use a `try` prefix, inspired by
[Gleam's naming
convention](https://hexdocs.pm/gleam_stdlib/gleam/list.html#try_map) and
mirroring stdlib's collection API more closely. Several new operations
have also been added.

- Rename `mapResult*` functions to `tryMap*`, `mapAll` to `tryMap`,
`fold`/`foldRight` to `tryFold`/`tryFoldRight`
([`d052cf5`](https://redirect.github.com/michaelbull/kotlin-result/commit/d052cf5c4c2cad12f9bb04686d5ecbf82fbf8ec6))
- The previous names are deprecated with `@Deprecated` + `ReplaceWith`
for easy migration
- Add `tryFind`, `tryFindLast`, `tryForEach`, `tryForEachIndexed`,
`tryReduce`, `tryReduceIndexed` for `Iterable`
([`d2011f0`](https://redirect.github.com/michaelbull/kotlin-result/commit/d2011f025873286cc550cab4174a5280560a01cd))
- Add `tryFilter`, `tryAssociate`, `tryFlatMap`, `tryGroupBy`,
`tryPartition` + variants for `Iterable`
([`f32985e`](https://redirect.github.com/michaelbull/kotlin-result/commit/f32985e4c9bafa0f4356aaca710245889b5acc15))

##### Flow Extensions

The `kotlin-result-coroutines` module now includes extensions for the
`kotlinx-coroutines` `Flow` type, bringing the same Result-aware
operations available on `Iterable` to asynchronous streams. This was
born out of the discussion in
[#&#8203;78](https://redirect.github.com/michaelbull/kotlin-result/issues/78).

- Add `Flow` extensions for `kotlin-coroutines`
([`15d44a0`](https://redirect.github.com/michaelbull/kotlin-result/commit/15d44a0c86795de5aa1899d68db2f2991cd9324a))
  - Factory: `Result<Flow<V>, E>.toFlow()`
- Flow: `filterOk`, `filterErr`, `onEachOk`, `onEachErr`, `allOk`,
`allErr`, `anyOk`, `anyErr`, `countOk`, `countErr`, `partition`,
`combine`, `combineErr`
- Try: `tryFilter`, `tryFilterNot`, `tryMap`, `tryMapNotNull`,
`tryFlatMap`, `tryForEach`, `tryReduce`, `tryFold`, `tryFind`,
`tryFindLast`, `tryAssociate`, `tryAssociateBy`, `tryAssociateWith`,
`tryGroupBy`, `tryPartition`

##### Other

- Rename `onSuccess`/`onFailure` to `onOk`/`onErr`
([`a7c66c0`](https://redirect.github.com/michaelbull/kotlin-result/commit/a7c66c0d8750c2602c748007a32b613a4e3c3220))
- The new indexed variants (`onEachOkIndexed`, `onEachErrIndexed`)
proved that the `Success`/`Failure` naming becomes too verbose.
`Ok`/`Err` is more readable.
- Rename `filterValues`/`filterErrors` to `filterOk`/`filterErr`
([`87f4c36`](https://redirect.github.com/michaelbull/kotlin-result/commit/87f4c36364b849313644fb8465f6e9091983117d))
- Aligns naming with existing
`allOk`/`allErr`/`anyOk`/`anyErr`/`countOk`/`countErr` conventions
- Add `onEachOk`, `onEachOkIndexed`, `onEachErr`, `onEachErrIndexed` for
`Iterable<Result>`
([`a7c66c0`](https://redirect.github.com/michaelbull/kotlin-result/commit/a7c66c0d8750c2602c748007a32b613a4e3c3220))
- Add `combineErr` and `combineTo`/`combineErrTo` for `Iterable<Result>`
([`1133e00`](https://redirect.github.com/michaelbull/kotlin-result/commit/1133e00a8ac14d7d45a2c492f779c55ae5198f07),
[`c06eeb3`](https://redirect.github.com/michaelbull/kotlin-result/commit/c06eeb373f81f582da9c101629ba50afb6f558f3))
- Propagate nested `coroutineBinding` failures by
[@&#8203;dbottillo](https://redirect.github.com/dbottillo)
([`d952b52`](https://redirect.github.com/michaelbull/kotlin-result/commit/d952b52141608f653855883f1cc6274d121341a2))
- Closes
[#&#8203;128](https://redirect.github.com/michaelbull/kotlin-result/issues/128)
and
[#&#8203;133](https://redirect.github.com/michaelbull/kotlin-result/issues/133)
- Make `BindingException` public
([`549ed30`](https://redirect.github.com/michaelbull/kotlin-result/commit/549ed30d0a7bed36175c2f6abcc0d93c830c7642))
- Fixes `'internal' type accessed from public inline declaration`
warning that will become an error in Kotlin 2.4
- Update Kotlin to 2.3.10
([`12ceff7`](https://redirect.github.com/michaelbull/kotlin-result/commit/12ceff754cb5ace39720a45603ae5c0dacf87b67))
- Update Gradle to 9.4.0
([`468b1a5`](https://redirect.github.com/michaelbull/kotlin-result/commit/468b1a5509403dd759917b137793f4482ffb4588))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My41OS4wIiwidXBkYXRlZEluVmVyIjoiNDMuNTkuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-12 06:58:39 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Copilotmsfjarvis
70e7c305d0 fix(deps): update androidx (#1098)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[androidx.test.uiautomator:uiautomator](https://developer.android.com/jetpack/androidx/releases/test-uiautomator#2.3.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `2.4.0-beta01` → `2.4.0-beta02` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.test.uiautomator:uiautomator/2.4.0-beta02?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.test.uiautomator:uiautomator/2.4.0-beta01/2.4.0-beta02?slim=true)
|
|
[androidx.core:core-ktx](https://developer.android.com/jetpack/androidx/releases/core#1.18.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.17.0` → `1.18.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.core:core-ktx/1.18.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.core:core-ktx/1.17.0/1.18.0?slim=true)
|
|
[androidx.core:core](https://developer.android.com/jetpack/androidx/releases/core#1.18.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.17.0` → `1.18.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.core:core/1.18.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.core:core/1.17.0/1.18.0?slim=true)
|
|
[androidx.activity:activity-compose](https://developer.android.com/jetpack/androidx/releases/activity#1.13.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.12.4` → `1.13.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.activity:activity-compose/1.13.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.activity:activity-compose/1.12.4/1.13.0?slim=true)
|
|
[androidx.activity:activity](https://developer.android.com/jetpack/androidx/releases/activity#1.13.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.12.4` → `1.13.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.activity:activity/1.13.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.activity:activity/1.12.4/1.13.0?slim=true)
|
|
[androidx.compose.runtime:runtime-saveable](https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.runtime:runtime-saveable/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.runtime:runtime-saveable/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.paging:paging-compose](https://developer.android.com/jetpack/androidx/releases/paging#3.4.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `3.4.1` → `3.4.2` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.paging:paging-compose/3.4.2?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.paging:paging-compose/3.4.1/3.4.2?slim=true)
|
|
[androidx.paging:paging-common](https://developer.android.com/jetpack/androidx/releases/paging#3.4.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `3.4.1` → `3.4.2` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.paging:paging-common/3.4.2?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.paging:paging-common/3.4.1/3.4.2?slim=true)
|
|
[androidx.datastore:datastore-preferences-core](https://developer.android.com/jetpack/androidx/releases/datastore#1.2.1)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.2.0` → `1.2.1` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.datastore:datastore-preferences-core/1.2.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.datastore:datastore-preferences-core/1.2.0/1.2.1?slim=true)
|
|
[androidx.datastore:datastore-core](https://developer.android.com/jetpack/androidx/releases/datastore#1.2.1)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.2.0` → `1.2.1` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.datastore:datastore-core/1.2.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.datastore:datastore-core/1.2.0/1.2.1?slim=true)
|
|
[androidx.datastore:datastore-preferences](https://developer.android.com/jetpack/androidx/releases/datastore#1.2.1)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.2.0` → `1.2.1` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.datastore:datastore-preferences/1.2.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.datastore:datastore-preferences/1.2.0/1.2.1?slim=true)
|
|
[androidx.compose.ui:ui-unit](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-unit/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-unit/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.ui:ui-tooling-preview](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-tooling-preview/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-tooling-preview/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.ui:ui-tooling](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-tooling/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-tooling/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.ui:ui-text](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-text/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-text/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.ui:ui-graphics](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-graphics/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-graphics/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.ui:ui](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.baselineprofile](https://developer.android.com/jetpack/androidx/releases/benchmark#1.4.1)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.5.0-alpha03` → `1.5.0-alpha04` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.baselineprofile:androidx.baselineprofile.gradle.plugin/1.5.0-alpha04?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.baselineprofile:androidx.baselineprofile.gradle.plugin/1.5.0-alpha03/1.5.0-alpha04?slim=true)
|
|
[androidx.benchmark:benchmark-macro-junit4](https://developer.android.com/jetpack/androidx/releases/benchmark#1.4.1)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.5.0-alpha03` → `1.5.0-alpha04` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.benchmark:benchmark-macro-junit4/1.5.0-alpha04?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.benchmark:benchmark-macro-junit4/1.5.0-alpha03/1.5.0-alpha04?slim=true)
|
|
[androidx.benchmark:benchmark-macro](https://developer.android.com/jetpack/androidx/releases/benchmark#1.4.1)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.5.0-alpha03` → `1.5.0-alpha04` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.benchmark:benchmark-macro/1.5.0-alpha04?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.benchmark:benchmark-macro/1.5.0-alpha03/1.5.0-alpha04?slim=true)
|
|
[androidx.compose.runtime:runtime-annotation](https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.runtime:runtime-annotation/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.runtime:runtime-annotation/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.runtime:runtime](https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.runtime:runtime/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.runtime:runtime/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.foundation:foundation-layout](https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.foundation:foundation-layout/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.foundation:foundation-layout/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.foundation:foundation](https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.foundation:foundation/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.foundation:foundation/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.animation:animation-core](https://developer.android.com/jetpack/androidx/releases/compose-animation#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.animation:animation-core/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.animation:animation-core/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.animation:animation](https://developer.android.com/jetpack/androidx/releases/compose-animation#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.animation:animation/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.animation:animation/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.collection:collection](https://developer.android.com/jetpack/androidx/releases/collection#1.6.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.5.0` → `1.6.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.collection:collection/1.6.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.collection:collection/1.5.0/1.6.0?slim=true)
|

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My41OS4wIiwidXBkYXRlZEluVmVyIjoiNDMuNTkuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: msfjarvis <13348378+msfjarvis@users.noreply.github.com>
2026-03-11 20:46:56 +00:00
GitHub Actions e2242fce2e feat(release): start next development iteration 2026-03-10 05:26:48 +00:00
GitHub Actions 153867426a feat(release): bump version 2026-03-10 05:20:43 +00:00
Harsh Shandilya 68c3e0f58d chore: update changelog 2026-03-10 03:39:56 +05:30
Harsh Shandilya 7c9b93fc3a refactor: extract strings to be translatable 2026-03-10 03:38:00 +05:30
Harsh Shandilya c6cf566b13 fix(android): refresh baseline profiles 2026-03-09 15:42:18 +05:30
Harsh Shandilya 51c62bb441 fix(benchmark): give up on emulators
Trusty old Pixel 4a will have to do
2026-03-09 15:42:10 +05:30
Harsh Shandilya 389de2f9af chore: update Lint baseline 2026-03-09 13:18:49 +05:30
Harsh Shandilya 19a0f67d6d chore: update changelog 2026-03-09 13:17:40 +05:30
Harsh Shandilya 5bd3c3fbb9 perf(common): reduce UI jank in deeply nested comments by flattening comment tree
Replace the recursive logic that rendered individual Columns inside a LazyColumn
with a simpler implementation that just makes a flat list that LazyColumn can
directly render.
2026-03-09 10:46:26 +05:30
Harsh Shandilya 12d9b1e720 perf(api): optimize Jsoup parsing by streaming ResponseBody
`value.string()` parses the whole body which can be quite large, instead
use the ByteStream to do it in a more memory efficient manner.
2026-03-09 10:46:26 +05:30
Harsh Shandilya 944200cb42 fix(build): adjust maxParallelForks to avoid memory starvation
If this actually spawns the 48 forks on my machine it'll exhaust
memory way before it can even run anything.
2026-03-09 10:46:26 +05:30
Harsh Shandilya 4fa947ea18 fix(benchmark): only clear app data once before running tests 2026-03-09 10:46:26 +05:30
Harsh Shandilya 5f9ee4effb fix(common): prevent theoretical ClassCastException in Theme.kt
When applying EdgeToEdge window styling inside LobstersTheme, the code
was directly casting LocalView.current.context to Activity. This causes
a ClassCastException in scenarios where Compose wraps the context inside a
ContextWrapper (e.g. TintContextWrapper).
2026-03-09 10:46:26 +05:30
Harsh Shandilya 956afbd0c2 fix(common): flow state down properly for lazy list items
Hoisting isSaved and isRead out also has the nice downstream benefit
of making the different UI states easier to test as well as have them
actually reflect in the UI instead of needing to be faked by the local
saved state.
2026-03-09 10:46:26 +05:30
Harsh Shandilya de2366cf94 perf(android): optimize read/saved post lookups
These are guaranteed to only have unique items so we can make the hot lookups in UI code be
a Set's O(1) instead of a List's O(N).
2026-03-09 10:46:26 +05:30
renovate[bot]andGitHub 7dde15b65d fix(deps): update sentry to v8.34.1 2026-03-05 17:40:44 +00:00
Harsh ShandilyaandGitHub 173b5ef242 Revert "fix(deps): update sentry to v8.34.0" (#1092) 2026-03-05 15:06:52 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
89110596d0 fix(deps): update sentry to v8.34.0 (#1090)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[io.sentry:sentry-bom](https://redirect.github.com/getsentry/sentry-java)
| `8.33.0` → `8.34.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/io.sentry:sentry-bom/8.34.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.sentry:sentry-bom/8.33.0/8.34.0?slim=true)
|
|
[io.sentry:sentry-android-sqlite](https://redirect.github.com/getsentry/sentry-java)
| `8.33.0` → `8.34.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/io.sentry:sentry-android-sqlite/8.34.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.sentry:sentry-android-sqlite/8.33.0/8.34.0?slim=true)
|
|
[io.sentry:sentry-android-core](https://redirect.github.com/getsentry/sentry-java)
| `8.33.0` → `8.34.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/io.sentry:sentry-android-core/8.34.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.sentry:sentry-android-core/8.33.0/8.34.0?slim=true)
|
| [io.sentry:sentry](https://redirect.github.com/getsentry/sentry-java)
| `8.33.0` → `8.34.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/io.sentry:sentry/8.34.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.sentry:sentry/8.33.0/8.34.0?slim=true)
|

---

### Release Notes

<details>
<summary>getsentry/sentry-java (io.sentry:sentry-bom)</summary>

###
[`v8.34.0`](https://redirect.github.com/getsentry/sentry-java/blob/HEAD/CHANGELOG.md#8340)

[Compare
Source](https://redirect.github.com/getsentry/sentry-java/compare/8.33.0...8.34.0)

##### Features

- Add scope-level attributes API
([#&#8203;5118](https://redirect.github.com/getsentry/sentry-java/pull/5118))
via
([#&#8203;5148](https://redirect.github.com/getsentry/sentry-java/pull/5148))
- Automatically include scope attributes in logs and metrics
([#&#8203;5120](https://redirect.github.com/getsentry/sentry-java/pull/5120))
- New APIs are `Sentry.setAttribute`, `Sentry.setAttributes`,
`Sentry.removeAttribute`
- Support collections and arrays in attribute type inference
([#&#8203;5124](https://redirect.github.com/getsentry/sentry-java/pull/5124))
- Add support for `SENTRY_SAMPLE_RATE` environment variable /
`sample-rate` property
([#&#8203;5112](https://redirect.github.com/getsentry/sentry-java/pull/5112))
- Create `sentry-opentelemetry-otlp` and
`sentry-opentelemetry-otlp-spring` modules for combining OpenTelemetry
SDK OTLP export with Sentry SDK
([#&#8203;5100](https://redirect.github.com/getsentry/sentry-java/pull/5100))
- OpenTelemetry is configured to send spans to Sentry directly using an
OTLP endpoint.
- Sentry only uses trace and span ID from OpenTelemetry (via
`OpenTelemetryOtlpEventProcessor`) but will not send spans through
OpenTelemetry nor use OpenTelemetry `Context` for `Scopes` propagation.
- See the OTLP setup docs for
[Java](https://docs.sentry.io/platforms/java/opentelemetry/setup/otlp/)
and [Spring
Boot](https://docs.sentry.io/platforms/java/guides/spring-boot/opentelemetry/setup/otlp/)
for installation and configuration instructions.
- Add screenshot masking support using view hierarchy
([#&#8203;5077](https://redirect.github.com/getsentry/sentry-java/pull/5077))
- Masks sensitive content (text, images) in error screenshots using the
same view hierarchy approach as Session Replay
- Requires the `sentry-android-replay` module to be present at runtime
for masking to work
  - Enable via code:
    ```kotlin
    SentryAndroid.init(context) { options ->
        options.isAttachScreenshot = true
        options.screenshot.setMaskAllText(true)
        options.screenshot.setMaskAllImages(true)
        // Or mask specific view classes
        options.screenshot.addMaskViewClass("com.example.MyCustomView")
    }
    ```
  - Or via `AndroidManifest.xml`:
    ```xml
<meta-data android:name="io.sentry.attach-screenshot"
android:value="true" />
<meta-data android:name="io.sentry.screenshot.mask-all-text"
android:value="true" />
<meta-data android:name="io.sentry.screenshot.mask-all-images"
android:value="true" />
    ```
- The `ManifestMetaDataReader` now read the `DIST`
([#&#8203;5107](https://redirect.github.com/getsentry/sentry-java/pull/5107))

##### Fixes

- Fix attribute type detection for `Long`, `Short`, `Byte`,
`BigInteger`, `AtomicInteger`, and `AtomicLong` being incorrectly
inferred as `double` instead of `integer`
([#&#8203;5122](https://redirect.github.com/getsentry/sentry-java/pull/5122))
- Remove `AndroidRuntimeManager` StrictMode relaxation to prevent ANRs
during SDK init
([#&#8203;5127](https://redirect.github.com/getsentry/sentry-java/pull/5127))
- **IMPORTANT:** StrictMode violations may appear again in debug builds.
This is intentional to prevent ANRs in production releases.
- Fix crash when unregistering `SystemEventsBroadcastReceiver` with
try-catch block.
([#&#8203;5106](https://redirect.github.com/getsentry/sentry-java/pull/5106))
- Use `peekDecorView` instead of `getDecorView` in
`SentryGestureListener` to avoid forcing view hierarchy construction
([#&#8203;5134](https://redirect.github.com/getsentry/sentry-java/pull/5134))
- Log an actionable error message when Relay returns HTTP 413 (Content
Too Large)
([#&#8203;5115](https://redirect.github.com/getsentry/sentry-java/pull/5115))
- Also switch the client report discard reason for all HTTP 4xx/5xx
errors (except 429) from `network_error` to `send_error`
- Trim DSN string before parsing to avoid `URISyntaxException` caused by
trailing whitespace
([#&#8203;5113](https://redirect.github.com/getsentry/sentry-java/pull/5113))
- Reduce allocations and bytecode instructions during `Sentry.init`
([#&#8203;5135](https://redirect.github.com/getsentry/sentry-java/pull/5135))

##### Dependencies

- Bump Native SDK from v0.12.7 to v0.13.1
([#&#8203;5104](https://redirect.github.com/getsentry/sentry-java/pull/5104))
-
[changelog](https://redirect.github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0131)
-
[diff](https://redirect.github.com/getsentry/sentry-native/compare/0.12.7...0.13.1)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My40OC4xIiwidXBkYXRlZEluVmVyIjoiNDMuNDguMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-04 22:17:53 +00:00
renovate[bot]andGitHub fa15fc9fb2 chore(deps): update gradle to v9.4.0 2026-03-04 14:35:44 +00:00
renovate[bot]andGitHub dac3d505d0 fix(deps): update agp to v9.1.0 2026-03-03 16:52:01 +00:00
renovate[bot]andGitHub 2dbd8b0cb0 fix(deps): update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.3.0 2026-03-03 05:03:29 +00:00
renovate[bot]andGitHub c3d5a4aeb2 chore(deps): update plugin poko to v0.21.3 2026-03-03 01:41:09 +00:00
renovate[bot]andGitHub 1636adec9d fix(deps): update metro to v0.11.2 2026-03-02 09:58:08 +00:00
GitHub Actions 55c3f3165a feat(release): start next development iteration 2026-02-28 13:17:26 +00:00
GitHub Actions 11f232ba44 feat(release): bump version 2026-02-28 13:13:11 +00:00
renovate[bot]andGitHub c750c492ae chore(deps): update plugin poko to v0.21.2 2026-02-27 22:16:39 +00:00
renovate[bot]andGitHub 0ee2fd5963 chore(deps): update gradle to v9.4.0-rc-2 2026-02-27 14:00:45 +00:00
renovate[bot]andGitHub 6529567817 chore(deps): update actions/upload-artifact action to v7 2026-02-27 02:41:17 +00:00
renovate[bot]andGitHub 219b53c23d chore(deps): update plugin dependencyanalysis to v3.6.1 2026-02-26 21:29:11 +00:00
renovate[bot]andGitHub 1c2635e2fd chore(deps): update plugin dependencyanalysis to v3.6.0 2026-02-26 08:58:40 +00:00
renovate[bot]andGitHub 960e226f8f fix(deps): update metro to v0.11.1 2026-02-26 06:01:29 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
e1e691ff4a fix(deps): update androidx (#1083)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[androidx.compose.runtime:runtime-saveable](https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.10.4)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha05` → `1.11.0-alpha06` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.runtime:runtime-saveable/1.11.0-alpha06?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.runtime:runtime-saveable/1.11.0-alpha05/1.11.0-alpha06?slim=true)
|
|
[androidx.compose.material3.adaptive:adaptive-navigation3](https://developer.android.com/jetpack/androidx/releases/compose-material3-adaptive#1.3.0-alpha09)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.3.0-alpha08` → `1.3.0-alpha09` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.material3.adaptive:adaptive-navigation3/1.3.0-alpha09?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.material3.adaptive:adaptive-navigation3/1.3.0-alpha08/1.3.0-alpha09?slim=true)
|
|
[androidx.compose.material3.adaptive:adaptive-layout](https://developer.android.com/jetpack/androidx/releases/compose-material3-adaptive#1.2.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.3.0-alpha08` → `1.3.0-alpha09` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.material3.adaptive:adaptive-layout/1.3.0-alpha09?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.material3.adaptive:adaptive-layout/1.3.0-alpha08/1.3.0-alpha09?slim=true)
|
|
[androidx.compose.material3.adaptive:adaptive](https://developer.android.com/jetpack/androidx/releases/compose-material3-adaptive#1.2.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.3.0-alpha08` → `1.3.0-alpha09` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.material3.adaptive:adaptive/1.3.0-alpha09?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.material3.adaptive:adaptive/1.3.0-alpha08/1.3.0-alpha09?slim=true)
|
|
[androidx.compose.ui:ui-unit](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.4)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha05` → `1.11.0-alpha06` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-unit/1.11.0-alpha06?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-unit/1.11.0-alpha05/1.11.0-alpha06?slim=true)
|
|
[androidx.compose.ui:ui-tooling-preview](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.4)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha05` → `1.11.0-alpha06` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-tooling-preview/1.11.0-alpha06?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-tooling-preview/1.11.0-alpha05/1.11.0-alpha06?slim=true)
|
|
[androidx.compose.ui:ui-tooling](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.4)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha05` → `1.11.0-alpha06` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-tooling/1.11.0-alpha06?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-tooling/1.11.0-alpha05/1.11.0-alpha06?slim=true)
|
|
[androidx.compose.ui:ui-text](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.4)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha05` → `1.11.0-alpha06` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-text/1.11.0-alpha06?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-text/1.11.0-alpha05/1.11.0-alpha06?slim=true)
|
|
[androidx.compose.ui:ui-graphics](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.4)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha05` → `1.11.0-alpha06` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-graphics/1.11.0-alpha06?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-graphics/1.11.0-alpha05/1.11.0-alpha06?slim=true)
|
|
[androidx.compose.ui:ui](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.4)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha05` → `1.11.0-alpha06` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui/1.11.0-alpha06?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui/1.11.0-alpha05/1.11.0-alpha06?slim=true)
|
|
[androidx.compose.runtime:runtime-annotation](https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.10.4)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha05` → `1.11.0-alpha06` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.runtime:runtime-annotation/1.11.0-alpha06?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.runtime:runtime-annotation/1.11.0-alpha05/1.11.0-alpha06?slim=true)
|
|
[androidx.compose.runtime:runtime](https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.10.4)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha05` → `1.11.0-alpha06` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.runtime:runtime/1.11.0-alpha06?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.runtime:runtime/1.11.0-alpha05/1.11.0-alpha06?slim=true)
|
|
[androidx.compose.material3:material3-window-size-class](https://developer.android.com/jetpack/androidx/releases/compose-material3#1.4.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.5.0-alpha14` → `1.5.0-alpha15` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.material3:material3-window-size-class/1.5.0-alpha15?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.material3:material3-window-size-class/1.5.0-alpha14/1.5.0-alpha15?slim=true)
|
|
[androidx.compose.material3:material3](https://developer.android.com/jetpack/androidx/releases/compose-material3#1.4.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.5.0-alpha14` → `1.5.0-alpha15` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.material3:material3/1.5.0-alpha15?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.material3:material3/1.5.0-alpha14/1.5.0-alpha15?slim=true)
|
|
[androidx.compose.foundation:foundation-layout](https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.10.4)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha05` → `1.11.0-alpha06` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.foundation:foundation-layout/1.11.0-alpha06?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.foundation:foundation-layout/1.11.0-alpha05/1.11.0-alpha06?slim=true)
|
|
[androidx.compose.foundation:foundation](https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.10.4)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha05` → `1.11.0-alpha06` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.foundation:foundation/1.11.0-alpha06?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.foundation:foundation/1.11.0-alpha05/1.11.0-alpha06?slim=true)
|
|
[androidx.compose.animation:animation-core](https://developer.android.com/jetpack/androidx/releases/compose-animation#1.10.4)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha05` → `1.11.0-alpha06` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.animation:animation-core/1.11.0-alpha06?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.animation:animation-core/1.11.0-alpha05/1.11.0-alpha06?slim=true)
|
|
[androidx.compose.animation:animation](https://developer.android.com/jetpack/androidx/releases/compose-animation#1.10.4)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha05` → `1.11.0-alpha06` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.animation:animation/1.11.0-alpha06?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.animation:animation/1.11.0-alpha05/1.11.0-alpha06?slim=true)
|

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4zNi4yIiwidXBkYXRlZEluVmVyIjoiNDMuMzYuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 22:53:09 +00:00
Harsh Shandilya 3a2779f6b6 feat(android): replace uses of old TagFilterRepository with TagBlockRepository 2026-02-26 01:20:01 +05:30
Harsh Shandilya 9ba11422ab feat(common): add new tag blocking implementation
Ultimately I decided to just use SQLite instead of trying to shove
a JSON-formatted Map into a DataStore<Preferences> instance. A migration
has been included to move both the current stable tag implementation
as well as the newly added time-bound tags into the SQL database, a Sentry
metric will track the migrations and when the metric hits zero, I will
drop the DataStore dependency.
2026-02-26 01:20:01 +05:30
Harsh Shandilya 72219b7187 feat: add a table for tag blocks 2026-02-26 01:20:01 +05:30
Harsh Shandilya d022ebf475 chore: cleanup changelog 2026-02-25 18:40:45 +05:30
renovate[bot]andGitHub ec031da893 fix(deps): update metro to v0.11.0 2026-02-25 07:02:48 +00:00
renovate[bot]andGitHub e081f428e7 fix(deps): update coil3 to v3.4.0 2026-02-24 19:14:28 +00:00
Harsh Shandilya a3d8d5b4d3 feat: allow blocking tags for a specific amount of time 2026-02-24 21:55:47 +05:30
renovate[bot]andGitHub 37523588e1 chore(deps): update gradle/actions digest to 0723195 2026-02-24 02:03:04 +00:00
renovate[bot]andGitHub e5d15109ce fix(deps): update dependency nl.littlerobots.version-catalog-update:nl.littlerobots.version-catalog-update.gradle.plugin to v1.1.0 2026-02-21 13:30:16 +00:00
renovate[bot]andGitHub a9099fe25b fix(deps): update agp to v9.1.0-rc01 2026-02-19 21:08:55 +00:00
renovate[bot]andGitHub 9091e0b096 fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.1.0 2026-02-19 13:08:24 +00:00
renovate[bot]andGitHub 3089d8d4f6 fix(deps): update sentry to v8.33.0 2026-02-18 14:31:02 +00:00
renovate[bot]andGitHub 1e2008115b chore(deps): update plugin ksp to v2.3.6 2026-02-18 01:54:10 +00:00
Harsh Shandilya 62f51c7e91 fix(common): hoist comment handler to view model layer 2026-02-16 02:30:43 +05:30
Harsh Shandilya 54fffdbd72 chore: update changelog 2026-02-16 02:30:43 +05:30
renovate[bot]andGitHub a15f809d86 fix(deps): update dependency org.junit:junit-bom to v6.0.3 2026-02-15 14:27:20 +00:00
Harsh Shandilya 0da0cb56bb fix(common): make tag background more muted 2026-02-15 12:28:50 +05:30
GitHub Actions a1b3e2686b feat(release): start next development iteration 2026-02-14 17:41:13 +00:00
GitHub Actions 650285f2b9 feat(release): bump version 2026-02-14 17:37:22 +00:00
renovate[bot]andGitHub 9857883b52 fix(deps): update metro to v0.10.4 2026-02-14 02:29:48 +00:00
renovate[bot]andGitHub cc4f102efc fix(deps): update agp to v9.1.0-alpha09 2026-02-12 19:48:39 +00:00
renovate[bot]andGitHub 7b4d13071d fix(deps): update androidx 2026-02-11 20:31:57 +00:00
Harsh Shandilya e1a0738afb fix(android): guard against an empty backstack
Fixes COMPOSE-LOBSTERS-A1
Fixes COMPOSE-LOBSTERS-9Z
2026-02-12 00:04:01 +05:30
renovate[bot]andGitHub fcc29213d0 fix(deps): update dependency dev.chrisbanes.haze:haze to v1.7.2 2026-02-11 01:10:10 +00:00
renovate[bot]andGitHub bbfc52dfe2 fix(deps): update metro to v0.10.3 2026-02-10 14:05:25 +00:00
Harsh ShandilyaandGitHub 3db67ddbb0 fix(build): remove Sentry Size Analysis
This is out of early access and billable now.

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
2026-02-10 16:33:33 +05:30
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
315df3032b fix(deps): update sentry to v8.32.0 (#1067)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[io.sentry:sentry-bom](https://redirect.github.com/getsentry/sentry-java)
| `8.31.0` → `8.32.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/io.sentry:sentry-bom/8.32.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.sentry:sentry-bom/8.31.0/8.32.0?slim=true)
|
|
[io.sentry:sentry-android-sqlite](https://redirect.github.com/getsentry/sentry-java)
| `8.31.0` → `8.32.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/io.sentry:sentry-android-sqlite/8.32.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.sentry:sentry-android-sqlite/8.31.0/8.32.0?slim=true)
|
|
[io.sentry:sentry-android-core](https://redirect.github.com/getsentry/sentry-java)
| `8.31.0` → `8.32.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/io.sentry:sentry-android-core/8.32.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.sentry:sentry-android-core/8.31.0/8.32.0?slim=true)
|
| [io.sentry:sentry](https://redirect.github.com/getsentry/sentry-java)
| `8.31.0` → `8.32.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/io.sentry:sentry/8.32.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.sentry:sentry/8.31.0/8.32.0?slim=true)
|

---

### Release Notes

<details>
<summary>getsentry/sentry-java (io.sentry:sentry-bom)</summary>

###
[`v8.32.0`](https://redirect.github.com/getsentry/sentry-java/blob/HEAD/CHANGELOG.md#8320)

[Compare
Source](https://redirect.github.com/getsentry/sentry-java/compare/8.31.0...8.32.0)

##### Features

- Add `installGroupsOverride` parameter and `installGroups` property to
Build Distribution SDK
([#&#8203;5062](https://redirect.github.com/getsentry/sentry-java/pull/5062))
- Update Android targetSdk to API 36 (Android 16)
([#&#8203;5016](https://redirect.github.com/getsentry/sentry-java/pull/5016))
- Add AndroidManifest support for Spotlight configuration via
`io.sentry.spotlight.enable` and `io.sentry.spotlight.url`
([#&#8203;5064](https://redirect.github.com/getsentry/sentry-java/pull/5064))
- Collect database transaction spans (`BEGIN`, `COMMIT`, `ROLLBACK`)
([#&#8203;5072](https://redirect.github.com/getsentry/sentry-java/pull/5072))
- To enable creation of these spans, set
`options.enableDatabaseTransactionTracing` to `true`
- `enable-database-transaction-tracing=true` when using
`sentry.properties`
- For Spring Boot, use `sentry.enable-database-transaction-tracing=true`
in `application.properties` or in `application.yml`:
    ```yaml
    sentry:
      enable-database-transaction-tracing: true
    ```
- Add support for collecting native crashes using Tombstones
([#&#8203;4933](https://redirect.github.com/getsentry/sentry-java/pull/4933),
[#&#8203;5037](https://redirect.github.com/getsentry/sentry-java/pull/5037))
- Added Tombstone integration that detects native crashes using
`ApplicationExitInfo.REASON_CRASH_NATIVE` on Android 12+
- Crashes enriched with Tombstones contain more crash details and
detailed thread info
- Tombstone and NDK integrations are now automatically merged into a
single crash event, eliminating duplicate reports
  - To enable it, add the integration in your Sentry initialization:
    ```kotlin
    SentryAndroid.init(context, options -> {
        options.isTombstoneEnabled = true
    })
    ```
    or in the `AndroidManifest.xml` using:
    ```xml
<meta-data android:name="io.sentry.tombstone.enable"
android:value="true" />
    ```

##### Fixes

- Extract `SpotlightIntegration` to separate `sentry-spotlight` module
to prevent insecure HTTP URLs from appearing in release APKs
([#&#8203;5064](https://redirect.github.com/getsentry/sentry-java/pull/5064))
- **Breaking:** Users who enable Spotlight must now add the
`io.sentry:sentry-spotlight` dependency:
    ```kotlin
    dependencies {
        debugImplementation("io.sentry:sentry-spotlight:<version>")
    }
    ```
- Fix scroll target detection for Jetpack Compose
([#&#8203;5017](https://redirect.github.com/getsentry/sentry-java/pull/5017))
- No longer fork Sentry `Scopes` for `reactor-kafka` consumer poll
`Runnable`
([#&#8203;5080](https://redirect.github.com/getsentry/sentry-java/pull/5080))
- This was causing a memory leak because `reactor-kafka`'s poll event
reschedules itself infinitely, and each invocation of
`SentryScheduleHook` created forked scopes with a parent reference,
building an unbounded chain that couldn't be garbage collected.
- Fix cold/warm app start type detection for Android devices running API
level 34+
([#&#8203;4999](https://redirect.github.com/getsentry/sentry-java/pull/4999))

##### Internal

- Establish new native exception mechanisms to differentiate events
generated by `sentry-native` from `ApplicationExitInfo`.
([#&#8203;5052](https://redirect.github.com/getsentry/sentry-java/pull/5052))
- Set `write` permission for `statuses` in the changelog preview GHA
workflow.
([#&#8203;5053](https://redirect.github.com/getsentry/sentry-java/pull/5053))

##### Dependencies

- Bump Native SDK from v0.12.3 to v0.12.4
([#&#8203;5061](https://redirect.github.com/getsentry/sentry-java/pull/5061))
-
[changelog](https://redirect.github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0124)
-
[diff](https://redirect.github.com/getsentry/sentry-native/compare/0.12.3...0.12.4)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi45Ny4wIiwidXBkYXRlZEluVmVyIjoiNDIuOTcuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-10 08:37:24 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
25f7189f68 chore(deps): pin gradle/actions action to f29f5a9 (#1066)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [gradle/actions](https://redirect.github.com/gradle/actions) | action
| pinDigest | → `f29f5a9` |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi45Ny4wIiwidXBkYXRlZEluVmVyIjoiNDIuOTcuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-10 05:55:25 +00:00
Harsh Shandilya 393af79ded fix(android): add Sentry breadcrumbs integration for Navigation3 2026-02-10 09:07:36 +05:30
Harsh Shandilya 00b9a7a0db fix(ci): validate Gradle wrapper during setup 2026-02-09 23:05:26 +05:30
Harsh Shandilya 62f9fb8b37 fix(build): upgrade to Gradle 9.4.0-rc-1 2026-02-09 23:02:51 +05:30
Harsh Shandilya 3f04bfbbca fix(android): add Sentry breadcrumbs to debug the widget crashes 2026-02-08 02:55:53 +05:30
Harsh Shandilya 573ca7a2f9 chore: refresh baseline profiles 2026-02-08 02:13:54 +05:30
Harsh Shandilya 69e36ca09a fix(build): also configure com.android.test plugin 2026-02-08 02:08:31 +05:30
Harsh Shandilya 5bc01eab4d fix(android): ensure Glance actionStartActivity invariant is upheld
Fixes COMPOSE-LOBSTERS-9X
2026-02-08 01:19:20 +05:30
Harsh Shandilya 48b641d3b8 refactor: replace FlowRow with FlexBox and regen Lint baselines 2026-02-08 01:16:06 +05:30
Harsh Shandilya a6517120b1 chore: expand preview tags list 2026-02-08 00:31:30 +05:30
renovate[bot]andGitHub 78fecb7192 fix(deps): update agp to v9.1.0-alpha08 2026-02-05 17:53:11 +00:00
renovate[bot]andGitHub c66b110525 fix(deps): update kotlin and compose to v2.3.10 2026-02-05 08:50:00 +00:00
renovate[bot]andGitHub e90ea6a6d4 fix(deps): update kotlin and compose to v2.3.10-rc2 2026-02-02 12:47:41 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>msfjarvis
ae580612dd Fix IllegalStateException in RetryAfterInterceptor (#1060)
`RetryAfterInterceptor` calls `chain.proceed()` twice when retrying
rate-limited requests, violating the network interceptor contract that
requires exactly one `proceed()` call.

## Changes

- Changed interceptor registration from `addNetworkInterceptor` to
`addInterceptor` in `OkHttpModule.kt`
- Application interceptors allow multiple `proceed()` calls for retries

```kotlin
// Before: network interceptors cannot retry
interceptors.forEach(::addNetworkInterceptor)

// After: application interceptors support retries
interceptors.forEach(::addInterceptor)
```

## Context

When the Lobsters API returns a 429 or 503 with `Retry-After` header,
the interceptor sleeps for the specified duration then retries the
request. This requires calling `proceed()` twice: once for the initial
attempt (line 31) and once for the retry (line 45).

> [!WARNING]
>
> <details>
> <summary>Firewall rules blocked me from connecting to one or more
addresses (expand for details)</summary>
>
> #### I tried to connect to the following addresses, but was blocked by
firewall rules:
>
> - `dl.google.com`
> - Triggering command: `/usr/lib/jvm/temurin-17-jdk-amd64/bin/java
/usr/lib/jvm/temurin-17-jdk-amd64/bin/java
--add-opens=java.base/java.lang=ALL-UNNAMED
--add-opens=java.base/java.lang.invoke=ALL-UNNAMED
--add-opens=java.base/java.util=ALL-UNNAMED
--add-opens=java.prefs/java.util.prefs=ALL-UNNAMED
--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
--add-opens=java.base/java.util=ALL-UNNAMED
--add-opens=java.prefs/java.util.prefs=ALL-UNNAMED
--add-opens=java.base/java.nio.charset=ALL-UNNAMED
--add-opens=java.base/java.net=ALL-UNNAMED
--add-opens=java.base/java.util.concurrent=ALL-UNNAMED
--add-opens=java.base/java.util.concurrent.atomic=ALL-UNNAMED
--add-opens=java.xml/javax.xml.namespace=ALL-UNNAMED
--add-opens=java.base/java.time=ALL-UNNAMED
-XX:&#43;HeapDumpOnOutOfMemoryError -Xmx3072m -Dfile.encoding=UTF-8
-Duser.country -Duser.language=en` (dns block)
>
> If you need me to access, download, or install something from one of
these locations, you can either:
>
> - Configure [Actions setup
steps](https://gh.io/copilot/actions-setup-steps) to set up my
environment, which run before the firewall is enabled
> - Add the appropriate URLs or hosts to the custom allowlist in this
repository's [Copilot coding agent
settings](https://github.com/msfjarvis/compose-lobsters/settings/copilot/coding_agent)
(admins only)
>
> </details>

<!-- START COPILOT CODING AGENT SUFFIX -->



<!-- START COPILOT ORIGINAL PROMPT -->



<details>

<summary>Original prompt</summary>

> Fix this crash: IllegalStateException: network interceptor
dev.msfjarvis.claw.core.network.RetryAfterInterceptor@4810a2c must call
proceed() exactly once
> at
dev.msfjarvis.claw.core.network.RetryAfterInterceptor.intercept(RetryAfterInterceptor.kt:45)


</details>



<!-- START COPILOT CODING AGENT TIPS -->
---

💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: msfjarvis <13348378+msfjarvis@users.noreply.github.com>
2026-02-01 06:06:41 +00:00
renovate[bot]andGitHub 3736086445 fix(deps): update agp to v9.1.0-alpha07 2026-01-30 20:51:54 +00:00
renovate[bot]andGitHub 0f817ff7f9 fix(deps): update dependency androidx.activity:activity to v1.12.3 2026-01-30 02:07:24 +00:00
renovate[bot]andGitHub bf20553e15 chore(deps): update gradle to v9.3.1 2026-01-29 18:06:39 +00:00
renovate[bot]andGitHub 850ee0bec8 fix(deps): update metro to v0.10.2 2026-01-29 05:02:07 +00:00
renovate[bot]andGitHub fd073e7210 fix(deps): update androidx 2026-01-29 02:10:04 +00:00
renovate[bot]andGitHub 8ba3a9c7fa chore(deps): update plugin ksp to v2.3.5 2026-01-28 20:40:11 +00:00
renovate[bot]andGitHub 9ec30ecf81 chore(deps): update gradle/actions digest to f29f5a9 2026-01-28 19:25:53 +00:00
renovate[bot]andGitHub a363b992a7 chore(deps): update plugin com.gradle.develocity to v4.3.2 2026-01-28 17:44:13 +00:00
renovate[bot]andGitHub b604c60cc4 fix(deps): update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.2.1 2026-01-27 22:38:48 +00:00
renovate[bot]andGitHub 4b80be25de fix(deps): update agp to v9.1.0-alpha06 2026-01-26 19:14:01 +00:00
Harsh Shandilya da65b056ee fix(build): tweak AGP config and regen Lint baselines 2026-01-27 00:42:43 +05:30
Harsh Shandilya 769fb6ce51 fix(android): count uses of import/export feature 2026-01-27 00:13:28 +05:30
renovate[bot]andGitHub 35a59bc53c fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.0.0 2026-01-26 11:02:33 +00:00
Harsh Shandilya a4d3af8049 fix(build): fix java target mismatch in build-logic module 2026-01-24 22:40:14 +05:30
Harsh Shandilya eadd898853 fix(build): enable some new Kotlin features 2026-01-24 22:38:35 +05:30
renovate[bot]andGitHub da72851ecc fix(deps): update metro to v0.10.1 2026-01-23 22:45:37 +00:00
Harsh Shandilya 006a2593c6 chore: update changelog 2026-01-23 21:15:24 +05:30
Harsh Shandilya e44f9eacf0 chore(ci): automatically inject the store links footer to release notes 2026-01-23 21:13:18 +05:30
renovate[bot]andGitHub 0d442880f0 fix(deps): update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.2.0 2026-01-22 21:23:44 +00:00
renovate[bot]andGitHub 6a7d2606f9 chore(deps): update actions/checkout action to v6.0.2 2026-01-22 17:11:34 +00:00
renovate[bot]andGitHub 01ac01a051 chore(deps): update actions/setup-java action to v5.2.0 2026-01-22 05:45:17 +00:00
renovate[bot]andGitHub 88dc15a7e7 fix(deps): update sentry to v8.31.0 2026-01-21 21:37:45 +00:00
renovate[bot]andGitHub e55d9b5007 fix(deps): update serialization to v1.10.0 2026-01-21 16:55:32 +00:00
renovate[bot]andGitHub ef90a8e100 fix(deps): update aboutlibraries to v14.0.0-b02 2026-01-21 13:47:43 +00:00
Harsh Shandilya 3e8bd94cf0 refactor: adopt Metro 0.10.0 features 2026-01-20 10:36:21 +05:30
renovate[bot]andGitHub e2b3671f4c fix(deps): update metro to v0.10.0 2026-01-20 00:56:42 +00:00
Harsh Shandilya 64c486d81c fix(build): upgrade to Kotlin 2.3.10-RC 2026-01-19 22:03:50 +05:30
Harsh Shandilya 6e9b4ccdae Revert "fix(android): make internal build type an alias for release"
This reverts commit 476e25f7d9.
2026-01-19 22:03:50 +05:30
Harsh Shandilya 04ab7d1b60 fix(build): only run Sentry size analysis for production builds 2026-01-19 21:28:18 +05:30
Harsh Shandilya 419e93f940 chore: roll forward secrets 2026-01-18 22:25:42 +05:30
CopilotandGitHub a6862d4e93 Apply tag filtering to hottest posts widget (#1047) 2026-01-18 20:43:11 +05:30
Harsh ShandilyaandGitHub 8f383c895d fix: unlock CI for drafts
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
2026-01-18 19:39:32 +05:30
renovate[bot]andGitHub b4ba657185 chore(deps): update gradle to v9.3.0 2026-01-16 14:37:06 +00:00
renovate[bot]andGitHub 80bdcf0ed9 fix(deps): update agp to v9.1.0-alpha05 2026-01-16 01:47:48 +00:00
renovate[bot]andGitHub da57278fea chore(deps): update plugin com.gradle.develocity to v4.3.1 2026-01-15 23:08:03 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
4dd9630e92 Add database backed cache for hottest posts widget (#1041)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-01-15 23:07:06 +00:00
renovate[bot]andGitHub d4118eaa67 fix(deps): update sentry to v8.30.0 2026-01-15 14:31:58 +00:00
renovate[bot]andGitHub ed97836be6 fix(deps): update androidx 2026-01-14 20:59:47 +00:00
renovate[bot]andGitHub e0ae2392f3 fix(deps): update aboutlibraries to v14.0.0-b01 2026-01-13 21:03:16 +00:00
renovate[bot]andGitHub fd041b4344 fix(deps): update androidx 2026-01-13 04:01:56 +00:00
Harsh Shandilya f16452c360 fix(common): improve rendering of lists in comments 2026-01-13 09:30:43 +05:30
Harsh Shandilya 1294d6fc3c fix(android): make baseline profile generation a manual knob 2026-01-13 08:22:26 +05:30
Harsh Shandilya 2fe7004b8f fix(ci): set Gradle property for CI emulators 2026-01-13 07:27:03 +05:30
Harsh Shandilya f7c8f0f979 fix: use Java 25 in CI 2026-01-13 06:57:17 +05:30
Harsh Shandilya 991e7ecac4 fix: accept all Android licenses in CI 2026-01-13 06:56:36 +05:30
Harsh Shandilya c6d1a79747 refactor: cleanup all DAGP reported violations 2026-01-13 06:26:55 +05:30
Harsh Shandilya 51eb6be86c fix(android): regenerate baseline profiles 2026-01-13 06:26:55 +05:30
Harsh Shandilya 7ee0071da6 feat: restore baseline profile generation job 2026-01-13 06:26:55 +05:30
Harsh Shandilya b29785a5fc fix(android): add tests for backstack and fix deeplink navigation 2026-01-12 21:29:41 +05:30
Harsh Shandilya a44e74788d fix: differentiate between prod and debug builds for deeplinks 2026-01-12 21:29:02 +05:30
Harsh Shandilya f1b8f38aca feat: convert post links to deeplinks in comments 2026-01-12 21:29:02 +05:30
Harsh Shandilya 661033c287 fix: capture IAE from GlanceAppWidgetManager.setWidgetPreviews
Fixes COMPOSE-LOBSTERS-9T
2026-01-10 23:16:23 +05:30
renovate[bot]andGitHub 23e24c465b fix(deps): update metro to v0.9.4 2026-01-10 04:37:22 +00:00
Harsh Shandilya 2817c41790 chore: refresh feature graphics 2026-01-10 00:18:00 +05:30
GitHub Actions ee268e35fa feat(release): start next development iteration 2026-01-09 14:08:57 +00:00
GitHub Actions 18235ed55d feat(release): bump version 2026-01-09 14:01:46 +00:00
renovate[bot]andGitHub cfb4e34db0 chore(deps): update plugin poko to v0.21.1 2026-01-09 01:46:40 +00:00
renovate[bot]andGitHub 35316450cf fix(deps): update agp to v9.1.0-alpha04 2026-01-08 19:05:00 +00:00
Harsh Shandilya f4ae71b3c8 fix: add missing SingleIn annotations everywhere 2026-01-07 22:20:00 +05:30
Harsh Shandilya f94c466003 fix(common): ensure DataStore instance is a singleton
Fixes COMPOSE-LOBSTERS-9R
2026-01-07 22:11:50 +05:30
Harsh Shandilya 1c69a4bcbe fix(android): wrap ContentResolver uses in try-catch
Bad network today caused these paths to fail for me

Fixes COMPOSE-LOBSTERS-9Q
2026-01-06 23:41:20 +05:30
renovate[bot]andGitHub 6a5e8daecf fix(deps): update dependency org.junit:junit-bom to v6.0.2 2026-01-06 17:57:44 +00:00
Harsh Shandilya 1ab313ed70 fix(ci): manually upload R8 mappings to Sentry 2026-01-06 23:26:21 +05:30
Harsh Shandilya 9dfffa5219 fixup! fix(android): finally fix Android Auto Backup 2026-01-06 21:54:36 +05:30
Harsh Shandilya e07bd441a8 fix(android): finally fix Android Auto Backup 2026-01-06 21:35:33 +05:30
renovate[bot]andGitHub ff71aeb4ef fix(deps): update agp to v9.1.0-alpha03 2026-01-05 17:53:49 +00:00
Harsh Shandilya f76bb0eb08 fix(maestro): remove failing assertion 2026-01-04 23:16:33 +05:30
Harsh Shandilya 589ea7585e refactor: replace Napier with plain android.util.Log 2026-01-04 22:49:34 +05:30
Harsh Shandilya cb6e24296d fix(ci): update base sha calculation logic to use commit object 2026-01-04 20:51:23 +05:30
Harsh Shandilya 287eb04913 fix(android): make samplePosts helper generic 2026-01-04 19:39:25 +05:30
Harsh Shandilya ccafc96c78 fix(android): tweak widget layout again 2026-01-04 15:24:49 +05:30
Harsh Shandilya 02ef739624 chore: update TEST_POST 2026-01-04 15:24:49 +05:30
Harsh Shandilya 37d364b5fa fixup! fix(build): source VCS info from environment instead 2026-01-04 13:22:22 +05:30
Harsh Shandilya dd8b62f7b8 fixup! fix(build): source VCS info from environment instead 2026-01-04 13:16:43 +05:30
Harsh Shandilya db0412c308 fixup! fix(build): source VCS info from environment instead 2026-01-04 13:10:59 +05:30
Harsh Shandilya 23b3afd0a9 fix(build): source VCS info from environment instead 2026-01-04 12:47:30 +05:30
Harsh Shandilya 33dc57a928 chore: update changelog 2026-01-04 12:47:30 +05:30
Harsh Shandilya 1540208966 fix(android): integrate tag filtering 2026-01-04 12:47:30 +05:30
Harsh Shandilya b29065e27c fix(android): redo tag filtering UI 2026-01-04 12:47:29 +05:30
Harsh Shandilya 14f5528157 chore: remove now-useless JvmSuppressWildcards 2026-01-04 11:59:55 +05:30
Harsh Shandilya 50720a06e4 fix(build): set VCS Info manually for all releases 2026-01-03 20:45:55 +05:30
Harsh Shandilya 9213edd132 fix(build): enable Sentry size analysis 2026-01-03 20:24:36 +05:30
Harsh Shandilya 4c0089676a fix(android): use actionStartActivity everywhere in widgets
Fixes COMPOSE-LOBSTERS-9P
2026-01-03 20:18:49 +05:30
Harsh Shandilya 71e54aac31 feat(android): add a 'See more posts' button to widgets 2026-01-03 03:07:32 +05:30
Harsh Shandilya e648bde12a refactor(android): expand deep linking destinations 2026-01-03 03:07:32 +05:30
Harsh Shandilya 27e670a1f1 fix(sentry): fully skip plugin application when disabled 2026-01-02 21:11:21 +05:30
Harsh Shandilya 4df64bb946 fix(ci): run builds on macOS 26 2026-01-02 21:11:21 +05:30
Harsh Shandilya 7efb206c68 fix(ci): add a release build job to CI
I've broken R8 minification enough times for this to not be checked
2026-01-02 21:11:21 +05:30
Harsh Shandilya 625064f1fa fix(android): update missing classes ProGuard configuration 2026-01-02 20:32:11 +05:30
renovate[bot]andGitHub f16b9a1b54 fix(deps): update metro to v0.9.3 2026-01-02 09:48:54 +00:00
Harsh Shandilya 4137132b40 fix(android): add static widget previews 2026-01-02 13:01:06 +05:30
Harsh Shandilya 8c7aa23532 feat(android): add a widget for hottest posts 2026-01-02 03:13:01 +05:30
Harsh Shandilya 2b7c3e790a fix(android): correct icon tint for comments button in widget 2026-01-02 01:52:33 +05:30
Harsh Shandilya b7721602ff fix(widget): correct check for comments action 2026-01-02 01:50:45 +05:30
Harsh Shandilya 15a04a7684 fix(android): set package for OpenCommentsCallback 2026-01-02 01:50:05 +05:30
Harsh Shandilya 3ffe7ca7a8 chore: silence false-positive lint warnings in version catalog 2026-01-01 21:18:45 +05:30
Harsh Shandilya 561dcb2bb9 fix: remove use of compose-bom
https://jakewharton.com/defuse-the-compose-bom/ enumerates the various reasons why it's not super useful anymore
2026-01-01 21:16:48 +05:30
renovate[bot]andGitHub 1e3ca5b822 fix(deps): update dependency org.jsoup:jsoup to v1.22.1 2026-01-01 04:35:02 +00:00
Harsh Shandilya 216a6eff3a fix(android): WidgetReceiver -> SavedPostsWidgetReceiver 2026-01-01 02:54:20 +05:30
Harsh Shandilya 3390031120 refactor(glance): clean up and consolidate 2026-01-01 02:51:52 +05:30
Harsh Shandilya f079421a00 fix: make widget icon slightly bigger 2026-01-01 02:36:40 +05:30
Harsh Shandilya aa8756065c chore: update changelog 2026-01-01 02:35:00 +05:30
Harsh Shandilya 6004d31af4 fix(build): cleanup all Lint violations 2026-01-01 02:26:11 +05:30
Harsh Shandilya f3a5956216 chore: refresh Lint baselines 2026-01-01 02:21:45 +05:30
Harsh Shandilya 9d38a02819 fix(android): resolve compile error from Glance preview 2026-01-01 02:12:21 +05:30
Harsh Shandilya 7f608fd536 fix(android): prevent extraneous paging loads
Without this the LazyPagingItems.get call would trigger a ton of extra
paging loads forcing 6-7 pages to get loaded when only 2 were needed.
2026-01-01 02:04:54 +05:30
Harsh Shandilya b242333e8a fix(android): make initial post loads lazy 2025-12-31 15:51:45 +05:30
Harsh Shandilya 663271d980 feat(android): rework widget UI and add previews to IDE and widget picker 2025-12-31 15:14:25 +05:30
Harsh Shandilya 67dd99ae42 fix(core): allow injecting dispatchers in more places 2025-12-31 11:44:08 +05:30
renovate[bot]andGitHub cd0d24ecc1 chore(deps): update dependency com.facebook:ktfmt to v0.61 2025-12-30 21:56:08 +00:00
Harsh Shandilya 6b96282a70 fix: update longstanding dumbass string 2025-12-30 23:49:36 +05:30
Harsh Shandilya 8f6f5c658b fix(android): use navigation helper consistently 2025-12-30 23:40:03 +05:30
Harsh Shandilya dbe2868584 fix: cut out some more padding from widget 2025-12-30 21:58:31 +05:30
Harsh Shandilya ab872b6eaa fix: properly use deep links for widget -> comments navigation 2025-12-30 21:30:48 +05:30
Harsh Shandilya c37621ed9a fix(android): make SavedPostUpdaterWorker periodic again 2025-12-30 21:14:35 +05:30
Harsh Shandilya 16f887792d fix(android): redo widget UI a tiny bit 2025-12-30 21:14:34 +05:30
Harsh Shandilya 7c39744ddd fixup! chore: update changelog 2025-12-30 18:15:27 +05:30
Harsh Shandilya b47be69808 chore: update changelog 2025-12-30 17:47:20 +05:30
Harsh Shandilya 7f0acae2da fix: handle process death for navigation state
Fixes COMPOSE-LOBSTERS-7J
2025-12-30 17:43:44 +05:30
Harsh Shandilya b3373473a7 fix: use actionRunCallback to launch browser from widget
Fixes COMPOSE-LOBSTERS-8M
2025-12-30 14:12:53 +05:30
renovate[bot]andGitHub 7c7c217f8b fix(deps): update agp to v9.1.0-alpha02 2025-12-29 22:29:33 +00:00
renovate[bot]andGitHub 252e5bc037 fix(deps): update aboutlibraries to v14.0.0-a02 2025-12-29 18:37:31 +00:00
renovate[bot]andGitHub 12c2708b50 fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.0.0-rc.1 2025-12-29 12:55:22 +00:00
Harsh Shandilya 5be3dee120 chore: increase Maestro test timeouts 2025-12-29 15:35:11 +05:30
Harsh Shandilya 2110c24f0c chore: reformat run-maestro-e2e-tests 2025-12-29 15:35:11 +05:30
Harsh Shandilya ecd8248c8a fix: cleanup all Maestro tests to speed up execution 2025-12-29 15:35:11 +05:30
Harsh Shandilya e6eeaafda1 fix: make feature graphic Maestro flow more reliable 2025-12-29 15:35:11 +05:30
Harsh Shandilya c9e5b1a714 chore: disable screenshot tests 2025-12-29 02:23:38 +05:30
Harsh Shandilya 14f09f9009 fix: bump aboutlibraries 2025-12-29 00:21:25 +05:30
Harsh Shandilya a17b27d078 refactor: migrate to AGP 9
Currently blocked on https://github.com/mikepenz/AboutLibraries/pull/1283 being released
2025-12-29 00:21:25 +05:30
Harsh Shandilya 3d978884ea fix: remove ClawBackStack#shouldStack flag
This behavior is desirable on all form factors IMO
2025-12-28 19:53:42 +05:30
Harsh Shandilya 271afb980f fix: wire up navigation to system backpress handler 2025-12-28 19:52:28 +05:30
Harsh Shandilya 8d384f1f2d fix(common): make user profile scrollable 2025-12-28 18:36:24 +05:30
Harsh Shandilya e4fb99ade8 fix: ensure detail views don't stack for tablets 2025-12-28 18:35:52 +05:30
Harsh Shandilya 1af36bea91 fix(common): add a test tag for the comments header 2025-12-28 18:28:45 +05:30
Harsh Shandilya 6580afd90e chore: update comment in InternalDatabaseApi 2025-12-28 16:56:25 +05:30
Harsh Shandilya 476e25f7d9 fix(android): make internal build type an alias for release 2025-12-28 16:53:51 +05:30
Harsh Shandilya 0f4850eed3 chore: dynamically retrieve device size for montage 2025-12-28 16:45:46 +05:30
Harsh Shandilya a66903e4cf chore: refresh feature graphics 2025-12-28 16:34:01 +05:30
Harsh Shandilya e4309a6741 chore: make refresh-feature-graphic also optimize PNGs 2025-12-28 16:30:30 +05:30
Harsh ShandilyaandGitHub 87b010dea6 fix: update kotlinx.serialization
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
2025-12-28 15:57:56 +05:30
Harsh Shandilya 3b98ee3ee0 fix: also add badge for zero comments 2025-12-28 11:55:24 +05:30
Harsh Shandilya da0e5d1d97 fix(common): tweak comment count badge design
The current version's text is too small
2025-12-28 02:10:32 +05:30
Harsh Shandilya b0a469036f fix: hoist saved state out of SaveButton 2025-12-28 02:10:32 +05:30
Harsh Shandilya b4d2dbb8dd fixup! fix: make save button's tap target larger 2025-12-27 18:56:49 +05:30
Harsh Shandilya a8b94079ca fix(common): add back comment count badge 2025-12-27 18:54:23 +05:30
Harsh Shandilya a72b7ce9d1 fix: make save button's tap target larger 2025-12-27 18:50:02 +05:30
Harsh Shandilya d1a3c098a7 fix(common): add missing OkHttp BOM 2025-12-27 10:58:09 +05:30
Harsh Shandilya 40e867585d fix: upgrade to Sentry Gradle Plugin 6.0.0-beta.3
Includes fixes for early manifest parsing and AGP 9.x
2025-12-27 10:20:49 +05:30
Harsh Shandilya 65ec8e1956 fix: remove outdated CC suppression for Spotless 2025-12-27 10:10:50 +05:30
Harsh Shandilya fe7fb7197a chore: run shfmt on scripts 2025-12-27 09:55:11 +05:30
Harsh Shandilya 5540d924c5 chore: merge run-maestro-flow script into refresh-feature-graphic
That is the only place that used it so it doesn't need to be standalone
2025-12-27 09:55:11 +05:30
Harsh Shandilya 6d0953742b feat(maestro): add an end-to-end test suite 2025-12-27 09:55:11 +05:30
Harsh Shandilya a6c47d0dee fix: add missing negation in SentryConfigurationPlugin 2025-12-27 09:43:15 +05:30
Harsh Shandilya 0394d91069 chore: refresh feature graphic and Fastlane screenshots 2025-12-26 22:47:39 +05:30
GitHub Actions 82273c3e42 feat(release): start next development iteration 2025-12-26 16:51:31 +00:00
GitHub Actions 766decbe55 feat(release): bump version 2025-12-26 16:41:48 +00:00
Harsh Shandilya 4a3bca55c4 chore: update changelog and reduce character count for Google Play 2025-12-26 22:07:47 +05:30
Harsh Shandilya 4a7921d187 fix: remove comment count from list items
This currently causes a crash during configuration change, will be restored once I can get it to not crash

Fixes COMPOSE-LOBSTERS-8Z
2025-12-26 22:02:18 +05:30
Harsh Shandilya 7f5dbf79e3 fix: instrument SQLDelight with Sentry spans 2025-12-26 21:40:50 +05:30
Harsh Shandilya a725db0483 fix: exclude non-lobsters domains from Sentry instrumentation
Fixes COMPOSE-LOBSTERS-7M
2025-12-26 21:23:11 +05:30
Harsh Shandilya 9c05e9b2e2 fix: key posts with index as well
Fixes COMPOSE-LOBSTERS-4N
2025-12-26 20:53:37 +05:30
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2fee56d566 fix(deps): update dependency androidx.sqlite:sqlite to v2.6.2 (#1008)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[androidx.sqlite:sqlite](https://developer.android.com/jetpack/androidx/releases/sqlite#2.6.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `2.5.2` -> `2.6.2` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.sqlite:sqlite/2.6.2?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.sqlite:sqlite/2.5.2/2.6.2?slim=true)
|

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi41OS4wIiwidXBkYXRlZEluVmVyIjoiNDIuNTkuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-26 05:40:48 +00:00
Harsh Shandilya 6f29a35af2 chore: sort all dependency declarations 2025-12-26 09:27:57 +05:30
Harsh Shandilya bc029bc60e chore: apply all DAGP suggestions 2025-12-26 09:27:57 +05:30
Harsh Shandilya f09f89dc6c fix(common): remove useless Context-aware ViewModels 2025-12-26 09:27:57 +05:30
Harsh ShandilyaandGitHub de1d21ec4f chore: update changelog
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
2025-12-25 22:19:54 +05:30
Harsh Shandilya 39c12f2173 refactor: push down more data operations to SQL 2025-12-25 19:47:47 +05:30
Harsh Shandilya 3ab2eb8b66 refactor: rework saved post updater
- Add a delayed random stagger between post updates
- Take posts from the last 30 days instead of just 30 posts
2025-12-25 19:27:32 +05:30
Harsh Shandilya 31b54e1443 Revert "refactor(build): automatically generate @AssistedFactory classes"
For some reason this is failing to compile for me from Android Studio, will investigate later.

This reverts commit 44d240c65e.
2025-12-25 19:07:12 +05:30
Harsh Shandilya b48bac29fc fix: cleanup duplicate WorkManager initialization 2025-12-25 19:01:46 +05:30
Harsh Shandilya 44d240c65e refactor(build): automatically generate @AssistedFactory classes 2025-12-25 18:03:57 +05:30
Harsh Shandilya 1d7d166704 refactor(core): simplify OkHttpModule a bit 2025-12-25 17:56:37 +05:30
Harsh ShandilyaandGitHub fc463a3c23 feat(core): add Retry-After header handling for rate limiting (#1006)
Fixes #642
2025-12-25 11:58:59 +00:00
Harsh Shandilya 55b458df49 chore: update changelog 2025-12-25 16:41:23 +05:30
Harsh Shandilya 76ad5f77b6 fix: upgrade to latest Glance 2025-12-25 16:40:07 +05:30
Harsh Shandilya a1af9974be refactor: migrate to Metro for dependency injection
Fixes #897
2025-12-25 16:40:07 +05:30
Harsh Shandilya e3d433d62f refactor(web): delete
Never used this
2025-12-25 16:09:19 +05:30
Harsh Shandilya 7a6878cdd1 fix(core): use longer network timeouts
Fixes #1003
2025-12-25 09:09:52 +05:30
renovate[bot]andGitHub 4d3fe5509f fix(deps): update dependency me.saket.unfurl:unfurl to v2.3.0 2025-12-22 02:17:07 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
be86dbb25b fix(deps): update aboutlibraries to v13.2.1 (#1000)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| com.mikepenz.aboutlibraries.plugin.android | `13.1.0` -> `13.2.1` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.mikepenz.aboutlibraries.plugin.android:com.mikepenz.aboutlibraries.plugin.android.gradle.plugin/13.2.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.mikepenz.aboutlibraries.plugin.android:com.mikepenz.aboutlibraries.plugin.android.gradle.plugin/13.1.0/13.2.1?slim=true)
|
|
[com.mikepenz:aboutlibraries-compose-m3](https://redirect.github.com/mikepenz/AboutLibraries)
| `13.1.0` -> `13.2.1` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.mikepenz:aboutlibraries-compose-m3/13.2.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.mikepenz:aboutlibraries-compose-m3/13.1.0/13.2.1?slim=true)
|

---

### Release Notes

<details>
<summary>mikepenz/AboutLibraries
(com.mikepenz:aboutlibraries-compose-m3)</summary>

###
[`v13.2.1`](https://redirect.github.com/mikepenz/AboutLibraries/compare/13.2.0...13.2.1)

[Compare
Source](https://redirect.github.com/mikepenz/AboutLibraries/compare/13.1.0...13.2.1)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi41OS4wIiwidXBkYXRlZEluVmVyIjoiNDIuNTkuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-19 19:04:41 +00:00
Harsh Shandilya 8adbd66e9d chore: remove obsolete Gradle properties 2025-12-20 00:24:32 +05:30
Harsh Shandilya 3ddfd77771 fix: upgrade kotlin-metadata-jvm for Dagger 2025-12-20 00:23:55 +05:30
renovate[bot]andGitHub fd910f40fa fix(deps): update kotlin and compose 2025-12-18 05:43:04 +00:00
renovate[bot]andGitHub ca8a16219d chore(deps): update dependency com.facebook:ktfmt to v0.60 2025-12-18 01:11:37 +00:00
renovate[bot]andGitHub 89fe85b229 fix(deps): update androidx 2025-12-17 19:55:01 +00:00
renovate[bot]andGitHub 7cdff634b7 fix(deps): update dependency io.sentry:sentry-bom to v8.29.0 2025-12-16 08:45:56 +00:00
renovate[bot]andGitHub 0c0fefec9b chore(deps): update actions/upload-artifact action to v6 2025-12-12 23:08:45 +00:00
renovate[bot]andGitHub 28660b8940 fix(deps): update dependency com.android.security.lint:lint to v1.0.4 2025-12-12 15:26:26 +00:00
renovate[bot]andGitHub 5c311097cd fix(deps): update agp to v8.13.2 2025-12-11 06:35:47 +00:00
renovate[bot]andGitHub 4cbeffadf5 chore(deps): update plugin com.gradle.develocity to v4.3 2025-12-10 20:24:48 +00:00
Harsh Shandilya dbddbfc5bc fix(android): make post import fallible and catch errors
Fixes COMPOSE-LOBSTERS-9D
2025-12-09 23:12:23 +05:30
Harsh Shandilya 8d603d6fbd refactor(core): set annotation targets 2025-12-09 23:12:04 +05:30
Harsh Shandilya 38770b6e86 fix: ensure HTML export writer is closed 2025-12-09 23:00:27 +05:30
GitHub Actions 43e3eaf6e3 feat(release): start next development iteration 2025-12-07 16:58:53 +00:00
300 changed files with 74136 additions and 36765 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 435 KiB

+6 -3
View File
@@ -54,10 +54,13 @@
"allowedVersions": "!/.*-1-8$/",
},
{
"matchPackageNames": [
"/^androidx.glance/",
"matchManagers": [
"github-actions"
],
"allowedVersions": "<=1.0.0",
"matchDepNames": [
"msfjarvis/compose-lobsters"
],
"enabled": false,
},
],
"customManagers": [
@@ -25,21 +25,35 @@ runs:
using: "composite"
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: ${{ inputs.fetch-depth }}
token: ${{ inputs.token }}
lfs: true
persist-credentials: ${{ inputs.persist-credentials }}
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
- name: Set up JDK
uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: temurin
java-version: 23
java-version: 25
- name: Accept Android SDK licenses
shell: bash
run: |
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses || true
- name: Set global Gradle properties
shell: bash
run: |
mkdir -p ~/.gradle/
echo "android.testoptions.manageddevices.emulator.gpu=swiftshader_indirect" >> ~/.gradle/gradle.properties
- name: Setup Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
with:
add-job-summary: always
cache-read-only: ${{ inputs.cache-read-only }}
+26 -8
View File
@@ -16,18 +16,17 @@ concurrency:
jobs:
check:
if: "github.event.pull_request.draft == false"
runs-on: macos-15
runs-on: macos-26
steps:
- name: Setup build environment
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@main
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@92aae743ab8e1c98741128f1bfda3715750f5cf6 # main
with:
fetch-depth: 0
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
dependency-graph: generate-and-submit
- name: Check changelog format
uses: mindsers/changelog-reader-action@32aa5b4c155d76c94e4ec883a223c947b2f02656 # v2.2.3
uses: mindsers/changelog-reader-action@1faaf50aa09d5793d9a100819973df801febfb31 # v2.4.0
with:
path: ./CHANGELOG.md
validation_level: error
@@ -39,24 +38,43 @@ jobs:
- name: (Fail-only) Upload test report
if: failure()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Test report
path: '**/build/reports/tests/**'
dependency-review:
runs-on: macos-15
runs-on: macos-26
if: "github.event_name == 'push' && github.event.ref != 'refs/heads/main'"
needs:
- check
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Dependency Review
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
base-ref: refs/heads/main
head-ref: ${{ github.ref }}
release-app:
runs-on: macos-26
needs:
- check
steps:
- name: Setup build environment
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@92aae743ab8e1c98741128f1bfda3715750f5cf6 # main
with:
cache-read-only: true
- name: Build release app
run: |
./scripts/setup-age.sh
./scripts/signing-setup.sh "$AGE_SECRET_KEY"
./gradlew --no-configuration-cache --stacktrace assembleRelease collectReleaseBundle
./scripts/signing-cleanup.sh
env:
AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
+58 -6
View File
@@ -13,10 +13,10 @@ concurrency:
jobs:
publish-google-play-release:
runs-on: macos-15
runs-on: macos-26
steps:
- name: Setup build environment
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@main
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@92aae743ab8e1c98741128f1bfda3715750f5cf6 # main
with:
token: ${{ secrets.POST_RELEASE_GH_TOKEN }}
fetch-depth: 0
@@ -53,7 +53,7 @@ jobs:
- name: Get changelog entry
id: changelog_reader
uses: mindsers/changelog-reader-action@32aa5b4c155d76c94e4ec883a223c947b2f02656 # v2.2.3
uses: mindsers/changelog-reader-action@1faaf50aa09d5793d9a100819973df801febfb31 # v2.4.0
with:
version: ${{ env.VERSION }}
path: ./CHANGELOG.md
@@ -72,14 +72,43 @@ jobs:
git add .
git commit -m 'feat(release): bump version'
- name: Calculate VCS info for Sentry
shell: bash
run: |
# Get HEAD commit SHA
HEAD_SHA=$(git rev-parse HEAD)
echo "SENTRY_VCS_HEAD_SHA=${HEAD_SHA}" >> $GITHUB_ENV
# Get parent info (format: commit parent1 [parent2...])
PARENTS=$(git rev-list --parents -n 1 HEAD)
PARENT_COUNT=$(echo "$PARENTS" | wc -w)
if [ "$PARENT_COUNT" -ge 3 ]; then
# Merge commit (commit + 2+ parents): use first parent
BASE_SHA=$(echo "$PARENTS" | awk '{print $2}')
else
# Normal commit: use last release
LAST_TAG=$(gh release list --limit 1 --json tagName -q '.[0].tagName' || echo "")
if [ -n "$LAST_TAG" ]; then
BASE_SHA=$(git rev-parse "${LAST_TAG}^{commit}")
else
BASE_SHA="${HEAD_SHA}"
fi
fi
echo "SENTRY_VCS_BASE_SHA=${BASE_SHA}" >> $GITHUB_ENV
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build release assets
shell: bash
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_VCS_HEAD_SHA: ${{ env.SENTRY_VCS_HEAD_SHA }}
SENTRY_VCS_BASE_SHA: ${{ env.SENTRY_VCS_BASE_SHA }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
./gradlew --no-configuration-cache assembleRelease collectReleaseApks collectReleaseBundle -PenableSentry
./gradlew --no-configuration-cache assembleRelease uploadSentryProguardMappingsRelease collectReleaseApks collectReleaseBundle -PenableSentry
- name: Clean secrets
run: scripts/signing-cleanup.sh
@@ -93,7 +122,7 @@ jobs:
printf '%s' "${CHANGELOG}" >> distribution/whatsnew/whatsnew-en-GB
- name: Publish bundle to Google Play
uses: r0adkll/upload-google-play@935ef9c68bb393a8e6116b1575626a7f5be3a7fb # v1.1.3
uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
with:
mappingFile: android/bundle/mapping.txt
packageName: dev.msfjarvis.claw.android
@@ -115,8 +144,31 @@ jobs:
# Push the tag to GitHub
git push origin "v${VERSION}"
# Create GitHub release notes with footer
RELEASE_NOTES=$(mktemp)
{
cat distribution/whatsnew/whatsnew-en-GB
echo ""
echo "---"
echo ""
echo '<a href="https://play.google.com/store/apps/details?id=dev.msfjarvis.claw.android">'
echo ' <img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"'
echo ' alt="Get it on Google Play"'
echo ' height="80" />'
echo '</a>'
echo ""
echo '<a href="https://android.izzysoft.de/repo/apk/dev.msfjarvis.claw.android">'
echo ' <img src="./.github/izzy-badge.webp"'
echo ' alt="Get it on IzzyOnDroid"'
echo ' height="65" />'
echo '</a>'
} > "$RELEASE_NOTES"
# Create a GitHub release
gh release create "v${VERSION}" --notes-file distribution/whatsnew/whatsnew-en-GB --title "v${VERSION}" ./android/apk/*.apk ./android/bundle/*.aab
gh release create "v${VERSION}" --notes-file "$RELEASE_NOTES" --title "v${VERSION}" ./android/apk/*.apk ./android/bundle/*.aab
# Clean up temp file
rm "$RELEASE_NOTES"
# Start the next development iteration
./gradlew -q --no-configuration-cache bumpSnapshot
+33 -3
View File
@@ -12,9 +12,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Setup build environment
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@main
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@92aae743ab8e1c98741128f1bfda3715750f5cf6 # main
with:
cache-read-only: true
fetch-depth: 0
- name: Configure Tramline
id: tramline
@@ -22,21 +23,50 @@ jobs:
with:
input: ${{ github.event.inputs.tramline-input }}
- name: Calculate VCS info for Sentry
shell: bash
run: |
# Get HEAD commit SHA
HEAD_SHA=$(git rev-parse HEAD)
echo "SENTRY_VCS_HEAD_SHA=${HEAD_SHA}" >> $GITHUB_ENV
# Get parent info (format: commit parent1 [parent2...])
PARENTS=$(git rev-list --parents -n 1 HEAD)
PARENT_COUNT=$(echo "$PARENTS" | wc -w)
if [ "$PARENT_COUNT" -ge 3 ]; then
# Merge commit (commit + 2+ parents): use first parent
BASE_SHA=$(echo "$PARENTS" | awk '{print $2}')
else
# Normal commit: use last release
LAST_TAG=$(gh release list --limit 1 --json tagName -q '.[0].tagName' || echo "")
if [ -n "$LAST_TAG" ]; then
BASE_SHA=$(git rev-parse "${LAST_TAG}^{commit}")
else
BASE_SHA="${HEAD_SHA}"
fi
fi
echo "SENTRY_VCS_BASE_SHA=${BASE_SHA}" >> $GITHUB_ENV
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build release app
run: |
./scripts/setup-age.sh
./scripts/signing-setup.sh "$AGE_SECRET_KEY"
./gradlew --no-configuration-cache --stacktrace assembleRelease collectReleaseBundle -PenableSentry
./gradlew --no-configuration-cache --stacktrace assembleRelease uploadSentryProguardMappingsRelease collectReleaseBundle -PenableSentry
./scripts/signing-cleanup.sh
env:
AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_VCS_HEAD_SHA: ${{ env.SENTRY_VCS_HEAD_SHA }}
SENTRY_VCS_BASE_SHA: ${{ env.SENTRY_VCS_BASE_SHA }}
ORG_GRADLE_PROJECT_VERSION_NAME: ${{ steps.tramline.outputs.version_name }}
ORG_GRADLE_PROJECT_VERSION_CODE: ${{ steps.tramline.outputs.version_code }}
- name: Upload app bundle
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-aab
path: android/build/outputs/bundle/release/android-release.aab
+1
View File
@@ -248,3 +248,4 @@ distribution/
screenshots/
claw-export.json
.kotlin/
.worktrees/
+128 -5
View File
@@ -7,6 +7,120 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.66.0] - 2026-06-02
### Added
- You can now upvote and reply to comments from the app, if you're logged in
### Changed
- The comments UI now tries to mimic Boost for Reddit
### Fixed
- Certificate pinning was removed to avoid errors when certificates were rotated
## [1.65.0] - 2026-05-29
### Fixed
- Address fallout from Lobste.rs changing its HTML markup. This will keep happening :(
### Changed
- The comment expand/collapse indicators now match the Lobsters UI
## [1.64.0] - 2026-05-26
### Added
- Allow logging in to avoid rate limiting problems.
### Changed
- The migration path for tag data format changes in 1.61.0 has been removed.
- The app no longer uses the unsupported JSON endpoints on lobste.rs, instead relying
on parsing the HTML pages instead.
## [1.63.0] - 2026-04-02
### Fixed
- Comments are no longer indicated as unread on the first visit of a post
### Changed
- Post titles in the list view can now take up 2 lines to make ellipsizing less likely
- Action buttons now have a prominent background to make their touch target more obvious
- Action buttons now have additional padding to avoid accidental presses when interacting
with system gestures
Thanks to Charles Lombardo for contributing the action button UI changes.
## [1.62.0] - 2026-03-10
### Fixed
- Improve comment scrolling performance
- Ensure saved/read state for posts updates properly in UI
- All strings in the app are now translatable, open to volunteer contributions.
## [1.61.0] - 2026-02-28
### Fixed
- Collapsed comments state are now persisted across navigation events
### Changed
- Tags use a more muted background color to avoid being distracting.
- Tag blocks can now be time-bound
## [1.60.0] - 2026-02-14
### Fixed
- Rare crash in widget handling
- Line-breaking behavior in comments now packs text densely
- Tag filtering now applies to widgets as well
### Changed
- Links to other posts now open in the app itself
- Widget handles bad network scenarios better
## [1.59.0] - 2026-01-09
### Fixed
- Crashes from process death
- Crashes in widget handling
- Android Auto Backup works again
### Changed
- Added a tag filtering option to settings
- Comment count badge has been restored
- Navigation behavior has been improved to avoid too-deep hierarchies
- Widget clicks are handled reliably
- Widget UI has been touched up and a preview added for Android 16+
- User profile page is now scrollable
## [1.58.0] - 2025-12-26
### Fixed
- Avoid crash from trying to import invalid backups
- Avoid crash when posts move between pages
### Changed
- Widget update is more reliable
- Increased network timeouts
- Better network retries and rate limit avoidance
- Temporarily remove comment count to fix crashes
## [1.57.0] - 2025-12-07
### Changed
@@ -98,7 +212,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Try to workaround an infrequent crash when scrolling hottest/newest posts
- Try to work around an infrequent crash when scrolling hottest/newest posts
### Changed
@@ -143,7 +257,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed a crash when clicking an item on the bottom navigation bar
too quickly
- Removed buggy deeplinks
- Removed buggy deep links
- Clicking a username now correctly navigates to the right page in-app
## [1.44.0] - 2024-03-19
@@ -400,7 +514,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Fix bug where newest posts tab actually showed hottest posts instead
- Fix bug where the newest posts tab actually showed the hottest posts instead
## [1.15.0] - 2022-12-20
@@ -422,7 +536,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Fix crash when viewing [jcs](https://lobste.rs/u/jcs)' comments
- Fix crash when viewing comments by ~jcs
## [1.12.0] - 2022-12-09
@@ -521,7 +635,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Initial Play Store release
[Unreleased]: https://github.com/msfjarvis/compose-lobsters/compare/v1.57.0...HEAD
[Unreleased]: https://github.com/msfjarvis/compose-lobsters/compare/v1.66.0...HEAD
[1.66.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.65.0...v1.66.0
[1.65.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.64.0...v1.65.0
[1.64.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.63.0...v1.64.0
[1.63.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.62.0...v1.63.0
[1.62.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.61.0...v1.62.0
[1.61.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.60.0...v1.61.0
[1.60.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.59.0...v1.60.0
[1.59.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.58.0...v1.59.0
[1.58.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.57.0...v1.58.0
[1.57.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.55.0...v1.57.0
[1.55.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.54.0...v1.55.0
[1.54.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.53.0...v1.54.0
+56 -18
View File
@@ -6,22 +6,25 @@
*/
@file:Suppress("UnstableApiUsage")
import dev.msfjarvis.claw.gradle.addTestDependencies
import dev.zacsweers.metro.gradle.DiagnosticSeverity
plugins {
id("dev.msfjarvis.claw.android-application")
id("dev.msfjarvis.claw.rename-artifacts")
id("dev.msfjarvis.claw.kotlin-android")
id("dev.msfjarvis.claw.kotlin-kapt")
id("dev.msfjarvis.claw.sentry")
id("dev.msfjarvis.claw.versioning-plugin")
id("kotlin-parcelize")
alias(libs.plugins.aboutlibraries)
alias(libs.plugins.anvil)
alias(libs.plugins.modulegraphassert)
alias(libs.plugins.whetstone)
alias(libs.plugins.baselineprofile)
alias(libs.plugins.licensee)
alias(libs.plugins.screenshot)
alias(libs.plugins.kotlin.composeCompiler)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.dependencyAnalysis)
alias(libs.plugins.metro)
}
android {
@@ -29,6 +32,7 @@ android {
defaultConfig.applicationId = "dev.msfjarvis.claw.android"
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildFeatures.compose = true
experimentalProperties["android.experimental.enableScreenshotTest"] = true
buildTypes.create("internal") {
matchingFallbacks += "release"
signingConfig = signingConfigs["debug"]
@@ -39,6 +43,11 @@ android {
aboutLibraries.collect.gitHubApiToken = providers.environmentVariable("GITHUB_TOKEN").orNull
baselineProfile {
mergeIntoMain = true
saveInSrc = true
}
licensee {
allow("Apache-2.0")
allow("MIT")
@@ -46,57 +55,78 @@ licensee {
ignoreDependencies("com.michael-bull.kotlin-result") { because("kotlin-result is ISC licensed") }
ignoreDependencies("org.commonmark") { because("Commonmark is BSD licensed") }
allowUrl("https://jsoup.org/license") { because("Jsoup is MIT licensed") }
allowUrl("https://opensource.org/license/MIT") { because("Ksoup is MIT licensed") }
}
metro { unusedGraphInputsSeverity = DiagnosticSeverity.ERROR }
moduleGraphAssert {
assertOnAnyBuild = true
maxHeight = 4
restricted = arrayOf(":core -X> :.*")
}
whetstone {
addOns {
compose.set(true)
workManager.set(true)
}
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
baselineProfile(projects.benchmark)
implementation(platform(libs.okhttp.bom))
implementation(libs.aboutLibraries.compose.core)
implementation(libs.aboutLibraries.core)
implementation(libs.aboutLibraries.m3)
implementation(libs.androidx.activity)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.compose.animation.core)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.foundation.layout)
implementation(libs.androidx.compose.glance)
implementation(libs.androidx.compose.glance.core)
implementation(libs.androidx.compose.glance.m3)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.adaptive.layout)
implementation(libs.androidx.compose.material3.window.size)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.compose.runtime.saveable)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.text)
implementation(libs.androidx.compose.ui.unit)
implementation(libs.androidx.annotation)
implementation(libs.androidx.collection)
implementation(libs.androidx.core)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.lifecycle.common)
implementation(libs.androidx.lifecycle.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.material3.navigation3)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.paging.common)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.profileinstaller)
implementation(libs.androidx.work.runtime)
implementation(libs.dagger)
implementation(libs.eithernet)
implementation(libs.haze)
implementation(libs.javax.inject)
implementation(libs.haze.blur)
implementation(libs.kotlin.parcelize.runtime)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.napier)
implementation(libs.kotlin.stdlib)
implementation(libs.metrox.android)
implementation(libs.metrox.viewmodel)
implementation(libs.metrox.viewmodel.compose)
implementation(libs.okhttp.core)
implementation(libs.okhttp.loggingInterceptor)
implementation(libs.retrofit)
implementation(libs.sentry)
implementation(libs.sentry.android.core)
implementation(libs.sqldelight.extensions.coroutines)
implementation(libs.sqldelight.runtime)
implementation(libs.swipe)
implementation(libs.unfurl)
implementation(projects.api)
@@ -105,7 +135,15 @@ dependencies {
implementation(projects.database.core)
implementation(projects.database.impl)
implementation(projects.model)
implementation(projects.web)
kapt(libs.dagger.compiler)
compileOnly(libs.androidx.compose.glance.preview)
debugRuntimeOnly(libs.androidx.compose.glance)
runtimeOnly(libs.androidx.profileinstaller)
screenshotTestImplementation(libs.screenshot.validation.api)
screenshotTestImplementation(libs.androidx.compose.ui.tooling)
addTestDependencies(project)
}
+122 -1
View File
@@ -1,4 +1,125 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 8.12.0-alpha02" type="baseline" client="gradle" dependencies="true" name="AGP (8.12.0-alpha02)" variant="all" version="8.12.0-alpha02">
<issues format="6" by="lint 9.2.1" type="baseline" client="gradle" dependencies="true" name="AGP (9.2.1)" variant="all" version="9.2.1">
<issue
id="Instantiatable"
message="This class should provide a default constructor (a public constructor with no arguments) (`dev.msfjarvis.claw.android.MainActivity`)"
errorLine1=" android:name=&quot;.MainActivity&quot;"
errorLine2=" ~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="28"
column="21"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.loadUrl is deprecated; consider using an alternative."
errorLine1=" webViewRef?.loadUrl(LOGIN_URL)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="69"
column="15"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.loadUrl is deprecated; consider using an alternative."
errorLine1=" webViewRef?.loadUrl(LOGIN_URL)"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="69"
column="35"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.WebView is deprecated; consider using an alternative."
errorLine1=" WebView(context)"
errorLine2=" ~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="78"
column="13"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.WebView is deprecated; consider using an alternative."
errorLine1=" WebView(context)"
errorLine2=" ~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="78"
column="21"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.getSettings is deprecated; consider using an alternative."
errorLine1=" settings.javaScriptEnabled = true"
errorLine2=" ~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="81"
column="17"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.setWebViewClient is deprecated; consider using an alternative."
errorLine1=" webViewClient ="
errorLine2=" ^">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="82"
column="17"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.setWebViewClient is deprecated; consider using an alternative."
errorLine1=" object : WebViewClient() {"
errorLine2=" ^">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="83"
column="19"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.loadUrl is deprecated; consider using an alternative."
errorLine1=" loadUrl(LOGIN_URL)"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="118"
column="17"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.loadUrl is deprecated; consider using an alternative."
errorLine1=" loadUrl(LOGIN_URL)"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="118"
column="25"/>
</issue>
<issue
id="MemberExtensionConflict"
message="`KeepUntilTransitionsFinished` is defined both as a member in class `androidx.compose.animation.ExitTransition.Companion` and an extension in package `androidx.compose.animation`. The defined behavior for this is to use the member, but since the extension is explicitly imported into this file, there&apos;s a chance that this was not expected. (One common way this happens is for members to be added to a class after code was already written to use an extension)."
errorLine1=" ) togetherWith ExitTransition.KeepUntilTransitionsFinished"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LobstersPostsScreen.kt"
line="259"
column="49"/>
</issue>
</issues>
@@ -1,14 +1,3 @@
-dontwarn android.window.OnBackInvokedCallback
-dontwarn android.window.OnBackInvokedDispatcher
-dontwarn org.bouncycastle.jsse.BCSSLParameters
-dontwarn org.bouncycastle.jsse.BCSSLSocket
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
-dontwarn org.conscrypt.Conscrypt$Version
-dontwarn org.conscrypt.Conscrypt
-dontwarn org.conscrypt.ConscryptHostnameVerifier
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE
-dontwarn androidx.window.extensions.WindowExtensions
-dontwarn androidx.window.extensions.WindowExtensionsProvider
-dontwarn androidx.window.extensions.area.ExtensionWindowAreaPresentation
@@ -26,3 +15,5 @@
-dontwarn androidx.window.sidecar.SidecarProvider
-dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo
-dontwarn io.mcarle.konvert.api.GeneratedKonverter
-dontwarn com.google.re2j.Matcher
-dontwarn com.google.re2j.Pattern
+46
View File
@@ -0,0 +1,46 @@
<!--
~ Copyright © Harsh Shandilya.
~ Use of this source code is governed by an MIT-style
~ license that can be found in the LICENSE file or at
~ https://opensource.org/licenses/MIT.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="claw-debug"
android:host="comments" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="claw-debug"
android:host="hottest" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="claw-debug"
android:host="newest" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="claw-debug"
android:host="saved" />
</intent-filter>
</activity>
</application>
</manifest>
+44 -5
View File
@@ -10,13 +10,16 @@
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".ClawApplication"
android:appComponentFactory="dev.msfjarvis.claw.android.BackupSafeAppComponentFactory"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/full_backup_content"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.Claw.SplashScreen"
tools:replace="android:appComponentFactory"
tools:targetApi="tiramisu">
<profileable
android:shell="true"
@@ -25,14 +28,42 @@
android:name=".MainActivity"
android:configChanges="assetsPaths|colorMode|density|fontScale|fontWeightAdjustment|grammaticalGender|keyboard|keyboardHidden|layoutDirection|locale|mcc|mnc|navigation|orientation|resourcesUnused|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:taskAffinity=""
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="claw" android:host="comments" android:pathPrefix="/" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="claw" android:host="hottest" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="claw" android:host="newest" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="claw" android:host="saved" />
</intent-filter>
</activity>
<activity
android:name=".glance.WidgetClickActivity"
android:exported="false"
android:theme="@android:style/Theme.NoDisplay"
android:excludeFromRecents="true"
android:taskAffinity="" />
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
@@ -42,14 +73,10 @@
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
<meta-data
android:name="com.deliveryhero.whetstone.worker.WhetstoneWorkerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<receiver
android:name=".glance.WidgetReceiver"
android:name=".glance.SavedPostsWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -59,6 +86,17 @@
android:resource="@xml/saved_posts_widget_info" />
</receiver>
<receiver
android:name=".glance.HottestPostsWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/hottest_posts_widget_info" />
</receiver>
<!-- Required: set your sentry.io project identifier (DSN) -->
<meta-data
android:name="io.sentry.dsn"
@@ -80,5 +118,6 @@
android:value="true" />
<!-- set the environment for this build -->
<meta-data android:name="io.sentry.environment" android:value="${sentryEnvironment}" />
<meta-data android:name="io.sentry.auto-init" android:value="false" />
</application>
</manifest>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android
import android.app.Application
import dev.zacsweers.metrox.android.MetroAppComponentFactory
/**
* Custom variant of [android.app.AppComponentFactory] that skips [ClawApplication] init when the
* app is running in a backup process for Android Auto Backup. In all normal cases, it will defer to
* [MetroAppComponentFactory].
*/
@Suppress("unused")
class BackupSafeAppComponentFactory : MetroAppComponentFactory() {
override fun instantiateApplicationCompat(cl: ClassLoader, className: String): Application {
if (isBackupAgentProcess()) {
return Application()
}
return super.instantiateApplicationCompat(cl, className)
}
private fun isBackupAgentProcess(): Boolean {
return try {
val processName = Application.getProcessName()
processName?.contains("backup") == true
} catch (_: Exception) {
false
}
}
}
@@ -7,42 +7,62 @@
package dev.msfjarvis.claw.android
import android.app.Application
import android.os.Build
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.deliveryhero.whetstone.Whetstone
import com.deliveryhero.whetstone.app.ApplicationComponentOwner
import com.deliveryhero.whetstone.app.ContributesAppInjector
import androidx.work.PeriodicWorkRequestBuilder
import dev.msfjarvis.claw.android.glance.SavedPostsWidgetReceiver
import dev.msfjarvis.claw.android.injection.AppGraph
import dev.msfjarvis.claw.android.work.SavedPostUpdaterWorker
import dev.msfjarvis.claw.core.injection.AppPlugin
import javax.inject.Inject
import dev.msfjarvis.claw.android.work.TagExpirationCleanupWorker
import dev.zacsweers.metro.createGraphFactory
import dev.zacsweers.metrox.android.MetroAppComponentProviders
import dev.zacsweers.metrox.android.MetroApplication
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@ContributesAppInjector(generateAppComponent = true)
class ClawApplication : Application(), ApplicationComponentOwner {
class ClawApplication : Application(), MetroApplication {
override val applicationComponent = GeneratedApplicationComponent.create(this)
val appGraph by lazy { createGraphFactory<AppGraph.Factory>().create(this) }
@Inject lateinit var plugins: Set<@JvmSuppressWildcards AppPlugin>
override val appComponentProviders: MetroAppComponentProviders
get() = appGraph
@OptIn(DelicateCoroutinesApi::class)
override fun onCreate() {
Whetstone.inject(this)
super.onCreate()
plugins.forEach { plugin -> plugin.apply(this) }
appGraph.plugins.forEach { plugin -> plugin.apply(this) }
val postUpdateWorkRequest =
OneTimeWorkRequestBuilder<SavedPostUpdaterWorker>()
PeriodicWorkRequestBuilder<SavedPostUpdaterWorker>(24, TimeUnit.HOURS)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
WorkManager.getInstance(this)
.enqueueUniqueWork(
uniqueWorkName = "updateSavedPosts",
existingWorkPolicy = ExistingWorkPolicy.KEEP,
request = postUpdateWorkRequest,
)
}
private companion object {
private const val POST_REFRESH_PERIOD = 24L // 24 hours
appGraph.workManager.enqueueUniquePeriodicWork(
uniqueWorkName = "updateSavedPosts",
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP,
request = postUpdateWorkRequest,
)
val tagCleanupWorkRequest =
PeriodicWorkRequestBuilder<TagExpirationCleanupWorker>(24, TimeUnit.HOURS).build()
appGraph.workManager.enqueueUniquePeriodicWork(
uniqueWorkName = "cleanupExpiredTags",
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP,
request = tagCleanupWorkRequest,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
GlobalScope.launch(appGraph.mainDispatcher) {
try {
GlanceAppWidgetManager(applicationContext)
.setWidgetPreviews(SavedPostsWidgetReceiver::class)
} catch (_: IllegalArgumentException) {
// Workaround for Motorola Android 15 bug where AppWidgetServiceImpl incorrectly
// reports registered widgets as invalid when setting previews.
// See: https://claw.sentry.io/share/issue/f8ee09821d8840b9b86293d3ffb627d3/
}
}
}
}
}
@@ -6,7 +6,9 @@
*/
package dev.msfjarvis.claw.android
import android.app.Activity
import android.app.assist.AssistContent
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -15,46 +17,96 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.UriHandler
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.deliveryhero.whetstone.Whetstone
import com.deliveryhero.whetstone.activity.ContributesActivityInjector
import androidx.lifecycle.ViewModelProvider
import androidx.navigation3.runtime.NavKey
import dev.msfjarvis.claw.android.ui.navigation.Comments
import dev.msfjarvis.claw.android.ui.navigation.Hottest
import dev.msfjarvis.claw.android.ui.navigation.Newest
import dev.msfjarvis.claw.android.ui.navigation.Saved
import dev.msfjarvis.claw.android.ui.screens.LobstersPostsScreen
import dev.msfjarvis.claw.common.theme.LobstersTheme
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
import javax.inject.Inject
import dev.msfjarvis.claw.core.injection.InjectedViewModelFactory
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.binding
import dev.zacsweers.metrox.android.ActivityKey
import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@ContributesActivityInjector
class MainActivity : ComponentActivity() {
@ContributesIntoMap(AppScope::class, binding<Activity>())
@ActivityKey
@Inject
class MainActivity(
private val uriHandler: UriHandler,
private val viewModelFactory: InjectedViewModelFactory,
) : ComponentActivity() {
override val defaultViewModelProviderFactory: ViewModelProvider.Factory
get() = viewModelFactory
@Inject lateinit var urlLauncher: UrlLauncher
var webUri: String? = null
var deepLinkDestination by mutableStateOf<NavKey?>(null)
private set
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
installSplashScreen()
Whetstone.inject(this)
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT)
// Don't set navigation bar style, the default matches the platform behavior.
)
handleIntent(intent)
setContent {
LobstersTheme(
dynamicColor = true,
providedValues = arrayOf(LocalUriHandler provides urlLauncher),
providedValues =
arrayOf(
LocalUriHandler provides uriHandler,
LocalMetroViewModelFactory provides viewModelFactory,
),
) {
val windowSizeClass = calculateWindowSizeClass(this)
LobstersPostsScreen(
urlLauncher = urlLauncher,
uriHandler = uriHandler,
windowSizeClass = windowSizeClass,
setWebUri = { url -> webUri = url },
deepLinkDestination = deepLinkDestination,
clearDeepLink = { deepLinkDestination = null },
)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent) {
val data = intent.data
if (data != null && data.scheme == BuildConfig.DEEPLINK_SCHEME) {
when (data.host) {
"comments" -> {
val postId = data.pathSegments.firstOrNull()
if (postId != null) {
deepLinkDestination = Comments(postId)
}
}
"newest" -> deepLinkDestination = Newest
"hottest" -> deepLinkDestination = Hottest
"saved" -> deepLinkDestination = Saved
}
}
}
override fun onProvideAssistContent(outContent: AssistContent?) {
super.onProvideAssistContent(outContent)
val uri = webUri
@@ -66,8 +118,4 @@ class MainActivity : ComponentActivity() {
}
}
}
companion object {
const val NAVIGATION_KEY = "postId"
}
}
@@ -0,0 +1,157 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.glance
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import com.slack.eithernet.ApiResult
import dev.msfjarvis.claw.android.BuildConfig
import dev.msfjarvis.claw.android.ClawApplication
import dev.msfjarvis.claw.android.MainActivity
import dev.msfjarvis.claw.android.R
import dev.msfjarvis.claw.database.local.CachedRemotePost
import dev.msfjarvis.claw.model.LobstersPost
import dev.msfjarvis.claw.model.UIPost
import dev.msfjarvis.claw.model.fromCachedRemotePost
import dev.msfjarvis.claw.model.toUIPost
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.first
class HottestPostsWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val appGraph = (context.applicationContext as ClawApplication).appGraph
val cachedRemotePostsRepository = appGraph.cachedRemotePostsRepository
val tagBlockRepository = appGraph.tagBlockRepository
val filteredTags =
try {
tagBlockRepository.getSavedTags().first()
} catch (_: Exception) {
emptySet()
}
val posts =
when (val postsResult = appGraph.lobstersApi.getHottestPosts(1)) {
is ApiResult.Success -> {
val uiPosts = postsResult.value.posts.map(LobstersPost::toUIPost)
// Cache the posts for future use when network is unavailable
// Using try-catch to prevent widget rendering failures if caching fails
try {
val cachedPosts = uiPosts.mapIndexed { index, post ->
CachedRemotePost(
shortId = post.shortId,
title = post.title,
url = post.url,
createdAt = post.createdAt,
commentCount = post.commentCount,
commentsUrl = post.commentsUrl,
submitterName = post.submitter,
tags = post.tags,
description = post.description,
userIsAuthor = post.userIsAuthor,
insertionOrder = index,
)
}
cachedRemotePostsRepository.savePosts(cachedPosts)
} catch (_: Exception) {
// Silently ignore caching failures - widget should still render
}
uiPosts.toImmutableList()
}
else -> {
// Fall back to cached posts when network fails
cachedRemotePostsRepository
.getCachedPosts()
.map(UIPost::fromCachedRemotePost)
.toImmutableList()
}
}
// Filter out posts with tags that are in the filtered tags set
val filteredPosts =
if (filteredTags.isEmpty()) {
posts
} else {
posts
.filter { post -> post.tags.none { tag -> filteredTags.contains(tag) } }
.toImmutableList()
}
provideContent { LobstersGlanceTheme { Content(context, filteredPosts) } }
}
override suspend fun providePreview(context: Context, widgetCategory: Int) {
provideContent { Content(context, samplePosts()) }
}
@SuppressLint("ComposeUnstableReceiver")
@Composable
private fun Content(
context: Context,
posts: ImmutableList<UIPost>,
modifier: GlanceModifier = GlanceModifier,
) {
WidgetContainer(
"Hottest posts",
listContent = {
items(posts) { post ->
Box(GlanceModifier.padding(horizontal = 16.dp, vertical = 4.dp)) { WidgetPostEntry(post) }
}
item {
Box(
contentAlignment = Alignment.Center,
modifier = GlanceModifier.fillMaxWidth().padding(16.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier =
GlanceModifier.background(GlanceTheme.colors.primary)
.cornerRadius(20.dp)
.padding(horizontal = 24.dp, vertical = 10.dp)
.clickable(
actionStartActivity(
Intent(Intent.ACTION_VIEW, "${BuildConfig.DEEPLINK_SCHEME}://hottest".toUri())
.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
setClass(LocalContext.current, MainActivity::class.java)
}
)
),
) {
Text(
text = context.getString(R.string.see_more_posts),
style =
TextStyle(color = GlanceTheme.colors.onPrimary, textAlign = TextAlign.Center),
)
}
}
}
},
modifier = modifier,
)
}
}
@@ -6,25 +6,26 @@
*/
package dev.msfjarvis.claw.android.glance
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import com.deliveryhero.whetstone.Whetstone
import com.deliveryhero.whetstone.broadcastreceiver.ContributesBroadcastReceiverInjector
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
import javax.inject.Inject
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.binding
import dev.zacsweers.metrox.android.BroadcastReceiverKey
@ContributesBroadcastReceiverInjector
class WidgetReceiver : GlanceAppWidgetReceiver() {
@Inject lateinit var viewModel: ClawViewModel
@ContributesIntoMap(AppScope::class, binding<BroadcastReceiver>())
@BroadcastReceiverKey
@Inject
class HottestPostsWidgetReceiver() : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget
get() = SavedPostsWidget(viewModel.savedPosts)
get() = HottestPostsWidget()
override fun onReceive(context: Context, intent: Intent) {
Whetstone.inject(this, context)
super.onReceive(context, intent)
}
}
@@ -6,69 +6,106 @@
*/
package dev.msfjarvis.claw.android.glance
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import androidx.compose.material3.MaterialTheme
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.glance.GlanceComposable
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.appWidgetBackground
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.material3.ColorProviders
import androidx.glance.preview.ExperimentalGlancePreviewApi
import androidx.glance.preview.Preview
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import dev.msfjarvis.claw.common.theme.DarkThemeColors
import dev.msfjarvis.claw.common.theme.LightThemeColors
import dev.msfjarvis.claw.android.BuildConfig
import dev.msfjarvis.claw.android.ClawApplication
import dev.msfjarvis.claw.android.MainActivity
import dev.msfjarvis.claw.android.R
import dev.msfjarvis.claw.model.UIPost
import dev.msfjarvis.claw.model.fromSavedPost
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
class SavedPostsWidget(private val posts: Flow<List<UIPost>>) : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val postWindow = posts.first().take(50).toImmutableList()
provideContent {
GlanceTheme(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) GlanceTheme.colors
else ColorProviders(light = LightThemeColors, dark = DarkThemeColors)
) {
WidgetHost(postWindow)
}
}
}
}
class SavedPostsWidget : GlanceAppWidget() {
@Composable
fun WidgetHost(posts: ImmutableList<UIPost>, modifier: GlanceModifier = GlanceModifier) {
LazyColumn(
modifier =
modifier.fillMaxSize().background(GlanceTheme.colors.background).appWidgetBackground(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
item {
val style = MaterialTheme.typography.titleLarge
Text(
text = "Saved posts",
style =
TextStyle(
color = GlanceTheme.colors.onBackground,
fontSize = style.fontSize,
fontWeight = style.fontWeight.toGlance(),
fontStyle = style.fontStyle.toGlance(),
),
modifier = GlanceModifier.padding(vertical = 8.dp),
)
}
items(posts) { post -> WidgetListEntry(post = post) }
override suspend fun provideGlance(context: Context, id: GlanceId) {
val appGraph = (context.applicationContext as ClawApplication).appGraph
val posts = appGraph.savedPostsRepository.getRecentPosts(50)
val postWindow = posts.first().map(UIPost::fromSavedPost).toImmutableList()
provideContent { LobstersGlanceTheme { Content(postWindow) } }
}
override suspend fun providePreview(context: Context, widgetCategory: Int) {
provideContent { Content(samplePosts()) }
}
@SuppressLint("ComposeUnstableReceiver")
@Composable
private fun Content(posts: ImmutableList<UIPost>, modifier: GlanceModifier = GlanceModifier) {
val context = LocalContext.current
WidgetContainer(
title = context.getString(R.string.saved_posts),
listContent = {
items(posts) { post ->
Box(GlanceModifier.padding(horizontal = 16.dp, vertical = 4.dp)) { WidgetPostEntry(post) }
}
item {
Box(
contentAlignment = Alignment.Center,
modifier = GlanceModifier.fillMaxWidth().padding(16.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier =
GlanceModifier.background(GlanceTheme.colors.primary)
.cornerRadius(20.dp)
.padding(horizontal = 24.dp, vertical = 10.dp)
.clickable(
actionStartActivity(
Intent(Intent.ACTION_VIEW, "${BuildConfig.DEEPLINK_SCHEME}://saved".toUri())
.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
setClass(LocalContext.current, MainActivity::class.java)
}
)
),
) {
Text(
text = context.getString(R.string.see_more_posts),
style =
TextStyle(color = GlanceTheme.colors.onPrimary, textAlign = TextAlign.Center),
)
}
}
}
},
modifier = modifier,
)
}
@SuppressLint("ComposeUnstableReceiver")
@OptIn(ExperimentalGlancePreviewApi::class)
@Preview
@GlanceComposable
@Composable
private fun Preview() {
Content(samplePosts())
}
}
@@ -0,0 +1,31 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.glance
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.binding
import dev.zacsweers.metrox.android.BroadcastReceiverKey
@ContributesIntoMap(AppScope::class, binding<BroadcastReceiver>())
@BroadcastReceiverKey
@Inject
class SavedPostsWidgetReceiver() : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget
get() = SavedPostsWidget()
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
}
}
@@ -0,0 +1,96 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.glance
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import androidx.core.net.toUri
import dev.msfjarvis.claw.android.BuildConfig
import dev.msfjarvis.claw.android.MainActivity
import io.sentry.Breadcrumb
import io.sentry.Sentry
import io.sentry.SentryLevel
/**
* Trampoline activity for handling widget clicks with explicit components.
*
* This activity is required to fix COMPOSE-LOBSTERS-9X/9P/8M. Glance's LazyColumn requires all
* actionStartActivity intents to have an explicit component set. For external URLs, we route
* through this activity which then launches the appropriate intent (either internal deep link or
* external browser).
*
* @see:
* https://developer.android.com/reference/kotlin/androidx/glance/appwidget/action/package-summary#actionStartActivity(android.content.Intent,androidx.glance.action.ActionParameters)
*/
class WidgetClickActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val url = intent.getStringExtra(EXTRA_URL)
val shortId = intent.getStringExtra(EXTRA_SHORT_ID)
val action = intent.action
when {
// Handle comments deep link
action == ACTION_OPEN_COMMENTS && shortId != null -> {
Sentry.addBreadcrumb(
Breadcrumb().apply {
category = "widget_click_debug"
message = "Comment deeplink"
level = SentryLevel.INFO
}
)
val commentsIntent =
Intent(Intent.ACTION_VIEW, "${BuildConfig.DEEPLINK_SCHEME}://comments/$shortId".toUri())
.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
setClass(this@WidgetClickActivity, MainActivity::class.java)
}
startActivity(commentsIntent)
}
// Handle external URL
url != null && (url.startsWith("http://") || url.startsWith("https://")) -> {
Sentry.addBreadcrumb(
Breadcrumb().apply {
category = "widget_click_debug"
message = "External URL"
level = SentryLevel.INFO
}
)
val urlIntent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(urlIntent)
}
// Handle internal/relative URL as comments link
url != null && (url.startsWith('/') || url.isEmpty()) -> {
Sentry.addBreadcrumb(
Breadcrumb().apply {
category = "widget_click_debug"
message = "Relative URL, parsed as a comment link"
level = SentryLevel.INFO
}
)
// For internal URLs without shortId, extract it from the URL or use comments action
val commentsIntent =
Intent(Intent.ACTION_VIEW, "${BuildConfig.DEEPLINK_SCHEME}://comments/$shortId".toUri())
.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
setClass(this@WidgetClickActivity, MainActivity::class.java)
}
startActivity(commentsIntent)
}
}
finish()
}
companion object {
const val ACTION_OPEN_COMMENTS = "dev.msfjarvis.claw.OPEN_COMMENTS"
const val EXTRA_URL = "dev.msfjarvis.claw.EXTRA_URL"
const val EXTRA_SHORT_ID = "dev.msfjarvis.claw.EXTRA_SHORT_ID"
}
}
@@ -1,77 +0,0 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.glance
import android.content.Intent
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.glance.GlanceComposable
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.action.ActionParameters.Key
import androidx.glance.action.actionParametersOf
import androidx.glance.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import dev.msfjarvis.claw.android.MainActivity
import dev.msfjarvis.claw.android.MainActivity.Companion.NAVIGATION_KEY
import dev.msfjarvis.claw.android.R
import dev.msfjarvis.claw.model.UIPost
private val destinationKey = Key<String>(NAVIGATION_KEY)
@Composable
@GlanceComposable
fun WidgetListEntry(post: UIPost, modifier: GlanceModifier = GlanceModifier) {
val titleStyle = MaterialTheme.typography.titleMedium
val commentsAction =
actionStartActivity<MainActivity>(actionParametersOf(destinationKey to post.shortId))
// If the URL starts with a /, it's a relative URL and we should open the comments page directly.
val postAction =
if (post.url.startsWith('/')) commentsAction
else actionStartActivity(Intent(Intent.ACTION_VIEW, post.url.toUri()))
Box(modifier.padding(8.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
GlanceModifier.fillMaxWidth()
.background(GlanceTheme.colors.surfaceVariant)
.cornerRadius(4.dp)
.padding(8.dp),
) {
Text(
text = post.title,
modifier = GlanceModifier.defaultWeight().padding(start = 4.dp).clickable(postAction),
style =
TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = titleStyle.fontSize,
fontWeight = titleStyle.fontWeight.toGlance(),
fontStyle = titleStyle.fontStyle.toGlance(),
),
)
Image(
provider = ImageProvider(R.drawable.ic_comment),
contentDescription = "${post.commentCount} comments",
modifier = GlanceModifier.padding(end = 4.dp).clickable(commentsAction),
)
}
}
}
@@ -0,0 +1,97 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.glance
import android.annotation.SuppressLint
import android.content.Intent
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.ColorFilter
import androidx.glance.GlanceComposable
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.action.clickable
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.preview.ExperimentalGlancePreviewApi
import androidx.glance.preview.Preview
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import dev.msfjarvis.claw.android.R
import dev.msfjarvis.claw.model.UIPost
@SuppressLint("ArgInFormattedQuantityStringRes")
@Composable
@GlanceComposable
fun WidgetPostEntry(post: UIPost, modifier: GlanceModifier = GlanceModifier) {
val titleStyle = MaterialTheme.typography.bodyMedium
val context = LocalContext.current
val commentsAction =
actionStartActivity(
Intent(context, WidgetClickActivity::class.java).apply {
action = WidgetClickActivity.ACTION_OPEN_COMMENTS
putExtra(WidgetClickActivity.EXTRA_SHORT_ID, post.shortId)
}
)
val postAction =
if (post.url.startsWith('/') || post.url.isEmpty()) commentsAction
else
actionStartActivity(
Intent(context, WidgetClickActivity::class.java).apply {
putExtra(WidgetClickActivity.EXTRA_URL, post.url)
putExtra(WidgetClickActivity.EXTRA_SHORT_ID, post.shortId)
}
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
modifier.fillMaxWidth().cornerRadius(8.dp).background(GlanceTheme.colors.secondaryContainer),
) {
Text(
text = post.title,
modifier =
GlanceModifier.defaultWeight()
.clickable(postAction)
.padding(horizontal = 8.dp, vertical = 4.dp)
.padding(start = 4.dp),
maxLines = 1,
style =
TextStyle(color = GlanceTheme.colors.onSecondaryContainer, fontSize = titleStyle.fontSize),
)
Image(
provider = ImageProvider(R.drawable.ic_comment),
contentDescription =
context.resources.getQuantityString(
R.plurals.comments,
post.commentCount,
post.commentCount,
),
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSecondaryContainer),
modifier =
GlanceModifier.clickable(commentsAction)
.padding(horizontal = 8.dp, vertical = 4.dp)
.padding(horizontal = 4.dp),
)
}
}
@OptIn(ExperimentalGlancePreviewApi::class)
@Preview
@GlanceComposable
@Composable
private fun WidgetPostEntryPreview() {
WidgetPostEntry(samplePosts(1).first())
}
@@ -6,22 +6,67 @@
*/
package dev.msfjarvis.claw.android.glance
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import android.annotation.SuppressLint
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceComposable
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.LazyListScope
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.material3.ColorProviders
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import dev.msfjarvis.claw.common.posts.TEST_POST
import dev.msfjarvis.claw.common.theme.DarkThemeColors
import dev.msfjarvis.claw.common.theme.LightThemeColors
import dev.msfjarvis.claw.model.UIPost
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
fun FontWeight?.toGlance(): androidx.glance.text.FontWeight? {
return when (this) {
FontWeight.Normal -> androidx.glance.text.FontWeight.Normal
FontWeight.Medium -> androidx.glance.text.FontWeight.Medium
FontWeight.Bold -> androidx.glance.text.FontWeight.Bold
else -> null
@Composable
fun LobstersGlanceTheme(content: @GlanceComposable @Composable () -> Unit) {
GlanceTheme(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) GlanceTheme.colors
else ColorProviders(light = LightThemeColors, dark = DarkThemeColors),
content,
)
}
@Composable
fun WidgetContainer(
title: String,
listContent: LazyListScope.() -> Unit,
modifier: GlanceModifier = GlanceModifier,
) {
Column(
modifier.fillMaxSize().background(GlanceTheme.colors.widgetBackground).cornerRadius(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = GlanceModifier.padding(16.dp).fillMaxWidth(),
) {
Text(text = title, style = TextStyle(fontSize = 18.sp, color = GlanceTheme.colors.onSurface))
}
LazyColumn(content = listContent)
}
}
fun FontStyle?.toGlance(): androidx.glance.text.FontStyle? {
return when (this) {
FontStyle.Normal -> androidx.glance.text.FontStyle.Normal
FontStyle.Italic -> androidx.glance.text.FontStyle.Italic
else -> null
}
@SuppressLint("VisibleForTests")
fun samplePosts(
count: Int = 5,
mutator: UIPost.(idx: Int) -> UIPost = { this },
): PersistentList<UIPost> {
return buildList(count) { repeat(count) { add(TEST_POST.mutator(it)) } }.toPersistentList()
}
@@ -0,0 +1,61 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.injection
import android.content.Context
import androidx.work.ListenableWorker
import androidx.work.WorkManager
import dev.msfjarvis.claw.android.viewmodel.CachedRemotePostsRepository
import dev.msfjarvis.claw.android.viewmodel.SavedPostsRepository
import dev.msfjarvis.claw.api.LobstersApi
import dev.msfjarvis.claw.common.tags.TagBlockRepository
import dev.msfjarvis.claw.core.coroutines.MainDispatcher
import dev.msfjarvis.claw.core.injection.AppPlugin
import dev.msfjarvis.claw.core.injection.InjectedWorkerFactory
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.DependencyGraph
import dev.zacsweers.metro.Multibinds
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metrox.android.MetroAppComponentProviders
import dev.zacsweers.metrox.viewmodel.ViewModelGraph
import kotlin.reflect.KClass
import kotlinx.coroutines.CoroutineDispatcher
@DependencyGraph(AppScope::class)
interface AppGraph : MetroAppComponentProviders, ViewModelGraph {
val plugins: Set<AppPlugin>
val workManager: WorkManager
@Provides
@SingleIn(AppScope::class)
fun providesWorkManager(application: Context): WorkManager {
return WorkManager.getInstance(application)
}
@Multibinds
val workerProviders:
Map<KClass<out ListenableWorker>, () -> InjectedWorkerFactory.WorkerInstanceFactory<*>>
val workerFactory: InjectedWorkerFactory
val savedPostsRepository: SavedPostsRepository
val cachedRemotePostsRepository: CachedRemotePostsRepository
val lobstersApi: LobstersApi
val tagBlockRepository: TagBlockRepository
@MainDispatcher val mainDispatcher: CoroutineDispatcher
@DependencyGraph.Factory
fun interface Factory {
fun create(@Provides context: Context): AppGraph
}
}
@@ -6,27 +6,27 @@
*/
package dev.msfjarvis.claw.android.injection
import com.deliveryhero.whetstone.app.ApplicationScope
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.github.aakira.napier.Napier
import android.util.Log
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import me.saket.unfurl.UnfurlLogger
import me.saket.unfurl.Unfurler
import okhttp3.OkHttpClient
@Module
@ContributesTo(ApplicationScope::class)
@BindingContainer
@ContributesTo(AppScope::class)
object MetadataExtractorModule {
@Provides
fun provideUnfurlLogger(): UnfurlLogger {
return object : UnfurlLogger {
override fun log(message: String) {
Napier.d(tag = "Unfurler") { message }
Log.d("Unfurler", message)
}
override fun log(e: Throwable, message: String) {
Napier.e(tag = "Unfurler", throwable = e) { message }
Log.e("Unfurler", message, e)
}
}
}
@@ -0,0 +1,37 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.injection
import android.app.Application
import dev.msfjarvis.claw.api.LobstersApi
import dev.msfjarvis.claw.core.injection.AppPlugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
import io.sentry.SentryOptions
import io.sentry.TypeCheckHint.OKHTTP_REQUEST
import io.sentry.android.core.SentryAndroid
import okhttp3.Request
@ContributesIntoSet(AppScope::class)
@Inject
class SentryConfigurationPlugin : AppPlugin {
override fun apply(application: Application) {
SentryAndroid.init(application) { options ->
options.beforeSend = SentryOptions.BeforeSendCallback { event, hint ->
val request = hint.getAs(OKHTTP_REQUEST, Request::class.java)
// Drop all OkHttp errors that are not about Lobsters specifically.
if (request != null && !LobstersApi.BASE_URL.contains(request.url.host)) {
null
} else {
event
}
}
}
}
}
@@ -1,24 +0,0 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.injection
import android.content.Context
import com.deliveryhero.whetstone.activity.ActivityScope
import com.squareup.anvil.annotations.ContributesTo
import com.squareup.anvil.annotations.optional.ForScope
import dagger.Module
import dagger.Provides
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
@Module
@ContributesTo(ActivityScope::class)
object UrlLauncherModule {
@Provides
fun provideUrlLauncher(@ForScope(ActivityScope::class) context: Context): UrlLauncher {
return UrlLauncher(context)
}
}
@@ -11,13 +11,14 @@ import android.util.Log
import androidx.work.Configuration
import androidx.work.WorkManager
import androidx.work.WorkerFactory
import com.deliveryhero.whetstone.app.ApplicationScope
import com.squareup.anvil.annotations.ContributesMultibinding
import dev.msfjarvis.claw.core.injection.AppPlugin
import javax.inject.Inject
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
@ContributesMultibinding(ApplicationScope::class)
class WorkManagerPlugin @Inject constructor(private val workerFactory: WorkerFactory) : AppPlugin {
@ContributesIntoSet(AppScope::class)
@Inject
class WorkManagerPlugin(private val workerFactory: WorkerFactory) : AppPlugin {
override fun apply(application: Application) {
WorkManager.initialize(
application,
@@ -10,23 +10,22 @@ import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.slack.eithernet.ApiResult.Failure
import com.slack.eithernet.ApiResult.Success
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dev.msfjarvis.claw.api.toError
import dev.msfjarvis.claw.core.coroutines.IODispatcher
import dev.msfjarvis.claw.model.LobstersPost
import dev.msfjarvis.claw.model.UIPost
import dev.msfjarvis.claw.model.toUIPost
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import java.io.IOException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
class LobstersPagingSource
@AssistedInject
constructor(
class LobstersPagingSource(
@Assisted private val remoteFetcher: RemoteFetcher<LobstersPost>,
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
@param:IODispatcher private val ioDispatcher: CoroutineDispatcher,
) : PagingSource<Int, UIPost>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UIPost> {
val page = params.key ?: STARTING_PAGE_INDEX
@@ -9,9 +9,6 @@ package dev.msfjarvis.claw.android.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.slack.eithernet.ApiResult
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.PAGE_SIZE
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.STARTING_PAGE_INDEX
import dev.msfjarvis.claw.api.LobstersSearchApi
@@ -20,6 +17,9 @@ import dev.msfjarvis.claw.core.coroutines.IODispatcher
import dev.msfjarvis.claw.model.LobstersPost
import dev.msfjarvis.claw.model.UIPost
import dev.msfjarvis.claw.model.toUIPost
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import java.io.IOException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
@@ -30,12 +30,11 @@ import kotlinx.coroutines.withContext
* relevant parameters to short-circuit when there is no query specified in order to avoid calling
* the API at all.
*/
class SearchPagingSource
@AssistedInject
constructor(
class SearchPagingSource(
private val searchApi: LobstersSearchApi,
@Assisted private val queryProvider: () -> String,
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
@param:IODispatcher private val ioDispatcher: CoroutineDispatcher,
) : PagingSource<Int, UIPost>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UIPost> {
val searchQuery = queryProvider()
@@ -45,13 +45,13 @@ fun ClawAppBar(
IconButton(onClick = { if (popBackStack() == null) activity?.finish() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Go back to previous screen",
contentDescription = stringResource(R.string.go_back_to_previous_screen),
)
}
} else {
Icon(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = "The app icon for Claw",
contentDescription = stringResource(R.string.the_app_icon_for_claw),
modifier = Modifier.size(48.dp),
)
}
@@ -64,10 +64,16 @@ fun ClawAppBar(
actions = {
if (isTopLevel) {
IconButton(onClick = { navigateTo(Search) }) {
Icon(imageVector = Icons.Filled.Search, contentDescription = "Search posts")
Icon(
imageVector = Icons.Filled.Search,
contentDescription = stringResource(R.string.search_posts),
)
}
IconButton(onClick = { navigateTo(Settings) }) {
Icon(imageVector = Icons.Filled.Tune, contentDescription = "Settings")
Icon(
imageVector = Icons.Filled.Tune,
contentDescription = stringResource(R.string.settings),
)
}
}
},
@@ -27,9 +27,9 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavKey
import dev.chrisbanes.haze.HazeDefaults
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.blur.HazeBlurDefaults
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import dev.msfjarvis.claw.android.ui.navigation.AppDestinations
import dev.msfjarvis.claw.common.ui.FloatingNavigationBar
@@ -62,6 +62,7 @@ fun ClawNavigationBar(
),
label = "",
content = {
val surfaceColor = MaterialTheme.colorScheme.surface
FloatingNavigationBar(
tonalElevation = 16.dp,
shape = MaterialTheme.shapes.extraLarge,
@@ -70,18 +71,15 @@ fun ClawNavigationBar(
.padding(horizontal = 16.dp)
.navigationBarsPadding()
.clip(MaterialTheme.shapes.extraLarge)
.hazeEffect(
hazeState,
style =
HazeStyle(
backgroundColor = MaterialTheme.colorScheme.surface,
tints = emptyList(),
blurRadius = 24.dp,
noiseFactor = 0f,
),
),
containerColor =
if (HazeDefaults.blurEnabled()) Color.Transparent else MaterialTheme.colorScheme.surface,
.hazeEffect(hazeState) {
blurEffect {
backgroundColor = surfaceColor
colorEffects = emptyList()
blurRadius = 24.dp
noiseFactor = 0f
}
},
containerColor = if (HazeBlurDefaults.blurEnabled()) Color.Transparent else surfaceColor,
) {
items.forEach { navItem ->
val isSelected = currentNavKey == navItem.navKey
@@ -8,22 +8,22 @@ package dev.msfjarvis.claw.android.ui
import android.content.Context
import android.content.Intent
import androidx.compose.ui.platform.UriHandler
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
import dev.msfjarvis.claw.common.posts.PostActions
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
import dev.msfjarvis.claw.model.LinkMetadata
import dev.msfjarvis.claw.model.UIPost
fun PostActions(
context: Context,
urlLauncher: UrlLauncher,
uriHandler: UriHandler,
viewModel: ClawViewModel,
navigateToComments: (String) -> Unit,
): PostActions {
return object : PostActions {
override fun viewPost(postId: String, postUrl: String, commentsUrl: String) {
viewModel.markPostAsRead(postId)
urlLauncher.openUri(postUrl.ifEmpty { commentsUrl })
uriHandler.openUri(postUrl.ifEmpty { commentsUrl })
}
override fun viewComments(postId: String) {
@@ -32,7 +32,7 @@ fun PostActions(
}
override fun viewCommentsPage(post: UIPost) {
urlLauncher.openUri(post.commentsUrl)
uriHandler.openUri(post.commentsUrl)
}
override fun toggleSave(post: UIPost) {
@@ -25,7 +25,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.msfjarvis.claw.android.R
import dev.msfjarvis.claw.common.posts.PostActions
import dev.msfjarvis.claw.common.ui.decorations.MonthHeader
import dev.msfjarvis.claw.model.UIPost
@@ -46,17 +48,27 @@ fun DatabasePosts(
Column(modifier = Modifier.align(Alignment.Center)) {
Icon(
imageVector = Icons.Filled.Inbox,
contentDescription = "Empty inbox icon",
contentDescription = stringResource(R.string.empty_inbox_icon),
modifier = Modifier.align(Alignment.CenterHorizontally).size(36.dp),
)
Text(text = "No saved posts", style = MaterialTheme.typography.headlineSmall)
Text(
text = stringResource(R.string.no_saved_posts),
style = MaterialTheme.typography.headlineSmall,
)
}
} else {
LazyColumn(state = listState, contentPadding = contentPadding) {
items.forEach { (month, posts) ->
stickyHeader(contentType = "month-header") { MonthHeader(label = month) }
items(items = posts, key = { it.shortId }, contentType = { "LobstersItem" }) { item ->
LobstersListItem(item = item, postActions = postActions)
val isSaved = postActions.isPostSaved(item)
val isRead = postActions.isPostRead(item)
LobstersListItem(
item = item,
isSaved = isSaved,
isRead = isRead,
postActions = postActions,
)
HorizontalDivider()
}
}
@@ -14,9 +14,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.dp
import dev.msfjarvis.claw.android.glance.samplePosts
import dev.msfjarvis.claw.common.posts.LobstersCard
import dev.msfjarvis.claw.common.posts.PostActions
import dev.msfjarvis.claw.common.posts.TEST_POST
import dev.msfjarvis.claw.common.posts.TEST_POST_ACTIONS
import dev.msfjarvis.claw.common.theme.LobstersTheme
import dev.msfjarvis.claw.common.ui.preview.ThemePreviews
@@ -25,7 +25,13 @@ import me.saket.swipe.SwipeAction
import me.saket.swipe.SwipeableActionsBox
@Composable
fun LobstersListItem(item: UIPost, postActions: PostActions, modifier: Modifier = Modifier) {
fun LobstersListItem(
item: UIPost,
isSaved: Boolean,
isRead: Boolean,
postActions: PostActions,
modifier: Modifier = Modifier,
) {
val commentsAction =
SwipeAction(
icon = rememberVectorPainter(Icons.AutoMirrored.Filled.Reply),
@@ -44,12 +50,25 @@ fun LobstersListItem(item: UIPost, postActions: PostActions, modifier: Modifier
swipeThreshold = 80.dp,
backgroundUntilSwipeThreshold = MaterialTheme.colorScheme.surfaceVariant,
) {
LobstersCard(post = item, postActions = postActions, modifier = modifier)
LobstersCard(
post = item,
isSaved = isSaved,
isRead = isRead,
postActions = postActions,
modifier = modifier,
)
}
}
@ThemePreviews
@Composable
private fun ItemPreview() {
LobstersTheme { LobstersListItem(item = TEST_POST, postActions = TEST_POST_ACTIONS) }
LobstersTheme {
LobstersListItem(
item = samplePosts(1).first(),
isSaved = true,
isRead = true,
postActions = TEST_POST_ACTIONS,
)
}
}
@@ -31,15 +31,16 @@ import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey
import dev.msfjarvis.claw.android.glance.samplePosts
import dev.msfjarvis.claw.common.posts.PostActions
import dev.msfjarvis.claw.common.posts.TEST_POST
import dev.msfjarvis.claw.common.posts.TEST_POST_ACTIONS
import dev.msfjarvis.claw.common.theme.LobstersTheme
import dev.msfjarvis.claw.common.ui.NetworkError
import dev.msfjarvis.claw.common.ui.ProgressBar
import dev.msfjarvis.claw.common.ui.preview.DevicePreviews
import dev.msfjarvis.claw.model.UIPost
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.flow.MutableStateFlow
@OptIn(ExperimentalMaterial3Api::class)
@@ -49,6 +50,7 @@ fun NetworkPosts(
listState: LazyListState,
postActions: PostActions,
contentPadding: PaddingValues,
filteredTags: ImmutableSet<String>,
modifier: Modifier = Modifier,
) {
ReportDrawnWhen { lazyPagingItems.itemCount > 0 }
@@ -77,13 +79,24 @@ fun NetworkPosts(
LazyColumn(contentPadding = contentPadding, state = listState) {
items(
count = lazyPagingItems.itemCount,
key = lazyPagingItems.itemKey { it.shortId },
// Intentionally keyed on just the index to avoid crashes
key = { index -> index },
contentType = lazyPagingItems.itemContentType { "LobstersItem" },
) { index ->
val item = lazyPagingItems[index]
if (item != null) {
LobstersListItem(item = item, postActions = postActions)
HorizontalDivider()
val shouldShowPost = item.tags.none { tag -> filteredTags.contains(tag) }
if (shouldShowPost) {
val isSaved = postActions.isPostSaved(item)
val isRead = postActions.isPostRead(item)
LobstersListItem(
item = item,
isSaved = isSaved,
isRead = isRead,
postActions = postActions,
)
HorizontalDivider()
}
}
}
if (lazyPagingItems.loadState.append == LoadState.Loading) {
@@ -104,7 +117,7 @@ fun NetworkPosts(
@DevicePreviews
@Composable
private fun ListPreview() {
val items = List(20) { TEST_POST.copy(shortId = "${TEST_POST.shortId}${it}") }
val items = samplePosts(20) { this.copy(shortId = "${this.shortId}${it}") }
val flow = MutableStateFlow(PagingData.from(items))
LobstersTheme {
NetworkPosts(
@@ -112,6 +125,7 @@ private fun ListPreview() {
listState = rememberLazyListState(),
postActions = TEST_POST_ACTIONS,
contentPadding = PaddingValues(),
filteredTags = persistentSetOf(),
)
}
}
@@ -19,6 +19,7 @@ import androidx.paging.compose.LazyPagingItems
import dev.msfjarvis.claw.common.posts.PostActions
import dev.msfjarvis.claw.common.ui.SearchBar
import dev.msfjarvis.claw.model.UIPost
import kotlinx.collections.immutable.ImmutableSet
@Composable
fun SearchList(
@@ -28,6 +29,7 @@ fun SearchList(
searchQuery: String,
setSearchQuery: (String) -> Unit,
contentPadding: PaddingValues,
filteredTags: ImmutableSet<String>,
modifier: Modifier = Modifier,
) {
val triggerSearch = { query: String ->
@@ -46,6 +48,7 @@ fun SearchList(
listState = listState,
postActions = postActions,
contentPadding = PaddingValues(0.dp),
filteredTags = filteredTags,
)
}
}
@@ -1,45 +0,0 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.ui.login
import android.webkit.CookieManager
import android.webkit.WebView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.google.accompanist.web.AccompanistWebViewClient
import com.google.accompanist.web.WebView
import com.google.accompanist.web.rememberWebViewState
import dev.msfjarvis.claw.api.LobstersApi
import io.github.aakira.napier.Napier
import okhttp3.HttpUrl.Companion.toHttpUrl
@Suppress("Unused")
@Composable
fun LobstersLoginWebView(modifier: Modifier = Modifier) {
val state = rememberWebViewState(url = "https://lobste.rs/login")
val webClient = remember {
object : AccompanistWebViewClient() {
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
Napier.d(tag = "WebView") { "onPageFinished(url=$url)" }
if (url != null && url.toHttpUrl() == LobstersApi.BASE_URL.toHttpUrl()) {
val cookies = CookieManager.getInstance().getCookie(LobstersApi.BASE_URL)
val trap =
cookies
.split(";")
.associate {
val split = it.split("=")
split[0] to split[1]
}["lobsters_trap"]
Napier.d(tag = "WebView") { "lobsters_trap=${trap}" }
}
}
}
}
WebView(state = state, client = webClient, modifier = modifier, captureBackPresses = false)
}
@@ -1,62 +0,0 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.ui.navigation
import androidx.compose.runtime.mutableStateListOf
import androidx.navigation3.runtime.NavKey
/**
* Naive implementation of a simple navigation back stack, backed by a
* [androidx.compose.runtime.snapshots.SnapshotStateList].
*
* Using a [java.util.Stack] would've made the API ridiculously simpler, but we would lose all the
* cool Compose benefits.
*
* Stacks function in a completely opposite order of lists, which means when you call
* first/firstOrNull on a stack, you expect to receive the item you added *last*, since stacks add
* to the front while lists add behind. To counter these expectations with the actual backing data
* structure, many APIs in this class are the inverse of identically named functions on [List].
*/
class ClawBackStack(private val initialDestination: NavKey) {
/**
* Marker interface for destinations that occupy the "top" level of the back stack.
*
* This is used by [dev.msfjarvis.claw.android.ui.navigation.ClawBackStack.add] to ensure that
* duplicate instances of these destinations do not end up in the back stack.
*/
interface TopLevelDestination
val backStack = mutableStateListOf(initialDestination)
/**
* Pushes a new destination onto the stack.
*
* Top level destinations are specially handled to ensure that the distance between the incoming
* [destination] and the [initialDestination] cannot have anything in between. This prevents users
* from getting stuck in a frustratingly long stack of top level destinations that are so easily
* accessible that they have no reason to be on the stack.
*/
fun add(destination: NavKey) {
if (destination is TopLevelDestination) {
backStack.clear()
if (destination != initialDestination) {
backStack.add(initialDestination)
}
}
backStack.add(destination)
}
/** Checks if the "top" item in the back stack is an instance of [TopLevelDestination]. */
fun isOnTopLevelDestination(): Boolean {
val top = firstOrNull()
return top != null && top is TopLevelDestination
}
fun firstOrNull() = backStack.lastOrNull()
fun removeLastOrNull() = backStack.removeLastOrNull()
}
@@ -16,19 +16,29 @@ import androidx.compose.material.icons.outlined.NewReleases
import androidx.compose.material.icons.outlined.Whatshot
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation3.runtime.NavKey
import dev.msfjarvis.claw.android.ui.navigation.ClawBackStack.TopLevelDestination
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
interface TopLevelDestination
interface NonStackable
@Parcelize @Serializable data object Hottest : NavKey, TopLevelDestination, Parcelable
@Parcelize @Serializable data object Newest : NavKey, TopLevelDestination, Parcelable
@Parcelize @Serializable data object Saved : NavKey, TopLevelDestination, Parcelable
@Parcelize @Serializable data class Comments(val postId: String) : NavKey, Parcelable
@Parcelize @Serializable data class Comments(val postId: String) : NavKey, Parcelable, NonStackable
@Parcelize @Serializable data class User(val username: String) : NavKey, Parcelable
@Parcelize @Serializable data class User(val username: String) : NavKey, Parcelable, NonStackable
@Parcelize
@Serializable
data class Reply(
val postId: String,
val commentId: String,
) : NavKey, Parcelable
@Parcelize @Serializable data object Search : NavKey, Parcelable
@@ -38,6 +48,8 @@ import kotlinx.serialization.Serializable
@Parcelize @Serializable data object TagFiltering : NavKey, Parcelable
@Parcelize @Serializable data object Login : NavKey, Parcelable
enum class AppDestinations(
val icon: ImageVector,
val label: String,
@@ -0,0 +1,241 @@
/** Taken from https://github.com/getsentry/sentry-java/pull/5023 */
@file:Suppress("UnstableApiUsage") // Sentry internal APIs are used by this module
package dev.msfjarvis.claw.android.ui.navigation
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import io.sentry.Breadcrumb
import io.sentry.BuildConfig
import io.sentry.Hint
import io.sentry.IScopes
import io.sentry.ITransaction
import io.sentry.ScopesAdapter
import io.sentry.SentryIntegrationPackageStorage
import io.sentry.SentryLevel
import io.sentry.SentryOptions
import io.sentry.SpanStatus
import io.sentry.TransactionContext
import io.sentry.TransactionOptions
import io.sentry.TypeCheckHint
import io.sentry.protocol.TransactionNameSource
import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
private const val TRACE_ORIGIN = "auto.navigation.navigation3"
private const val NAVIGATION_OP = "navigation"
/** Holder for the active navigation transaction in a composition scope. */
internal class NavigationTransactionHolder {
internal var activeTransaction: ITransaction? = null
}
/**
* A Composable that observes a back stack [List] and captures a [Breadcrumb] and starts an
* [ITransaction] for each navigation event, sending them to Sentry.
*
* This integration is designed for Android Navigation 3 which uses a back stack-based approach with
* Compose state observation instead of traditional listeners.
*
* Each invocation of this composable maintains independent transaction state, allowing multiple
* navigation instances (e.g., in split-screen or multi-pane layouts) to coexist without
* interference.
*
* @param T The type of keys in the back stack
* @param backStack The current back stack to observe for navigation changes
* @param enableNavigationBreadcrumbs Whether the integration should capture breadcrumbs for
* navigation events.
* @param enableNavigationTracing Whether the integration should start a new idle [ITransaction]
* with [SentryOptions.idleTimeout] for navigation events.
* @param keyToRoute A function to extract a route name from a back stack key. Defaults to
* [Any.toString].
* @param scopes The [IScopes] instance to use for capturing events. Defaults to the singleton
* instance.
*/
@Composable
fun <T> SentryNavigation3Traced(
@SuppressLint("ComposeUnstableCollections") backStack: List<T>,
enableNavigationBreadcrumbs: Boolean = true,
enableNavigationTracing: Boolean = true,
keyToRoute: (T) -> String? = { it.toString() },
scopes: IScopes = ScopesAdapter.getInstance(),
) {
val backStackSnapshot by rememberUpdatedState(backStack)
val enableBreadcrumbsSnapshot by rememberUpdatedState(enableNavigationBreadcrumbs)
val enableTracingSnapshot by rememberUpdatedState(enableNavigationTracing)
val keyToRouteSnapshot by rememberUpdatedState(keyToRoute)
val scopesSnapshot by rememberUpdatedState(scopes)
val transactionHolder = remember { NavigationTransactionHolder() }
DisposableEffect(Unit) {
addIntegrationToSdkVersion("Navigation3")
val observer =
SentryBackStackObserver(
enableNavigationBreadcrumbs = enableBreadcrumbsSnapshot,
enableNavigationTracing = enableTracingSnapshot,
keyToRoute = keyToRouteSnapshot,
scopes = scopesSnapshot,
transactionHolder = transactionHolder,
)
@SuppressLint("RawDispatchersUse")
val scope =
kotlinx.coroutines.CoroutineScope(
kotlinx.coroutines.Dispatchers.Main + kotlinx.coroutines.SupervisorJob()
)
@SuppressLint("DenyListedApi")
val job =
snapshotFlow { backStackSnapshot }
.drop(1) // Skip initial state
.onEach { currentStack ->
val currentKey = currentStack.lastOrNull()
val previousKey = observer.previousKey
if (currentKey != null && currentKey != previousKey) {
observer.handleNavigation(currentKey, currentStack)
observer.previousKey = currentKey
}
}
.launchIn(scope)
onDispose { job.cancel() }
}
}
/** Internal observer that monitors back stack changes and creates Sentry events. */
internal class SentryBackStackObserver<T>(
private val enableNavigationBreadcrumbs: Boolean,
private val enableNavigationTracing: Boolean,
private val keyToRoute: (T) -> String?,
private val scopes: IScopes,
private val transactionHolder: NavigationTransactionHolder,
) {
internal var previousKey: T? = null
private val isPerformanceEnabled: Boolean
get() = scopes.options.isTracingEnabled && enableNavigationTracing
init {
SentryIntegrationPackageStorage.getInstance()
.addPackage("maven:io.sentry:sentry-android-navigation3", BuildConfig.VERSION_NAME)
}
internal fun handleNavigation(currentKey: T, currentStack: List<T>) {
val currentRoute = keyToRoute(currentKey)
if (currentRoute != null) {
addBreadcrumb(currentRoute, currentStack)
if (scopes.options.isEnableScreenTracking) {
scopes.configureScope { it.screen = currentRoute }
}
startTracing(currentRoute, currentStack)
}
}
private fun addBreadcrumb(toRoute: String, currentStack: List<T>) {
if (!enableNavigationBreadcrumbs) {
return
}
val breadcrumb =
Breadcrumb().apply {
type = NAVIGATION_OP
category = NAVIGATION_OP
val fromKey = previousKey
if (fromKey != null) {
val fromRoute = keyToRoute(fromKey)
if (fromRoute != null) {
data["from"] = fromRoute
}
}
data["to"] = toRoute
// Capture back stack keys as a list
val backStackKeys = currentStack.mapNotNull { keyToRoute(it) }
if (backStackKeys.isNotEmpty()) {
data["back_stack"] = backStackKeys
}
level = SentryLevel.INFO
}
val hint = Hint()
hint.set(TypeCheckHint.ANDROID_NAV_DESTINATION, toRoute)
scopes.addBreadcrumb(breadcrumb, hint)
}
private fun startTracing(routeName: String, currentStack: List<T>) {
if (!isPerformanceEnabled) {
io.sentry.util.TracingUtils.startNewTrace(scopes)
return
}
// Finish previous transaction in this navigation scope
if (transactionHolder.activeTransaction != null) {
stopTracing()
}
val transactionOptions =
TransactionOptions().also {
it.isWaitForChildren = true
it.idleTimeout = scopes.options.idleTimeout
// Set deadline timeout based on configured option
val deadlineTimeoutMillis = scopes.options.deadlineTimeout
// No deadline when zero or negative value is set
it.deadlineTimeout = if (deadlineTimeoutMillis <= 0) null else deadlineTimeoutMillis
it.isTrimEnd = true
}
val transaction =
scopes.startTransaction(
TransactionContext(routeName, TransactionNameSource.ROUTE, NAVIGATION_OP),
transactionOptions,
)
transaction.spanContext.origin = TRACE_ORIGIN
// Capture back stack keys as data
val backStackKeys = currentStack.mapNotNull { keyToRoute(it) }
if (backStackKeys.isNotEmpty()) {
transaction.setData("back_stack", backStackKeys)
}
scopes.configureScope { scope ->
scope.withTransaction { tx ->
if (tx == null) {
scope.transaction = transaction
}
}
}
transactionHolder.activeTransaction = transaction
}
private fun stopTracing() {
val status = transactionHolder.activeTransaction?.status ?: SpanStatus.OK
transactionHolder.activeTransaction?.finish(status)
// clear transaction from scope so others can bind to it
scopes.configureScope { scope ->
scope.withTransaction { tx ->
if (tx == transactionHolder.activeTransaction) {
scope.clearTransaction()
}
}
}
transactionHolder.activeTransaction = null
}
}
@@ -6,7 +6,9 @@
*/
package dev.msfjarvis.claw.android.ui.screens
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.annotation.VisibleForTesting
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
@@ -33,19 +35,22 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.paging.compose.collectAsLazyPagingItems
import com.deliveryhero.whetstone.compose.injectedViewModel
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.msfjarvis.claw.android.MainActivity
import dev.msfjarvis.claw.android.R
import dev.msfjarvis.claw.android.ui.PostActions
import dev.msfjarvis.claw.android.ui.decorations.ClawAppBar
import dev.msfjarvis.claw.android.ui.decorations.ClawNavigationBar
@@ -55,36 +60,50 @@ import dev.msfjarvis.claw.android.ui.lists.DatabasePosts
import dev.msfjarvis.claw.android.ui.lists.NetworkPosts
import dev.msfjarvis.claw.android.ui.navigation.AboutLibraries
import dev.msfjarvis.claw.android.ui.navigation.AppDestinations
import dev.msfjarvis.claw.android.ui.navigation.ClawBackStack
import dev.msfjarvis.claw.android.ui.navigation.ClawNavigationType
import dev.msfjarvis.claw.android.ui.navigation.Comments
import dev.msfjarvis.claw.android.ui.navigation.Hottest
import dev.msfjarvis.claw.android.ui.navigation.Login
import dev.msfjarvis.claw.android.ui.navigation.Newest
import dev.msfjarvis.claw.android.ui.navigation.NonStackable
import dev.msfjarvis.claw.android.ui.navigation.Reply
import dev.msfjarvis.claw.android.ui.navigation.Saved
import dev.msfjarvis.claw.android.ui.navigation.Search
import dev.msfjarvis.claw.android.ui.navigation.SentryNavigation3Traced
import dev.msfjarvis.claw.android.ui.navigation.Settings
import dev.msfjarvis.claw.android.ui.navigation.TagFiltering
import dev.msfjarvis.claw.android.ui.navigation.TopLevelDestination
import dev.msfjarvis.claw.android.ui.navigation.User
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
import dev.msfjarvis.claw.android.viewmodel.SettingsViewModel
import dev.msfjarvis.claw.common.comments.CommentsPage
import dev.msfjarvis.claw.common.comments.reply.ReplyScreen
import dev.msfjarvis.claw.common.tags.TagFilterViewModel
import dev.msfjarvis.claw.common.tags.TagList
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
import dev.msfjarvis.claw.common.user.UserProfile
import dev.zacsweers.metrox.viewmodel.metroViewModel
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun LobstersPostsScreen(
urlLauncher: UrlLauncher,
uriHandler: UriHandler,
windowSizeClass: WindowSizeClass,
setWebUri: (String?) -> Unit,
modifier: Modifier = Modifier,
viewModel: ClawViewModel = injectedViewModel(),
deepLinkDestination: NavKey? = null,
clearDeepLink: () -> Unit = {},
viewModel: ClawViewModel = metroViewModel(),
settingsViewModel: SettingsViewModel = metroViewModel(),
tagFilterViewModel: TagFilterViewModel = metroViewModel(key = "tag_filter"),
) {
val navigationType = ClawNavigationType.fromSize(windowSizeClass.widthSizeClass)
val libraries by produceLibraries()
val clawBackStack = remember { ClawBackStack(Hottest) }
val backStack = rememberNavBackStack(Hottest)
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
// region Pain
@@ -97,31 +116,27 @@ fun LobstersPostsScreen(
val snackbarHostState = remember { SnackbarHostState() }
val hazeState = remember { HazeState() }
val hottestPosts = viewModel.hottestPosts.collectAsLazyPagingItems()
val newestPosts = viewModel.newestPosts.collectAsLazyPagingItems()
val savedPosts by viewModel.savedPostsByMonth.collectAsStateWithLifecycle(persistentMapOf())
val savedPostsCount by viewModel.savedPostsCount.collectAsStateWithLifecycle(0L)
val navigationType = ClawNavigationType.fromSize(windowSizeClass.widthSizeClass)
val filteredTags by tagFilterViewModel.filteredTags.collectAsStateWithLifecycle(persistentSetOf())
val isLoggedIn by settingsViewModel.isLoggedIn.collectAsStateWithLifecycle(false)
val username by settingsViewModel.username.collectAsStateWithLifecycle(null)
val postIdOverride = activity?.intent?.extras?.getString(MainActivity.NAVIGATION_KEY)
LaunchedEffect(Unit) {
if (postIdOverride != null) {
clawBackStack.add(Comments(postIdOverride))
LaunchedEffect(deepLinkDestination) {
if (deepLinkDestination != null) {
navigateTo(backStack, deepLinkDestination, allowStacking = true)
clearDeepLink()
}
}
val navItems =
persistentListOf(
NavigationItem(AppDestinations.HOTTEST) {
coroutineScope.launch {
if (hottestPosts.itemCount > 0) hottestListState.animateScrollToItem(index = 0)
}
coroutineScope.launch { hottestListState.animateScrollToItem(index = 0) }
},
NavigationItem(AppDestinations.NEWEST) {
coroutineScope.launch {
if (newestPosts.itemCount > 0) newestListState.animateScrollToItem(index = 0)
}
coroutineScope.launch { newestListState.animateScrollToItem(index = 0) }
},
NavigationItem(AppDestinations.SAVED) {
coroutineScope.launch { if (savedPosts.isNotEmpty()) savedListState.scrollToItem(0) }
@@ -130,26 +145,30 @@ fun LobstersPostsScreen(
// endregion
val postActions = remember {
PostActions(context, urlLauncher, viewModel) { clawBackStack.add(Comments(it)) }
PostActions(context, uriHandler, viewModel) {
navigateTo(backStack, Comments(it), allowStacking = false)
}
}
BackHandler(enabled = backStack.size > 1) { popBackStack(backStack) }
Scaffold(
topBar = {
ClawAppBar(
activity = activity,
isTopLevel = clawBackStack.isOnTopLevelDestination(),
navigateTo = { clawBackStack.add(it) },
popBackStack = { clawBackStack.removeLastOrNull() },
isTopLevel = backStack.lastOrNull() is TopLevelDestination,
navigateTo = { destination -> navigateTo(backStack, destination) },
popBackStack = { popBackStack(backStack) },
)
},
bottomBar = {
val currentDestination = clawBackStack.firstOrNull()
val currentDestination = backStack.lastOrNull()
AnimatedVisibility(visible = navigationType == ClawNavigationType.BOTTOM_NAVIGATION) {
ClawNavigationBar(
items = navItems,
currentNavKey = currentDestination,
navigateTo = { clawBackStack.add(it) },
isVisible = clawBackStack.isOnTopLevelDestination(),
navigateTo = { destination -> navigateTo(backStack, destination) },
isVisible = backStack.lastOrNull() is TopLevelDestination,
hazeState = hazeState,
)
}
@@ -157,21 +176,27 @@ fun LobstersPostsScreen(
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = Modifier.semantics { testTagsAsResourceId = true },
) { contentPadding ->
SentryNavigation3Traced(backStack = backStack)
Row {
AnimatedVisibility(visible = navigationType == ClawNavigationType.NAVIGATION_RAIL) {
val currentDestination = clawBackStack.firstOrNull()
val currentDestination = backStack.lastOrNull()
ClawNavigationRail(
items = navItems,
currentNavKey = currentDestination,
navigateTo = { clawBackStack.add(it) },
isVisible = clawBackStack.isOnTopLevelDestination(),
navigateTo = { destination -> navigateTo(backStack, destination) },
isVisible = backStack.lastOrNull() is TopLevelDestination,
)
}
NavDisplay(
backStack = clawBackStack.backStack,
backStack = backStack,
modifier = modifier.hazeSource(hazeState),
sceneStrategy = listDetailStrategy,
onBack = { clawBackStack.removeLastOrNull() },
sceneStrategies = listOf(listDetailStrategy),
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator()),
onBack = {
if (popBackStack(backStack) == null) {
activity?.finish()
}
},
predictivePopTransitionSpec = {
slideInHorizontally(initialOffsetX = { -it }, animationSpec = tween(200)) togetherWith
slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200))
@@ -182,22 +207,26 @@ fun LobstersPostsScreen(
metadata = ListDetailSceneStrategy.listPane(detailPlaceholder = { Placeholder() })
) {
setWebUri("https://lobste.rs/")
val hottestPosts = viewModel.hottestPosts.collectAsLazyPagingItems()
NetworkPosts(
lazyPagingItems = hottestPosts,
listState = hottestListState,
postActions = postActions,
contentPadding = contentPadding,
filteredTags = filteredTags,
)
}
entry<Newest>(
metadata = ListDetailSceneStrategy.listPane(detailPlaceholder = { Placeholder() })
) {
setWebUri("https://lobste.rs/")
val newestPosts = viewModel.newestPosts.collectAsLazyPagingItems()
NetworkPosts(
lazyPagingItems = newestPosts,
listState = newestListState,
postActions = postActions,
contentPadding = contentPadding,
filteredTags = filteredTags,
)
}
entry<Saved>(
@@ -212,11 +241,28 @@ fun LobstersPostsScreen(
)
}
entry<Comments>(metadata = ListDetailSceneStrategy.detailPane()) { dest ->
val commentsPostActions = remember {
PostActions(context, uriHandler, viewModel) {
navigateTo(backStack, Comments(it), allowStacking = true)
}
}
CommentsPage(
postId = dest.postId,
postActions = postActions,
postActions = commentsPostActions,
contentPadding = contentPadding,
openUserProfile = { clawBackStack.add(User(it)) },
openUserProfile = { navigateTo(backStack, User(it)) },
openReplyScreen = { postId, commentId ->
navigateTo(backStack, Reply(postId, commentId), allowStacking = true)
},
)
}
entry<Reply>(metadata = ListDetailSceneStrategy.extraPane()) { dest ->
ReplyScreen(
commentId = dest.commentId,
contentPadding = contentPadding,
postId = dest.postId,
onReplySubmitted = { popBackStack(backStack) },
modifier = Modifier.fillMaxSize(),
)
}
entry<User>(
@@ -237,17 +283,22 @@ fun LobstersPostsScreen(
UserProfile(
username = dest.username,
contentPadding = contentPadding,
openUserProfile = { clawBackStack.add(User(it)) },
openUserProfile = { navigateTo(backStack, User(it)) },
)
}
entry<Settings>(metadata = ListDetailSceneStrategy.extraPane()) {
SettingsScreen(
openInputStream = context.contentResolver::openInputStream,
openOutputStream = context.contentResolver::openOutputStream,
openLibrariesScreen = { clawBackStack.add(AboutLibraries) },
openLibrariesScreen = { navigateTo(backStack, AboutLibraries) },
openRepository = {
urlLauncher.openUri("https://github.com/msfjarvis/compose-lobsters")
uriHandler.openUri("https://github.com/msfjarvis/compose-lobsters")
},
openTagFiltering = { navigateTo(backStack, TagFiltering) },
openLoginScreen = { navigateTo(backStack, Login) },
onLogout = { settingsViewModel.logout() },
isLoggedIn = isLoggedIn,
username = username,
importPosts = viewModel::importPosts,
exportPostsAsJson = viewModel::exportPostsAsJson,
exportPostsAsHtml = viewModel::exportPostsAsHtml,
@@ -257,13 +308,16 @@ fun LobstersPostsScreen(
modifier = Modifier.fillMaxSize(),
)
}
entry<Login>(metadata = ListDetailSceneStrategy.extraPane()) {
LoginScreen(
onLoginSuccess = settingsViewModel::saveCookie,
popBackStack = { popBackStack(backStack) },
modifier = Modifier.fillMaxSize(),
)
}
entry<Search>(metadata = ListDetailSceneStrategy.extraPane()) {
setWebUri("https://lobste.rs/search")
SearchScreen(
viewModel = viewModel,
postActions = postActions,
contentPadding = contentPadding,
)
SearchScreen(postActions = postActions, contentPadding = contentPadding)
}
entry<AboutLibraries>(metadata = ListDetailSceneStrategy.extraPane()) {
LibrariesContainer(
@@ -279,7 +333,52 @@ fun LobstersPostsScreen(
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun navigateTo(
backStack: MutableList<NavKey>,
destination: NavKey,
allowStacking: Boolean = false,
) {
if (destination is TopLevelDestination) {
backStack.clear()
if (destination != Hottest) {
backStack.add(Hottest)
}
backStack.add(destination)
return
}
if (destination is NonStackable) {
if (allowStacking && destination is Comments) {
val lastItem = backStack.lastOrNull()
if (lastItem is Comments && lastItem.postId == destination.postId) {
return
}
backStack.add(destination)
return
}
val existingEntry = backStack.firstOrNull {
it is NonStackable && it::class.java.isAssignableFrom(destination::class.java)
}
if (existingEntry != null) {
backStack.remove(existingEntry)
}
}
backStack.add(destination)
}
private fun popBackStack(backStack: MutableList<NavKey>): NavKey? {
if (backStack.size <= 1) {
return null
}
return backStack.removeAt(backStack.lastIndex)
}
@Composable
private fun Placeholder(modifier: Modifier = Modifier) {
Text(text = "Placeholder", modifier = modifier)
Text(text = stringResource(R.string.no_post_selected), modifier = modifier)
}
@@ -0,0 +1,131 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.ui.screens
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.webkit.CookieManager
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
private const val LOGIN_URL = "https://lobste.rs/login"
private const val LOBSTERS_URL = "https://lobste.rs"
internal fun parseAuthenticatedUsername(usernameResult: String): String? {
return usernameResult.removeSurrounding("\"").takeUnless { it.isBlank() || it == "null" }
}
@Composable
fun LoginScreen(
onLoginSuccess: (String, String) -> Unit,
popBackStack: () -> Unit,
modifier: Modifier = Modifier,
) {
var isLoading by remember { mutableStateOf(true) }
var hasError by remember { mutableStateOf(false) }
var webViewRef by remember { mutableStateOf<WebView?>(null) }
Scaffold(modifier = modifier) { contentPadding ->
Box(modifier = Modifier.padding(contentPadding).fillMaxSize()) {
if (hasError) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Failed to load login page.")
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
hasError = false
isLoading = true
webViewRef?.loadUrl(LOGIN_URL)
}
) {
Text("Retry")
}
}
} else {
AndroidView(
factory = { context ->
WebView(context)
.apply {
@SuppressLint("SetJavaScriptEnabled")
settings.javaScriptEnabled = true
webViewClient =
object : WebViewClient() {
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
isLoading = true
}
override fun onPageFinished(view: WebView, url: String) {
isLoading = false
if (!url.contains("/login")) {
@SuppressLint("DeprecatedCall")
view.evaluateJavascript(
"document.body && document.body.getAttribute('data-username')"
) { usernameResult ->
val username = parseAuthenticatedUsername(usernameResult)
if (username != null) {
val cookie = CookieManager.getInstance().getCookie(LOBSTERS_URL)
if (cookie != null) {
onLoginSuccess(cookie, username)
popBackStack()
}
}
}
}
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
if (request.isForMainFrame) {
isLoading = false
hasError = true
}
}
}
loadUrl(LOGIN_URL)
}
.also { webViewRef = it }
},
modifier = Modifier.fillMaxSize(),
)
}
if (isLoading && !hasError) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter))
}
}
}
}
@@ -9,22 +9,29 @@ package dev.msfjarvis.claw.android.ui.screens
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.collectAsLazyPagingItems
import com.deliveryhero.whetstone.compose.injectedViewModel
import dev.msfjarvis.claw.android.ui.lists.SearchList
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
import dev.msfjarvis.claw.common.posts.PostActions
import dev.msfjarvis.claw.common.tags.TagFilterViewModel
import dev.zacsweers.metrox.viewmodel.metroViewModel
import kotlinx.collections.immutable.toPersistentSet
@Composable
fun SearchScreen(
postActions: PostActions,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
viewModel: ClawViewModel = injectedViewModel(),
viewModel: ClawViewModel = metroViewModel(),
tagFilterViewModel: TagFilterViewModel = metroViewModel(key = "tag_filter"),
) {
val listState = rememberLazyListState()
val searchResults = viewModel.searchResults.collectAsLazyPagingItems()
val filteredTags by tagFilterViewModel.filteredTags.collectAsStateWithLifecycle(emptySet())
SearchList(
lazyPagingItems = searchResults,
listState = listState,
@@ -32,6 +39,7 @@ fun SearchScreen(
searchQuery = viewModel.searchQuery,
contentPadding = contentPadding,
setSearchQuery = { query -> viewModel.searchQuery = query },
filteredTags = filteredTags.toPersistentSet(),
modifier = modifier,
)
}
@@ -19,8 +19,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Bookmarks
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.ImportExport
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -38,10 +40,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.msfjarvis.claw.android.BuildConfig
import dev.msfjarvis.claw.android.R
import dev.msfjarvis.claw.common.theme.LobstersTheme
import dev.msfjarvis.claw.common.ui.preview.ThemePreviews
import io.sentry.Sentry
import java.io.InputStream
import java.io.OutputStream
import kotlinx.coroutines.CoroutineScope
@@ -50,14 +55,27 @@ import kotlinx.coroutines.launch
private const val JSON_MIME_TYPE = "application/json"
private const val HTML_MIME_TYPE = "application/html"
internal fun loggedInAccountText(username: String?): String {
return if (username.isNullOrBlank()) {
"Sign out of your lobste.rs account"
} else {
"Logged in as $username"
}
}
@Composable
fun SettingsScreen(
openLibrariesScreen: () -> Unit,
openRepository: () -> Unit,
openTagFiltering: () -> Unit,
openLoginScreen: () -> Unit,
onLogout: () -> Unit,
isLoggedIn: Boolean,
username: String?,
snackbarHostState: SnackbarHostState,
openInputStream: (Uri) -> InputStream?,
openOutputStream: (Uri) -> OutputStream?,
importPosts: suspend (InputStream) -> Unit,
importPosts: suspend (InputStream) -> Result<Unit>,
exportPostsAsJson: suspend (OutputStream) -> Unit,
exportPostsAsHtml: suspend (OutputStream) -> Unit,
contentPadding: PaddingValues,
@@ -66,11 +84,42 @@ fun SettingsScreen(
) {
val coroutineScope = rememberCoroutineScope()
Column(modifier.padding(contentPadding)) {
// Account Section
SectionHeader(title = "Account")
if (isLoggedIn) {
ListItem(
headlineContent = { Text("Log out") },
supportingContent = { Text(loggedInAccountText(username)) },
leadingContent = {
Icon(
imageVector = Icons.Filled.AccountCircle,
contentDescription = null,
modifier = Modifier.height(32.dp),
)
},
modifier = Modifier.clickable(onClick = onLogout),
)
} else {
ListItem(
headlineContent = { Text("Log in with lobste.rs") },
supportingContent = { Text("Sign in to your lobste.rs account") },
leadingContent = {
Icon(
imageVector = Icons.Filled.AccountCircle,
contentDescription = null,
modifier = Modifier.height(32.dp),
)
},
modifier = Modifier.clickable(onClick = openLoginScreen),
)
}
Spacer(modifier = Modifier.height(24.dp))
// Data Management Section
SectionHeader(title = "Data Management")
SectionHeader(title = stringResource(R.string.data_management))
ListItem(
headlineContent = { Text("Data transfer") },
supportingContent = { Text("Import and export your saved posts") },
headlineContent = { Text(stringResource(R.string.data_transfer)) },
supportingContent = { Text(stringResource(R.string.import_and_export_your_saved_posts)) },
leadingContent = {
Icon(
imageVector = Icons.Filled.ImportExport,
@@ -95,10 +144,18 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp))
// App Information Section
SectionHeader(title = "App Information")
SectionHeader(title = stringResource(R.string.app_information))
ListItem(
headlineContent = { Text("Version") },
supportingContent = { Text("${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") },
headlineContent = { Text(stringResource(R.string.version)) },
supportingContent = {
Text(
stringResource(
R.string.version_placeholder,
BuildConfig.VERSION_NAME,
BuildConfig.VERSION_CODE,
)
)
},
leadingContent = {
Icon(
imageVector = Icons.Filled.Code,
@@ -108,8 +165,8 @@ fun SettingsScreen(
},
)
ListItem(
headlineContent = { Text("Saved posts") },
supportingContent = { Text("$savedPostsCount posts saved locally") },
headlineContent = { Text(stringResource(R.string.saved_posts)) },
supportingContent = { Text(stringResource(R.string.posts_saved_locally, savedPostsCount)) },
leadingContent = {
Icon(
imageVector = Icons.Filled.Bookmarks,
@@ -118,14 +175,28 @@ fun SettingsScreen(
)
},
)
ListItem(
headlineContent = { Text(stringResource(R.string.tag_filtering)) },
supportingContent = { Text(stringResource(R.string.filter_posts_by_tags)) },
leadingContent = {
Icon(
imageVector = Icons.Filled.FilterList,
contentDescription = null,
modifier = Modifier.height(32.dp),
)
},
modifier = Modifier.clickable(onClick = openTagFiltering),
)
Spacer(modifier = Modifier.height(24.dp))
// About Section
SectionHeader(title = "About")
SectionHeader(title = stringResource(R.string.about))
ListItem(
headlineContent = { Text("Libraries") },
supportingContent = { Text("View open source libraries used in this app") },
headlineContent = { Text(stringResource(R.string.libraries)) },
supportingContent = {
Text(stringResource(R.string.view_open_source_libraries_used_in_this_))
},
leadingContent = {
Icon(
imageVector = Icons.AutoMirrored.Filled.LibraryBooks,
@@ -136,8 +207,8 @@ fun SettingsScreen(
modifier = Modifier.clickable(onClick = openLibrariesScreen),
)
ListItem(
headlineContent = { Text("Source code") },
supportingContent = { Text("View the source code on GitHub") },
headlineContent = { Text(stringResource(R.string.source_code)) },
supportingContent = { Text(stringResource(R.string.view_the_source_code_on_github)) },
leadingContent = {
Icon(
imageVector = Icons.Filled.Code,
@@ -171,7 +242,7 @@ private fun ImportPosts(
openInputStream: (Uri) -> InputStream?,
coroutineScope: CoroutineScope,
snackbarHostState: SnackbarHostState,
importPosts: suspend (InputStream) -> Unit,
importPosts: suspend (InputStream) -> Result<Unit>,
modifier: Modifier = Modifier,
) {
val importAction =
@@ -181,18 +252,29 @@ private fun ImportPosts(
return@rememberLauncherForActivityResult
}
coroutineScope.launch {
openInputStream(uri)?.use { stream ->
importPosts(stream)
snackbarHostState.showSnackbarDismissing("Successfully imported posts")
try {
openInputStream(uri)?.use { stream ->
val result = importPosts(stream)
if (result.isSuccess) {
snackbarHostState.showSnackbarDismissing("Successfully imported posts")
} else {
snackbarHostState.showSnackbarDismissing("Failed to import posts")
}
} ?: snackbarHostState.showSnackbarDismissing("Unable to open selected file")
} catch (e: Exception) {
snackbarHostState.showSnackbarDismissing("Failed to open file: ${e.message}")
}
}
}
OutlinedButton(
onClick = { importAction.launch(JSON_MIME_TYPE) },
onClick = {
importAction.launch(JSON_MIME_TYPE)
Sentry.metrics().count("import", 1.0)
},
shape = MaterialTheme.shapes.extraSmall,
modifier = modifier,
) {
Text(text = "Import")
Text(text = stringResource(R.string.import_action))
}
}
@@ -213,9 +295,19 @@ private inline fun ExportPosts(
return@rememberLauncherForActivityResult
}
coroutineScope.launch {
openOutputStream(uri)?.use { stream ->
exportPostsAsJson(stream)
snackbarHostState.showSnackbarDismissing("Successfully exported posts")
try {
openOutputStream(uri)?.use { stream ->
exportPostsAsJson(stream)
snackbarHostState.showSnackbarDismissing("Successfully exported posts")
Sentry.metrics().count("json_export_success", 1.0)
}
?: run {
snackbarHostState.showSnackbarDismissing("Unable to open output file")
Sentry.metrics().count("json_export_failure", 1.0)
}
} catch (e: Exception) {
snackbarHostState.showSnackbarDismissing("Failed to export posts: ${e.message}")
Sentry.metrics().count("json_export_failure", 1.0)
}
}
}
@@ -227,9 +319,19 @@ private inline fun ExportPosts(
return@rememberLauncherForActivityResult
}
coroutineScope.launch {
openOutputStream(uri)?.use { stream ->
exportPostsAsHtml(stream)
snackbarHostState.showSnackbarDismissing("Successfully exported posts")
try {
openOutputStream(uri)?.use { stream ->
exportPostsAsHtml(stream)
snackbarHostState.showSnackbarDismissing("Successfully exported posts")
Sentry.metrics().count("html_export_success", 1.0)
}
?: run {
snackbarHostState.showSnackbarDismissing("Unable to open output file")
Sentry.metrics().count("html_export_failure", 1.0)
}
} catch (e: Exception) {
snackbarHostState.showSnackbarDismissing("Failed to export posts: ${e.message}")
Sentry.metrics().count("html_export_failure", 1.0)
}
}
}
@@ -239,20 +341,23 @@ private inline fun ExportPosts(
shape = MaterialTheme.shapes.extraSmall,
modifier = modifier,
) {
Text(text = "Export")
Text(text = stringResource(R.string.export))
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text("JSON") },
text = { Text(stringResource(R.string.json)) },
onClick = {
expanded = false
jsonExportAction.launch("claw-export.json")
},
leadingIcon = {
Icon(imageVector = Icons.Filled.Code, contentDescription = "Export as JSON")
Icon(
imageVector = Icons.Filled.Code,
contentDescription = stringResource(R.string.export_as_json),
)
},
)
DropdownMenuItem(
text = { Text("Bookmarks") },
text = { Text(stringResource(R.string.bookmarks)) },
onClick = {
expanded = false
htmlExportAction.launch("claw-export.html")
@@ -260,7 +365,7 @@ private inline fun ExportPosts(
leadingIcon = {
Icon(
imageVector = Icons.Filled.Bookmarks,
contentDescription = "Export as browser bookmarks",
contentDescription = stringResource(R.string.export_as_browser_bookmarks),
)
},
)
@@ -281,10 +386,15 @@ private fun SettingsScreenPreview() {
SettingsScreen(
openLibrariesScreen = {},
openRepository = {},
openTagFiltering = {},
openLoginScreen = {},
onLogout = {},
isLoggedIn = false,
username = null,
snackbarHostState = SnackbarHostState(),
openInputStream = { null },
openOutputStream = { null },
importPosts = {},
importPosts = { Result.success(Unit) },
exportPostsAsJson = {},
exportPostsAsHtml = {},
contentPadding = PaddingValues(),
@@ -0,0 +1,51 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.viewmodel
import android.util.Log
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import dev.msfjarvis.claw.android.BuildConfig
import dev.msfjarvis.claw.core.coroutines.DatabaseReadDispatcher
import dev.msfjarvis.claw.core.coroutines.DatabaseWriteDispatcher
import dev.msfjarvis.claw.database.local.CachedRemotePost
import dev.msfjarvis.claw.database.local.CachedRemotePostQueries
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
@Inject
class CachedRemotePostsRepository(
private val cachedRemotePostQueries: CachedRemotePostQueries,
@param:DatabaseReadDispatcher private val readDispatcher: CoroutineDispatcher,
@param:DatabaseWriteDispatcher private val writeDispatcher: CoroutineDispatcher,
) {
val cachedPosts = cachedRemotePostQueries.selectAllPosts().asFlow().mapToList(readDispatcher)
fun getRecentPosts(limit: Long) =
cachedRemotePostQueries.selectRecentPosts(limit).asFlow().mapToList(readDispatcher)
suspend fun getCachedPosts(): List<CachedRemotePost> {
return withContext(readDispatcher) { cachedRemotePostQueries.selectAllPosts().executeAsList() }
}
suspend fun savePosts(posts: List<CachedRemotePost>) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "Caching remote posts: ${posts.joinToString(",") { it.shortId }}")
}
withContext(writeDispatcher) {
cachedRemotePostQueries.transaction {
cachedRemotePostQueries.deleteAllPosts()
posts.forEach { post -> cachedRemotePostQueries.insertOrReplacePost(post) }
}
}
}
private companion object {
private const val TAG = "CachedRemotePostsRepository"
}
}
@@ -15,41 +15,48 @@ import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.intl.Locale
import androidx.glance.appwidget.updateAll
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.deliveryhero.whetstone.app.ApplicationScope
import com.deliveryhero.whetstone.viewmodel.ContributesViewModel
import com.squareup.anvil.annotations.optional.ForScope
import dev.msfjarvis.claw.android.glance.SavedPostsWidget
import dev.msfjarvis.claw.android.paging.LobstersPagingSource
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.PAGE_SIZE
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.STARTING_PAGE_INDEX
import dev.msfjarvis.claw.android.paging.SearchPagingSource
import dev.msfjarvis.claw.api.LobstersApi
import dev.msfjarvis.claw.api.toPostsResult
import dev.msfjarvis.claw.core.coroutines.IODispatcher
import dev.msfjarvis.claw.core.coroutines.MainDispatcher
import dev.msfjarvis.claw.model.UIPost
import dev.msfjarvis.claw.model.fromSavedPost
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.binding
import dev.zacsweers.metrox.viewmodel.ViewModelKey
import java.io.InputStream
import java.io.OutputStream
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import kotlinx.collections.immutable.ImmutableMap
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
@ContributesViewModel
class ClawViewModel
@Inject
constructor(
@ViewModelKey
@ContributesIntoMap(scope = AppScope::class, binding = binding<ViewModel>())
class ClawViewModel(
context: Context,
private val api: LobstersApi,
private val readPostsRepository: ReadPostsRepository,
private val savedPostsRepository: SavedPostsRepository,
@@ -57,26 +64,45 @@ constructor(
private val dataTransferRepository: DataTransferRepository,
private val pagingSourceFactory: LobstersPagingSource.Factory,
private val searchPagingSourceFactory: SearchPagingSource.Factory,
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
@ForScope(ApplicationScope::class) context: Context,
@param:IODispatcher private val ioDispatcher: CoroutineDispatcher,
@param:MainDispatcher private val mainDispatcher: CoroutineDispatcher,
) : AndroidViewModel(context as Application) {
val hottestPosts =
Pager(
config = PagingConfig(pageSize = PAGE_SIZE),
initialKey = STARTING_PAGE_INDEX,
pagingSourceFactory = { pagingSourceFactory.create(api::getHottestPosts) },
)
.flow
.cachedIn(viewModelScope)
val newestPosts =
Pager(
config = PagingConfig(pageSize = PAGE_SIZE),
initialKey = STARTING_PAGE_INDEX,
pagingSourceFactory = { pagingSourceFactory.create(api::getNewestPosts) },
)
.flow
.cachedIn(viewModelScope)
private var _hottestPosts: Flow<PagingData<UIPost>>? = null
val hottestPosts: Flow<PagingData<UIPost>>
get() {
if (_hottestPosts == null) {
_hottestPosts =
Pager(
config = PagingConfig(pageSize = PAGE_SIZE),
initialKey = STARTING_PAGE_INDEX,
pagingSourceFactory = {
pagingSourceFactory.create { page -> api.getHottestPosts(page).toPostsResult() }
},
)
.flow
.cachedIn(viewModelScope)
}
return requireNotNull(_hottestPosts) { "_hottestPosts should be initialized here" }
}
private var _newestPosts: Flow<PagingData<UIPost>>? = null
val newestPosts: Flow<PagingData<UIPost>>
get() {
if (_newestPosts == null) {
_newestPosts =
Pager(
config = PagingConfig(pageSize = PAGE_SIZE),
initialKey = STARTING_PAGE_INDEX,
pagingSourceFactory = {
pagingSourceFactory.create { page -> api.getNewestPosts(page).toPostsResult() }
},
)
.flow
.cachedIn(viewModelScope)
}
return requireNotNull(_newestPosts) { "_newestPosts should be initialized here" }
}
val searchResults =
Pager(
PagingConfig(pageSize = PAGE_SIZE),
@@ -85,45 +111,37 @@ constructor(
)
.flow
val savedPosts = savedPostsRepository.savedPosts.map { it.map(UIPost.Companion::fromSavedPost) }
val savedPostsCount = savedPosts.map { it.size.toLong() }
val savedPostsCount = savedPostsRepository.savedPosts.map { it.size.toLong() }
val savedPostsByMonth
get() = savedPosts.map(::groupSavedPosts)
get() =
savedPostsRepository.savedPostsSortedByDate.map { posts ->
posts
.map(UIPost.Companion::fromSavedPost)
.groupBy { post ->
val time = post.createdAt.toLocalDateTime()
"${time.month.name.lowercase().capitalize(Locale.current)} ${time.year}"
}
.toImmutableMap()
}
var searchQuery by mutableStateOf("")
private var _readPosts = emptyList<String>()
private var _savedPosts = emptyList<String>()
private var _readPosts by mutableStateOf(emptySet<String>())
private var _savedPosts by mutableStateOf(emptySet<String>())
init {
viewModelScope.launch { savedPosts.collectLatest { _savedPosts = it.map(UIPost::shortId) } }
viewModelScope.launch { readPostsRepository.readPosts.collectLatest { _readPosts = it } }
}
private fun groupSavedPosts(items: List<UIPost>): ImmutableMap<String, List<UIPost>> {
val sorted =
items.sortedWith { post1, post2 ->
val post1Date = post1.createdAt.toLocalDateTime()
val post2Date = post2.createdAt.toLocalDateTime()
if (post2Date.isBefore(post1Date)) {
-1
} else if (post2Date.isAfter(post1Date)) {
1
} else {
0
}
}
return sorted
.groupBy { post ->
val time = post.createdAt.toLocalDateTime()
"${time.month.name.lowercase().capitalize(Locale.current)} ${time.year}"
}
.toImmutableMap()
viewModelScope.launch {
savedPosts.collectLatest { _savedPosts = it.map(UIPost::shortId).toSet() }
}
viewModelScope.launch {
readPostsRepository.readPosts.collectLatest { _readPosts = it.toSet() }
}
}
fun toggleSave(post: UIPost) {
viewModelScope.launch {
savedPostsRepository.toggleSave(post)
withContext(mainDispatcher) { SavedPostsWidget(savedPosts).updateAll(getApplication()) }
withContext(mainDispatcher) { SavedPostsWidget().updateAll(getApplication()) }
}
}
@@ -138,7 +156,8 @@ constructor(
suspend fun getLinkMetadata(url: String) =
withContext(ioDispatcher) { linkMetadataRepository.getLinkMetadata(url) }
suspend fun importPosts(input: InputStream) = dataTransferRepository.importPosts(input)
suspend fun importPosts(input: InputStream): Result<Unit> =
dataTransferRepository.importPosts(input)
suspend fun exportPostsAsJson(output: OutputStream) =
dataTransferRepository.exportPostsAsJson(output)
@@ -156,7 +175,7 @@ constructor(
* solution.
*/
private fun String.toLocalDateTime(): LocalDateTime {
if (isEmpty()) return LocalDateTime.now(ZoneId.systemDefault())
return LocalDateTime.from(DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(this))
if (isEmpty()) return Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
return Instant.parse(this).toLocalDateTime(TimeZone.currentSystemDefault())
}
}
@@ -9,11 +9,10 @@ package dev.msfjarvis.claw.android.viewmodel
import dev.msfjarvis.claw.core.coroutines.IODispatcher
import dev.msfjarvis.claw.database.SavedPostSerializer
import dev.msfjarvis.claw.database.local.SavedPost
import dev.zacsweers.metro.Inject
import java.io.InputStream
import java.io.OutputStream
import java.time.Instant
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import kotlin.time.Instant
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@@ -23,20 +22,24 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream
@OptIn(ExperimentalSerializationApi::class)
class DataTransferRepository
@Inject
constructor(
@OptIn(ExperimentalSerializationApi::class)
class DataTransferRepository(
private val json: Json,
private val savedPostsRepository: SavedPostsRepository,
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
@param:IODispatcher private val ioDispatcher: CoroutineDispatcher,
) {
private val serializer = ListSerializer(SavedPostSerializer)
suspend fun importPosts(input: InputStream) {
val posts: List<SavedPost> =
withContext(ioDispatcher) { json.decodeFromStream(serializer, input) }
savedPostsRepository.savePosts(posts)
suspend fun importPosts(input: InputStream): Result<Unit> {
return try {
val posts: List<SavedPost> =
withContext(ioDispatcher) { json.decodeFromStream(serializer, input) }
savedPostsRepository.savePosts(posts)
Result.success(Unit)
} catch (t: Throwable) {
Result.failure(t)
}
}
suspend fun exportPostsAsJson(output: OutputStream) {
@@ -45,11 +48,8 @@ constructor(
}
suspend fun exportPostsAsHTML(output: OutputStream) {
fun computeTimestamp(post: SavedPost): Long {
val temporal = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(post.createdAt)
val instant = Instant.from(temporal)
return instant.toEpochMilli()
}
fun computeTimestamp(post: SavedPost): Long =
Instant.parse(post.createdAt).toEpochMilliseconds()
val posts = savedPostsRepository.savedPosts.first()
val header =
@@ -84,10 +84,6 @@ constructor(
)
append("</DD></p>\n")
}
withContext(ioDispatcher) {
val writer = output.bufferedWriter()
writer.write(html)
writer.flush()
}
withContext(ioDispatcher) { output.bufferedWriter().use { writer -> writer.write(html) } }
}
}
@@ -8,16 +8,15 @@ package dev.msfjarvis.claw.android.viewmodel
import dev.msfjarvis.claw.core.coroutines.IODispatcher
import dev.msfjarvis.claw.model.LinkMetadata
import javax.inject.Inject
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import me.saket.unfurl.Unfurler
class LinkMetadataRepository
@Inject
constructor(
class LinkMetadataRepository(
private val unfurler: Unfurler,
@IODispatcher private val dispatcher: CoroutineDispatcher,
@param:IODispatcher private val dispatcher: CoroutineDispatcher,
) {
suspend fun getLinkMetadata(url: String): LinkMetadata {
val result = withContext(dispatcher) { unfurler.unfurl(url) }
@@ -11,16 +11,15 @@ import app.cash.sqldelight.coroutines.mapToList
import dev.msfjarvis.claw.core.coroutines.DatabaseReadDispatcher
import dev.msfjarvis.claw.core.coroutines.DatabaseWriteDispatcher
import dev.msfjarvis.claw.database.local.ReadPostsQueries
import javax.inject.Inject
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
class ReadPostsRepository
@Inject
constructor(
class ReadPostsRepository(
private val readPostsQueries: ReadPostsQueries,
@DatabaseReadDispatcher private val readDispatcher: CoroutineDispatcher,
@DatabaseWriteDispatcher private val writeDispatcher: CoroutineDispatcher,
@param:DatabaseReadDispatcher private val readDispatcher: CoroutineDispatcher,
@param:DatabaseWriteDispatcher private val writeDispatcher: CoroutineDispatcher,
) {
val readPosts = readPostsQueries.selectAllPosts().asFlow().mapToList(readDispatcher)
@@ -6,41 +6,56 @@
*/
package dev.msfjarvis.claw.android.viewmodel
import android.util.Log
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import dev.msfjarvis.claw.android.BuildConfig
import dev.msfjarvis.claw.core.coroutines.DatabaseReadDispatcher
import dev.msfjarvis.claw.core.coroutines.DatabaseWriteDispatcher
import dev.msfjarvis.claw.database.local.SavedPost
import dev.msfjarvis.claw.database.local.SavedPostQueries
import dev.msfjarvis.claw.model.UIPost
import dev.msfjarvis.claw.model.toSavedPost
import io.github.aakira.napier.Napier
import javax.inject.Inject
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withContext
class SavedPostsRepository
@Inject
constructor(
class SavedPostsRepository(
private val savedPostQueries: SavedPostQueries,
@DatabaseReadDispatcher private val readDispatcher: CoroutineDispatcher,
@DatabaseWriteDispatcher private val writeDispatcher: CoroutineDispatcher,
@param:DatabaseReadDispatcher private val readDispatcher: CoroutineDispatcher,
@param:DatabaseWriteDispatcher private val writeDispatcher: CoroutineDispatcher,
) {
val savedPosts = savedPostQueries.selectAllPosts().asFlow().mapToList(readDispatcher)
val savedPostsSortedByDate =
savedPostQueries.selectAllPostsSortedByDate().asFlow().mapToList(readDispatcher)
fun getPostsFromLastNDays(days: Long) =
savedPostQueries.selectPostsFromLastNDays(days.toString()).asFlow().mapToList(readDispatcher)
fun getRecentPosts(limit: Long) =
savedPostQueries.selectRecentPosts(limit).asFlow().mapToList(readDispatcher)
suspend fun toggleSave(post: UIPost) {
if (savedPosts.firstOrNull().orEmpty().any { it.shortId == post.shortId }) {
Napier.d(tag = TAG) { "Removing post: ${post.shortId}" }
val exists =
withContext(readDispatcher) { savedPostQueries.postExists(post.shortId).executeAsOne() }
if (exists) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "Removing post: ${post.shortId}")
}
withContext(writeDispatcher) { savedPostQueries.deletePost(post.shortId) }
} else {
Napier.d(tag = TAG) { "Saving post: ${post.shortId}" }
if (BuildConfig.DEBUG) {
Log.d(TAG, "Saving post: ${post.shortId}")
}
withContext(writeDispatcher) { savedPostQueries.insertOrReplacePost(post.toSavedPost()) }
}
}
suspend fun savePosts(posts: List<SavedPost>) {
Napier.d(tag = TAG) { "Saving posts: ${posts.joinToString(",") { it.shortId }}" }
if (BuildConfig.DEBUG) {
Log.d(TAG, "Saving posts: ${posts.joinToString(",") { it.shortId }}")
}
withContext(writeDispatcher) {
savedPostQueries.transaction {
posts.forEach { post -> savedPostQueries.insertOrReplacePost(post) }
@@ -0,0 +1,54 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.msfjarvis.claw.core.network.SessionCookieStore
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.binding
import dev.zacsweers.metrox.viewmodel.ViewModelKey
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
@Inject
@ViewModelKey
@ContributesIntoMap(scope = AppScope::class, binding = binding<ViewModel>())
class SettingsViewModel(
private val sessionCookieStore: SessionCookieStore,
private val webViewCookieStore: WebViewCookieStore,
) : ViewModel() {
val isLoggedIn =
sessionCookieStore
.isLoggedIn()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false,
)
val username =
sessionCookieStore
.username()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null,
)
fun logout() {
sessionCookieStore.clear()
webViewCookieStore.clearLobstersCookies()
}
fun saveCookie(cookie: String, username: String) {
sessionCookieStore.set(cookie, username)
}
}
@@ -0,0 +1,25 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.viewmodel
import android.webkit.CookieManager
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
interface WebViewCookieStore {
fun clearLobstersCookies()
}
@Inject
@ContributesBinding(AppScope::class)
class AndroidWebViewCookieStore : WebViewCookieStore {
override fun clearLobstersCookies() {
CookieManager.getInstance().removeAllCookies(null)
CookieManager.getInstance().flush()
}
}
@@ -10,68 +10,81 @@ import android.content.Context
import androidx.glance.appwidget.updateAll
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.deliveryhero.whetstone.worker.ContributesWorker
import com.deliveryhero.whetstone.worker.WorkerScope
import com.slack.eithernet.ApiResult.Success
import com.squareup.anvil.annotations.optional.ForScope
import dev.msfjarvis.claw.android.glance.SavedPostsWidget
import dev.msfjarvis.claw.android.viewmodel.SavedPostsRepository
import dev.msfjarvis.claw.api.LobstersApi
import dev.msfjarvis.claw.model.UIPost
import dev.msfjarvis.claw.model.fromSavedPost
import dev.msfjarvis.claw.core.injection.InjectedWorkerFactory
import dev.msfjarvis.claw.core.injection.WorkerKey
import dev.msfjarvis.claw.database.local.SavedPost
import dev.msfjarvis.claw.model.toSavedPost
import java.time.Instant
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.binding
import kotlin.random.Random
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
/**
* WorkManager-backed [CoroutineWorker] that gets all the posts from [SavedPostsRepository], fetches
* their newest state using the [LobstersApi] and then writes them back to the database. This allows
* saved posts that were saved before comment counts were added to be able to show a comment count
* and for new-enough posts that are still getting comments to have an accurate one.
* WorkManager-backed [CoroutineWorker] that gets all the posts from [SavedPostsRepository] that
* were created in the last 30 days, fetches their newest state using the [LobstersApi] and then
* writes them back to the database. API calls are staggered with a configurable delay to avoid rate
* limiting. This allows saved posts to stay up-to-date with current comment counts and other
* metadata changes.
*/
@ContributesWorker
class SavedPostUpdaterWorker
@Inject
constructor(
@ForScope(WorkerScope::class) appContext: Context,
workerParams: WorkerParameters,
@AssistedInject
class SavedPostUpdaterWorker(
context: Context,
@Assisted params: WorkerParameters,
private val savedPostsRepository: SavedPostsRepository,
private val lobstersApi: LobstersApi,
) : CoroutineWorker(appContext, workerParams) {
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val postsToUpdate =
savedPostsRepository.savedPosts
.first()
.sortedBy { post ->
Instant.from(DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(post.createdAt))
}
.take(30)
val postsToUpdate = savedPostsRepository.getPostsFromLastNDays(DAYS_TO_UPDATE).first()
val updatedPosts = mutableListOf<dev.msfjarvis.claw.database.local.SavedPost>()
if (postsToUpdate.isEmpty()) {
return Result.success()
}
for (post in postsToUpdate) {
val updatedPosts = mutableListOf<SavedPost>()
for ((index, post) in postsToUpdate.withIndex()) {
when (val result = lobstersApi.getPostDetails(post.shortId)) {
is Success -> {
updatedPosts.add(result.value.toSavedPost())
}
else -> {
// Continue to the next post if the API call fails
}
else -> {}
}
if (index < postsToUpdate.lastIndex) {
// Add a random delay between API calls to avoid rate limiting
val delayMs = Random.nextLong(MIN_DELAY_MS, MAX_DELAY_MS)
delay(delayMs)
}
// Add a 1-second delay to avoid rate-limiting
delay(1000)
}
savedPostsRepository.savePosts(updatedPosts)
if (updatedPosts.isNotEmpty()) {
savedPostsRepository.savePosts(updatedPosts)
SavedPostsWidget().updateAll(applicationContext)
}
SavedPostsWidget(
savedPostsRepository.savedPosts.map { it.map(UIPost.Companion::fromSavedPost) }
)
.updateAll(applicationContext)
return Result.success()
}
private companion object {
const val DAYS_TO_UPDATE = 30L
const val MIN_DELAY_MS = 500L
const val MAX_DELAY_MS = 2000L
}
@WorkerKey(SavedPostUpdaterWorker::class)
@ContributesIntoMap(
AppScope::class,
binding = binding<InjectedWorkerFactory.WorkerInstanceFactory<*>>(),
)
@AssistedFactory
abstract class Factory : InjectedWorkerFactory.WorkerInstanceFactory<SavedPostUpdaterWorker>
}
@@ -0,0 +1,40 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.work
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dev.msfjarvis.claw.common.tags.TagBlockRepository
import dev.msfjarvis.claw.core.injection.InjectedWorkerFactory
import dev.msfjarvis.claw.core.injection.WorkerKey
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.binding
@AssistedInject
class TagExpirationCleanupWorker(
context: Context,
@Assisted params: WorkerParameters,
private val tagBlockRepository: TagBlockRepository,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
tagBlockRepository.removeExpiredTags()
return Result.success()
}
@WorkerKey(TagExpirationCleanupWorker::class)
@ContributesIntoMap(
AppScope::class,
binding = binding<InjectedWorkerFactory.WorkerInstanceFactory<*>>(),
)
@AssistedFactory
abstract class Factory : InjectedWorkerFactory.WorkerInstanceFactory<TagExpirationCleanupWorker>
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -9,8 +9,7 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:colorControlNormal"
android:alpha="0.6"
android:tint="?android:attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
+36 -1
View File
@@ -4,6 +4,41 @@
~ license that can be found in the LICENSE file or at
~ https://opensource.org/licenses/MIT.
-->
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name">Claw</string>
<string name="saved_posts_widget_description">View your saved Lobsters posts</string>
<string name="hottest_posts_widget_description">View the hottest posts on Lobsters</string>
<string name="saved_posts">Saved posts</string>
<string name="see_more_posts">See more posts</string>
<plurals name="comments">
<item quantity="one">%1$d comment</item>
<item quantity="other">%1$d comments</item>
</plurals>
<string name="no_post_selected">No post selected</string>
<string name="data_management">Data Management</string>
<string name="data_transfer">Data transfer</string>
<string name="import_and_export_your_saved_posts">Import and export your saved posts</string>
<string name="app_information">App Information</string>
<string name="version">Version</string>
<string name="version_placeholder">%1$s (%2$s)</string>
<string name="posts_saved_locally">%1$s posts saved locally</string>
<string name="tag_filtering">Tag filtering</string>
<string name="filter_posts_by_tags">Filter posts by tags</string>
<string name="about">About</string>
<string name="libraries">Libraries</string>
<string name="view_open_source_libraries_used_in_this_">View open source libraries used in this app</string>
<string name="source_code">Source code</string>
<string name="view_the_source_code_on_github">View the source code on GitHub</string>
<string name="import_action">Import</string>
<string name="export">Export</string>
<string name="json">JSON</string>
<string name="export_as_json">Export as JSON</string>
<string name="bookmarks">Bookmarks</string>
<string name="export_as_browser_bookmarks">Export as browser bookmarks</string>
<string name="go_back_to_previous_screen">Go back to previous screen</string>
<string name="the_app_icon_for_claw">The app icon for Claw</string>
<string name="search_posts">Search posts</string>
<string name="settings">Settings</string>
<string name="empty_inbox_icon">Empty inbox icon</string>
<string name="no_saved_posts">No saved posts</string>
</resources>
@@ -8,12 +8,12 @@
<cloud-backup>
<include
domain="database"
path="databases" />
path="." />
</cloud-backup>
<device-transfer>
<include
domain="database"
path="databases" />
path="." />
</device-transfer>
</data-extraction-rules>
@@ -7,5 +7,5 @@
<full-backup-content>
<include
domain="database"
path="databases" />
path="." />
</full-backup-content>
@@ -0,0 +1,17 @@
<!--
~ Copyright © Harsh Shandilya.
~ Use of this source code is governed by an MIT-style
~ license that can be found in the LICENSE file or at
~ https://opensource.org/licenses/MIT.
-->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/hottest_posts_widget_description"
android:previewImage="@drawable/hottest_posts_widget_preview"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="349dp"
android:minHeight="110dp"
android:minResizeWidth="349dp"
android:minResizeHeight="110dp"
android:resizeMode="vertical|horizontal"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />
@@ -5,6 +5,8 @@
~ https://opensource.org/licenses/MIT.
-->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/saved_posts_widget_description"
android:previewImage="@drawable/saved_posts_widget_preview"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="349dp"
android:minHeight="110dp"
@@ -0,0 +1,71 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.ui.screens
import androidx.navigation3.runtime.NavKey
import com.google.common.truth.Truth.assertThat
import dev.msfjarvis.claw.android.ui.navigation.Comments
import dev.msfjarvis.claw.android.ui.navigation.Hottest
import dev.msfjarvis.claw.android.ui.navigation.Newest
import dev.msfjarvis.claw.android.ui.navigation.Saved
import org.junit.jupiter.api.Test
class BackStackBehaviorTest {
private fun makeBackStack(vararg keys: NavKey) = mutableListOf(*keys)
@Test
fun `navigating from between top level destinations prevents stacking`() {
val backStack = makeBackStack(Hottest)
navigateTo(backStack, Newest)
assertThat(backStack).containsExactly(Hottest, Newest).inOrder()
navigateTo(backStack, Saved)
assertThat(backStack).containsExactly(Hottest, Saved).inOrder()
}
@Test
fun `NonStackable destinations do not stack by default`() {
val backStack = makeBackStack(Hottest, Comments("abc123"))
navigateTo(backStack, Comments("def456"))
assertThat(backStack).containsExactly(Hottest, Comments("def456")).inOrder()
}
@Test
fun `NonStackable destinations stack when allowStacking = true`() {
val backStack = makeBackStack(Hottest, Comments("abc123"))
navigateTo(backStack, Comments("def456"), allowStacking = true)
assertThat(backStack).containsExactly(Hottest, Comments("abc123"), Comments("def456")).inOrder()
}
@Test
fun `Same destination cannot be stacked on itself`() {
val backStack = makeBackStack(Hottest, Comments("abc123"))
navigateTo(backStack, Comments("abc123"), allowStacking = true)
assertThat(backStack).containsExactly(Hottest, Comments("abc123")).inOrder()
}
@Test
fun `Same destination can be stacked with a gap in between`() {
val backStack = makeBackStack(Hottest, Comments("abc123"), Comments("def456"))
navigateTo(backStack, Comments("abc123"), allowStacking = true)
assertThat(backStack)
.containsExactly(Hottest, Comments("abc123"), Comments("def456"), Comments("abc123"))
.inOrder()
}
}
@@ -0,0 +1,72 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.viewmodel
import com.google.common.truth.Truth.assertThat
import dev.msfjarvis.claw.core.network.SessionCookieStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.junit.jupiter.api.Test
class SettingsViewModelTest {
@Test
fun `logout clears persisted and webview sessions`() {
val sessionCookieStore = FakeSessionCookieStore()
val webViewCookieStore = FakeWebViewCookieStore()
val viewModel = SettingsViewModel(sessionCookieStore, webViewCookieStore)
viewModel.logout()
assertThat(sessionCookieStore.cleared).isTrue()
assertThat(webViewCookieStore.cleared).isTrue()
}
@Test
fun `saveCookie persists username`() {
val sessionCookieStore = FakeSessionCookieStore()
val viewModel = SettingsViewModel(sessionCookieStore, FakeWebViewCookieStore())
viewModel.saveCookie("cookie=value", "alice")
assertThat(sessionCookieStore.savedCookie).isEqualTo("cookie=value")
assertThat(sessionCookieStore.savedUsername).isEqualTo("alice")
}
private class FakeSessionCookieStore : SessionCookieStore {
var cleared = false
var savedCookie: String? = null
var savedUsername: String? = null
override fun get(): String? = savedCookie
override fun getUsername(): String? = savedUsername
override fun set(cookie: String, username: String) {
savedCookie = cookie
savedUsername = username
}
override fun clear() {
cleared = true
savedCookie = null
savedUsername = null
}
override fun isLoggedIn(): Flow<Boolean> = flowOf(savedUsername != null)
override fun username(): Flow<String?> = flowOf(savedUsername)
}
private class FakeWebViewCookieStore : WebViewCookieStore {
var cleared = false
override fun clearLobstersCookies() {
cleared = true
}
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
#
# This file was automatically generated by 'versioning-plugin'. DO NOT EDIT MANUALLY.
#
versioning-plugin.versionCode=15700
versioning-plugin.versionName=1.57.0
versioning-plugin.versionCode=16600
versioning-plugin.versionName=1.66.0
+9 -13
View File
@@ -7,31 +7,27 @@
import dev.msfjarvis.claw.gradle.addTestDependencies
plugins {
id("dev.msfjarvis.claw.android-library")
id("dev.msfjarvis.claw.kotlin-android")
alias(libs.plugins.anvil)
alias(libs.plugins.whetstone)
id("dev.msfjarvis.claw.kotlin-jvm")
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.dependencyAnalysis)
alias(libs.plugins.metro)
}
android { namespace = "dev.msfjarvis.claw.api" }
anvil { generateDaggerFactories.set(true) }
dependencies {
api(libs.dagger)
api(libs.eithernet)
api(libs.javax.inject)
api(libs.kotlinx.serialization.core)
api(libs.okhttp.core)
api(libs.retrofit)
api(projects.model)
implementation(libs.eithernet.integration.retrofit)
implementation(libs.jsoup)
implementation(libs.ksoup)
implementation(libs.kspoon)
implementation(libs.retrofit.kotlinxSerializationConverter)
testImplementation(libs.eithernet.test.fixtures)
testImplementation(libs.kotlin.reflect)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.kotlinx.serialization.core)
testImplementation(libs.kotlinx.serialization.json)
testImplementation(libs.kotlinx.datetime)
addTestDependencies(project)
}
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 8.12.0-alpha02" type="baseline" client="gradle" dependencies="true" name="AGP (8.12.0-alpha02)" variant="all" version="8.12.0-alpha02">
<issues format="6" by="lint 9.1.0" type="baseline" client="gradle" dependencies="true" name="AGP (9.1.0)" variant="all" version="9.1.0">
</issues>
@@ -0,0 +1,97 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.api
import com.slack.eithernet.ApiResult
import com.slack.eithernet.ApiResult.Failure
import com.slack.eithernet.ApiResult.Success
import dev.zacsweers.metro.Inject
import java.io.IOException
import okhttp3.MultipartBody
private const val XML_HTTP_REQUEST = "XMLHttpRequest"
private const val XML_HTTP_REQUEST_DUPLICATE = "XMLHttpRequest, XMLHttpRequest"
private const val ACCEPT_ANY = "*/*"
@Inject
class AuthenticatedLobstersApi(private val api: LobstersApi) {
suspend fun upvoteComment(commentId: String): ApiResult<Unit, Unit> {
return withCSRFToken { csrfToken ->
api.upvoteComment(commentId, csrfToken, XML_HTTP_REQUEST, part("reason", ""))
}
}
suspend fun unvoteComment(commentId: String): ApiResult<Unit, Unit> {
return withCSRFToken { csrfToken ->
api.unvoteComment(commentId, csrfToken, XML_HTTP_REQUEST, part("reason", ""))
}
}
suspend fun reply(commentId: String, storyId: String, comment: String): ApiResult<Unit, Unit> {
val referer = "${LobstersApi.BASE_URL}/s/$storyId"
return withCSRFToken { csrfToken ->
when (val formResponse = api.getReplyForm(commentId, csrfToken, XML_HTTP_REQUEST, referer)) {
is Success -> {
val form = formResponse.value
if (!form.isValid()) {
ApiResult.unknownFailure(IOException("Lobsters reply form was missing required fields"))
} else {
api.postReply(
csrfToken = csrfToken,
requestedWith = XML_HTTP_REQUEST_DUPLICATE,
referer = referer,
origin = LobstersApi.BASE_URL,
accept = ACCEPT_ANY,
authenticityToken = part("authenticity_token", form.authenticityToken),
storyId = part("story_id", form.storyId),
method = part("_method", form.method),
parentCommentShortId = part("parent_comment_short_id", form.parentCommentShortId),
comment = part("comment", comment),
commit = part("commit", "Post"),
)
}
}
is Failure -> formResponse.toUnitFailure()
}
}
}
private suspend fun withCSRFToken(
block: suspend (String) -> ApiResult<Unit, Unit>
): ApiResult<Unit, Unit> {
return when (val token = api.getCSRFToken()) {
is Success -> {
val value = token.value.value
if (value.isBlank()) {
ApiResult.unknownFailure(IOException("Lobsters CSRF token was empty"))
} else {
block(value)
}
}
is Failure -> token.toUnitFailure()
}
}
private fun ReplyForm.isValid(): Boolean =
authenticityToken.isNotBlank() &&
storyId.isNotBlank() &&
method.isNotBlank() &&
parentCommentShortId.isNotBlank()
private fun part(name: String, value: String): MultipartBody.Part =
MultipartBody.Part.createFormData(name, value)
private fun Failure<*>.toUnitFailure(): Failure<Unit> =
when (this) {
is Failure.ApiFailure -> ApiResult.apiFailure(error as? Unit)
is Failure.HttpFailure -> ApiResult.httpFailure(code, error as? Unit)
is Failure.NetworkFailure -> ApiResult.networkFailure(error)
is Failure.UnknownFailure -> ApiResult.unknownFailure(error)
}
}
@@ -7,31 +7,94 @@
package dev.msfjarvis.claw.api
import com.slack.eithernet.ApiResult
import com.slack.eithernet.ApiResult.Failure
import com.slack.eithernet.ApiResult.Success
import dev.burnoo.kspoon.annotation.Selector
import dev.msfjarvis.claw.model.LobstersPost
import dev.msfjarvis.claw.model.LobstersPostDetails
import dev.msfjarvis.claw.model.Tag
import dev.msfjarvis.claw.model.User
import kotlinx.serialization.Serializable
import okhttp3.MultipartBody
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
@Serializable class PostsPage(@Selector("li.story") val posts: List<LobstersPost> = emptyList())
@Serializable class TagsPage(@Selector("ol.category_tags > li") val tags: List<Tag> = emptyList())
fun ApiResult<PostsPage, Unit>.toPostsResult(): ApiResult<List<LobstersPost>, Unit> =
when (this) {
is Success -> ApiResult.success(value.posts)
is Failure.ApiFailure -> ApiResult.apiFailure(error)
is Failure.HttpFailure -> ApiResult.httpFailure(code, error)
is Failure.NetworkFailure -> ApiResult.networkFailure(error)
is Failure.UnknownFailure -> ApiResult.unknownFailure(error)
}
/** Simple interface defining an API for lobste.rs */
interface LobstersApi {
@GET("page/{page}.json")
suspend fun getHottestPosts(@Path("page") page: Int): ApiResult<List<LobstersPost>, Unit>
@GET("page/{page}")
suspend fun getHottestPosts(@Path("page") page: Int): ApiResult<PostsPage, Unit>
@GET("newest/page/{page}.json")
suspend fun getNewestPosts(@Path("page") page: Int): ApiResult<List<LobstersPost>, Unit>
@GET("newest/page/{page}")
suspend fun getNewestPosts(@Path("page") page: Int): ApiResult<PostsPage, Unit>
@GET("s/{postId}.json")
@GET("s/{postId}")
suspend fun getPostDetails(@Path("postId") postId: String): ApiResult<LobstersPostDetails, Unit>
@GET("~{username}.json")
suspend fun getUser(@Path("username") username: String): ApiResult<User, Unit>
@GET("~{username}") suspend fun getUser(@Path("username") username: String): ApiResult<User, Unit>
@GET("/") suspend fun getCSRFToken(): ApiResult<CSRFToken, Unit>
@GET("tags.json") suspend fun getTags(): ApiResult<List<Tag>, Unit>
@GET("tags") suspend fun getTags(): ApiResult<TagsPage, Unit>
@Multipart
@POST("comments/{commentId}/upvote")
suspend fun upvoteComment(
@Path("commentId") commentId: String,
@Header("x-csrf-token") csrfToken: String,
@Header("x-requested-with") requestedWith: String,
@Part reason: MultipartBody.Part,
): ApiResult<Unit, Unit>
@Multipart
@POST("comments/{commentId}/unvote")
suspend fun unvoteComment(
@Path("commentId") commentId: String,
@Header("x-csrf-token") csrfToken: String,
@Header("x-requested-with") requestedWith: String,
@Part reason: MultipartBody.Part,
): ApiResult<Unit, Unit>
@GET("comments/{commentId}/reply")
suspend fun getReplyForm(
@Path("commentId") commentId: String,
@Header("x-csrf-token") csrfToken: String,
@Header("x-requested-with") requestedWith: String,
@Header("Referer") referer: String,
): ApiResult<ReplyForm, Unit>
@Multipart
@POST("comments")
suspend fun postReply(
@Header("x-csrf-token") csrfToken: String,
@Header("x-requested-with") requestedWith: String,
@Header("Referer") referer: String,
@Header("Origin") origin: String,
@Header("Accept") accept: String,
@Part authenticityToken: MultipartBody.Part,
@Part storyId: MultipartBody.Part,
@Part method: MultipartBody.Part,
@Part parentCommentShortId: MultipartBody.Part,
@Part comment: MultipartBody.Part,
@Part commit: MultipartBody.Part,
): ApiResult<Unit, Unit>
companion object {
const val BASE_URL = "https://lobste.rs"
@@ -0,0 +1,14 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.api
data class ReplyForm(
val authenticityToken: String,
val storyId: String,
val method: String,
val parentCommentShortId: String,
)
@@ -6,18 +6,18 @@
*/
package dev.msfjarvis.claw.api.converters
import com.fleeksoft.ksoup.Ksoup
import dev.msfjarvis.claw.api.CSRFToken
import dev.msfjarvis.claw.api.LobstersApi
import java.lang.reflect.Type
import okhttp3.ResponseBody
import org.jsoup.Jsoup
import retrofit2.Converter
import retrofit2.Retrofit
object CSRFTokenConverter : Converter<ResponseBody, CSRFToken> {
override fun convert(value: ResponseBody): CSRFToken {
val token =
Jsoup.parse(value.string(), LobstersApi.BASE_URL)
Ksoup.parse(value.string(), baseUri = LobstersApi.BASE_URL)
.select("meta[name=\"csrf-token\"]")
.firstOrNull()
?.attr("content")
@@ -30,8 +30,8 @@ object CSRFTokenConverter : Converter<ResponseBody, CSRFToken> {
type: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit,
): Converter<ResponseBody, CSRFToken> {
return CSRFTokenConverter
): Converter<ResponseBody, *>? {
return if (type == CSRFToken::class.java) CSRFTokenConverter else null
}
}
}
@@ -0,0 +1,40 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.api.converters
import com.fleeksoft.ksoup.Ksoup
import dev.msfjarvis.claw.api.LobstersApi
import dev.msfjarvis.claw.api.ReplyForm
import java.lang.reflect.Type
import okhttp3.ResponseBody
import retrofit2.Converter
import retrofit2.Retrofit
object ReplyFormConverter : Converter<ResponseBody, ReplyForm> {
override fun convert(value: ResponseBody): ReplyForm {
val document = Ksoup.parse(value.string(), baseUri = LobstersApi.BASE_URL)
fun inputValue(name: String): String =
document.select("input[name=\"$name\"]").firstOrNull()?.attr("value").orEmpty()
return ReplyForm(
authenticityToken = inputValue("authenticity_token"),
storyId = inputValue("story_id"),
method = inputValue("_method"),
parentCommentShortId = inputValue("parent_comment_short_id"),
)
}
object Factory : Converter.Factory() {
override fun responseBodyConverter(
type: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit,
): Converter<ResponseBody, *>? {
return if (type == ReplyForm::class.java) ReplyFormConverter else null
}
}
}
@@ -6,21 +6,21 @@
*/
package dev.msfjarvis.claw.api.converters
import com.fleeksoft.ksoup.Ksoup
import com.fleeksoft.ksoup.nodes.Element
import com.fleeksoft.ksoup.select.Elements
import dev.msfjarvis.claw.api.LobstersApi
import dev.msfjarvis.claw.model.LobstersPost
import java.lang.reflect.Type
import okhttp3.ResponseBody
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.select.Elements
import retrofit2.Converter
import retrofit2.Retrofit
object SearchConverter : Converter<ResponseBody, List<LobstersPost>> {
override fun convert(value: ResponseBody): List<LobstersPost> {
val elements =
Jsoup.parse(value.string(), LobstersApi.BASE_URL).select("div.story_liner.h-entry")
return elements.map(::parsePost)
return Ksoup.parse(value.string(), baseUri = LobstersApi.BASE_URL)
.select("div.story_liner.h-entry")
.map(::parsePost)
}
private fun parsePost(elem: Element): LobstersPost {
@@ -31,7 +31,8 @@ object SearchConverter : Converter<ResponseBody, List<LobstersPost>> {
val url = titleElement.attr("href")
val tags = elem.select("span.tags > a").map(Element::text)
val (commentCount, commentsUrl) = getCommentsData(elem.select("span.comments_label"))
val submitter = elem.select("div.byline > a.u-author").text()
val submitter =
elem.select("div.byline > a[href^=/~]:not([tabindex]):not([aria-hidden=true])").text()
val userIsAuthor =
(elem.select("div.byline > span").first()?.text() ?: "").contains(
"authored",
@@ -0,0 +1,29 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.api.converters
import java.lang.reflect.Type
import kotlin.Unit
import okhttp3.ResponseBody
import retrofit2.Converter
import retrofit2.Retrofit
object UnitConverter : Converter<ResponseBody, Unit> {
override fun convert(value: ResponseBody) {
value.close()
}
object Factory : Converter.Factory() {
override fun responseBodyConverter(
type: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit,
): Converter<ResponseBody, *>? {
return if (type == Unit::class.java) UnitConverter else null
}
}
}
@@ -6,24 +6,32 @@
*/
package dev.msfjarvis.claw.api.injection
import com.deliveryhero.whetstone.app.ApplicationScope
import com.fleeksoft.ksoup.Ksoup
import com.slack.eithernet.integration.retrofit.ApiResultCallAdapterFactory
import com.slack.eithernet.integration.retrofit.ApiResultConverterFactory
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntKey
import dagger.multibindings.IntoMap
import dev.burnoo.kspoon.Kspoon
import dev.msfjarvis.claw.api.AuthenticatedLobstersApi
import dev.msfjarvis.claw.api.LobstersApi
import dev.msfjarvis.claw.api.LobstersSearchApi
import dev.msfjarvis.claw.api.converters.CSRFTokenConverter
import dev.msfjarvis.claw.api.converters.ReplyFormConverter
import dev.msfjarvis.claw.api.converters.SearchConverter
import javax.inject.Named
import javax.inject.Qualifier
import dev.msfjarvis.claw.api.converters.UnitConverter
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.IntKey
import dev.zacsweers.metro.IntoMap
import dev.zacsweers.metro.Named
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.Qualifier
import dev.zacsweers.metro.SingleIn
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.CallAdapter
import retrofit2.Converter
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.create
/**
@@ -33,14 +41,15 @@ import retrofit2.create
* uses [IntoMap] with [IntKey]s to fake the presence of a fixed order by sorting on the key of the
* injected [Map]s when injecting them into [Retrofit].
*/
@Module
@ContributesTo(ApplicationScope::class)
@BindingContainer
@ContributesTo(AppScope::class)
object RetrofitModule {
@Provides
@SingleIn(AppScope::class)
fun provideRetrofit(
client: OkHttpClient,
converterFactories: Map<Int, @JvmSuppressWildcards Converter.Factory>,
callAdapterFactories: Map<Int, @JvmSuppressWildcards CallAdapter.Factory>,
converterFactories: Map<Int, Converter.Factory>,
callAdapterFactories: Map<Int, CallAdapter.Factory>,
@Named("LobstersURL") baseUrl: String,
): Retrofit {
return Retrofit.Builder()
@@ -53,10 +62,11 @@ object RetrofitModule {
@Provides
@SearchApi
@SingleIn(AppScope::class)
fun provideSearchApiRetrofit(
client: OkHttpClient,
@SearchApi converterFactories: List<@JvmSuppressWildcards Converter.Factory>,
callAdapterFactories: Map<Int, @JvmSuppressWildcards CallAdapter.Factory>,
@SearchApi converterFactories: List<Converter.Factory>,
callAdapterFactories: Map<Int, CallAdapter.Factory>,
@Named("LobstersURL") baseUrl: String,
): Retrofit {
return Retrofit.Builder()
@@ -68,9 +78,17 @@ object RetrofitModule {
.build()
}
@Provides fun provideApi(retrofit: Retrofit): LobstersApi = retrofit.create()
@Provides
@SingleIn(AppScope::class)
fun provideApi(retrofit: Retrofit): LobstersApi = retrofit.create()
@Provides
@SingleIn(AppScope::class)
fun provideAuthenticatedApi(api: LobstersApi): AuthenticatedLobstersApi =
AuthenticatedLobstersApi(api)
@Provides
@SingleIn(AppScope::class)
fun provideSearchApi(@SearchApi retrofit: Retrofit): LobstersSearchApi = retrofit.create()
@Provides
@@ -78,19 +96,40 @@ object RetrofitModule {
@IntoMap
fun provideApiResultConverter(): Converter.Factory = ApiResultConverterFactory
@Provides
@IntKey(1)
@IntoMap
fun provideCSRFTokenConverter(): Converter.Factory = CSRFTokenConverter.Factory
@Provides
@IntKey(2)
@IntoMap
fun provideReplyFormConverter(): Converter.Factory = ReplyFormConverter.Factory
@Provides
@IntKey(3)
@IntoMap
fun provideUnitConverter(): Converter.Factory = UnitConverter.Factory
@Provides
@IntKey(4)
@IntoMap
fun provideKspoonConverter(): Converter.Factory =
Kspoon {
parse = { html -> Ksoup.parse(html, baseUri = LobstersApi.BASE_URL) }
coerceInputValues = true
}
.toFormat()
.asConverterFactory("text/html".toMediaType())
@Provides
@IntKey(0)
@IntoMap
fun provideApiResultCallAdapter(): CallAdapter.Factory = ApiResultCallAdapterFactory
@Provides
@IntKey(Int.MAX_VALUE)
@IntoMap
fun provideCSRFTokenConverter(): Converter.Factory = CSRFTokenConverter.Factory
@Provides
@SearchApi
fun provideConverters(): List<@JvmSuppressWildcards Converter.Factory> =
fun provideConverters(): List<Converter.Factory> =
listOf(ApiResultConverterFactory, SearchConverter.Factory)
@Provides @Named("LobstersURL") fun provideLobstersUrl(): String = LobstersApi.BASE_URL
@@ -6,14 +6,15 @@
*/
package dev.msfjarvis.claw.api
import com.fleeksoft.ksoup.Ksoup
import com.google.common.truth.Truth.assertThat
import com.slack.eithernet.ApiResult.Success
import com.slack.eithernet.test.newEitherNetController
import dev.msfjarvis.claw.model.LobstersPost
import dev.burnoo.kspoon.Kspoon
import dev.msfjarvis.claw.model.LobstersPostDetails
import dev.msfjarvis.claw.model.Tag
import dev.msfjarvis.claw.model.User
import dev.msfjarvis.claw.util.TestUtils.assertIs
import kotlin.time.Instant
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@@ -25,23 +26,130 @@ class ApiTest {
@Test
fun `api gets correct number of items`() = runTest {
val posts = api.getHottestPosts(1)
assertIs<Success<List<LobstersPost>>>(posts)
assertThat(posts.value).hasSize(25)
assertIs<Success<PostsPage>>(posts)
assertThat(posts.value.posts).hasSize(25)
}
@Test
fun `posts with no urls`() = runTest {
val posts = api.getHottestPosts(1)
assertIs<Success<List<LobstersPost>>>(posts)
val commentsOnlyPosts = posts.value.asSequence().filter { it.url.isEmpty() }.toSet()
assertIs<Success<PostsPage>>(posts)
val commentsOnlyPosts = posts.value.posts.asSequence().filter { it.url.isEmpty() }.toSet()
assertThat(commentsOnlyPosts).hasSize(0)
}
@Test
fun `api parses hottest HTML fixture fields`() = runTest {
val posts = api.getHottestPosts(1)
assertIs<Success<PostsPage>>(posts)
val firstPost = posts.value.posts[0]
assertThat(firstPost.shortId).isEqualTo("jp3nva")
assertThat(firstPost.title).isEqualTo("You probably don't need Yocto, and that's fine")
assertThat(firstPost.submitter).isEqualTo("rw-rw-rw-")
assertThat(firstPost.commentCount).isEqualTo(9)
assertThat(firstPost.commentsUrl)
.isEqualTo("https://lobste.rs/s/jp3nva/you_probably_don_t_need_yocto_s_fine")
assertThat(firstPost.tags).containsExactly("linux")
assertThat(firstPost.userIsAuthor).isTrue()
assertThat(firstPost.createdAt).isEqualTo("2026-05-29T09:08:12Z")
Instant.parse(firstPost.createdAt)
val secondPost = posts.value.posts[1]
assertThat(secondPost.shortId).isEqualTo("lc26ar")
assertThat(secondPost.title).isEqualTo("SQLite Does Not Accept Agentic Code")
assertThat(secondPost.submitter).isEqualTo("hoistbypetard")
assertThat(secondPost.commentCount).isEqualTo(15)
assertThat(secondPost.commentsUrl)
.isEqualTo("https://lobste.rs/s/lc26ar/sqlite_does_not_accept_agentic_code")
assertThat(secondPost.tags).containsExactly("vibecoding")
assertThat(secondPost.userIsAuthor).isFalse()
val noCommentsPost = posts.value.posts.first { it.shortId == "1fkt8w" }
assertThat(noCommentsPost.title).isEqualTo("Patching my guitar amp's firmware")
assertThat(noCommentsPost.submitter).isEqualTo("mcf")
assertThat(noCommentsPost.commentCount).isEqualTo(0)
assertThat(noCommentsPost.commentsUrl)
.isEqualTo("https://lobste.rs/s/1fkt8w/patching_my_guitar_amp_s_firmware")
assertThat(noCommentsPost.tags).containsExactly("hardware", "reversing")
assertThat(noCommentsPost.userIsAuthor).isTrue()
}
@Test
fun `api gets newest posts`() = runTest {
val posts = api.getNewestPosts(1)
assertIs<Success<PostsPage>>(posts)
assertThat(posts.value.posts).hasSize(25)
}
@Test
fun `post details with comments`() = runTest {
val postDetails = api.getPostDetails("tdfoqh")
assertIs<Success<LobstersPostDetails>>(postDetails)
assertThat(postDetails.value.comments).hasSize(10)
val comments = postDetails.value.comments
assertThat(comments).hasSize(10)
assertThat(comments.first().user).isEqualTo("dpercy")
assertThat(comments.first { it.shortId == "pcvbcd" }.score).isEqualTo(2)
assertThat(comments.first().comment).contains("Maybe take the max, instead of the sum?")
assertThat(comments.first { it.shortId == "pcvbcd" }.parentComment).isEqualTo("m3wyu5")
assertThat(comments.first { it.shortId == "lqqn3a" }.parentComment).isEqualTo("owddle")
}
@Test
fun `comments without visible upvoter count have one point from the author`() {
val kspoon = Kspoon {
parse = { html -> Ksoup.parse(html, baseUri = LobstersApi.BASE_URL) }
coerceInputValues = true
}
val postDetails =
kspoon.parse<LobstersPostDetails>(
"""
<ol class="stories">
<li class="story" data-shortid="story1">
<span class="link h-cite"><a href="/s/story1/test">Test story</a></span>
<div class="byline">
<a class="u-author" href="/~/submitter">submitter</a>
<time data-at-unix="1710000000"></time>
</div>
</li>
</ol>
<ol class="comments">
<li class="comments_subtree">
<div class="comment" data-shortid="abc123">
<div class="voters"></div>
<div class="details">
<div class="byline">
<a href="/~/author">author</a>
<a href="/c/abc123"><time data-at-unix="1710000000"></time></a>
</div>
<div class="comment_text"><p>Hello</p></div>
</div>
</div>
</li>
</ol>
"""
.trimIndent()
)
assertThat(postDetails.comments.single().score).isEqualTo(1)
}
@Test
fun `edited comments expose a single timestamp and edited state`() = runTest {
val postDetails = api.getPostDetails("tdfoqh")
assertIs<Success<LobstersPostDetails>>(postDetails)
val editedComment = postDetails.value.comments.first { it.shortId == "pcvbcd" }
assertThat(editedComment.edited).isTrue()
assertThat(editedComment.timestamp.epochSeconds).isEqualTo(1658588955)
}
@Test
fun `post details preserves upvoted comments`() = runTest {
val postDetails = wrapper.upvotedPostDetails
assertThat(postDetails.comments.filter { it.isUpvoted }.map { it.shortId })
.containsExactly("ncdsfc")
}
@Test
@@ -57,16 +165,27 @@ class ApiTest {
assertIs<Success<CSRFToken>>(token)
assertThat(token.value.value)
.isEqualTo(
"RPe1m3JaXAq0ti7t15hNixpLvmgJ793Ti6y4fOpxtfenyXQK3WU19m7MJb8RGoIf-tC3C9Ilwb8pv0WJUWLbNA"
"dvJ8r_CkOImcHQ5ZLUWlJeQVoPEPQ3rK85DNgiZJcehafqwYP8jESW8AhMf0uQGLqqLbsarYiISCghnDaUd6wA"
)
}
@Test
fun `retrieve tags`() = runTest {
val tags = api.getTags()
assertIs<Success<List<Tag>>>(tags)
assertThat(tags.value).isNotEmpty()
assertThat(tags.value.first().tag).isEqualTo("ruby")
assertThat(tags.value.first().description).isEqualTo("Ruby programming")
assertIs<Success<TagsPage>>(tags)
assertThat(tags.value.tags).isNotEmpty()
val rubyTag = tags.value.tags.first { it.tag == "ruby" }
assertThat(rubyTag.description).isEqualTo("Ruby programming")
assertThat(rubyTag.privileged).isFalse()
assertThat(rubyTag.active).isTrue()
assertThat(rubyTag.category).isEmpty()
assertThat(rubyTag.isMedia).isFalse()
assertThat(rubyTag.hotnessMod).isEqualTo(0.0)
val newsTag = tags.value.tags.first { it.tag == "news" }
assertThat(newsTag.active).isFalse()
val videoTag = tags.value.tags.first { it.tag == "video" }
assertThat(videoTag.isMedia).isTrue()
}
}
@@ -6,39 +6,39 @@
*/
package dev.msfjarvis.claw.api
import com.fleeksoft.ksoup.Ksoup
import com.slack.eithernet.ApiResult.Companion.success
import com.slack.eithernet.test.EitherNetController
import com.slack.eithernet.test.enqueue
import dev.burnoo.kspoon.Kspoon
import dev.msfjarvis.claw.api.converters.CSRFTokenConverter
import dev.msfjarvis.claw.model.LobstersPost
import dev.msfjarvis.claw.model.LobstersPostDetails
import dev.msfjarvis.claw.model.Tag
import dev.msfjarvis.claw.model.User
import dev.msfjarvis.claw.util.TestUtils.getResource
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
@OptIn(ExperimentalSerializationApi::class)
class ApiWrapper(controller: EitherNetController<LobstersApi>) {
private val json = Json {
ignoreUnknownKeys = true
namingStrategy = JsonNamingStrategy.SnakeCase
private val kspoon = Kspoon {
parse = { html -> Ksoup.parse(html, baseUri = LobstersApi.BASE_URL) }
coerceInputValues = true
}
private val hottest: List<LobstersPost> = json.decodeFromString(getResource("hottest.json"))
private val postsPage: PostsPage = kspoon.parse<PostsPage>(getResource("hottest_page.html"))
private val postDetails: LobstersPostDetails =
json.decodeFromString(getResource("post_details_tdfoqh.json"))
private val user: User = json.decodeFromString(getResource("msfjarvis.json"))
private val tags: List<Tag> = json.decodeFromString(getResource("tags.json"))
kspoon.parse(getResource("post_details_tdfoqh.html"))
val upvotedPostDetails: LobstersPostDetails =
kspoon.parse(getResource("post_details_upvoted.html"))
private val user: User = kspoon.parse(getResource("msfjarvis.html"))
private val tags: TagsPage = kspoon.parse(getResource("tags.html"))
val api = controller.api
val authenticatedApi = AuthenticatedLobstersApi(api)
init {
controller.enqueue(LobstersApi::getHottestPosts) { success(hottest) }
controller.enqueue(LobstersApi::getHottestPosts) { success(hottest) }
controller.enqueue(LobstersApi::getHottestPosts) { success(postsPage) }
controller.enqueue(LobstersApi::getHottestPosts) { success(postsPage) }
controller.enqueue(LobstersApi::getHottestPosts) { success(postsPage) }
controller.enqueue(LobstersApi::getNewestPosts) { success(postsPage) }
controller.enqueue(LobstersApi::getPostDetails) { success(postDetails) }
controller.enqueue(LobstersApi::getUser) { success(user) }
controller.enqueue(LobstersApi::getTags) { success(tags) }
@@ -49,5 +49,36 @@ class ApiWrapper(controller: EitherNetController<LobstersApi>) {
)
)
}
controller.enqueue(LobstersApi::getCSRFToken) {
success(
CSRFTokenConverter.convert(
getResource("csrf_page.html").toResponseBody("text/html".toMediaType())
)
)
}
controller.enqueue(LobstersApi::getCSRFToken) {
success(
CSRFTokenConverter.convert(
getResource("csrf_page.html").toResponseBody("text/html".toMediaType())
)
)
}
controller.enqueue(LobstersApi::getCSRFToken) {
success(
CSRFTokenConverter.convert(
getResource("csrf_page.html").toResponseBody("text/html".toMediaType())
)
)
}
controller.enqueue(LobstersApi::upvoteComment) { success(Unit) }
controller.enqueue(LobstersApi::unvoteComment) { success(Unit) }
controller.enqueue(LobstersApi::getReplyForm) {
success(
dev.msfjarvis.claw.api.converters.ReplyFormConverter.convert(
getResource("reply_form.html").toResponseBody("text/html".toMediaType())
)
)
}
controller.enqueue(LobstersApi::postReply) { success(Unit) }
}
}
@@ -0,0 +1,108 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.api
import com.google.common.truth.Truth.assertThat
import com.slack.eithernet.ApiResult
import dev.msfjarvis.claw.model.LobstersPostDetails
import dev.msfjarvis.claw.model.User
import kotlinx.coroutines.test.runTest
import okhttp3.MultipartBody
import org.junit.jupiter.api.Test
class AuthenticatedLobstersApiHeaderTest {
@Test
fun `reply submission matches browser xhr headers`() = runTest {
val api = RecordingLobstersApi()
val authenticatedApi = AuthenticatedLobstersApi(api)
authenticatedApi.reply("kkiqui", "2tskyy", "hello")
assertThat(api.replyFormRequestedWith).isEqualTo("XMLHttpRequest")
assertThat(api.replyFormReferer).isEqualTo("https://lobste.rs/s/2tskyy")
assertThat(api.postReplyRequestedWith).isEqualTo("XMLHttpRequest, XMLHttpRequest")
assertThat(api.postReplyReferer).isEqualTo("https://lobste.rs/s/2tskyy")
assertThat(api.postReplyOrigin).isEqualTo(LobstersApi.BASE_URL)
assertThat(api.postReplyAccept).isEqualTo("*/*")
}
}
private class RecordingLobstersApi : LobstersApi {
var replyFormRequestedWith: String? = null
var replyFormReferer: String? = null
var postReplyRequestedWith: String? = null
var postReplyReferer: String? = null
var postReplyOrigin: String? = null
var postReplyAccept: String? = null
override suspend fun getHottestPosts(page: Int): ApiResult<PostsPage, Unit> = error("unused")
override suspend fun getNewestPosts(page: Int): ApiResult<PostsPage, Unit> = error("unused")
override suspend fun getPostDetails(postId: String): ApiResult<LobstersPostDetails, Unit> =
error("unused")
override suspend fun getUser(username: String): ApiResult<User, Unit> = error("unused")
override suspend fun getCSRFToken(): ApiResult<CSRFToken, Unit> =
ApiResult.success(CSRFToken("csrf-token"))
override suspend fun getTags(): ApiResult<TagsPage, Unit> = error("unused")
override suspend fun upvoteComment(
commentId: String,
csrfToken: String,
requestedWith: String,
reason: MultipartBody.Part,
): ApiResult<Unit, Unit> = error("unused")
override suspend fun unvoteComment(
commentId: String,
csrfToken: String,
requestedWith: String,
reason: MultipartBody.Part,
): ApiResult<Unit, Unit> = error("unused")
override suspend fun getReplyForm(
commentId: String,
csrfToken: String,
requestedWith: String,
referer: String,
): ApiResult<ReplyForm, Unit> {
replyFormRequestedWith = requestedWith
replyFormReferer = referer
return ApiResult.success(
ReplyForm(
authenticityToken = "auth-token",
storyId = "2tskyy",
method = "post",
parentCommentShortId = commentId,
)
)
}
override suspend fun postReply(
csrfToken: String,
requestedWith: String,
referer: String,
origin: String,
accept: String,
authenticityToken: MultipartBody.Part,
storyId: MultipartBody.Part,
method: MultipartBody.Part,
parentCommentShortId: MultipartBody.Part,
comment: MultipartBody.Part,
commit: MultipartBody.Part,
): ApiResult<Unit, Unit> {
postReplyRequestedWith = requestedWith
postReplyReferer = referer
postReplyOrigin = origin
postReplyAccept = accept
return ApiResult.success(Unit)
}
}
@@ -0,0 +1,39 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.api
import com.slack.eithernet.ApiResult.Success
import com.slack.eithernet.test.newEitherNetController
import dev.msfjarvis.claw.util.TestUtils.assertIs
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AuthenticatedLobstersApiTest {
private val wrapper = ApiWrapper(newEitherNetController())
private val authenticatedApi = wrapper.authenticatedApi
@Test
fun `upvote comment succeeds`() = runTest {
val result = authenticatedApi.upvoteComment("0unzyg")
assertIs<Success<Unit>>(result)
}
@Test
fun `unvote comment succeeds`() = runTest {
val result = authenticatedApi.unvoteComment("0unzyg")
assertIs<Success<Unit>>(result)
}
@Test
fun `reply succeeds`() = runTest {
val result = authenticatedApi.reply("edtrox", "znlkib", "Thanks for the details!")
assertIs<Success<Unit>>(result)
}
}
@@ -31,27 +31,26 @@ class SearchApiTest {
private val results =
listOf(
LobstersPost(
shortId = "vvky3g",
shortId = "kgem4b",
createdAt = "",
title = "2024 State of Rust Survey Results",
url = "https://blog.rust-lang.org/2025/02/13/2024-State-Of-Rust-Survey-results.html",
title = "The social contract of writing",
url = "https://jola.dev/posts/the-social-contract-of-writing",
description = "",
commentCount = 10,
commentsUrl = "https://lobste.rs/s/vvky3g/2024_state_rust_survey_results",
submitter = "obsoleszenz",
userIsAuthor = false,
tags = listOf("rust"),
commentCount = 38,
commentsUrl = "https://lobste.rs/s/kgem4b/social_contract_writing",
submitter = "joladev",
userIsAuthor = true,
tags = listOf("philosophy", "vibecoding"),
),
LobstersPost(
shortId = "wjoric",
shortId = "gydtkf",
createdAt = "",
title = "New hack uses prompt injection to corrupt Geminis long-term memory",
url =
"https://arstechnica.com/security/2025/02/new-hack-uses-prompt-injection-to-corrupt-geminis-long-term-memory/",
title = "AI Resist List",
url = "https://airesistlist.org/",
description = "",
commentCount = 0,
commentsUrl = "https://lobste.rs/s/wjoric/new_hack_uses_prompt_injection_corrupt",
submitter = "laktak",
commentsUrl = "https://lobste.rs/s/gydtkf/ai_resist_list",
submitter = "chobeat",
userIsAuthor = false,
tags = listOf("ai"),
),
@@ -0,0 +1,38 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.api.converters
import com.google.common.truth.Truth.assertThat
import dev.msfjarvis.claw.util.TestUtils.getResource
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.jupiter.api.Test
class CSRFTokenConverterTest {
@Test
fun `converter extracts CSRF token`() {
val token =
CSRFTokenConverter.convert(
getResource("csrf_page.html").toResponseBody("text/html".toMediaType())
)
assertThat(token.value)
.isEqualTo(
"dvJ8r_CkOImcHQ5ZLUWlJeQVoPEPQ3rK85DNgiZJcehafqwYP8jESW8AhMf0uQGLqqLbsarYiISCghnDaUd6wA"
)
}
@Test
fun `converter returns empty token when CSRF token is absent`() {
val token =
CSRFTokenConverter.convert(
"<html><head></head><body></body></html>".toResponseBody("text/html".toMediaType())
)
assertThat(token.value).isEmpty()
}
}
@@ -0,0 +1,41 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.api.converters
import com.google.common.truth.Truth.assertThat
import dev.msfjarvis.claw.util.TestUtils.getResource
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.jupiter.api.Test
class ReplyFormConverterTest {
@Test
fun `converter extracts reply form hidden fields`() {
val form =
ReplyFormConverter.convert(
getResource("reply_form.html").toResponseBody("text/html".toMediaType())
)
assertThat(form.authenticityToken)
.isEqualTo(
"AI0414bnzi152-mE0JTWEtwq5B0ZhALBW1W7rGiG5zR-sFaJjWzdARXFM7w_DbPQqWNjzh9bufWZbXG39v5T6g"
)
assertThat(form.storyId).isEqualTo("znlkib")
assertThat(form.method).isEqualTo("post")
assertThat(form.parentCommentShortId).isEqualTo("edtrox")
}
@Test
fun `converter returns empty strings when reply form fields are absent`() {
val form = ReplyFormConverter.convert("<form></form>".toResponseBody("text/html".toMediaType()))
assertThat(form.authenticityToken).isEmpty()
assertThat(form.storyId).isEmpty()
assertThat(form.method).isEmpty()
assertThat(form.parentCommentShortId).isEmpty()
}
}
@@ -0,0 +1,54 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.api.converters
import com.google.common.truth.Truth.assertThat
import dev.msfjarvis.claw.model.LobstersPost
import dev.msfjarvis.claw.util.TestUtils.getResource
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.jupiter.api.Test
class SearchConverterTest {
@Test
fun `converter parses search result HTML`() {
val posts =
SearchConverter.convert(
getResource("search_chatgpt_page.html").toResponseBody("text/html".toMediaType())
)
assertThat(posts).hasSize(20)
assertThat(posts)
.containsAtLeast(
LobstersPost(
shortId = "kgem4b",
createdAt = "",
title = "The social contract of writing",
url = "https://jola.dev/posts/the-social-contract-of-writing",
description = "",
commentCount = 38,
commentsUrl = "https://lobste.rs/s/kgem4b/social_contract_writing",
submitter = "joladev",
userIsAuthor = true,
tags = listOf("philosophy", "vibecoding"),
),
LobstersPost(
shortId = "gydtkf",
createdAt = "",
title = "AI Resist List",
url = "https://airesistlist.org/",
description = "",
commentCount = 0,
commentsUrl = "https://lobste.rs/s/gydtkf/ai_resist_list",
submitter = "chobeat",
userIsAuthor = false,
tags = listOf("ai"),
),
)
.inOrder()
}
}
+1430 -4
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
<!doctype html>
<html lang="en" >
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<link rel="icon" sizes="144x144" type="image/png" href="/touch-icon-144.png">
<link rel="apple-touch-icon" sizes="57x57" href="/touch-icon.png">
<link rel="apple-touch-icon" sizes="114x114" href="/touch-icon.png">
<link rel="apple-touch-icon" sizes="72x72" href="/touch-icon-144.png">
<link rel="apple-touch-icon" sizes="144x144" href="/touch-icon-144.png">
<link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="Lobsters">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="always">
<meta name="theme-color" content="#AC130D">
<meta name="story-flags" content="{&quot;O&quot;:&quot;Off-topic&quot;,&quot;A&quot;:&quot;Already Posted&quot;,&quot;B&quot;:&quot;Broken Link&quot;,&quot;S&quot;:&quot;Spam&quot;,&quot;&quot;:&quot;Cancel&quot;}">
<meta name="comment-flags" content="{&quot;O&quot;:&quot;Off-topic&quot;,&quot;M&quot;:&quot;Me-too&quot;,&quot;T&quot;:&quot;Troll&quot;,&quot;U&quot;:&quot;Unkind&quot;,&quot;S&quot;:&quot;Spam&quot;,&quot;&quot;:&quot;Cancel&quot;}">
<meta property="og:type" content="user">
<meta property="og:site_name" content="Lobsters">
<meta property="og:title" content="msfjarvis">
<meta property="og:description" content="Android and Kotlin developer, currently working for Cloudflare
openpgp4fpr:8F87050B0F9CB84115157399B7843F823355E9B9
">
<meta property="og:image" content="/avatars/msfjarvis-100.png">
<link rel="shortcut icon" href="/favicon.ico">
<title>msfjarvis | Lobsters</title>
<link rel="stylesheet" href="/assets/application-8d029e37.css">
<link rel="stylesheet" href="/assets/system-system-fd030c9d.css">
<link rel="stylesheet" href="/assets/tom-select-00e8031d.css" data-turbo-track="reload">
<link rel="stylesheet" href="/assets/TomSelect_remove_button-4d5c34b9.css" data-turbo-track="reload">
<meta name="csrf-param" content="authenticity_token">
<meta name="csrf-token" content="qzhV0a2izz-6n516jIrQyKzp06y_9Fb33KCrZtCTdx6_KPhm894c4F-8c5CTUkxlC_xqaR2nsqf416xcza-daw">
<meta name="robots" content="noai, noimageai">
<script type="speculationrules" nonce="/3z7A9BvciTcEaW9gzJQnQ==">
{
"prefetch": [
{
"where": {
"href_matches": "/*"
},
"eagerness": "moderate"
}
]
}
</script>
</head>
<body data-username='' data-now-unix='1780070762'>
<header id="nav">
<a id="logo" style="background-color: #6e0000;" href="/" title="Lobsters (Current traffic: 17%)"></a>
<div class="navholder">
<nav class="links">
<a class="" href="/active">Active</a>
<a class="" href="/recent">Recent</a>
<a class="" href="/comments">Comments</a>
<a class="" href="/search">Search</a>
<a class="corner" href="/login">Login</a>
</nav>
</div>
<nav class="corner">
<a class="corner" href="/login">Login</a>
</nav>
</header>
<div id="inside">
<h1 class="">msfjarvis</h1>
<section class="profile">
<div id="gravatar">
<img srcset="/avatars/msfjarvis-100.png 1x, /avatars/msfjarvis-200.png 2x" class="avatar" alt="msfjarvis avatar" loading="lazy" decoding="async" src="/avatars/msfjarvis-100.png" width="100" height="100">
</div>
<div class="labelled_grid">
<label>Status</label>
<span>
Active
user
</span>
<label>Joined</label>
<span>
<time title="2020-04-24 11:41:56" datetime="2020-04-24 11:41:56" data-at-unix="1587746516">6 years ago</time>
by <a href="/users#msfjarvis">invitation</a> from
<a href="/~Amolith">Amolith</a>
</span>
<label>Karma</label>
<span>
2817
</span>
<label>Stories Submitted</label>
<span>
<a href="/~msfjarvis/stories">38</a>, most commonly tagged <a class="tag tag_rust" title="Rust programming" href="/t/rust">rust</a>
</span>
<label>Comments Posted</label>
<span><a href="/~msfjarvis/threads">258</a></span>
<label>Homepage</label>
<span>
<a href="https://msfjarvis.dev"
rel="me ugc">https://msfjarvis.dev</a>
</span>
<label>GitHub</label>
<span>
<a href="https://github.com/msfjarvis"
rel="me ugc">https://github.com/msfjarvis</a>
</span>
<label>Mastodon</label>
<span>
<a href="https://androiddev.social/@msfjarvis"
rel="me ugc">@msfjarvis@androiddev.social
</a>
</span>
<label>About</label>
<div class="shorten_first_p">
<p>Android and Kotlin developer, currently working for <a href="https://cloudflare.com/" rel="ugc">Cloudflare</a></p>
<p>openpgp4fpr:8F87050B0F9CB84115157399B7843F823355E9B9</p>
</div>
</div>
</section>
</div>
<footer>
<span id="office-hours-live">
</span>
<nav>
<a href="/about">About</a>
<a href="/tags">Tags</a>
<a href="/filters">Filter</a>
<a href="/moderations">Moderation Log</a>
</nav>
</footer>
<span id="iab-pcm-sdk"></span><span id="iab-autofill-sdk"></span>
</body>
</html>
-1
View File
@@ -1 +0,0 @@
{"username":"msfjarvis","created_at":"2020-04-24T11:41:56.000-05:00","is_admin":false,"about":"Android and Kotlin developer, currently working for [Dyte](https://dyte.io/)","is_moderator":false,"karma":1343,"avatar_url":"/avatars/msfjarvis-100.png","invited_by_user":"Amolith","github_username":"msfjarvis"}
+813
View File
@@ -0,0 +1,813 @@
<!doctype html>
<html lang="en" >
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<link rel="icon" sizes="144x144" type="image/png" href="/touch-icon-144.png">
<link rel="apple-touch-icon" sizes="57x57" href="/touch-icon.png">
<link rel="apple-touch-icon" sizes="114x114" href="/touch-icon.png">
<link rel="apple-touch-icon" sizes="72x72" href="/touch-icon-144.png">
<link rel="apple-touch-icon" sizes="144x144" href="/touch-icon-144.png">
<link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="Lobsters">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="always">
<meta name="theme-color" content="#AC130D">
<meta name="story-flags" content="{&quot;O&quot;:&quot;Off-topic&quot;,&quot;A&quot;:&quot;Already Posted&quot;,&quot;B&quot;:&quot;Broken Link&quot;,&quot;S&quot;:&quot;Spam&quot;,&quot;&quot;:&quot;Cancel&quot;}">
<meta name="comment-flags" content="{&quot;O&quot;:&quot;Off-topic&quot;,&quot;M&quot;:&quot;Me-too&quot;,&quot;T&quot;:&quot;Troll&quot;,&quot;U&quot;:&quot;Unkind&quot;,&quot;S&quot;:&quot;Spam&quot;,&quot;&quot;:&quot;Cancel&quot;}">
<meta property="og:type" content="article">
<meta property="og:site_name" content="Lobsters">
<meta property="og:title" content="Ranking comments by sum of replies&#39; scores">
<meta property="og:description" content="10 comments">
<meta property="og:image" content="https://lobste.rs/story_image/tdfoqh.png">
<meta property="article:author" content="https://lobste.rs/~gioele">
<link rel="shortcut icon" href="/favicon.ico">
<link href="https://lobste.rs/s/tdfoqh/ranking_comments_by_sum_replies_scores" rev="canonical" rel="self alternate shorter shorturl shortlink">
<title>Ranking comments by sum of replies&#39; scores | Lobsters</title>
<link rel="stylesheet" href="/assets/application-8d029e37.css">
<link rel="stylesheet" href="/assets/system-system-fd030c9d.css">
<link rel="stylesheet" href="/assets/tom-select-00e8031d.css" data-turbo-track="reload">
<link rel="stylesheet" href="/assets/TomSelect_remove_button-4d5c34b9.css" data-turbo-track="reload">
<meta name="csrf-param" content="authenticity_token">
<meta name="csrf-token" content="4i6TIGJDGnIZG7YaRMHPyYYg-LoCzf2nZznREoRDfQv26Eln3eTfll3z-c6sypfCq2kn5OtRI3_cD3ZCNQOe4Q">
<meta name="robots" content="noai, noimageai">
<script type="speculationrules" nonce="0Tcy0yahOOA5lchr3moUHw==">
{
"prefetch": [
{
"where": {
"href_matches": "/*"
},
"eagerness": "moderate"
}
]
}
</script>
</head>
<body data-username='' data-now-unix='1780070763'>
<header id="nav">
<a id="logo" style="background-color: #6e0000;" href="/" title="Lobsters (Current traffic: 17%)"></a>
<div class="navholder">
<nav class="links">
<a class="" href="/active">Active</a>
<a class="" href="/recent">Recent</a>
<a class="" href="/comments">Comments</a>
<a class="" href="/search">Search</a>
<a class="corner" href="/login">Login</a>
</nav>
</div>
<nav class="corner">
<a class="corner" href="/login">Login</a>
</nav>
</header>
<div id="inside">
<ol class="stories">
<li id="story_tdfoqh" data-shortid="tdfoqh" class="story">
<div class="story_liner h-entry">
<div class="voters">
<a class="upvoter" href="/login">18</a>
</div>
<div class="details" id="header_story_01g8ke0w20enk85qfby8bp0cgy">
<span role="heading" aria-level="1" class="link h-cite u-repost-of">
<a href="/s/tdfoqh/ranking_comments_by_sum_replies_scores" rel="ugc noreferrer" class="u-url">Ranking comments by sum of replies&#39; scores</a>
</span>
<a class="description_present" title="There are often cases where comments with a poor score (say, 1 point), invite high-quality replies that receive many upvotes.
These threads are not shown at the top of the story&#39;s comments page because their &quot;root comment&quot; has a low score.
An example, as of 2022-07-22 17:00 UTC, is &lt;https://lobste.rs/s/ekvqcf/random_wallpaper_with_just_bash_systemd#c_vop9bt&gt;:
* the root comment has 1 point,
* the first reply has 12 points,
* and, in total, 18 points have been accumulated by all the r..." href="/s/tdfoqh/ranking_comments_by_sum_replies_scores">&#x2636;</a>
<span class="tags">
<a class="tag tag_meta" title="Lobsters-related bikeshedding - report bugs at https://github.com/lobsters/lobsters" href="/t/meta">meta</a>
</span>
<div class="byline">
<span>authored by</span>
<a tabindex="-1" aria-hidden="true" href="/~gioele"><img srcset="/avatars/gioele-16.png 1x, /avatars/gioele-32.png 2x" class="avatar" alt="gioele avatar" loading="lazy" decoding="async" src="/avatars/gioele-16.png" width="16" height="16"></a>
<a class="user_is_author" href="/~gioele">gioele</a>
<time title="2022-07-22 12:06:59" datetime="2022-07-22 12:06:59" data-at-unix="1658509619">3 years ago</time>
<span class="comments_label">
<span aria-hidden="true"> | </span>
<a role="heading" aria-level="2" href="#comments-tdfoqh">
10
comments
</a>
</span>
</div>
</div>
</div>
</li>
</ol>
<div class="story_content">
<div class="story_text">
<p>There are often cases where comments with a poor score (say, 1 point), invite high-quality replies that receive many upvotes.</p>
<p>These threads are not shown at the top of the story's comments page because their "root comment" has a low score.</p>
<p>An example, as of 2022-07-22 17:00 UTC, is <a href="https://lobste.rs/s/ekvqcf/random_wallpaper_with_just_bash_systemd#c_vop9bt" rel="ugc">https://lobste.rs/s/ekvqcf/random_wallpaper_with_just_bash_systemd#c_vop9bt</a>:</p>
<ul>
<li>the root comment has 1 point,</li>
<li>the first reply has 12 points,</li>
<li>and, in total, 18 points have been accumulated by all the replies with score &gt; 1 point.</li>
</ul>
<p>Because the root comment has only 1 point, it remains at the bottom of the page, bringing down with itself also the good comment with 12 points and all the other upvoted comments.</p>
<p>Could an alternative ranking system be put in place, where the threads and subthreads are not sorted by the score of their root comment, but by the <em>sum of all the scores of their children</em>?</p>
<p>(Maybe taking into account only children with <code>&gt;1</code> point, to avoid giving importance to superficial back-and-forth discussions.)</p>
</div>
</div>
<ol class="comments comments1">
<li class="comments_subtree" id="comments-tdfoqh">
<ol class="comments" id="story_comments">
<li class="comments_subtree">
<input id="comment_folder_ncdsfc"
class="comment_folder_button" type="checkbox"
>
<div id="c_ncdsfc"
data-shortid="ncdsfc"
class="comment
">
<div class="voters">
<label for="comment_folder_ncdsfc" class="comment_folder"></label>
<a class="upvoter" title="18" href="/login">18</a>
</div>
<div class="details">
<div class="byline">
<a name="c_ncdsfc"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~dpercy"><img srcset="/avatars/dpercy-16.png 1x, /avatars/dpercy-32.png 2x" class="avatar" alt="dpercy avatar" loading="lazy" decoding="async" src="/avatars/dpercy-16.png" width="16" height="16"></a>
<a href="/~dpercy">dpercy</a>
<a href="/c/ncdsfc"><time title="2022-07-22 14:04:36" datetime="2022-07-22 14:04:36" data-at-unix="1658516676">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<blockquote>
<p>to avoid giving importance to superficial back-and-forth discussions</p>
</blockquote>
<p>Maybe take the max, instead of the sum? That way it's still comparing the score of individual comments. A highly-upvoted child would rank higher than a less-upvoted toplevel comment, but long threads wouldn't get any bonus.</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_m3wyu5"
class="comment_folder_button" type="checkbox"
>
<div id="c_m3wyu5"
data-shortid="m3wyu5"
class="comment
">
<div class="voters">
<label for="comment_folder_m3wyu5" class="comment_folder"></label>
<a class="upvoter" title="8" href="/login">8</a>
</div>
<div class="details">
<div class="byline">
<a name="c_m3wyu5"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~friendlysock"><img srcset="/avatars/friendlysock-16.png 1x, /avatars/friendlysock-32.png 2x" class="avatar" alt="friendlysock avatar" loading="lazy" decoding="async" src="/avatars/friendlysock-16.png" width="16" height="16"></a>
<a class="inactive_user" aria-label="friendlysock - Inactive user" href="/~friendlysock">friendlysock</a>
<a href="/c/m3wyu5"><time title="2022-07-23 01:04:43" datetime="2022-07-23 01:04:43" data-at-unix="1658556283">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>For better or worse, the deeper you go into threads the less likely it is that the discussion is still about the original submission. Your proposal, or the max variant presented elsewhere, I fear would unduly reward flamebait and off-topic discussion.</p>
</div>
</div>
</div>
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_pcvbcd"
class="comment_folder_button" type="checkbox"
>
<div id="c_pcvbcd"
data-shortid="pcvbcd"
class="comment
">
<div class="voters">
<label for="comment_folder_pcvbcd" class="comment_folder"></label>
<a class="upvoter" title="2" href="/login">2</a>
</div>
<div class="details">
<div class="byline">
<a name="c_pcvbcd"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~gioele"><img srcset="/avatars/gioele-16.png 1x, /avatars/gioele-32.png 2x" class="avatar" alt="gioele avatar" loading="lazy" decoding="async" src="/avatars/gioele-16.png" width="16" height="16"></a>
<a class="user_is_author user_is_submitter" aria-label="gioele - Submitter" href="/~gioele">gioele</a>
edited
<a href="/c/pcvbcd"><time title="2022-07-23 10:09:15" datetime="2022-07-23 10:09:15" data-at-unix="1658588955">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<blockquote>
<p>Your proposal, or the max variant presented elsewhere, I fear would unduly reward flamebait and off-topic discussion.</p>
</blockquote>
<p>Such off-topic discussions that stray away from the main topic do exist, are a nuisance, and, surely, they should not be rewarded with more attention. However, such sub-threads exists as a long string of short comments with no or very few upvotes. (I wonder if the data confirms this or it is just my impression.)</p>
<p>For this reason I believe that a) not counting in comments with score==1 or b) <a href="https://lobste.rs/~dpercy" rel="ugc">@dpercy</a>'s max variant would avoid rewarding such discussions.</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
</ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_9vyyu5"
class="comment_folder_button" type="checkbox"
>
<div id="c_9vyyu5"
data-shortid="9vyyu5"
class="comment
">
<div class="voters">
<label for="comment_folder_9vyyu5" class="comment_folder"></label>
<a class="upvoter" title="5" href="/login">5</a>
</div>
<div class="details">
<div class="byline">
<a name="c_9vyyu5"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~kghose"><img srcset="/avatars/kghose-16.png 1x, /avatars/kghose-32.png 2x" class="avatar" alt="kghose avatar" loading="lazy" decoding="async" src="/avatars/kghose-16.png" width="16" height="16"></a>
<a href="/~kghose">kghose</a>
<a href="/c/9vyyu5"><time title="2022-07-22 13:39:20" datetime="2022-07-22 13:39:20" data-at-unix="1658515160">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>Just like to point out that the assumption here is that upvotes is a metric of quality. On lobste.rs as in any other social context, upvotes/support is a measure of popularity.</p>
<p>There are many instances where the two are correlated, and some important instances where they are not.</p>
</div>
</div>
</div>
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_owddle"
class="comment_folder_button" type="checkbox"
>
<div id="c_owddle"
data-shortid="owddle"
class="comment
">
<div class="voters">
<label for="comment_folder_owddle" class="comment_folder"></label>
<a class="upvoter" title="7" href="/login">7</a>
</div>
<div class="details">
<div class="byline">
<a name="c_owddle"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~gioele"><img srcset="/avatars/gioele-16.png 1x, /avatars/gioele-32.png 2x" class="avatar" alt="gioele avatar" loading="lazy" decoding="async" src="/avatars/gioele-16.png" width="16" height="16"></a>
<a class="user_is_author user_is_submitter" aria-label="gioele - Submitter" href="/~gioele">gioele</a>
<a href="/c/owddle"><time title="2022-07-22 13:57:19" datetime="2022-07-22 13:57:19" data-at-unix="1658516239">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<blockquote>
<p>Just like to point out that the assumption here is that upvotes is a metric of quality. On lobste.rs as in any other social context, upvotes/support is a measure of popularity.</p>
</blockquote>
<p>I fully agree: upvotes are a metric of popularity and not of quality. But this proposal does not assume that.</p>
<p>Regardless of what upvotes represent, currently threads are sorted in descending order of (upvotes, time). All that this proposal suggests, is to change that to (sum(upvotes of root-and-children), time).</p>
<p>The only assumption here is: if ranking a thread by upvotes of its root comment is considered good (and at the moment it is), then ranking by the sum of the upvotes of the whole thread is better.</p>
</div>
</div>
</div>
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_lqqn3a"
class="comment_folder_button" type="checkbox"
>
<div id="c_lqqn3a"
data-shortid="lqqn3a"
class="comment
">
<div class="voters">
<label for="comment_folder_lqqn3a" class="comment_folder"></label>
<a class="upvoter" title="2" href="/login">2</a>
</div>
<div class="details">
<div class="byline">
<a name="c_lqqn3a"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~kghose"><img srcset="/avatars/kghose-16.png 1x, /avatars/kghose-32.png 2x" class="avatar" alt="kghose avatar" loading="lazy" decoding="async" src="/avatars/kghose-16.png" width="16" height="16"></a>
<a href="/~kghose">kghose</a>
<a href="/c/lqqn3a"><time title="2022-07-22 14:28:27" datetime="2022-07-22 14:28:27" data-at-unix="1658518107">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>Tangential to your effort here, on a personal note, if I find a discussion interesting I read all the posts regardless of ranking. On the discussions I find worthwhile I find that post quality is unrelated to upvotes.</p>
<p>On some discussions I find the top voted comment has devolved into a long tail of niche discussion, often acrimonious, which is not as useful as later posts.</p>
<p>Worst to pick out of the noise are replies to replies which are great but buried in not so great comments.</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
</ol>
</li>
</ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_7b0jgw"
class="comment_folder_button" type="checkbox"
>
<div id="c_7b0jgw"
data-shortid="7b0jgw"
class="comment
">
<div class="voters">
<label for="comment_folder_7b0jgw" class="comment_folder"></label>
<a class="upvoter" title="4" href="/login">4</a>
</div>
<div class="details">
<div class="byline">
<a name="c_7b0jgw"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~FeepingCreature"><img srcset="/avatars/FeepingCreature-16.png 1x, /avatars/FeepingCreature-32.png 2x" class="avatar" alt="FeepingCreature avatar" loading="lazy" decoding="async" src="/avatars/FeepingCreature-16.png" width="16" height="16"></a>
<a href="/~FeepingCreature">FeepingCreature</a>
<a href="/c/7b0jgw"><time title="2022-07-23 11:26:03" datetime="2022-07-23 11:26:03" data-at-unix="1658593563">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>Ranking by max or sum upvotes optimizes for photogenic smackdowns of bad opinions.</p>
<p>Maybe disaggregate "agreement upvotes" and "contribution upvotes"?</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_ei3nck"
class="comment_folder_button" type="checkbox"
>
<div id="c_ei3nck"
data-shortid="ei3nck"
class="comment
">
<div class="voters">
<label for="comment_folder_ei3nck" class="comment_folder"></label>
<a class="upvoter" title="2" href="/login">2</a>
</div>
<div class="details">
<div class="byline">
<a name="c_ei3nck"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~FRIGN"><img srcset="/avatars/FRIGN-16.png 1x, /avatars/FRIGN-32.png 2x" class="avatar" alt="FRIGN avatar" loading="lazy" decoding="async" src="/avatars/FRIGN-16.png" width="16" height="16"></a>
<a href="/~FRIGN">FRIGN</a>
edited
<a href="/c/ei3nck"><time title="2022-07-23 10:48:49" datetime="2022-07-23 10:48:49" data-at-unix="1658591329">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>I feel kind of honored to be taken as an example, so please let me share my personal thoughts (TL;DR below):</p>
<p>It is impossible to find <em>the</em> definite ranking system, but let me elaborate by using 3 extremes to see where the current and proposed systems fail:</p>
<ol>
<li>
<em>highly controversial root comment</em> with score 0 (100 upvotes, 100 downvotes), logically controversial responses with median 0,</li>
<li>
<em>root comment preaching to the choir</em> with logically high score and usually high-score responses,</li>
<li>
<em>low-score root comment with high-score responses</em> (like the presented case)</li>
<li>
<em>OC high-value root comment</em> with high score and high-value responses</li>
</ol>
<p>It all depends on personal preference though, but I personally would like more controversial topics to be on top and less controversial ones to be secondary. The reason for that is that you otherwise might end up with echo-chambers, given users are encouraged to preach to the choir for a high karma score. As a tangent, I would prefer two karmas for each user: A "paragon-karma" and "renegade-karma" whose sum would be an "influence" score (in the end up- or downvotes both reflect a certain influence) and whose difference would be a "non-controversiality-score" (a low difference reflects controversiality, a high score the opposite). All in all, maybe we should move away complete from the miriad of downvote-options on lobsters and simply have an "agree" and "disagree". If something is spam, you can report it, if something is incorrect, you can write a response rectifying it (which would then be subject to a vote of agreement and disagreement, depending on how well you state your case).</p>
<p>The proposal to add replies into the weight would not change the ranking of controversial topics (1), but even moreso weigh comments preaching to the choir (2) because everyone replying would aim to also get some karma. It would solve the presented case (3) though. Case (4) would also be favoured.</p>
<p>The proposal by <a href="https://lobste.rs/~dpercy" rel="ugc">@dpercy</a> to the take the max would not change (1)'s ranking, probably not affect (2) but help (3) as well. (4) is also positively affected.</p>
<p>TL;DR, here's my proposal so more highly-controversial topics (those that are interesting) are more favoured: Consider root comment and replies as equals and only take <em>influence</em> (sum of up- and downvotes) as a score.</p>
<p>This benefits (1), which in the current form end up at the bottom, which makes zero sense. It also benefits (2) and (4), which is a forced compromise, given the score does not really show how "original" a comment is (this is why Reddit probably introduced awards to allow users to weigh very good posts). By taking all replies into account, it also, of course, benefits (3), which makes sense to push up.</p>
<p>Replies with no votes increase the influence of the thread by 1, indeed, but isn't the purpose of the score to show what the hivemind thinks? By scoring them as zero (which was proposed here), you effectively value an elaborated response lower than a simple upvote of the original comment. Instead, why not simply "fold" long subthreads so they don't take up too much space?</p>
<p>As another tangent: By weighing root and children, this might encourage people to respond to "deep" threads instead of writing a new post.</p>
<p>Let's see what <a href="https://lobste.rs/~pushcx" rel="ugc">@pushcx</a> decides in the end. :)</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_4l8mzk"
class="comment_folder_button" type="checkbox"
>
<div id="c_4l8mzk"
data-shortid="4l8mzk"
class="comment
">
<div class="voters">
<label for="comment_folder_4l8mzk" class="comment_folder"></label>
<a class="upvoter" title="2" href="/login">2</a>
</div>
<div class="details">
<div class="byline">
<a name="c_4l8mzk"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~feoh"><img srcset="/avatars/feoh-16.png 1x, /avatars/feoh-32.png 2x" class="avatar" alt="feoh avatar" loading="lazy" decoding="async" src="/avatars/feoh-16.png" width="16" height="16"></a>
<a href="/~feoh">feoh</a>
<a href="/c/4l8mzk"><time title="2022-07-22 12:30:20" datetime="2022-07-22 12:30:20" data-at-unix="1658511020">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>I really like this idea but have no idea what its implementation would represent in terms of additional load to the server.</p>
</div>
</div>
</div>
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_qy0l9k"
class="comment_folder_button" type="checkbox"
>
<div id="c_qy0l9k"
data-shortid="qy0l9k"
class="comment
">
<div class="voters">
<label for="comment_folder_qy0l9k" class="comment_folder"></label>
<a class="upvoter" title="5" href="/login">5</a>
</div>
<div class="details">
<div class="byline">
<a name="c_qy0l9k"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~pushcx"><img srcset="/avatars/pushcx-16.png 1x, /avatars/pushcx-32.png 2x" class="avatar" alt="pushcx avatar" loading="lazy" decoding="async" src="/avatars/pushcx-16.png" width="16" height="16"></a>
<a href="/~pushcx">pushcx</a>
<span class="hat hat_sysop" title="Granted 2017-10-15"><span class="crown">Sysop</span></span>
<a href="/c/qy0l9k"><time title="2022-07-22 16:12:09" datetime="2022-07-22 16:12:09" data-at-unix="1658524329">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>I've moved most of the <a href="https://github.com/lobsters/lobsters/blob/cf52ab9f3f0b3ac805effc2e718f15922cb67332/app/models/comment.rb#L340" rel="ugc">vote</a> into the db, but <a href="https://github.com/lobsters/lobsters/blob/cf52ab9f3f0b3ac805effc2e718f15922cb67332/app/models/comment.rb#L219" rel="ugc">not all</a>. If <code>calculated_confidence</code> finished its move into the db almost any algorithm would be faster than the current implementation.</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
</ol>
</li>
</ol>
</li>
</ol>
<br>
</div>
<footer>
<span id="office-hours-live">
</span>
<nav>
<a href="/about">About</a>
<a href="/tags">Tags</a>
<a href="/filters">Filter</a>
<a href="/moderations">Moderation Log</a>
</nav>
</footer>
<span id="iab-pcm-sdk"></span><span id="iab-autofill-sdk"></span>
</body>
</html>
File diff suppressed because one or more lines are too long
+803
View File
@@ -0,0 +1,803 @@
<!doctype html>
<html lang="en" >
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<link rel="icon" sizes="144x144" type="image/png" href="/touch-icon-144.png">
<link rel="apple-touch-icon" sizes="57x57" href="/touch-icon.png">
<link rel="apple-touch-icon" sizes="114x114" href="/touch-icon.png">
<link rel="apple-touch-icon" sizes="72x72" href="/touch-icon-144.png">
<link rel="apple-touch-icon" sizes="144x144" href="/touch-icon-144.png">
<link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="Lobsters">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="always">
<meta name="theme-color" content="#AC130D">
<meta name="story-flags" content="{&quot;O&quot;:&quot;Off-topic&quot;,&quot;A&quot;:&quot;Already Posted&quot;,&quot;B&quot;:&quot;Broken Link&quot;,&quot;S&quot;:&quot;Spam&quot;,&quot;&quot;:&quot;Cancel&quot;}">
<meta name="comment-flags" content="{&quot;O&quot;:&quot;Off-topic&quot;,&quot;M&quot;:&quot;Me-too&quot;,&quot;T&quot;:&quot;Troll&quot;,&quot;U&quot;:&quot;Unkind&quot;,&quot;S&quot;:&quot;Spam&quot;,&quot;&quot;:&quot;Cancel&quot;}">
<meta property="og:type" content="article">
<meta property="og:site_name" content="Lobsters">
<meta property="og:title" content="Ranking comments by sum of replies&#39; scores">
<meta property="og:description" content="10 comments">
<meta property="og:image" content="https://lobste.rs/story_image/tdfoqh.png">
<meta property="article:author" content="https://lobste.rs/~gioele">
<link rel="shortcut icon" href="/favicon.ico">
<link href="https://lobste.rs/s/tdfoqh/ranking_comments_by_sum_replies_scores" rev="canonical" rel="self alternate shorter shorturl shortlink">
<title>Ranking comments by sum of replies&#39; scores | Lobsters</title>
<link rel="stylesheet" href="/assets/application-50d965fb.css">
<link rel="stylesheet" href="/assets/system-system-fd030c9d.css">
<link rel="stylesheet" href="/assets/tom-select-00e8031d.css" data-turbo-track="reload">
<link rel="stylesheet" href="/assets/TomSelect_remove_button-4d5c34b9.css" data-turbo-track="reload">
<meta name="csrf-param" content="authenticity_token">
<meta name="csrf-token" content="I9QGMwqhWWuCkGBSdDF7vV0v16wATa6x6zmxpaCQKS4iehHByjKy34YLK6cPpB626ZL-FaO10jcm3edeXIFrBw">
<meta name="robots" content="noai, noimageai">
<script type="speculationrules" nonce="owvfCFqNS/eLJHNrSN2nYg==">
{
"prefetch": [
{
"where": {
"href_matches": "/*"
},
"eagerness": "moderate"
}
]
}
</script>
</head>
<body data-username='' data-now-unix='1778381500'>
<header id="nav">
<a id="logo" style="background-color: #550000;" href="/" title="Lobsters (Current traffic: 3%)"></a>
<div class="navholder">
<nav class="links">
<a class="" href="/active">Active</a>
<a class="" href="/recent">Recent</a>
<a class="" href="/comments">Comments</a>
<a class="" href="/search">Search</a>
<a class="corner" href="/login">Login</a>
</nav>
</div>
<nav class="corner">
<a class="corner" href="/login">Login</a>
</nav>
</header>
<div id="inside">
<ol class="stories">
<li id="story_tdfoqh" data-shortid="tdfoqh" class="story">
<div class="story_liner h-entry">
<div class="voters">
<a class="upvoter" href="/login">18</a>
</div>
<div class="details" id="header_story_01g8ke0w20enk85qfby8bp0cgy">
<span role="heading" aria-level="1" class="link h-cite u-repost-of">
<a href="/s/tdfoqh/ranking_comments_by_sum_replies_scores" rel="ugc noreferrer" class="u-url">Ranking comments by sum of replies&#39; scores</a>
</span>
<a class="description_present" title="There are often cases where comments with a poor score (say, 1 point), invite high-quality replies that receive many upvotes.
These threads are not shown at the top of the story&#39;s comments page because their &quot;root comment&quot; has a low score.
An example, as of 2022-07-22 17:00 UTC, is &lt;https://lobste.rs/s/ekvqcf/random_wallpaper_with_just_bash_systemd#c_vop9bt&gt;:
* the root comment has 1 point,
* the first reply has 12 points,
* and, in total, 18 points have been accumulated by all the r..." href="/s/tdfoqh/ranking_comments_by_sum_replies_scores">&#x2636;</a>
<span class="tags">
<a class="tag tag_meta" title="Lobsters-related bikeshedding - report bugs at https://github.com/lobsters/lobsters" href="/t/meta">meta</a>
</span>
<div class="byline">
<span>authored by</span>
<a tabindex="-1" aria-hidden="true" href="/~gioele"><img srcset="/avatars/gioele-16.png 1x, /avatars/gioele-32.png 2x" class="avatar" alt="gioele avatar" loading="lazy" decoding="async" src="/avatars/gioele-16.png" width="16" height="16"></a>
<a class="u-author h-card user_is_author" href="/~gioele">gioele</a>
<time title="2022-07-22 12:06:59" datetime="2022-07-22 12:06:59" data-at-unix="1658509619">3 years ago</time>
<span class="comments_label">
<span aria-hidden="true"> | </span>
<a role="heading" aria-level="2" href="/s/tdfoqh/ranking_comments_by_sum_replies_scores#comments_story_01g8ke0w20enk85qfby8bp0cgy">
10
comments
</a>
</span>
</div>
</div>
</div>
</li>
</ol>
<div class="story_content">
<div class="story_text">
<p>There are often cases where comments with a poor score (say, 1 point), invite high-quality replies that receive many upvotes.</p>
<p>These threads are not shown at the top of the story's comments page because their "root comment" has a low score.</p>
<p>An example, as of 2022-07-22 17:00 UTC, is <a href="https://lobste.rs/s/ekvqcf/random_wallpaper_with_just_bash_systemd#c_vop9bt" rel="ugc">https://lobste.rs/s/ekvqcf/random_wallpaper_with_just_bash_systemd#c_vop9bt</a>:</p>
<ul>
<li>the root comment has 1 point,</li>
<li>the first reply has 12 points,</li>
<li>and, in total, 18 points have been accumulated by all the replies with score &gt; 1 point.</li>
</ul>
<p>Because the root comment has only 1 point, it remains at the bottom of the page, bringing down with itself also the good comment with 12 points and all the other upvoted comments.</p>
<p>Could an alternative ranking system be put in place, where the threads and subthreads are not sorted by the score of their root comment, but by the <em>sum of all the scores of their children</em>?</p>
<p>(Maybe taking into account only children with <code>&gt;1</code> point, to avoid giving importance to superficial back-and-forth discussions.)</p>
</div>
</div>
<ol class="comments comments1">
<li class="comments_subtree" id="story_comments">
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_ncdsfc"
class="comment_folder_button" type="checkbox"
>
<div id="c_ncdsfc"
data-shortid="ncdsfc"
class="comment upvoted
">
<div class="voters">
<label for="comment_folder_ncdsfc" class="comment_folder"></label>
<a class="upvoter" title="18" href="/login">18</a>
</div>
<div class="details">
<div class="byline">
<a name="c_ncdsfc"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~dpercy"><img srcset="/avatars/dpercy-16.png 1x, /avatars/dpercy-32.png 2x" class="avatar" alt="dpercy avatar" loading="lazy" decoding="async" src="/avatars/dpercy-16.png" width="16" height="16"></a>
<a href="/~dpercy">dpercy</a>
<a href="/c/ncdsfc"><time title="2022-07-22 14:04:36" datetime="2022-07-22 14:04:36" data-at-unix="1658516676">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<blockquote>
<p>to avoid giving importance to superficial back-and-forth discussions</p>
</blockquote>
<p>Maybe take the max, instead of the sum? That way it's still comparing the score of individual comments. A highly-upvoted child would rank higher than a less-upvoted toplevel comment, but long threads wouldn't get any bonus.</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_m3wyu5"
class="comment_folder_button" type="checkbox"
>
<div id="c_m3wyu5"
data-shortid="m3wyu5"
class="comment
">
<div class="voters">
<label for="comment_folder_m3wyu5" class="comment_folder"></label>
<a class="upvoter" title="8" href="/login">8</a>
</div>
<div class="details">
<div class="byline">
<a name="c_m3wyu5"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~friendlysock"><img srcset="/avatars/friendlysock-16.png 1x, /avatars/friendlysock-32.png 2x" class="avatar" alt="friendlysock avatar" loading="lazy" decoding="async" src="/avatars/friendlysock-16.png" width="16" height="16"></a>
<a class="inactive_user" aria-label="friendlysock - Inactive user" href="/~friendlysock">friendlysock</a>
<a href="/c/m3wyu5"><time title="2022-07-23 01:04:43" datetime="2022-07-23 01:04:43" data-at-unix="1658556283">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>For better or worse, the deeper you go into threads the less likely it is that the discussion is still about the original submission. Your proposal, or the max variant presented elsewhere, I fear would unduly reward flamebait and off-topic discussion.</p>
</div>
</div>
</div>
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_pcvbcd"
class="comment_folder_button" type="checkbox"
>
<div id="c_pcvbcd"
data-shortid="pcvbcd"
class="comment
">
<div class="voters">
<label for="comment_folder_pcvbcd" class="comment_folder"></label>
<a class="upvoter" title="2" href="/login">2</a>
</div>
<div class="details">
<div class="byline">
<a name="c_pcvbcd"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~gioele"><img srcset="/avatars/gioele-16.png 1x, /avatars/gioele-32.png 2x" class="avatar" alt="gioele avatar" loading="lazy" decoding="async" src="/avatars/gioele-16.png" width="16" height="16"></a>
<a class="user_is_author" aria-label="gioele - Author" href="/~gioele">gioele</a>
edited
<a href="/c/pcvbcd"><time title="2022-07-23 10:09:15" datetime="2022-07-23 10:09:15" data-at-unix="1658588955">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<blockquote>
<p>Your proposal, or the max variant presented elsewhere, I fear would unduly reward flamebait and off-topic discussion.</p>
</blockquote>
<p>Such off-topic discussions that stray away from the main topic do exist, are a nuisance, and, surely, they should not be rewarded with more attention. However, such sub-threads exists as a long string of short comments with no or very few upvotes. (I wonder if the data confirms this or it is just my impression.)</p>
<p>For this reason I believe that a) not counting in comments with score==1 or b) <a href="https://lobste.rs/~dpercy" rel="ugc">@dpercy</a>'s max variant would avoid rewarding such discussions.</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
</ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_9vyyu5"
class="comment_folder_button" type="checkbox"
>
<div id="c_9vyyu5"
data-shortid="9vyyu5"
class="comment
">
<div class="voters">
<label for="comment_folder_9vyyu5" class="comment_folder"></label>
<a class="upvoter" title="5" href="/login">5</a>
</div>
<div class="details">
<div class="byline">
<a name="c_9vyyu5"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~kghose"><img srcset="/avatars/kghose-16.png 1x, /avatars/kghose-32.png 2x" class="avatar" alt="kghose avatar" loading="lazy" decoding="async" src="/avatars/kghose-16.png" width="16" height="16"></a>
<a href="/~kghose">kghose</a>
<a href="/c/9vyyu5"><time title="2022-07-22 13:39:20" datetime="2022-07-22 13:39:20" data-at-unix="1658515160">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>Just like to point out that the assumption here is that upvotes is a metric of quality. On lobste.rs as in any other social context, upvotes/support is a measure of popularity.</p>
<p>There are many instances where the two are correlated, and some important instances where they are not.</p>
</div>
</div>
</div>
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_owddle"
class="comment_folder_button" type="checkbox"
>
<div id="c_owddle"
data-shortid="owddle"
class="comment
">
<div class="voters">
<label for="comment_folder_owddle" class="comment_folder"></label>
<a class="upvoter" title="7" href="/login">7</a>
</div>
<div class="details">
<div class="byline">
<a name="c_owddle"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~gioele"><img srcset="/avatars/gioele-16.png 1x, /avatars/gioele-32.png 2x" class="avatar" alt="gioele avatar" loading="lazy" decoding="async" src="/avatars/gioele-16.png" width="16" height="16"></a>
<a class="user_is_author" aria-label="gioele - Author" href="/~gioele">gioele</a>
<a href="/c/owddle"><time title="2022-07-22 13:57:19" datetime="2022-07-22 13:57:19" data-at-unix="1658516239">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<blockquote>
<p>Just like to point out that the assumption here is that upvotes is a metric of quality. On lobste.rs as in any other social context, upvotes/support is a measure of popularity.</p>
</blockquote>
<p>I fully agree: upvotes are a metric of popularity and not of quality. But this proposal does not assume that.</p>
<p>Regardless of what upvotes represent, currently threads are sorted in descending order of (upvotes, time). All that this proposal suggests, is to change that to (sum(upvotes of root-and-children), time).</p>
<p>The only assumption here is: if ranking a thread by upvotes of its root comment is considered good (and at the moment it is), then ranking by the sum of the upvotes of the whole thread is better.</p>
</div>
</div>
</div>
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_lqqn3a"
class="comment_folder_button" type="checkbox"
>
<div id="c_lqqn3a"
data-shortid="lqqn3a"
class="comment
">
<div class="voters">
<label for="comment_folder_lqqn3a" class="comment_folder"></label>
<a class="upvoter" title="2" href="/login">2</a>
</div>
<div class="details">
<div class="byline">
<a name="c_lqqn3a"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~kghose"><img srcset="/avatars/kghose-16.png 1x, /avatars/kghose-32.png 2x" class="avatar" alt="kghose avatar" loading="lazy" decoding="async" src="/avatars/kghose-16.png" width="16" height="16"></a>
<a href="/~kghose">kghose</a>
<a href="/c/lqqn3a"><time title="2022-07-22 14:28:27" datetime="2022-07-22 14:28:27" data-at-unix="1658518107">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>Tangential to your effort here, on a personal note, if I find a discussion interesting I read all the posts regardless of ranking. On the discussions I find worthwhile I find that post quality is unrelated to upvotes.</p>
<p>On some discussions I find the top voted comment has devolved into a long tail of niche discussion, often acrimonious, which is not as useful as later posts.</p>
<p>Worst to pick out of the noise are replies to replies which are great but buried in not so great comments.</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
</ol>
</li>
</ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_7b0jgw"
class="comment_folder_button" type="checkbox"
>
<div id="c_7b0jgw"
data-shortid="7b0jgw"
class="comment
">
<div class="voters">
<label for="comment_folder_7b0jgw" class="comment_folder"></label>
<a class="upvoter" title="4" href="/login">4</a>
</div>
<div class="details">
<div class="byline">
<a name="c_7b0jgw"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~FeepingCreature"><img srcset="/avatars/FeepingCreature-16.png 1x, /avatars/FeepingCreature-32.png 2x" class="avatar" alt="FeepingCreature avatar" loading="lazy" decoding="async" src="/avatars/FeepingCreature-16.png" width="16" height="16"></a>
<a href="/~FeepingCreature">FeepingCreature</a>
<a href="/c/7b0jgw"><time title="2022-07-23 11:26:03" datetime="2022-07-23 11:26:03" data-at-unix="1658593563">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>Ranking by max or sum upvotes optimizes for photogenic smackdowns of bad opinions.</p>
<p>Maybe disaggregate "agreement upvotes" and "contribution upvotes"?</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_ei3nck"
class="comment_folder_button" type="checkbox"
>
<div id="c_ei3nck"
data-shortid="ei3nck"
class="comment
">
<div class="voters">
<label for="comment_folder_ei3nck" class="comment_folder"></label>
<a class="upvoter" title="2" href="/login">2</a>
</div>
<div class="details">
<div class="byline">
<a name="c_ei3nck"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~FRIGN"><img srcset="/avatars/FRIGN-16.png 1x, /avatars/FRIGN-32.png 2x" class="avatar" alt="FRIGN avatar" loading="lazy" decoding="async" src="/avatars/FRIGN-16.png" width="16" height="16"></a>
<a href="/~FRIGN">FRIGN</a>
edited
<a href="/c/ei3nck"><time title="2022-07-23 10:48:49" datetime="2022-07-23 10:48:49" data-at-unix="1658591329">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>I feel kind of honored to be taken as an example, so please let me share my personal thoughts (TL;DR below):</p>
<p>It is impossible to find <em>the</em> definite ranking system, but let me elaborate by using 3 extremes to see where the current and proposed systems fail:</p>
<ol>
<li>
<em>highly controversial root comment</em> with score 0 (100 upvotes, 100 downvotes), logically controversial responses with median 0,</li>
<li>
<em>root comment preaching to the choir</em> with logically high score and usually high-score responses,</li>
<li>
<em>low-score root comment with high-score responses</em> (like the presented case)</li>
<li>
<em>OC high-value root comment</em> with high score and high-value responses</li>
</ol>
<p>It all depends on personal preference though, but I personally would like more controversial topics to be on top and less controversial ones to be secondary. The reason for that is that you otherwise might end up with echo-chambers, given users are encouraged to preach to the choir for a high karma score. As a tangent, I would prefer two karmas for each user: A "paragon-karma" and "renegade-karma" whose sum would be an "influence" score (in the end up- or downvotes both reflect a certain influence) and whose difference would be a "non-controversiality-score" (a low difference reflects controversiality, a high score the opposite). All in all, maybe we should move away complete from the miriad of downvote-options on lobsters and simply have an "agree" and "disagree". If something is spam, you can report it, if something is incorrect, you can write a response rectifying it (which would then be subject to a vote of agreement and disagreement, depending on how well you state your case).</p>
<p>The proposal to add replies into the weight would not change the ranking of controversial topics (1), but even moreso weigh comments preaching to the choir (2) because everyone replying would aim to also get some karma. It would solve the presented case (3) though. Case (4) would also be favoured.</p>
<p>The proposal by <a href="https://lobste.rs/~dpercy" rel="ugc">@dpercy</a> to the take the max would not change (1)'s ranking, probably not affect (2) but help (3) as well. (4) is also positively affected.</p>
<p>TL;DR, here's my proposal so more highly-controversial topics (those that are interesting) are more favoured: Consider root comment and replies as equals and only take <em>influence</em> (sum of up- and downvotes) as a score.</p>
<p>This benefits (1), which in the current form end up at the bottom, which makes zero sense. It also benefits (2) and (4), which is a forced compromise, given the score does not really show how "original" a comment is (this is why Reddit probably introduced awards to allow users to weigh very good posts). By taking all replies into account, it also, of course, benefits (3), which makes sense to push up.</p>
<p>Replies with no votes increase the influence of the thread by 1, indeed, but isn't the purpose of the score to show what the hivemind thinks? By scoring them as zero (which was proposed here), you effectively value an elaborated response lower than a simple upvote of the original comment. Instead, why not simply "fold" long subthreads so they don't take up too much space?</p>
<p>As another tangent: By weighing root and children, this might encourage people to respond to "deep" threads instead of writing a new post.</p>
<p>Let's see what <a href="https://lobste.rs/~pushcx" rel="ugc">@pushcx</a> decides in the end. :)</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_4l8mzk"
class="comment_folder_button" type="checkbox"
>
<div id="c_4l8mzk"
data-shortid="4l8mzk"
class="comment
">
<div class="voters">
<label for="comment_folder_4l8mzk" class="comment_folder"></label>
<a class="upvoter" title="2" href="/login">2</a>
</div>
<div class="details">
<div class="byline">
<a name="c_4l8mzk"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~feoh"><img srcset="/avatars/feoh-16.png 1x, /avatars/feoh-32.png 2x" class="avatar" alt="feoh avatar" loading="lazy" decoding="async" src="/avatars/feoh-16.png" width="16" height="16"></a>
<a href="/~feoh">feoh</a>
<a href="/c/4l8mzk"><time title="2022-07-22 12:30:20" datetime="2022-07-22 12:30:20" data-at-unix="1658511020">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>I really like this idea but have no idea what its implementation would represent in terms of additional load to the server.</p>
</div>
</div>
</div>
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_qy0l9k"
class="comment_folder_button" type="checkbox"
>
<div id="c_qy0l9k"
data-shortid="qy0l9k"
class="comment
">
<div class="voters">
<label for="comment_folder_qy0l9k" class="comment_folder"></label>
<a class="upvoter" title="5" href="/login">5</a>
</div>
<div class="details">
<div class="byline">
<a name="c_qy0l9k"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~pushcx"><img srcset="/avatars/pushcx-16.png 1x, /avatars/pushcx-32.png 2x" class="avatar" alt="pushcx avatar" loading="lazy" decoding="async" src="/avatars/pushcx-16.png" width="16" height="16"></a>
<a href="/~pushcx">pushcx</a>
<span class="hat hat_sysop" title="Granted 2017-10-15"><span class="crown">Sysop</span></span>
<a href="/c/qy0l9k"><time title="2022-07-22 16:12:09" datetime="2022-07-22 16:12:09" data-at-unix="1658524329">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>I've moved most of the <a href="https://github.com/lobsters/lobsters/blob/cf52ab9f3f0b3ac805effc2e718f15922cb67332/app/models/comment.rb#L340" rel="ugc">vote</a> into the db, but <a href="https://github.com/lobsters/lobsters/blob/cf52ab9f3f0b3ac805effc2e718f15922cb67332/app/models/comment.rb#L219" rel="ugc">not all</a>. If <code>calculated_confidence</code> finished its move into the db almost any algorithm would be faster than the current implementation.</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
</ol>
</li>
</ol>
</li>
</ol>
<br>
</div>
<footer>
<span id="office-hours-live">
</span>
<nav>
<a href="/about">About</a>
<a href="/tags">Tags</a>
<a href="/filters">Filter</a>
<a href="/moderations">Moderation Log</a>
</nav>
</footer>
<span id="iab-pcm-sdk"></span><span id="iab-autofill-sdk"></span>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
<div class="comment_form_container" data-shortid="">
<form action="/comments" accept-charset="UTF-8" method="post"><input type="hidden" name="authenticity_token" value="AI0414bnzi152-mE0JTWEtwq5B0ZhALBW1W7rGiG5zR-sFaJjWzdARXFM7w_DbPQqWNjzh9bufWZbXG39v5T6g" autocomplete="off">
<input value="znlkib" autocomplete="off" type="hidden" name="story_id" id="story_id" />
<input value="post" autocomplete="off" type="hidden" name="_method" id="_method" />
<input value="edtrox" autocomplete="off" type="hidden" name="parent_comment_short_id" id="parent_comment_short_id" />
<div style="width: 100%;">
<textarea rows="5" placeholder="" required="required" name="comment" id="comment">
</textarea>
<div class="controls">
<div class="buttons">
<input type="submit" name="commit" value="Post" class="comment-post" data-disable-with="Post">
</div>
</div>
</div>
</form>
</div>
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More