[1.549.*] Pre-release merge (#1172)

This commit is contained in:
tramline-github[bot]
2026-05-23 14:30:07 +00:00
committed by GitHub
18 changed files with 234 additions and 65 deletions
+10 -21
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<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 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"
@@ -19,7 +19,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="64"
line="69"
column="15"/>
</issue>
@@ -30,7 +30,7 @@
errorLine2=" ~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="64"
line="69"
column="35"/>
</issue>
@@ -41,7 +41,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="73"
line="78"
column="13"/>
</issue>
@@ -52,7 +52,7 @@
errorLine2=" ~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="73"
line="78"
column="21"/>
</issue>
@@ -63,7 +63,7 @@
errorLine2=" ~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="75"
line="81"
column="17"/>
</issue>
@@ -74,7 +74,7 @@
errorLine2=" ^">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="76"
line="82"
column="17"/>
</issue>
@@ -85,7 +85,7 @@
errorLine2=" ^">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="77"
line="83"
column="19"/>
</issue>
@@ -96,7 +96,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="107"
line="118"
column="17"/>
</issue>
@@ -107,7 +107,7 @@
errorLine2=" ~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="107"
line="118"
column="25"/>
</issue>
@@ -122,15 +122,4 @@
column="49"/>
</issue>
<issue
id="SetJavaScriptEnabled"
message="Using `setJavaScriptEnabled` can introduce XSS vulnerabilities into your application, review carefully"
errorLine1=" settings.javaScriptEnabled = true"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="75"
column="17"/>
</issue>
</issues>
@@ -119,6 +119,7 @@ fun LobstersPostsScreen(
val filteredTags by tagFilterViewModel.filteredTags.collectAsStateWithLifecycle(persistentSetOf())
val isLoggedIn by settingsViewModel.isLoggedIn.collectAsStateWithLifecycle(false)
val username by settingsViewModel.username.collectAsStateWithLifecycle(null)
LaunchedEffect(deepLinkDestination) {
if (deepLinkDestination != null) {
@@ -283,6 +284,7 @@ fun LobstersPostsScreen(
openLoginScreen = { navigateTo(backStack, Login) },
onLogout = { settingsViewModel.logout() },
isLoggedIn = isLoggedIn,
username = username,
importPosts = viewModel::importPosts,
exportPostsAsJson = viewModel::exportPostsAsJson,
exportPostsAsHtml = viewModel::exportPostsAsHtml,
@@ -6,6 +6,7 @@
*/
package dev.msfjarvis.claw.android.ui.screens
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.webkit.CookieManager
import android.webkit.WebResourceError
@@ -37,9 +38,13 @@ 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) -> Unit,
onLoginSuccess: (String, String) -> Unit,
popBackStack: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -72,6 +77,7 @@ fun LoginScreen(
factory = { context ->
WebView(context)
.apply {
@SuppressLint("SetJavaScriptEnabled")
settings.javaScriptEnabled = true
webViewClient =
object : WebViewClient() {
@@ -81,15 +87,20 @@ fun LoginScreen(
override fun onPageFinished(view: WebView, url: String) {
isLoading = false
// Both /login and /login/2fa contain "/login" — only act when
// navigating away from both (i.e., successful authentication).
// CookieManager requires the full URL scheme+host to return cookies.
if (!url.contains("/login")) {
val cookie = CookieManager.getInstance().getCookie(LOBSTERS_URL)
if (cookie != null) {
onLoginSuccess(cookie)
@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()
}
}
}
popBackStack()
}
}
@@ -55,6 +55,14 @@ 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,
@@ -63,6 +71,7 @@ fun SettingsScreen(
openLoginScreen: () -> Unit,
onLogout: () -> Unit,
isLoggedIn: Boolean,
username: String?,
snackbarHostState: SnackbarHostState,
openInputStream: (Uri) -> InputStream?,
openOutputStream: (Uri) -> OutputStream?,
@@ -80,7 +89,7 @@ fun SettingsScreen(
if (isLoggedIn) {
ListItem(
headlineContent = { Text("Log out") },
supportingContent = { Text("Sign out of your lobste.rs account") },
supportingContent = { Text(loggedInAccountText(username)) },
leadingContent = {
Icon(
imageVector = Icons.Filled.AccountCircle,
@@ -381,6 +390,7 @@ private fun SettingsScreenPreview() {
openLoginScreen = {},
onLogout = {},
isLoggedIn = false,
username = null,
snackbarHostState = SnackbarHostState(),
openInputStream = { null },
openOutputStream = { null },
@@ -20,7 +20,10 @@ import kotlinx.coroutines.flow.stateIn
@Inject
@ViewModelKey
@ContributesIntoMap(scope = AppScope::class, binding = binding<ViewModel>())
class SettingsViewModel(private val sessionCookieStore: SessionCookieStore) : ViewModel() {
class SettingsViewModel(
private val sessionCookieStore: SessionCookieStore,
private val webViewCookieStore: WebViewCookieStore,
) : ViewModel() {
val isLoggedIn =
sessionCookieStore
@@ -31,11 +34,21 @@ class SettingsViewModel(private val sessionCookieStore: SessionCookieStore) : Vi
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) {
sessionCookieStore.set(cookie)
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()
}
}
@@ -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
}
}
}
@@ -14,6 +14,7 @@ import dev.burnoo.kspoon.Kspoon
import dev.msfjarvis.claw.model.LobstersPostDetails
import dev.msfjarvis.claw.model.User
import dev.msfjarvis.claw.util.TestUtils.assertIs
import java.time.Instant
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@@ -135,6 +136,16 @@ class ApiTest {
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(Instant.from(editedComment.timestamp).epochSecond).isEqualTo(1658588955)
}
@Test
fun `post details preserves upvoted comments`() = runTest {
val postDetails = wrapper.upvotedPostDetails
@@ -273,8 +273,8 @@ private fun CommentEntry(
buildCommenterString(
commenterName = comment.user,
score = comment.score,
createdAt = comment.createdAt,
lastEditedAt = comment.lastEditedAt,
timestamp = comment.timestamp,
edited = comment.edited,
nameColorOverride =
if (commentNode.isPostAuthor) MaterialTheme.colorScheme.tertiary else null,
),
@@ -293,20 +293,14 @@ private fun CommentEntry(
private fun buildCommenterString(
commenterName: String,
score: Int,
createdAt: TemporalAccessor,
lastEditedAt: TemporalAccessor,
timestamp: TemporalAccessor,
edited: Boolean,
nameColorOverride: Color? = null,
): AnnotatedString {
val now = System.currentTimeMillis()
val createdRelative =
val relativeTime =
DateUtils.getRelativeTimeSpanString(
Instant.from(createdAt).toEpochMilli(),
now,
DateUtils.MINUTE_IN_MILLIS,
)
val lastEditedRelative =
DateUtils.getRelativeTimeSpanString(
Instant.from(lastEditedAt).toEpochMilli(),
Instant.from(timestamp).toEpochMilli(),
now,
DateUtils.MINUTE_IN_MILLIS,
)
@@ -323,13 +317,11 @@ private fun buildCommenterString(
append(' ')
append('•')
append(' ')
append(createdRelative.toString())
if (lastEditedRelative != createdRelative) {
append(relativeTime.toString())
if (edited) {
append(' ')
append('(')
append("Edited")
append(' ')
append(lastEditedRelative.toString())
append(')')
}
}
@@ -31,8 +31,12 @@ class SqlDelightSessionCookieStore(
return queries.get().executeAsOneOrNull()
}
override fun set(cookie: String) {
queries.upsert(cookie)
override fun getUsername(): String? {
return queries.getUsername().executeAsOneOrNull()?.username
}
override fun set(cookie: String, username: String) {
queries.upsert(cookie, username)
}
override fun clear() {
@@ -40,6 +44,12 @@ class SqlDelightSessionCookieStore(
}
override fun isLoggedIn(): Flow<Boolean> {
return queries.get().asFlow().mapToOneOrNull(readDispatcher).map { it != null }
return queries.getUsername().asFlow().mapToOneOrNull(readDispatcher).map {
it?.username != null
}
}
override fun username(): Flow<String?> {
return queries.getUsername().asFlow().mapToOneOrNull(readDispatcher).map { it?.username }
}
}
@@ -157,8 +157,8 @@ class CommentsHandlerTest {
comment = "Comment $shortId",
url = "https://lobste.rs/s/$shortId",
score = 1,
createdAt = Instant.EPOCH,
lastEditedAt = Instant.EPOCH,
timestamp = Instant.EPOCH,
edited = false,
parentComment = parentComment,
user = "user-$shortId",
)
@@ -35,6 +35,6 @@ class LobstersCookieJar(private val store: SessionCookieStore) : CookieJar {
// Serialize back to a single Set-Cookie header value using the first matching cookie.
// lobste.rs sets one session cookie (lobsters_trap); joining handles edge cases.
val raw = cookies.joinToString("; ") { "${it.name}=${it.value}" }
store.set(raw)
store.set(raw, store.getUsername().orEmpty())
}
}
@@ -11,9 +11,13 @@ import kotlinx.coroutines.flow.Flow
interface SessionCookieStore {
fun get(): String?
fun set(cookie: String)
fun getUsername(): String?
fun set(cookie: String, username: String)
fun clear()
fun isLoggedIn(): Flow<Boolean>
fun username(): Flow<String?>
}
@@ -1,13 +1,17 @@
CREATE TABLE SessionCookie (
id INTEGER NOT NULL PRIMARY KEY DEFAULT 1,
value TEXT NOT NULL
value TEXT NOT NULL,
username TEXT
);
get:
SELECT value FROM SessionCookie WHERE id = 1;
getUsername:
SELECT username FROM SessionCookie WHERE id = 1;
upsert:
INSERT OR REPLACE INTO SessionCookie(id, value) VALUES (1, ?);
INSERT OR REPLACE INTO SessionCookie(id, value, username) VALUES (1, ?, ?);
deleteAll:
DELETE FROM SessionCookie;
@@ -0,0 +1 @@
ALTER TABLE SessionCookie ADD COLUMN username TEXT;
@@ -36,10 +36,10 @@ class Comment(
val score: Int = 1,
@Serializable(with = CommentInstantSerializer::class)
@Selector("div.byline a[href^=/c/] time", attr = "data-at-unix", defValue = "")
val createdAt: TemporalAccessor,
@Serializable(with = CommentInstantSerializer::class)
@Selector("div.byline a[href^=/c/] time", attr = "data-at-unix", defValue = "")
val lastEditedAt: TemporalAccessor,
val timestamp: TemporalAccessor,
@Serializable(with = CommentEditedSerializer::class)
@Selector("div.byline span", defValue = "")
val edited: Boolean = false,
@Serializable(with = EmptyStringAsNullSerializer::class)
@Selector(":root", attr = "data-parent-shortid", defValue = "")
val parentComment: String? = null,
@@ -0,0 +1,23 @@
/*
* 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.model
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
internal object CommentEditedSerializer : KSerializer<Boolean> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("CommentEdited", PrimitiveKind.BOOLEAN)
override fun deserialize(decoder: Decoder): Boolean = decoder.decodeString().contains("edited")
override fun serialize(encoder: Encoder, value: Boolean) = encoder.encodeBoolean(value)
}
@@ -53,8 +53,10 @@ internal object CommentsSerializer : KSerializer<List<Comment>> {
}
private fun Element.toComment(parentComment: String?): Comment {
val timestamp = selectFirst("div.byline a[href^=/c/] time")?.attr("data-at-unix").orEmpty()
val byline = selectFirst("div.byline")
val timestamp = byline?.selectFirst("a[href^=/c/] time")?.attr("data-at-unix").orEmpty()
val parsedTimestamp = timestamp.toTemporalAccessor()
val isEdited = byline?.text()?.contains("edited") == true
return Comment(
shortId = attr("data-shortid"),
comment = selectFirst("div.comment_text")?.html().orEmpty(),
@@ -68,8 +70,8 @@ internal object CommentsSerializer : KSerializer<List<Comment>> {
?.trim()
?.takeUnless { it == "~" }
?.toIntOrNull() ?: 1,
createdAt = parsedTimestamp,
lastEditedAt = parsedTimestamp,
timestamp = parsedTimestamp,
edited = isEdited,
parentComment = parentComment,
user =
getElementsByClass("byline")