git.iko.soy/parseable-android
on 0bc5ab9 help sign in
37 files changed+433−1860
since splitsnapshots
unifiedsplit
.dockerignore−15 Dockerfile−72 app/src/main/java/com/parseable/android/MainActivity.kt+1−21 app/src/main/java/com/parseable/android/data/FormatUtils.kt+2−19 app/src/main/java/com/parseable/android/data/SqlUtils.kt−16 app/src/main/java/com/parseable/android/data/TimestampUtils.kt+14−57 app/src/main/java/com/parseable/android/data/api/ParseableApiClient.kt+31−88 app/src/main/java/com/parseable/android/data/local/FavoriteStreamDao.kt+1−11 app/src/main/java/com/parseable/android/data/local/ParseableDatabase.kt+1−1 app/src/main/java/com/parseable/android/data/local/SavedServer.kt+1−8 app/src/main/java/com/parseable/android/data/model/Models.kt+2−57 app/src/main/java/com/parseable/android/data/repository/ParseableRepository.kt+4−15 app/src/main/java/com/parseable/android/data/repository/SettingsRepository.kt+21−139 app/src/main/java/com/parseable/android/di/AppModule.kt−53 app/src/main/java/com/parseable/android/ui/navigation/NavGraph.kt+10−28 app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsScreen.kt app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsViewModel.kt+13−43 app/src/main/java/com/parseable/android/ui/screens/login/LoginScreen.kt+8−55 app/src/main/java/com/parseable/android/ui/screens/login/LoginViewModel.kt+12−88 app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.kt+33−82 app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerViewModel.kt+112−385 app/src/main/java/com/parseable/android/ui/screens/logviewer/SeverityColor.kt+8−37 app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoScreen.kt+86−100 app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoViewModel.kt+10−59 app/src/main/java/com/parseable/android/ui/screens/settings/SettingsScreen.kt+6−13 app/src/main/java/com/parseable/android/ui/screens/settings/SettingsViewModel.kt+22−100 app/src/main/java/com/parseable/android/ui/screens/streams/StreamsScreen.kt+15−20 app/src/main/java/com/parseable/android/ui/screens/streams/StreamsViewModel.kt+10−52 app/src/test/java/com/parseable/android/AlertsViewModelTest.kt−4 app/src/test/java/com/parseable/android/EscapeSqlTest.kt−27 app/src/test/java/com/parseable/android/FormatTimestampTest.kt−14 app/src/test/java/com/parseable/android/LogViewerViewModelTest.kt−54 app/src/test/java/com/parseable/android/LoginViewModelTest.kt+4−28 app/src/test/java/com/parseable/android/PrivateHostTest.kt−73 app/src/test/java/com/parseable/android/SettingsViewModelTest.kt+6−10 app/src/test/java/com/parseable/android/SeverityDetectionTest.kt−13 app/src/test/java/com/parseable/android/StreamsViewModelTest.kt−3
.dockerignoredeleted−15
@@ -1,15 +0,0 @@
1−# Keep the build context small, but DO keep .git — build.gradle.kts derives the
2−# app version from `git log -1`, so the repository history must be present.
3−build/
4−app/build/
5−.gradle/
6−*.iml
7−.idea/
8−.DS_Store
9−local.properties
10−captures/
11−.externalNativeBuild/
12−.cxx/
13−*.keystore
14−*.jks
15−.jj/
Dockerfiledeleted−72
@@ -1,72 +0,0 @@
1−# syntax=docker/dockerfile:1
2−
3−# ---- Build stage -------------------------------------------------------------
4−# Builds the Parseable Android APK from source. Mirrors the GitHub Actions CI:
5−# JDK 17, Android SDK (compileSdk 35), Gradle 8.9, with the wrapper generated
6−# on the fly (gradle-wrapper.jar is gitignored). The app's build.gradle.kts
7−# derives its version from `git log`, so git + the .git directory are required.
8−#
9−# Pinned to linux/amd64: the Android toolchain (aapt2) ships only x86-64 Linux
10−# binaries. On an arm64 host (Apple Silicon) a native arm64 image can't exec
11−# them ("rosetta error: failed to open elf .../ld-linux-x86-64.so.2"); a full
12−# amd64 image gives Rosetta/QEMU a complete x86-64 userland to emulate. On an
13−# amd64 host this is native. Override with --build-arg BUILD_PLATFORM=... .
14−ARG BUILD_PLATFORM=linux/amd64
15−FROM --platform=${BUILD_PLATFORM} eclipse-temurin:17-jdk-jammy AS build
16−
17−# Versions (override at build time with --build-arg if needed)
18−ARG GRADLE_VERSION=8.9
19−ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708
20−ARG ANDROID_PLATFORM=35
21−ARG ANDROID_BUILD_TOOLS=35.0.0
22−# Gradle task to run: assembleDebug (default) or assembleRelease
23−ARG GRADLE_TASK=assembleDebug
24−
25−ENV ANDROID_SDK_ROOT=/opt/android-sdk \
26− ANDROID_HOME=/opt/android-sdk \
27− GRADLE_USER_HOME=/root/.gradle \
28− DEBIAN_FRONTEND=noninteractive
29−
30−# Base tooling: git (version stamp), unzip/curl (fetching SDK + Gradle)
31−RUN apt-get update && \
32− apt-get install -y --no-install-recommends git unzip curl ca-certificates && \
33− rm -rf /var/lib/apt/lists/*
34−
35−# Android SDK command-line tools
36−RUN mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools" && \
37− curl -fsSL "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" -o /tmp/cmdline-tools.zip && \
38− unzip -q /tmp/cmdline-tools.zip -d "${ANDROID_SDK_ROOT}/cmdline-tools" && \
39− mv "${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools" "${ANDROID_SDK_ROOT}/cmdline-tools/latest" && \
40− rm /tmp/cmdline-tools.zip
41−
42−ENV PATH="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${PATH}"
43−
44−# Accept licenses and install required SDK packages
45−RUN yes | sdkmanager --licenses >/dev/null && \
46− sdkmanager --install \
47− "platform-tools" \
48− "platforms;android-${ANDROID_PLATFORM}" \
49− "build-tools;${ANDROID_BUILD_TOOLS}" >/dev/null
50−
51−# Gradle (used to generate the wrapper, then the wrapper drives the build)
52−RUN curl -fsSL "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" -o /tmp/gradle.zip && \
53− unzip -q /tmp/gradle.zip -d /opt && \
54− rm /tmp/gradle.zip
55−ENV PATH="/opt/gradle-${GRADLE_VERSION}/bin:${PATH}"
56−
57−WORKDIR /workspace
58−
59−# Copy the full project (including .git for the version stamp; see .dockerignore)
60−COPY . .
61−
62−# Generate the Gradle wrapper, then build the APK
63−RUN gradle wrapper --gradle-version "${GRADLE_VERSION}" && \
64− ./gradlew --no-daemon "${GRADLE_TASK}"
65−
66−# ---- Export stage ------------------------------------------------------------
67−# Minimal final image holding just the built APK(s) under /apks.
68−# Extract them to ./out with:
69−# docker build --target export --output type=local,dest=./out .
70−# (A scratch image has no shell, so use --output, not docker create/cp.)
71−FROM scratch AS export
72−COPY --from=build /workspace/app/build/outputs/apk/ /apks/
app/src/main/java/com/parseable/android/MainActivity.kt+1−21
@@ -51,17 +51,6 @@ class MainActivity : ComponentActivity() {
5151 repository.configure(savedConfig)
5252 }
5353 startDestination = if (savedConfig != null) Routes.STREAMS else Routes.LOGIN
54−
55− // Best-effort startup reconciliation for state a row-collapsing migration
56− // (raw SQL) can't reach: re-link a dangling active_server_id to its
57− // surviving row, then drop password entries orphaned by the collapse.
58− // Runs after startDestination so it never blocks first paint.
59− try {
60− settingsRepository.reconcileActiveServerId()
61− settingsRepository.cleanupOrphanedServerPasswords()
62− } catch (_: Exception) {
63− // Reconciliation/cleanup are non-critical; ignore failures.
64− }
6554 }
6655
6756 val dest = startDestination
@@ -72,17 +61,8 @@ class MainActivity : ComponentActivity() {
7261 LaunchedEffect(navController) {
7362 repository.authErrors
7463 .onEach {
75− // Several parallel requests can each surface a 401 in quick
76− // succession. Skip re-navigating (and re-clearing) when we're
77− // already on the login screen, so a burst can't thrash the
78− // back stack or re-trigger the "session expired" message.
79− val onLogin = navController.currentDestination
80− ?.route?.startsWith(Routes.LOGIN) == true
81− if (onLogin) return@onEach
8264 settingsRepository.clearConfig()
83− // The login screen shows the "session expired" message
84− // itself via the sessionExpired flag; showing it here too
85− // would queue a duplicate snackbar.
65+ errorHandler.showError("Session expired. Please log in again.")
8666 navController.navigate(Routes.login(sessionExpired = true)) {
8767 popUpTo(0) { inclusive = true }
8868 }
app/src/main/java/com/parseable/android/data/FormatUtils.kt+2−19
@@ -2,10 +2,7 @@ package com.parseable.android.data
22
33import java.util.Locale
44
5−// "EB" is included so PB is never the last unit: the promotion guard below (which only fires
6−// when a higher unit exists) then handles a PB value in [1023.5, 1024) — promoting it to "1.00 EB"
7−// instead of rendering "1024 PB". The remaining unhandled top-unit case sits at ~1.2e24 bytes.
8−private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
5+private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB")
96
107/**
118 * Converts a raw byte-count string (e.g. "123456789") to a human-readable
@@ -15,28 +12,14 @@ private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
1512fun formatBytes(raw: String?): String? {
1613 if (raw == null) return null
1714 val bytes = raw.trim().toDoubleOrNull() ?: return raw
18− // toDoubleOrNull accepts "NaN"/"Infinity"; those slip past the range guards below and
19− // would format as "NaN B" / "Infinity PB". Pass them through unchanged instead.
20− if (!bytes.isFinite()) return raw
2115 if (bytes < 0) return raw
22− // Use 1023.5, not 1024, as the cutoff: "%.0f" rounds half-up, so a byte count in
23− // [1023.5, 1024) would render as "1024 B" — a value that should promote to "1.00 KB".
24− // Such inputs fall through to the unit-scaling path, where the promotion guard below
25− // (value >= 1023.5) handles them.
26− if (bytes < 1023.5) return String.format(Locale.US, "%.0f B", bytes)
16+ if (bytes < 1024) return String.format(Locale.US, "%.0f B", bytes)
2717 var value = bytes
2818 var unitIndex = 0
2919 while (value >= 1024 && unitIndex < BYTE_UNITS.size - 1) {
3020 value /= 1024
3121 unitIndex++
3222 }
33− // A value just under 1024 (e.g. 1023.999 KB) is formatted with "%.0f", which rounds it to
34− // "1024 KB" — a unit that should never display. Promote to the next unit so it renders as
35− // "1.00 MB". The "%.1f"/"%.2f" branches operate far below 1024, so only this case crosses.
36− if (value >= 1023.5 && unitIndex < BYTE_UNITS.size - 1) {
37− value /= 1024
38− unitIndex++
39− }
4023 return if (value >= 100) {
4124 String.format(Locale.US, "%.0f %s", value, BYTE_UNITS[unitIndex])
4225 } else if (value >= 10) {
app/src/main/java/com/parseable/android/data/SqlUtils.kt−16
@@ -5,19 +5,3 @@ fun escapeSql(value: String): String = value.replace("'", "''")
55
66/** Escape a SQL identifier (table/column name) for use inside double quotes. */
77fun escapeIdentifier(name: String): String = name.replace("\"", "\"\"")
8−
9−/**
10− * Escape a user-supplied substring for use as the body of a LIKE/ILIKE pattern.
11− *
12− * Besides the single-quote escaping every string literal needs, this neutralizes the
13− * LIKE wildcards `%` and `_` (and the escape character `\` itself) so that searching or
14− * filtering for a literal `%` or `_` matches that character instead of acting as a
15− * wildcard. The backslash must be escaped first so the backslashes added for `%`/`_`
16− * aren't doubled. Callers must pair the result with an `ESCAPE '\'` clause.
17− */
18−fun escapeLikePattern(value: String): String =
19− value
20− .replace("\\", "\\\\")
21− .replace("%", "\\%")
22− .replace("_", "\\_")
23− .replace("'", "''")
app/src/main/java/com/parseable/android/data/TimestampUtils.kt+14−57
@@ -1,69 +1,26 @@
11package com.parseable.android.data
22
33import java.time.Instant
4−import java.time.LocalDateTime
5−import java.time.OffsetDateTime
64import java.time.ZoneId
7−import java.time.ZoneOffset
85import java.time.ZonedDateTime
96import java.time.format.DateTimeFormatter
10−import java.util.Locale
117
12−// Pin to Locale.US so the MMM month token is always rendered as a stable English
13−// abbreviation (e.g. "Jun") regardless of the device's locale.
14−private val displayFormatter = DateTimeFormatter.ofPattern("MMM dd HH:mm:ss.SSS", Locale.US)
8+private val displayFormatter = DateTimeFormatter.ofPattern("MMM dd HH:mm:ss.SSS")
159
1610fun formatTimestamp(raw: String): String {
17− // Parseable/DataFusion sometimes renders p_timestamp with a space between the date and
18− // time ("2026-06-20 12:00:00.000") instead of the ISO 'T', and may append a zone/offset
19− // after another space ("... +00:00", "... UTC"). Normalize both so the parsers below
20− // accept them; values already in canonical ISO form are left untouched.
21− var iso = raw.trim()
22− // Convert the FIRST space (date↔time separator) to 'T', unless a 'T' already precedes it
23− // (in which case that space sits before a zone token, handled next).
24− val firstSpace = iso.indexOf(' ')
25− if (firstSpace != -1 && 'T' !in iso.substring(0, firstSpace + 1)) {
26− iso = iso.substring(0, firstSpace) + "T" + iso.substring(firstSpace + 1)
27− }
28− // Drop a space sitting between the time and a trailing offset/zone token:
29− // "...12:00:00 +00:00" → "...12:00:00+00:00", "...12:00:00 UTC" → "...12:00:00UTC".
30− iso = iso.replace(Regex("(?<=\\d)\\s+(?=[+\\-Z]|UTC|GMT)"), "")
31− // Map a textual "UTC"/"GMT" suffix to the ISO 'Z' the parsers understand (GMT is exactly
32− // UTC, so this is a safe rewrite). Other textual zones (e.g. "PST") are ambiguous, so they
33− // are left untouched and fall through to the raw-string passthrough rather than risk
34− // displaying a wrong local time.
35− if (iso.endsWith("UTC")) iso = iso.removeSuffix("UTC") + "Z"
36− else if (iso.endsWith("GMT")) iso = iso.removeSuffix("GMT") + "Z"
37−
38− // Fast path: an ISO-8601 instant in UTC (e.g. "2026-06-20T12:00:00Z").
39− try {
40− val instant = Instant.parse(iso)
41− return ZonedDateTime.ofInstant(instant, ZoneId.systemDefault()).format(displayFormatter)
42− } catch (_: Exception) {
43− // Fall through to the offset-aware parser.
44− }
45−
46− // General path: any ISO-8601 timestamp carrying an explicit offset, including
47− // "+00:00" and non-UTC offsets like "+05:30" that Instant.parse rejects.
48− try {
49− val offset = OffsetDateTime.parse(iso)
50− return offset.atZoneSameInstant(ZoneId.systemDefault()).format(displayFormatter)
11+ return try {
12+ val instant = Instant.parse(raw)
13+ val local = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
14+ local.format(displayFormatter)
5115 } catch (_: Exception) {
52− // Fall through to the naive (offset-less) parser.
16+ // Try alternate format with +00:00 suffix
17+ try {
18+ val normalized = raw.replace("+00:00", "Z")
19+ val instant = Instant.parse(normalized)
20+ val local = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
21+ local.format(displayFormatter)
22+ } catch (_: Exception) {
23+ raw
24+ }
5325 }
54−
55− // Final path: an offset-less ISO-8601 timestamp (e.g. "2026-06-20T12:00:00.000").
56− // Parseable stores p_timestamp in UTC, so interpret a naive timestamp as UTC and
57− // convert to the device's zone — otherwise these would render in UTC while
58− // offset-carrying values render in local time, an inconsistent mix.
59− try {
60− val local = LocalDateTime.parse(iso)
61− return local.atOffset(ZoneOffset.UTC)
62− .atZoneSameInstant(ZoneId.systemDefault())
63− .format(displayFormatter)
64− } catch (_: Exception) {
65− // Unparseable — show the server value verbatim rather than nothing.
66− }
67−
68− return raw
6926}
app/src/main/java/com/parseable/android/data/api/ParseableApiClient.kt+31−88
@@ -77,18 +77,11 @@ class ParseableApiClient @Inject constructor() {
7777
7878 fun configure(serverConfig: ServerConfig) {
7979 val url = serverConfig.serverUrl.trimEnd('/')
80− // Leave the auth header empty for blank credentials so isConfigured() reports
81− // false instead of silently sending a bogus "Basic <user:>" header.
82− val auth = if (serverConfig.username.isBlank() && serverConfig.password.isBlank()) {
83− ""
84− } else {
85− val credentials = "${serverConfig.username}:${serverConfig.password}"
86− "Basic " + android.util.Base64.encodeToString(
87− // Explicit UTF-8 per RFC 7617 rather than the platform default charset.
88− credentials.toByteArray(Charsets.UTF_8),
89− android.util.Base64.NO_WRAP
90− )
91− }
80+ val credentials = "${serverConfig.username}:${serverConfig.password}"
81+ val auth = "Basic " + android.util.Base64.encodeToString(
82+ credentials.toByteArray(),
83+ android.util.Base64.NO_WRAP
84+ )
9285 config = ClientConfig(
9386 baseUrl = url,
9487 authHeader = auth,
@@ -103,48 +96,30 @@ class ParseableApiClient @Inject constructor() {
10396 }
10497
10598 fun shutdown() {
106− // Only evict idle connections. The dispatcher's executor is SHARED across the
107− // secure/insecure clients of this @Singleton; shutting it down is irreversible and
108− // would make every later request throw RejectedExecutionException after a
109− // clearConfig()/configure() cycle. Eviction is enough to release pooled sockets.
11099 sharedPool.evictAll()
100+ sharedDispatcher.executorService.shutdown()
111101 }
112102
113103 private fun encodePathSegment(segment: String): String =
114104 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
115105
116106 private fun buildRequest(path: String): Request.Builder {
117− // Snapshot the volatile config ONCE and derive both the URL/auth header and the
118− // TLS client (secure vs. insecure) from it. Selecting the client lazily in
119− // executeRequest would re-read `config`, so a concurrent server switch could
120− // dispatch server A's URL/auth through server B's TLS client. Pinning the client
121− // onto the request as a tag keeps the whole request internally consistent.
122107 val snapshot = config
123− val selectedClient = if (snapshot.allowInsecure) insecureClient else secureClient
124− val builder = Request.Builder()
108+ return Request.Builder()
125109 .url("${snapshot.baseUrl}$path")
126− .tag(OkHttpClient::class.java, selectedClient)
127− // Don't send a literal empty "Authorization:" header for unconfigured calls
128− // (e.g. pre-login connectivity probes) — strict servers/proxies may reject it.
129− if (snapshot.authHeader.isNotEmpty()) {
130− builder.header("Authorization", snapshot.authHeader)
131− }
132− return builder
110+ .header("Authorization", snapshot.authHeader)
133111 }
134112
135113 private suspend fun executeRequest(request: Request): ApiResult<String> =
136114 withContext(Dispatchers.IO) {
137115 try {
138− val callClient = request.tag(OkHttpClient::class.java) ?: client
139− callClient.newCall(request).execute().use { response ->
116+ client.newCall(request).execute().use { response ->
140117 val body = response.body?.string() ?: ""
141118 if (response.isSuccessful) {
142119 ApiResult.Success(body)
143120 } else {
144121 ApiResult.Error(
145− // ifBlank, not ifEmpty: a whitespace-only error body would otherwise
146− // be shown verbatim as a blank message instead of the status reason.
147− message = body.ifBlank { response.message },
122+ message = body.ifEmpty { response.message },
148123 code = response.code
149124 )
150125 }
@@ -152,7 +127,7 @@ class ParseableApiClient @Inject constructor() {
152127 } catch (e: SocketTimeoutException) {
153128 ApiResult.Error(message = "Connection timed out")
154129 } catch (e: UnknownHostException) {
155− ApiResult.Error(message = "Unable to resolve host")
130+ ApiResult.Error(message = "Unable to resolve host \"${e.message}\"")
156131 } catch (e: ConnectException) {
157132 ApiResult.Error(message = "Connection refused. Is the server running?")
158133 } catch (e: SSLException) {
@@ -165,21 +140,13 @@ class ParseableApiClient @Inject constructor() {
165140 }
166141 }
167142
168− private fun <T> parseResponse(description: String, parse: () -> T): ApiResult<T> {
143+ private fun <T> parseResponse(body: String, description: String, parse: () -> T): ApiResult<T> {
169144 return try {
170145 ApiResult.Success(parse())
171146 } catch (e: SerializationException) {
172− ApiResult.Error("Invalid response format from server", code = ApiResult.PARSE_ERROR_CODE)
147+ ApiResult.Error("Invalid response format from server")
173148 } catch (e: IllegalArgumentException) {
174− ApiResult.Error("Unexpected response format for $description", code = ApiResult.PARSE_ERROR_CODE)
175− } catch (e: Exception) {
176− // kotlinx-serialization's JsonElement accessors (jsonArray/jsonObject/
177− // jsonPrimitive) throw IllegalStateException — not IllegalArgumentException —
178− // when an element has the wrong type. Catch any remaining parse-time failure
179− // so a malformed payload surfaces as an error instead of an uncaught crash.
180− // parse() is non-suspending, so there's no CancellationException to preserve.
181− Timber.w(e, "Failed to parse response for $description")
182− ApiResult.Error("Unexpected response format for $description", code = ApiResult.PARSE_ERROR_CODE)
149+ ApiResult.Error("Unexpected response format for $description")
183150 }
184151 }
185152
@@ -189,7 +156,7 @@ class ParseableApiClient @Inject constructor() {
189156 suspend fun getAbout(): ApiResult<AboutInfo> {
190157 val request = buildRequest("/api/v1/about").get().build()
191158 return when (val result = executeRequest(request)) {
192− is ApiResult.Success -> parseResponse("about info") {
159+ is ApiResult.Success -> parseResponse(result.data, "about info") {
193160 json.decodeFromString<AboutInfo>(result.data)
194161 }
195162 is ApiResult.Error -> result
@@ -230,7 +197,7 @@ class ParseableApiClient @Inject constructor() {
230197 }
231198 ApiResult.Success(streams)
232199 } catch (e2: Exception) {
233− ApiResult.Error("Failed to parse streams: ${e2.message}", code = ApiResult.PARSE_ERROR_CODE)
200+ ApiResult.Error("Failed to parse streams: ${e2.message}")
234201 }
235202 }
236203 }
@@ -245,7 +212,7 @@ class ParseableApiClient @Inject constructor() {
245212 val encoded = encodePathSegment(stream)
246213 val request = buildRequest("/api/v1/logstream/$encoded/schema").get().build()
247214 return when (val result = executeRequest(request)) {
248− is ApiResult.Success -> parseResponse("schema") {
215+ is ApiResult.Success -> parseResponse(result.data, "schema") {
249216 json.decodeFromString<StreamSchema>(result.data)
250217 }
251218 is ApiResult.Error -> result
@@ -259,7 +226,7 @@ class ParseableApiClient @Inject constructor() {
259226 val encoded = encodePathSegment(stream)
260227 val request = buildRequest("/api/v1/logstream/$encoded/stats").get().build()
261228 return when (val result = executeRequest(request)) {
262− is ApiResult.Success -> parseResponse("stats") {
229+ is ApiResult.Success -> parseResponse(result.data, "stats") {
263230 json.decodeFromString<StreamStats>(result.data)
264231 }
265232 is ApiResult.Error -> result
@@ -273,7 +240,7 @@ class ParseableApiClient @Inject constructor() {
273240 val encoded = encodePathSegment(stream)
274241 val request = buildRequest("/api/v1/logstream/$encoded/info").get().build()
275242 return when (val result = executeRequest(request)) {
276− is ApiResult.Success -> parseResponse("stream info") {
243+ is ApiResult.Success -> parseResponse(result.data, "stream info") {
277244 val element = json.parseToJsonElement(result.data)
278245 (element as? JsonObject)
279246 ?: throw IllegalArgumentException("Expected JSON object, got ${element::class.simpleName}")
@@ -293,15 +260,8 @@ class ParseableApiClient @Inject constructor() {
293260 try {
294261 ApiResult.Success(json.decodeFromString<List<RetentionConfig>>(result.data))
295262 } catch (_: Exception) {
296− parseResponse("retention config") {
297− val single = json.decodeFromString<RetentionConfig>(result.data)
298− // An empty object ({}) decodes into an all-null config — that means
299− // "no retention configured" (zero rules), not one empty rule.
300− if (single.description == null && single.duration == null && single.action == null) {
301− emptyList()
302− } else {
303− listOf(single)
304− }
263+ parseResponse(result.data, "retention config") {
264+ listOf(json.decodeFromString<RetentionConfig>(result.data))
305265 }
306266 }
307267 }
@@ -331,7 +291,7 @@ class ParseableApiClient @Inject constructor() {
331291 .build()
332292
333293 return when (val result = executeRequest(request)) {
334− is ApiResult.Success -> parseResponse("query results") {
294+ is ApiResult.Success -> parseResponse(result.data, "query results") {
335295 val element = json.parseToJsonElement(result.data)
336296 val elements = (element as? JsonArray)
337297 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
@@ -356,14 +316,11 @@ class ParseableApiClient @Inject constructor() {
356316 suspend fun listAlerts(): ApiResult<List<Alert>> {
357317 val request = buildRequest("/api/v1/alerts").get().build()
358318 return when (val result = executeRequest(request)) {
359− is ApiResult.Success -> parseResponse("alerts") {
319+ is ApiResult.Success -> parseResponse(result.data, "alerts") {
360320 val element = json.parseToJsonElement(result.data)
361321 val alertsArray = when (element) {
362322 is JsonArray -> element
363− // Use a safe cast, not the `.jsonArray` accessor: a present-but-non-array
364− // value (e.g. {"alerts": null} or an object) makes `.jsonArray` throw, which
365− // would surface as a parse error instead of degrading to an empty list.
366− is JsonObject -> (element["alerts"] as? JsonArray) ?: JsonArray(emptyList())
323+ is JsonObject -> element["alerts"]?.jsonArray ?: JsonArray(emptyList())
367324 else -> JsonArray(emptyList())
368325 }
369326 alertsArray.mapNotNull { alertElement ->
@@ -398,7 +355,7 @@ class ParseableApiClient @Inject constructor() {
398355 .post(requestBody)
399356 .build()
400357 return when (val result = executeRequest(request)) {
401− is ApiResult.Success -> parseResponse("alert") {
358+ is ApiResult.Success -> parseResponse(result.data, "alert") {
402359 json.decodeFromString<Alert>(result.data)
403360 }
404361 is ApiResult.Error -> result
@@ -411,7 +368,7 @@ class ParseableApiClient @Inject constructor() {
411368 suspend fun listUsers(): ApiResult<List<JsonObject>> {
412369 val request = buildRequest("/api/v1/user").get().build()
413370 return when (val result = executeRequest(request)) {
414− is ApiResult.Success -> parseResponse("users") {
371+ is ApiResult.Success -> parseResponse(result.data, "users") {
415372 val element = json.parseToJsonElement(result.data)
416373 val elements = (element as? JsonArray)
417374 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
@@ -427,22 +384,8 @@ class ParseableApiClient @Inject constructor() {
427384 suspend fun listFilters(): ApiResult<List<SavedFilter>> {
428385 val request = buildRequest("/api/v1/filters").get().build()
429386 return when (val result = executeRequest(request)) {
430− is ApiResult.Success -> parseResponse("filters") {
431− val element = json.parseToJsonElement(result.data)
432− val array = (element as? JsonArray)
433− ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
434− array.mapNotNull { filterElement ->
435− try {
436− val filter = json.decodeFromJsonElement<SavedFilter>(filterElement)
437− // coerceInputValues turns a server-null identity field into "". Skip such
438− // entries rather than surface a nameless/stream-less ghost filter (and so
439− // one malformed entry doesn't fail the whole list).
440− if (filter.filterName.isBlank() || filter.streamName.isBlank()) null else filter
441− } catch (e: Exception) {
442− Timber.w(e, "Skipping malformed filter entry")
443− null
444− }
445− }
387+ is ApiResult.Success -> parseResponse(result.data, "filters") {
388+ json.decodeFromString<List<SavedFilter>>(result.data)
446389 }
447390 is ApiResult.Error -> result
448391 }
@@ -455,7 +398,7 @@ class ParseableApiClient @Inject constructor() {
455398 val encoded = encodePathSegment(filterId)
456399 val request = buildRequest("/api/v1/filters/$encoded").get().build()
457400 return when (val result = executeRequest(request)) {
458− is ApiResult.Success -> parseResponse("filter") {
401+ is ApiResult.Success -> parseResponse(result.data, "filter") {
459402 json.decodeFromString<SavedFilter>(result.data)
460403 }
461404 is ApiResult.Error -> result
@@ -472,7 +415,7 @@ class ParseableApiClient @Inject constructor() {
472415 .post(requestBody)
473416 .build()
474417 return when (val result = executeRequest(request)) {
475− is ApiResult.Success -> parseResponse("filter") {
418+ is ApiResult.Success -> parseResponse(result.data, "filter") {
476419 json.decodeFromString<SavedFilter>(result.data)
477420 }
478421 is ApiResult.Error -> result
@@ -490,7 +433,7 @@ class ParseableApiClient @Inject constructor() {
490433 .put(requestBody)
491434 .build()
492435 return when (val result = executeRequest(request)) {
493− is ApiResult.Success -> parseResponse("filter") {
436+ is ApiResult.Success -> parseResponse(result.data, "filter") {
494437 json.decodeFromString<SavedFilter>(result.data)
495438 }
496439 is ApiResult.Error -> result
app/src/main/java/com/parseable/android/data/local/FavoriteStreamDao.kt+1−11
@@ -12,14 +12,10 @@ interface FavoriteStreamDao {
1212 @Query("SELECT * FROM favorite_streams ORDER BY addedAt DESC")
1313 fun getAll(): Flow<List<FavoriteStream>>
1414
15− // Ordered most-recently-favorited first so the UI can show favorites in recency order
16− // (toSet() downstream preserves this iteration order via LinkedHashSet).
1715 @Query("SELECT streamName FROM favorite_streams ORDER BY addedAt DESC")
1816 fun getAllNames(): Flow<List<String>>
1917
20− // IGNORE (not REPLACE): re-favoriting an existing stream must keep its original
21− // addedAt so the getAll() "ORDER BY addedAt DESC" ordering stays stable.
22− @Insert(onConflict = OnConflictStrategy.IGNORE)
18+ @Insert(onConflict = OnConflictStrategy.REPLACE)
2319 suspend fun insert(favorite: FavoriteStream)
2420
2521 @Delete
@@ -30,10 +26,4 @@ interface FavoriteStreamDao {
3026
3127 @Query("SELECT EXISTS(SELECT 1 FROM favorite_streams WHERE streamName = :name)")
3228 fun isFavorite(name: String): Flow<Boolean>
33−
34− // One-shot suspend variant. toggleFavorite() must decide insert-vs-delete from the
35− // committed DB state rather than the async-mirrored UI state, so rapid double-taps
36− // can't both observe a stale value and take the same branch.
37− @Query("SELECT EXISTS(SELECT 1 FROM favorite_streams WHERE streamName = :name)")
38− suspend fun isFavoriteNow(name: String): Boolean
3929}
app/src/main/java/com/parseable/android/data/local/ParseableDatabase.kt+1−1
@@ -5,7 +5,7 @@ import androidx.room.RoomDatabase
55
66@Database(
77 entities = [FavoriteStream::class, SavedServer::class],
8− version = 3,
8+ version = 2,
99 exportSchema = true,
1010)
1111abstract class ParseableDatabase : RoomDatabase() {
app/src/main/java/com/parseable/android/data/local/SavedServer.kt+1−8
@@ -1,16 +1,9 @@
11package com.parseable.android.data.local
22
33import androidx.room.Entity
4−import androidx.room.Index
54import androidx.room.PrimaryKey
65
7−@Entity(
8− tableName = "saved_servers",
9− // One logical server per (url, username). The dedupe in SettingsRepository.saveServer
10− // relies on this invariant; enforcing it in the schema means REPLACE actually upserts on
11− // the natural key instead of silently inserting a duplicate row if the read-back misses.
12− indices = [Index(value = ["serverUrl", "username"], unique = true)],
13−)
6+@Entity(tableName = "saved_servers")
147data class SavedServer(
158 @PrimaryKey(autoGenerate = true)
169 val id: Long = 0,
app/src/main/java/com/parseable/android/data/model/Models.kt+2−57
@@ -1,43 +1,9 @@
11package com.parseable.android.data.model
22
3−import kotlinx.serialization.KSerializer
43import kotlinx.serialization.SerialName
54import kotlinx.serialization.Serializable
6−import kotlinx.serialization.descriptors.PrimitiveKind
7−import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
8−import kotlinx.serialization.descriptors.SerialDescriptor
9−import kotlinx.serialization.encoding.Decoder
10−import kotlinx.serialization.encoding.Encoder
11−import kotlinx.serialization.json.JsonDecoder
125import kotlinx.serialization.json.JsonElement
13−import kotlinx.serialization.json.JsonNull
146import kotlinx.serialization.json.JsonObject
15−import kotlinx.serialization.json.JsonPrimitive
16−
17−/**
18− * Deserializes a value that some Parseable builds return as a JSON string ("12345") and other
19− * builds return as a JSON number (12345) into a [String], so stats parsing doesn't fail outright
20− * on the numeric form. Applied to the size fields below, which are rendered via [formatBytes]
21− * (itself tolerant of both a raw byte count and a pre-formatted string).
22− */
23−object FlexibleStringSerializer : KSerializer<String> {
24− override val descriptor: SerialDescriptor =
25− PrimitiveSerialDescriptor("FlexibleString", PrimitiveKind.STRING)
26−
27− override fun serialize(encoder: Encoder, value: String) = encoder.encodeString(value)
28−
29− override fun deserialize(decoder: Decoder): String {
30− val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeString()
31− val element = jsonDecoder.decodeJsonElement()
32− // JsonNull is itself a JsonPrimitive whose .content is the literal string "null". For the
33− // nullable fields this serializer is applied to, the compiler-generated nullable wrapper
34− // already intercepts a JSON null before deserialize() runs, so this never fires today. The
35− // guard exists so that if this serializer is ever attached to a non-null field, a JSON null
36− // surfaces as "" (rendered as absent) rather than the misleading literal text "null".
37− if (element is JsonNull) return ""
38− return (element as? JsonPrimitive)?.content ?: element.toString()
39− }
40−}
417
428/**
439 * Server connection configuration stored in DataStore.
@@ -81,31 +47,25 @@ data class StreamStats(
8147@Serializable
8248data class IngestionStats(
8349 val count: Long? = null,
84− @Serializable(with = FlexibleStringSerializer::class)
8550 val size: String? = null,
8651 val format: String? = null,
8752 @SerialName("lifetime_count")
8853 val lifetimeCount: Long? = null,
8954 @SerialName("lifetime_size")
90− @Serializable(with = FlexibleStringSerializer::class)
9155 val lifetimeSize: String? = null,
9256 @SerialName("deleted_count")
9357 val deletedCount: Long? = null,
9458 @SerialName("deleted_size")
95− @Serializable(with = FlexibleStringSerializer::class)
9659 val deletedSize: String? = null,
9760)
9861
9962@Serializable
10063data class StorageStats(
101− @Serializable(with = FlexibleStringSerializer::class)
10264 val size: String? = null,
10365 val format: String? = null,
10466 @SerialName("lifetime_size")
105− @Serializable(with = FlexibleStringSerializer::class)
10667 val lifetimeSize: String? = null,
10768 @SerialName("deleted_size")
108− @Serializable(with = FlexibleStringSerializer::class)
10969 val deletedSize: String? = null,
11070)
11171
@@ -192,14 +152,11 @@ data class Alert(
192152 val datasets: List<String> = emptyList(),
193153) {
194154 /** Best-effort display name from either response format. */
195− val displayName: String
196− get() = title?.takeIf { it.isNotBlank() }
197− ?: name?.takeIf { it.isNotBlank() }
198− ?: "Unnamed Alert"
155+ val displayName: String get() = title ?: name ?: "Unnamed Alert"
199156
200157 /** Whether the alert is active, derived from either response format. */
201158 val isEnabled: Boolean get() = when {
202− state != null -> !state.equals("Disabled", ignoreCase = true)
159+ state != null -> state != "Disabled"
203160 enabled != null -> enabled
204161 else -> true
205162 }
@@ -221,8 +178,6 @@ sealed class ApiResult<out T> {
221178 val isUnauthorized: Boolean get() = code == 401
222179 val isNotFound: Boolean get() = code == 404
223180 val isServerError: Boolean get() = code in 500..599
224− // Only genuine transport failures use code 0. A successful HTTP response whose body
225− // failed to parse carries PARSE_ERROR_CODE so it isn't mislabeled as a network error.
226181 val isNetworkError: Boolean get() = code == 0
227182
228183 val userMessage: String get() = when {
@@ -240,14 +195,4 @@ sealed class ApiResult<out T> {
240195 else -> message
241196 }
242197 }
243−
244− companion object {
245− /**
246− * Code used for client-side response-parsing failures: the HTTP request succeeded
247− * but the body was malformed/unexpected. Distinct from 0 (network/transport failure)
248− * so [Error.isNetworkError] and [Error.userMessage] don't show a misleading
249− * "Network error" for what is actually a server-data problem.
250− */
251− const val PARSE_ERROR_CODE = -1
252− }
253198}
app/src/main/java/com/parseable/android/data/repository/ParseableRepository.kt+4−15
@@ -71,23 +71,12 @@ class ParseableRepository @Inject constructor(
7171 * Does NOT trigger [checkAuth] so the global auth-error handler won't fire during login.
7272 */
7373 suspend fun verifyServer(): ApiResult<AboutInfo> {
74− return when (val aboutResult = apiClient.getAbout()) {
74+ return when (val result = apiClient.getAbout()) {
7575 is ApiResult.Success -> {
76− // GET /about is not auth-gated on Parseable, so a success here only proves
77− // the server is reachable and is Parseable — NOT that the credentials are
78− // valid. Probe an auth-required endpoint (logstream listing, which the app
79− // needs anyway) so wrong credentials are rejected at login instead of being
80− // persisted and only failing on the first real request. Call apiClient
81− // directly to avoid checkAuth firing the global auth-error handler mid-login.
82− when (val authProbe = apiClient.listStreams()) {
83− is ApiResult.Success -> {
84− aboutCache = CacheEntry(aboutResult.data)
85− aboutResult
86− }
87− is ApiResult.Error -> authProbe
88− }
76+ aboutCache = CacheEntry(result.data)
77+ result
8978 }
90− is ApiResult.Error -> aboutResult
79+ is ApiResult.Error -> result
9180 }
9281 }
9382
app/src/main/java/com/parseable/android/data/repository/SettingsRepository.kt+21−139
@@ -73,17 +73,8 @@ class SettingsRepository @Inject constructor(
7373 Timber.e(e, "Failed to read DataStore")
7474 emit(emptyPreferences())
7575 }
76− .map {
77− // Read url/username/tls AND the encrypted password together under configMutex,
78− // re-reading a fresh DataStore snapshot inside the lock. Writers (switch/save/clear)
79− // hold this mutex across their whole multi-store edit, so acquiring it here guarantees
80− // the url/username returned always belong to the same server as the password. A
81− // lock-free read could otherwise pair one server's username with another server's
82− // password if a concurrent switch's writes landed between the DataStore read and the
83− // encrypted-password read. All callers consume this via .first(), so serializing the
84− // read behind the (brief) write critical sections costs at most one extra snapshot read.
76+ .map { prefs ->
8577 configMutex.withLock {
86− val prefs = context.dataStore.data.first()
8778 val url = prefs[serverUrlKey] ?: return@withLock null
8879 val user = prefs[usernameKey] ?: return@withLock null
8980 val pass = try {
@@ -112,33 +103,19 @@ class SettingsRepository @Inject constructor(
112103
113104 suspend fun getSavedPassword(): ServerConfig? = serverConfig.first()
114105
115− /**
116− * Persist the server config. Returns true only if the credentials were durably saved.
117− * Callers (e.g. login) must not report success when this returns false — otherwise the
118− * session would appear logged in but vanish on the next launch.
119− */
120− suspend fun saveServerConfig(config: ServerConfig): Boolean {
121− return configMutex.withLock {
106+ suspend fun saveServerConfig(config: ServerConfig) {
107+ configMutex.withLock {
122108 try {
123− // Persist the password (encrypted) first and confirm it committed. If the
124− // Keystore-backed write fails we abort before touching DataStore, so we never
125− // leave a new URL/username paired with a stale password.
126− val passwordSaved = withContext(Dispatchers.IO) {
127− encryptedPrefs.edit().putString("password", config.password).commit()
128− }
129− if (!passwordSaved) {
130− Timber.e("Failed to persist credentials securely; aborting config save")
131− return@withLock false
132− }
133109 context.dataStore.edit { prefs ->
134110 prefs[serverUrlKey] = config.serverUrl
135111 prefs[usernameKey] = config.username
136112 prefs[useTlsKey] = config.useTls
137113 }
138− true
114+ withContext(Dispatchers.IO) {
115+ encryptedPrefs.edit().putString("password", config.password).apply()
116+ }
139117 } catch (e: Exception) {
140118 Timber.e(e, "Failed to save server config")
141− false
142119 }
143120 }
144121 }
@@ -153,9 +130,7 @@ class SettingsRepository @Inject constructor(
153130 prefs.remove(activeServerIdKey)
154131 }
155132 withContext(Dispatchers.IO) {
156− // commit() (not apply()) so the cleared password is durably gone before we
157− // return — a queued async clear could survive into the next session.
158− encryptedPrefs.edit().remove("password").commit()
133+ encryptedPrefs.edit().remove("password").apply()
159134 }
160135 } catch (e: Exception) {
161136 Timber.e(e, "Failed to clear config")
@@ -170,15 +145,12 @@ class SettingsRepository @Inject constructor(
170145 /**
171146 * Persist the current server connection as a saved server entry.
172147 * If a server with the same URL and username already exists, updates it.
173− * Returns the server ID, or null if the credentials could not be persisted.
148+ * Returns the server ID.
174149 */
175− suspend fun saveServer(config: ServerConfig): Long? {
150+ suspend fun saveServer(config: ServerConfig): Long {
176151 return configMutex.withLock {
177152 val existing = savedServerDao.findByUrlAndUsername(config.serverUrl, config.username)
178− // Use a UUID, not System.currentTimeMillis(): two distinct new servers saved
179− // within the same millisecond would otherwise share a key and the second
180− // would overwrite the first's encrypted password.
181− val passwordKey = existing?.passwordKey ?: "server_pwd_${java.util.UUID.randomUUID()}"
153+ val passwordKey = existing?.passwordKey ?: "server_pwd_${System.currentTimeMillis()}"
182154 val server = SavedServer(
183155 id = existing?.id ?: 0,
184156 serverUrl = config.serverUrl,
@@ -187,19 +159,8 @@ class SettingsRepository @Inject constructor(
187159 passwordKey = passwordKey,
188160 addedAt = existing?.addedAt ?: System.currentTimeMillis(),
189161 )
190− // Persist the password durably (commit + check) BEFORE inserting the row and
191− // marking it active. With apply() a crash could leave a saved-server row and an
192− // active-server-id pointing at a passwordKey that was never flushed, making the
193− // server unusable (switchToServer would return null).
194− val passwordSaved = withContext(Dispatchers.IO) {
195− encryptedPrefs.edit().putString(passwordKey, config.password).commit()
196− }
197− if (!passwordSaved) {
198− // Return null rather than existing?.id: reusing the existing id would look like a
199− // successful update to the caller, while the new password was never written and a
200− // later switchToServer would silently load the stale credentials.
201− Timber.e("Failed to persist saved-server credentials securely; aborting saveServer")
202− return@withLock null
162+ withContext(Dispatchers.IO) {
163+ encryptedPrefs.edit().putString(passwordKey, config.password).apply()
203164 }
204165 val id = savedServerDao.insert(server)
205166
@@ -234,16 +195,6 @@ class SettingsRepository @Inject constructor(
234195 useTls = server.useTls,
235196 )
236197
237− // Persist the active password durably (commit + check) BEFORE updating the active
238− // DataStore config. With apply() a crash could leave the active URL/username pointing
239− // at a stale/missing password — the exact inconsistency saveServerConfig avoids.
240− val passwordSaved = withContext(Dispatchers.IO) {
241− encryptedPrefs.edit().putString("password", config.password).commit()
242− }
243− if (!passwordSaved) {
244− Timber.e("Failed to persist credentials securely; aborting server switch")
245− return@withLock null
246− }
247198 // Write to active config
248199 context.dataStore.edit { prefs ->
249200 prefs[serverUrlKey] = config.serverUrl
@@ -251,6 +202,9 @@ class SettingsRepository @Inject constructor(
251202 prefs[useTlsKey] = config.useTls
252203 prefs[activeServerIdKey] = serverId
253204 }
205+ withContext(Dispatchers.IO) {
206+ encryptedPrefs.edit().putString("password", config.password).apply()
207+ }
254208 config
255209 }
256210 }
@@ -258,92 +212,20 @@ class SettingsRepository @Inject constructor(
258212 /**
259213 * Remove a saved server and its stored password.
260214 * If the deleted server was the active one, the active server ID is cleared.
261− * Returns true if the deleted server was the active connection (so callers can clear any
262− * now-stale displayed server info), false otherwise.
263215 */
264− suspend fun deleteServer(serverId: Long): Boolean {
265− return configMutex.withLock {
266− val server = savedServerDao.getById(serverId) ?: return@withLock false
216+ suspend fun deleteServer(serverId: Long) {
217+ configMutex.withLock {
218+ val server = savedServerDao.getById(serverId) ?: return@withLock
267219 withContext(Dispatchers.IO) {
268− // commit() so the password is durably removed before we drop the row that
269− // references its key — otherwise a crash could orphan the encrypted entry.
270− encryptedPrefs.edit().remove(server.passwordKey).commit()
220+ encryptedPrefs.edit().remove(server.passwordKey).apply()
271221 }
272222 savedServerDao.deleteById(serverId)
273223
274− // Clear active server reference if it pointed at the deleted server. The active
275− // connection keys (url/username/tls) and the active encrypted password live
276− // separately from the per-server row, so they must be torn down too — otherwise
277− // serverConfig still emits a valid config and the app stays logged in to the
278− // server we just deleted (and restores that session on next launch).
224+ // Clear active server reference if it pointed at the deleted server
279225 val currentActiveId = context.dataStore.data.first()[activeServerIdKey]
280− val wasActive = currentActiveId == serverId
281− if (wasActive) {
226+ if (currentActiveId == serverId) {
282227 context.dataStore.edit { prefs ->
283228 prefs.remove(activeServerIdKey)
284− prefs.remove(serverUrlKey)
285− prefs.remove(usernameKey)
286− prefs.remove(useTlsKey)
287− }
288− withContext(Dispatchers.IO) {
289− encryptedPrefs.edit().remove("password").commit()
290− }
291− }
292− wasActive
293− }
294− }
295−
296− /**
297− * Repair a dangling active_server_id left by a row-collapsing migration. MIGRATION_2_3 deletes
298− * duplicate saved_servers rows as raw SQL, but the active-server pointer lives in DataStore and
299− * can't be touched there — so after that upgrade it may reference one of the deleted duplicates.
300− * Re-link it to the surviving row for the active (url, username) when one exists; otherwise just
301− * clear the stale pointer. The active connection (url/username/password) is left intact, so a
302− * working session is never logged out — only the saved-servers radio selection is corrected.
303− * Best-effort, safe to run at startup.
304− */
305− suspend fun reconcileActiveServerId() {
306− configMutex.withLock {
307− val prefs = context.dataStore.data.first()
308− val activeId = prefs[activeServerIdKey] ?: return@withLock
309− if (savedServerDao.getById(activeId) != null) return@withLock
310− val url = prefs[serverUrlKey]
311− val user = prefs[usernameKey]
312− val surviving = if (url != null && user != null) {
313− savedServerDao.findByUrlAndUsername(url, user)
314− } else {
315− null
316− }
317− context.dataStore.edit { p ->
318− if (surviving != null) {
319− p[activeServerIdKey] = surviving.id
320− } else {
321− p.remove(activeServerIdKey)
322− }
323− }
324− }
325− }
326−
327− /**
328− * Remove EncryptedSharedPreferences password entries that no longer correspond to a
329− * saved_servers row. Row-collapsing schema migrations (e.g. MIGRATION_2_3, which deletes
330− * duplicate rows) run as raw SQL and can't reach EncryptedSharedPreferences, so the
331− * removed rows' "server_pwd_*" keys would otherwise linger encrypted on disk forever.
332− * Best-effort, safe to run at startup. Only touches per-server keys ("server_pwd_*") so
333− * the active-connection "password" key is never disturbed.
334− */
335− suspend fun cleanupOrphanedServerPasswords() {
336− configMutex.withLock {
337− val referenced = savedServerDao.getAll().first().map { it.passwordKey }.toSet()
338− withContext(Dispatchers.IO) {
339− val orphans = encryptedPrefs.all.keys.filter {
340− it.startsWith("server_pwd_") && it !in referenced
341− }
342− if (orphans.isNotEmpty()) {
343− encryptedPrefs.edit().apply {
344− orphans.forEach { remove(it) }
345− }.commit()
346− Timber.i("Removed ${orphans.size} orphaned server password entries")
347229 }
348230 }
349231 }
app/src/main/java/com/parseable/android/di/AppModule.kt−53
@@ -2,8 +2,6 @@ package com.parseable.android.di
22
33import android.content.Context
44import androidx.room.Room
5−import androidx.room.migration.Migration
6−import androidx.sqlite.db.SupportSQLiteDatabase
75import com.parseable.android.data.local.FavoriteStreamDao
86import com.parseable.android.data.local.ParseableDatabase
97import com.parseable.android.data.local.SavedServerDao
@@ -18,61 +16,10 @@ import javax.inject.Singleton
1816@InstallIn(SingletonComponent::class)
1917object AppModule {
2018
21− /**
22− * v1 → v2 added the `saved_servers` table (multi-server management). This is NOT a
23− * rebuildable cache like favorites: it holds the user's saved server list and the keys
24− * into EncryptedSharedPreferences for each server's password. Losing it strands users'
25− * credentials, so the upgrade must be a real additive migration, not a destructive wipe.
26− *
27− * The CREATE TABLE statement must match the schema Room generates for the SavedServer
28− * entity exactly (column names, affinities, NOT NULL, the AUTOINCREMENT primary key) or
29− * Room's post-migration schema validation will throw on first open.
30− */
31− private val MIGRATION_1_2 = object : Migration(1, 2) {
32− override fun migrate(db: SupportSQLiteDatabase) {
33− db.execSQL(
34− "CREATE TABLE IF NOT EXISTS `saved_servers` (" +
35− "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
36− "`serverUrl` TEXT NOT NULL, " +
37− "`username` TEXT NOT NULL, " +
38− "`useTls` INTEGER NOT NULL, " +
39− "`passwordKey` TEXT NOT NULL, " +
40− "`addedAt` INTEGER NOT NULL)"
41− )
42− }
43− }
44−
45− /**
46− * v2 → v3 adds a UNIQUE index on (serverUrl, username) so the one-row-per-server invariant
47− * is enforced by the schema rather than only by app logic. Any pre-existing duplicate rows
48− * (possible because v2 had no constraint) must be collapsed first or the index creation
49− * fails — keep the lowest id for each (url, username). The index name and SQL must match
50− * what Room generates for the entity's @Index or post-migration validation will throw.
51− */
52− private val MIGRATION_2_3 = object : Migration(2, 3) {
53− override fun migrate(db: SupportSQLiteDatabase) {
54− db.execSQL(
55− "DELETE FROM `saved_servers` WHERE `id` NOT IN " +
56− "(SELECT MIN(`id`) FROM `saved_servers` GROUP BY `serverUrl`, `username`)"
57− )
58− db.execSQL(
59− "CREATE UNIQUE INDEX IF NOT EXISTS " +
60− "`index_saved_servers_serverUrl_username` " +
61− "ON `saved_servers` (`serverUrl`, `username`)"
62− )
63− }
64− }
65−
6619 @Provides
6720 @Singleton
6821 fun provideDatabase(@ApplicationContext context: Context): ParseableDatabase =
6922 Room.databaseBuilder(context, ParseableDatabase::class.java, "parseable.db")
70− // Register real migrations for any schema bump that must preserve user data.
71− // IMPORTANT: every future version bump needs a Migration added here — the
72− // destructive fallback below is only a last resort for unmigrated paths and will
73− // silently drop ALL tables (including saved_servers) if it ever runs on upgrade.
74− // Exported schemas (see ParseableDatabase, exportSchema = true) provide the diffs.
75− .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
7623 .fallbackToDestructiveMigration()
7724 .build()
7825
app/src/main/java/com/parseable/android/ui/navigation/NavGraph.kt+10−28
@@ -16,6 +16,7 @@ import com.parseable.android.ui.screens.logviewer.StreamInfoScreen
1616import com.parseable.android.ui.screens.about.AboutScreen
1717import com.parseable.android.ui.screens.settings.SettingsScreen
1818import com.parseable.android.ui.screens.streams.StreamsScreen
19+import java.net.URLDecoder
1920import java.net.URLEncoder
2021
2122object Routes {
@@ -30,16 +31,9 @@ object Routes {
3031 fun login(sessionExpired: Boolean = false) =
3132 if (sessionExpired) "login?sessionExpired=true" else LOGIN
3233 fun logViewer(streamName: String) =
33− "log_viewer/${encodeArg(streamName)}"
34+ "log_viewer/${URLEncoder.encode(streamName, "UTF-8")}"
3435 fun streamInfo(streamName: String) =
35− "stream_info/${encodeArg(streamName)}"
36−
37− // URLEncoder maps space to "+", but Navigation decodes captured path args with Uri.decode,
38− // which only understands "%20" and leaves "+" untouched (it would survive as a literal
39− // "+"). Normalize to "%20" so the name Navigation hands back matches the original. Do NOT
40− // decode again at the call site — Navigation already does that one decode for us.
41− private fun encodeArg(value: String) =
42− URLEncoder.encode(value, "UTF-8").replace("+", "%20")
36+ "stream_info/${URLEncoder.encode(streamName, "UTF-8")}"
4337}
4438
4539@Composable
@@ -120,8 +114,9 @@ fun ParseableNavGraph(
120114 }
121115 return@composable
122116 }
123− // Navigation already URI-decodes captured path args, so use the value as-is.
124− val streamName = backStackEntry.arguments?.getString("streamName") ?: ""
117+ val streamName = URLDecoder.decode(
118+ backStackEntry.arguments?.getString("streamName") ?: "", "UTF-8"
119+ )
125120 LogViewerScreen(
126121 streamName = streamName,
127122 onBack = { navController.popBackStack() },
@@ -137,22 +132,14 @@ fun ParseableNavGraph(
137132 route = Routes.STREAM_INFO,
138133 arguments = listOf(navArgument("streamName") { type = NavType.StringType }),
139134 ) { backStackEntry ->
140− // Navigation already URI-decodes captured path args, so use the value as-is.
141− val streamName = backStackEntry.arguments?.getString("streamName") ?: ""
135+ val streamName = URLDecoder.decode(
136+ backStackEntry.arguments?.getString("streamName") ?: "", "UTF-8"
137+ )
142138 StreamInfoScreen(
143139 streamName = streamName,
144140 onBack = { navController.popBackStack() },
145141 onStreamDeleted = {
146− // popBackStack no-ops (returns false) when there's no streams entry on
147− // the back stack — e.g. when the user arrived via the deep link. In that
148− // case navigate to streams fresh so we don't strand them on the info
149− // screen for a now-deleted stream.
150− val popped = navController.popBackStack(Routes.STREAMS, inclusive = false)
151− if (!popped) {
152− navController.navigate(Routes.STREAMS) {
153− popUpTo(0) { inclusive = true }
154− }
155− }
142+ navController.popBackStack(Routes.STREAMS, inclusive = false)
156143 },
157144 )
158145 }
@@ -195,11 +182,6 @@ fun ParseableNavGraph(
195182 popUpTo(0) { inclusive = true }
196183 }
197184 },
198− onLoggedOut = {
199− navController.navigate(Routes.LOGIN) {
200− popUpTo(0) { inclusive = true }
201− }
202− },
203185 )
204186 }
205187
app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsScreen.kt

Binary file not shown.

app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsViewModel.kt+13−43
@@ -6,7 +6,6 @@ import com.parseable.android.data.model.Alert
66import com.parseable.android.data.model.ApiResult
77import com.parseable.android.data.repository.ParseableRepository
88import dagger.hilt.android.lifecycle.HiltViewModel
9−import kotlinx.coroutines.Job
109import kotlinx.coroutines.flow.MutableStateFlow
1110import kotlinx.coroutines.flow.StateFlow
1211import kotlinx.coroutines.flow.asStateFlow
@@ -29,40 +28,19 @@ class AlertsViewModel @Inject constructor(
2928 private val _state = MutableStateFlow(AlertsState())
3029 val state: StateFlow<AlertsState> = _state.asStateFlow()
3130
32− private var refreshJob: Job? = null
33−
34− init {
35− // Load once on creation; the screen no longer refreshes on every RESUME.
36− refresh()
37− }
38−
3931 fun refresh() {
40− // Cancel any in-flight load so out-of-order completions can't overwrite a newer alert
41− // list with a stale one (init load, pull-to-refresh and deleteAlert->refresh can overlap).
42− refreshJob?.cancel()
43− refreshJob = viewModelScope.launch {
32+ viewModelScope.launch {
4433 _state.update { it.copy(isLoading = true, error = null) }
45− loadAlerts()
46− }
47− }
48−
49− /**
50− * Fetches the alert list and writes a terminal state update (clearing isLoading). Callers set
51− * isLoading = true before invoking. Extracted so deleteAlert can reload within its own
52− * (never-cancelled) job rather than delegating to refresh(), whose refreshJob can be cancelled
53− * by a concurrent refresh before it clears the spinner.
54− */
55− private suspend fun loadAlerts() {
56− when (val result = repository.listAlerts()) {
57− is ApiResult.Success -> {
58− // Sort by the same field the UI shows (displayName = title ?: name), otherwise
59− // alerts that carry both fields, or a mix of per-stream and global formats,
60− // render in an order that doesn't match their visible labels.
61− val sorted = result.data.sortedBy { it.displayName.lowercase() }
62− _state.update { it.copy(alerts = sorted, isLoading = false) }
63− }
64− is ApiResult.Error -> {
65− _state.update { it.copy(isLoading = false, error = result.userMessage) }
34+ when (val result = repository.listAlerts()) {
35+ is ApiResult.Success -> {
36+ val sorted = result.data.sortedBy { a ->
37+ (a.name ?: a.title ?: "").lowercase()
38+ }
39+ _state.update { it.copy(alerts = sorted, isLoading = false) }
40+ }
41+ is ApiResult.Error -> {
42+ _state.update { it.copy(isLoading = false, error = result.userMessage) }
43+ }
6644 }
6745 }
6846 }
@@ -75,19 +53,11 @@ class AlertsViewModel @Inject constructor(
7553 _state.update { it.copy(alertToDelete = null) }
7654 }
7755
78− private var deleteJob: Job? = null
79−
8056 fun deleteAlert(alertId: String) {
81− // Guard against a double-tap on the confirm button firing two concurrent DELETEs.
82− if (deleteJob?.isActive == true) return
83− deleteJob = viewModelScope.launch {
57+ viewModelScope.launch {
8458 _state.update { it.copy(alertToDelete = null, isLoading = true, error = null) }
8559 when (val result = repository.deleteAlert(alertId)) {
86− // Reload inline rather than via refresh(): refresh() runs on the cancellable
87− // refreshJob, so a pull-to-refresh landing right after could cancel it before it
88− // clears isLoading, leaving the spinner stuck. This job is never cancelled by a
89− // refresh, so its terminal state update always runs.
90− is ApiResult.Success -> loadAlerts()
60+ is ApiResult.Success -> refresh()
9161 is ApiResult.Error -> {
9262 _state.update { it.copy(isLoading = false, error = result.userMessage) }
9363 }
app/src/main/java/com/parseable/android/ui/screens/login/LoginScreen.kt+8−55
@@ -40,8 +40,8 @@ fun LoginScreen(
4040 var passwordVisible by rememberSaveable { mutableStateOf(false) }
4141 val errorHandler = LocalErrorHandler.current
4242
43− LaunchedEffect(Unit) {
44− viewModel.loginSuccessEvent.collect {
43+ LaunchedEffect(state.loginSuccess) {
44+ if (state.loginSuccess) {
4545 onLoginSuccess()
4646 }
4747 }
@@ -178,7 +178,12 @@ fun LoginScreen(
178178
179179 if (state.allowInsecure) {
180180 val isPrivateNetwork = remember(state.serverUrl) {
181− isPrivateHost(extractHost(state.serverUrl))
181+ val host = state.serverUrl
182+ .removePrefix("http://").removePrefix("https://")
183+ .substringBefore("/").substringBefore(":")
184+ host.isBlank() || host == "localhost" || host.startsWith("127.") ||
185+ host.startsWith("10.") || host.startsWith("192.168.") ||
186+ host.matches(Regex("^172\\.(1[6-9]|2[0-9]|3[01])\\..*"))
182187 }
183188 if (!isPrivateNetwork) {
184189 Card(
@@ -250,55 +255,3 @@ fun LoginScreen(
250255 }
251256 }
252257}
253−
254−/**
255− * Extract the bare host from a possibly-schemeless, possibly-ported URL, handling
256− * IPv6 literals (`[::1]:8000`) and bare IPv6 addresses correctly.
257− */
258−internal fun extractHost(url: String): String {
259− // Drop the scheme if present, then the path.
260− var hostPort = url.substringAfter("://").substringBefore("/")
261− return if (hostPort.startsWith("[")) {
262− // Bracketed IPv6 literal, e.g. "[::1]:8000" -> "::1"
263− hostPort.substringAfter("[").substringBefore("]")
264− } else {
265− // Strip ":port" only when there's a single colon (host:port). A bare IPv6
266− // address has multiple colons and no brackets, so leave it intact.
267− if (hostPort.count { it == ':' } == 1) hostPort.substringBefore(":") else hostPort
268− }
269−}
270−
271−/**
272− * Whether [host] is a loopback/private-range address. Used to decide whether the
273− * plaintext-HTTP warning is needed. Matches actual IPv4 octet ranges (not string
274− * prefixes, so "10.example.com" is correctly treated as public) and IPv6
275− * loopback/link-local/unique-local addresses.
276− */
277−internal fun isPrivateHost(host: String): Boolean {
278− if (host.isBlank()) return true
279− val h = host.lowercase()
280− if (h == "localhost" || h.endsWith(".localhost")) return true
281−
282− // IPv6 ranges (only meaningful for actual IPv6 literals, which contain ':').
283− if (h.contains(":")) {
284− if (h == "::1") return true // loopback
285− if (h.startsWith("fe80:")) return true // link-local fe80::/10
286− if (h.startsWith("fc") || h.startsWith("fd")) return true // unique-local fc00::/7
287− return false
288− }
289−
290− // IPv4: require four numeric octets in 0..255, then match private ranges.
291− val octets = h.split(".")
292− if (octets.size == 4 && octets.all { val n = it.toIntOrNull(); n != null && n in 0..255 }) {
293− val a = octets[0].toInt()
294− val b = octets[1].toInt()
295− return when {
296− a == 10 -> true // 10.0.0.0/8
297− a == 127 -> true // loopback 127.0.0.0/8
298− a == 192 && b == 168 -> true // 192.168.0.0/16
299− a == 172 && b in 16..31 -> true // 172.16.0.0/12
300− else -> false
301− }
302− }
303− return false
304−}
app/src/main/java/com/parseable/android/ui/screens/login/LoginViewModel.kt+12−88
@@ -7,12 +7,10 @@ import com.parseable.android.data.model.ServerConfig
77import com.parseable.android.data.repository.ParseableRepository
88import com.parseable.android.data.repository.SettingsRepository
99import dagger.hilt.android.lifecycle.HiltViewModel
10−import kotlinx.coroutines.channels.Channel
1110import kotlinx.coroutines.flow.MutableStateFlow
1211import kotlinx.coroutines.flow.StateFlow
1312import kotlinx.coroutines.flow.asStateFlow
1413import kotlinx.coroutines.flow.first
15−import kotlinx.coroutines.flow.receiveAsFlow
1614import kotlinx.coroutines.flow.update
1715import kotlinx.coroutines.launch
1816import javax.inject.Inject
@@ -24,6 +22,7 @@ data class LoginState(
2422 val allowInsecure: Boolean = false,
2523 val isLoading: Boolean = false,
2624 val error: String? = null,
25+ val loginSuccess: Boolean = false,
2726 val serverUrlError: String? = null,
2827 val usernameError: String? = null,
2928 val passwordError: String? = null,
@@ -39,15 +38,6 @@ class LoginViewModel @Inject constructor(
3938 private val _state = MutableStateFlow(LoginState())
4039 val state: StateFlow<LoginState> = _state.asStateFlow()
4140
42− /**
43− * Emitted exactly once when login succeeds so the screen navigates away. A one-shot [Channel]
44− * (like the codebase's other nav events) rather than a retained StateFlow flag: a retained
45− * `loginSuccess = true` would re-fire navigation on any later recomposition that re-collects
46− * the state if this ViewModel instance outlived the first navigation.
47− */
48− private val _loginSuccessEvent = Channel<Unit>(Channel.BUFFERED)
49− val loginSuccessEvent = _loginSuccessEvent.receiveAsFlow()
50−
5141 init {
5242 viewModelScope.launch {
5343 val config = settingsRepository.serverConfig.first()
@@ -65,39 +55,9 @@ class LoginViewModel @Inject constructor(
6555 }
6656
6757 fun loginWithSavedCredentials() {
68− // Guard against a second tap (or a concurrent onLogin()) launching a duplicate
69− // login. Set isLoading synchronously so the guard is effective before the
70− // coroutine starts.
71− if (_state.value.isLoading) return
72− _state.update { it.copy(isLoading = true, error = null) }
7358 viewModelScope.launch {
74− val config = settingsRepository.getSavedPassword()
75− if (config == null) {
76− // URL/username are present but the saved password can't be read (e.g. the
77− // Keystore entry was lost). Surface this instead of silently stopping the
78− // spinner, and hide the now-useless "use saved credentials" button.
79− _state.update {
80− it.copy(
81− isLoading = false,
82− hasSavedCredentials = false,
83− error = "Saved credentials could not be read. Please log in again.",
84− )
85− }
86− return@launch
87− }
88− // Restore the saved URL/username so the saved password is paired with the
89− // server/user it was saved for, even if the user edited those fields.
90− _state.update {
91− it.copy(
92− serverUrl = config.serverUrl,
93− username = config.username,
94− allowInsecure = !config.useTls,
95− serverUrlError = null,
96− usernameError = null,
97− passwordError = null,
98− )
99− }
100− performLogin(config.serverUrl, config.username, config.password, !config.useTls)
59+ val config = settingsRepository.getSavedPassword() ?: return@launch
60+ performLogin(config.password)
10161 }
10262 }
10363
@@ -120,15 +80,6 @@ class LoginViewModel @Inject constructor(
12080 fun onLogin() {
12181 val current = _state.value
12282
123− // With saved credentials the password field is intentionally left empty (the user is
124− // expected to tap "Use saved credentials"). Tapping the primary Connect button in that
125− // state should do the obvious thing — log in with the saved password — rather than
126− // reject with "Password is required".
127− if (current.password.isBlank() && current.hasSavedCredentials) {
128− loginWithSavedCredentials()
129− return
130− }
131−
13283 // Validate
13384 var hasError = false
13485 var urlError: String? = null
@@ -159,34 +110,19 @@ class LoginViewModel @Inject constructor(
159110 return
160111 }
161112
162− // Guard against a duplicate in-flight login and set isLoading synchronously so a
163− // fast second tap can't launch a concurrent performLogin that races on _state.
164− if (current.isLoading) return
165− _state.update { it.copy(isLoading = true, error = null) }
166− // Pass every field from the same snapshot so the validated password can't be
167− // paired with a URL/username the user edited after tapping.
168113 viewModelScope.launch {
169− performLogin(current.serverUrl, current.username, current.password, current.allowInsecure)
114+ performLogin(current.password)
170115 }
171116 }
172117
173− private suspend fun performLogin(
174− serverUrl: String,
175− username: String,
176− password: String,
177− allowInsecure: Boolean,
178− ) {
118+ private suspend fun performLogin(password: String) {
119+ val current = _state.value
179120 _state.update { it.copy(isLoading = true, error = null) }
180121
181− // Strip trailing slashes so the persisted URL matches the canonical form the API
182− // client uses (ParseableApiClient.configure also does trimEnd('/')). Without this,
183− // "http://host:8000" and "http://host:8000/" persist as distinct strings, defeating
184− // saveServer's (serverUrl, username) dedup and the UNIQUE index — the same server
185− // ends up as two saved-server rows.
186− var url = serverUrl.trim().trimEnd('/')
122+ var url = current.serverUrl.trim()
187123 val urlLower = url.lowercase()
188124 if (!urlLower.startsWith("http://") && !urlLower.startsWith("https://")) {
189− url = if (allowInsecure) "http://$url" else "https://$url"
125+ url = if (current.allowInsecure) "http://$url" else "https://$url"
190126 }
191127
192128 val isValidUrl = try {
@@ -207,7 +143,7 @@ class LoginViewModel @Inject constructor(
207143
208144 val config = ServerConfig(
209145 serverUrl = url,
210− username = username.trim(),
146+ username = current.username.trim(),
211147 password = password,
212148 useTls = url.lowercase().startsWith("https"),
213149 )
@@ -220,21 +156,9 @@ class LoginViewModel @Inject constructor(
220156 // Uses verifyServer() to avoid triggering the global auth-error handler.
221157 when (val aboutResult = repository.verifyServer()) {
222158 is ApiResult.Success -> {
223− // Only report success if the credentials were durably persisted.
224− // Otherwise the app would navigate in and clear the typed password,
225− // yet bounce back to login on next launch with nothing to restore.
226− if (settingsRepository.saveServerConfig(config)) {
227− settingsRepository.saveServer(config)
228− _state.update { it.copy(isLoading = false, password = "") }
229− _loginSuccessEvent.send(Unit)
230− } else {
231− _state.update {
232− it.copy(
233− isLoading = false,
234− error = "Connected, but your credentials couldn't be saved securely on this device. Please try again.",
235− )
236− }
237− }
159+ settingsRepository.saveServerConfig(config)
160+ settingsRepository.saveServer(config)
161+ _state.update { it.copy(isLoading = false, loginSuccess = true, password = "") }
238162 }
239163 is ApiResult.Error -> {
240164 val errorMsg = if (aboutResult.isUnauthorized) {
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.kt+33−82
@@ -101,10 +101,6 @@ fun LogViewerScreen(
101101 actions = {
102102 StreamingToggleButton(
103103 isStreaming = state.isStreaming,
104− // Live-tail re-derives its own default SELECT * query in the poller, so
105− // it can't honor a custom SQL projection/filter. Disable the toggle while
106− // a custom query is active to avoid prepending mismatched rows.
107− enabled = state.customSql.isBlank(),
108104 onClick = viewModel::toggleStreaming,
109105 )
110106 IconButton(onClick = { onStreamInfo(streamName) }) {
@@ -171,9 +167,7 @@ fun LogViewerScreen(
171167 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
172168 keyboardActions = KeyboardActions(onSearch = {
173169 keyboardController?.hide()
174− // Runs the search now and cancels the pending debounce (which stops live-tail
175− // and refreshes) so we don't fire a second, redundant refresh ~300ms later.
176− viewModel.submitSearch()
170+ viewModel.refresh()
177171 }),
178172 modifier = Modifier
179173 .fillMaxWidth()
@@ -287,41 +281,9 @@ fun LogViewerScreen(
287281 // Log entries
288282 PullToRefreshBox(
289283 isRefreshing = state.isLoading,
290− onRefresh = viewModel::pullRefresh,
284+ onRefresh = viewModel::refresh,
291285 modifier = Modifier.fillMaxSize(),
292286 ) {
293− // Hoisted above the error/loading/empty/list branches so the scroll position,
294− // the clipboard handle, and the viewing-top / load-more effects survive
295− // transitions through those states. If they lived inside the list `else` branch
296− // they would be disposed and recreated on every empty/loading/error transition,
297− // resetting scroll to the top and re-arming loadMore mid-load.
298− val listState = rememberLazyListState()
299− val clipboard = remember {
300− context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
301− }
302−
303− // Tell the ViewModel when the list is at the top so it can clear the live-tail
304− // "+N new" badge — new rows prepend at the top and are seen immediately there.
305− val atTop by remember {
306− derivedStateOf {
307− listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
308− }
309− }
310− LaunchedEffect(atTop) { viewModel.setViewingTop(atTop) }
311−
312− // Auto-load more when scrolling near the bottom.
313− val shouldLoadMore by remember {
314− derivedStateOf {
315− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
316− lastVisible >= listState.layoutInfo.totalItemsCount - 5
317− }
318− }
319− LaunchedEffect(shouldLoadMore, state.hasMore, state.isLoading) {
320− if (shouldLoadMore && state.hasMore && !state.isLoading) {
321− viewModel.loadMore()
322− }
323− }
324−
325287 if (state.error != null && state.logs.isEmpty()) {
326288 Box(
327289 modifier = Modifier.fillMaxSize(),
@@ -388,6 +350,24 @@ fun LogViewerScreen(
388350 }
389351 }
390352 } else {
353+ val listState = rememberLazyListState()
354+ val clipboard = remember {
355+ context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
356+ }
357+
358+ // Auto-load more when scrolling near the bottom
359+ if (state.hasMore && !state.isLoading) {
360+ val shouldLoadMore by remember {
361+ derivedStateOf {
362+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
363+ lastVisible >= listState.layoutInfo.totalItemsCount - 5
364+ }
365+ }
366+ LaunchedEffect(shouldLoadMore) {
367+ if (shouldLoadMore) viewModel.loadMore()
368+ }
369+ }
370+
391371 LazyColumn(
392372 state = listState,
393373 contentPadding = PaddingValues(8.dp),
@@ -400,24 +380,19 @@ fun LogViewerScreen(
400380 val key = state.logKeys.getOrElse(index) { "log_$index" }
401381 LogEntryCard(
402382 logEntry = logEntry,
403− stableKey = key,
404383 isExpanded = expandedLogKey == key,
405384 onClick = {
406385 if (expandedLogKey == key) {
407386 // Collapsing: if the top of the entry is scrolled
408387 // above the viewport, jump to it so the user sees
409388 // the collapsed card instead of a random position.
410− // Resolve the row by its stable key, not the captured
411− // index — live-tail prepends shift every row's index,
412− // so the closed-over index can point at the wrong row.
413389 val itemInfo = listState.layoutInfo.visibleItemsInfo
414− .firstOrNull { it.key == key }
390+ .firstOrNull { it.index == index }
415391 val needsScroll = itemInfo == null || itemInfo.offset < 0
416392 expandedLogKey = null
417393 if (needsScroll) {
418− val target = state.logKeys.indexOf(key).takeIf { it >= 0 } ?: index
419394 scope.launch {
420− listState.animateScrollToItem(target)
395+ listState.animateScrollToItem(index)
421396 }
422397 }
423398 } else {
@@ -534,18 +509,10 @@ fun LogViewerScreen(
534509 if (!dir.mkdirs() && !dir.isDirectory) {
535510 throw java.io.IOException("Failed to create shared_logs directory")
536511 }
537− // Clean up STALE exports only. A share chooser opened moments
538− // ago may still be reading a recent file via its FileProvider
539− // URI; deleting every file (as before) could pull one out from
540− // under an in-flight share. Keep anything written in the last
541− // hour and give each export a unique name so concurrent shares
542− // never collide.
543− val cutoff = System.currentTimeMillis() - 60 * 60 * 1000L
544− dir.listFiles()?.forEach { old ->
545− if (old.lastModified() < cutoff) old.delete()
546− }
512+ // Clean up previous exports
513+ dir.listFiles()?.forEach { old -> old.delete() }
547514 val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
548− val file = java.io.File(dir, "logs_${safeFileName}_${System.currentTimeMillis()}.json")
515+ val file = java.io.File(dir, "logs_$safeFileName.json")
549516 file.bufferedWriter().use { writer ->
550517 // Use compact JSON for large exports to reduce memory/file size
551518 val encoder = if (logs.size > 1000) Json else prettyJson
@@ -607,18 +574,17 @@ fun LogViewerScreen(
607574 val start = dateRangePickerState.selectedStartDateMillis
608575 val end = dateRangePickerState.selectedEndDateMillis
609576 if (start != null && end != null) {
610− // DateRangePicker returns midnight UTC for the selected date, so the
611− // calendar date must be read back in UTC — reading it in the local zone
612− // would roll west-of-UTC users to the previous day. Once we have the
613− // tapped date, build the boundaries to cover the full day locally.
577+ // DateRangePicker returns midnight UTC for the selected date.
578+ // Adjust to cover the full end day in the user's local timezone:
579+ // Convert to local date, get end-of-day, convert back to UTC millis.
614580 val localZone = java.time.ZoneId.systemDefault()
615581 val endLocalDate = java.time.Instant.ofEpochMilli(end)
616− .atZone(java.time.ZoneOffset.UTC).toLocalDate()
582+ .atZone(localZone).toLocalDate()
617583 val endOfDayUtc = endLocalDate.plusDays(1)
618584 .atStartOfDay(localZone)
619585 .toInstant().toEpochMilli() - 1
620586 val startLocalDate = java.time.Instant.ofEpochMilli(start)
621− .atZone(java.time.ZoneOffset.UTC).toLocalDate()
587+ .atZone(localZone).toLocalDate()
622588 val startUtc = startLocalDate
623589 .atStartOfDay(localZone)
624590 .toInstant().toEpochMilli()
@@ -701,10 +667,9 @@ private fun TimeRangeBar(
701667@Composable
702668private fun StreamingToggleButton(
703669 isStreaming: Boolean,
704− enabled: Boolean,
705670 onClick: () -> Unit,
706671) {
707− IconButton(onClick = onClick, enabled = enabled || isStreaming) {
672+ IconButton(onClick = onClick) {
708673 if (isStreaming) {
709674 val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
710675 val alpha by infiniteTransition.animateFloat(
@@ -735,7 +700,6 @@ private fun StreamingToggleButton(
735700@Composable
736701fun LogEntryCard(
737702 logEntry: JsonObject,
738− stableKey: String,
739703 isExpanded: Boolean,
740704 onClick: () -> Unit,
741705 onCopied: () -> Unit = {},
@@ -754,9 +718,7 @@ fun LogEntryCard(
754718 ) {
755719 Column(modifier = Modifier.padding(12.dp)) {
756720 // Timestamp row
757− // Key the derived values on the content-stable log key (not the JsonObject
758− // instance), so live-tail re-emissions of unchanged rows don't re-parse them.
759− val rawTimestamp = remember(stableKey) {
721+ val rawTimestamp = remember(logEntry) {
760722 try {
761723 logEntry["p_timestamp"]?.jsonPrimitive?.content
762724 ?: logEntry["datetime"]?.jsonPrimitive?.content
@@ -781,7 +743,7 @@ fun LogEntryCard(
781743
782744 if (!isExpanded) {
783745 // Compact view: show first meaningful fields
784− val preview = remember(stableKey) {
746+ val preview = remember(logEntry) {
785747 logEntry.entries
786748 .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
787749 .take(3)
@@ -896,17 +858,6 @@ fun FilterBottomSheet(
896858 onApplyFilter: (column: String, operator: String, value: String) -> Unit,
897859) {
898860 var selectedColumn by remember { mutableStateOf(columns.firstOrNull() ?: "") }
899− // The filter icon is always enabled, so the sheet can open before the schema (and thus
900− // `columns`) has loaded. In that case `selectedColumn` initializes to "" and — because the
901− // remember above isn't keyed on `columns` — would stay blank even after columns arrive,
902− // leaving the Column field empty and the Apply button permanently disabled. Backfill the
903− // default once columns are available, but only while the selection is still blank so a
904− // user's manual pick isn't clobbered by a late schema refresh.
905− LaunchedEffect(columns) {
906− if (selectedColumn.isBlank()) {
907− selectedColumn = columns.firstOrNull() ?: ""
908− }
909− }
910861 var selectedOperator by remember { mutableStateOf("=") }
911862 var filterValue by remember { mutableStateOf("") }
912863 var columnDropdownExpanded by remember { mutableStateOf(false) }
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerViewModel.kt+112−385
@@ -3,7 +3,6 @@ package com.parseable.android.ui.screens.logviewer
33import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
55import com.parseable.android.data.escapeIdentifier
6−import com.parseable.android.data.escapeLikePattern
76import com.parseable.android.data.escapeSql
87import com.parseable.android.data.model.*
98import com.parseable.android.data.repository.ParseableRepository
@@ -16,13 +15,8 @@ import kotlinx.coroutines.flow.asStateFlow
1615import kotlinx.coroutines.flow.update
1716import kotlinx.coroutines.isActive
1817import kotlinx.coroutines.launch
19−import kotlinx.coroutines.sync.Mutex
20−import kotlinx.coroutines.sync.withLock
2118import kotlinx.serialization.json.JsonObject
2219import kotlinx.serialization.json.jsonPrimitive
23−import java.time.Instant
24−import java.time.LocalDateTime
25−import java.time.OffsetDateTime
2620import java.time.ZoneOffset
2721import java.time.ZonedDateTime
2822import java.time.format.DateTimeFormatter
@@ -40,21 +34,10 @@ enum class TimeRange(val label: String, val minutes: Long) {
4034 CUSTOM("Custom", 0),
4135}
4236
43−/** The original (column, operator, value) a filter chip was built from. */
44−data class FilterCondition(
45− val column: String,
46− val operator: String,
47− val value: String,
48−)
49−
5037data class FilterState(
5138 val searchQuery: String = "",
5239 val activeFilters: List<String> = emptyList(),
5340 val filterClauses: List<String> = emptyList(),
54− // Structured form of each chip, index-aligned with activeFilters/filterClauses.
55− // Kept so saved filters reconstruct from real values instead of re-parsing the
56− // human-readable display string (which is ambiguous when a value contains an operator).
57− val filterConditions: List<FilterCondition> = emptyList(),
5841 val customSql: String = "",
5942 val isSearching: Boolean = false,
6043)
@@ -89,10 +72,6 @@ data class LogViewerState(
8972 val hasMore: Boolean = false,
9073 val customStartTime: Long? = null,
9174 val customEndTime: Long? = null,
92− // Upper time bound (epoch millis) fixed at the first page of a paging session, so loadMore()
93− // doesn't slide a relative ("last N minutes") window forward as it re-queries. Null until the
94− // first refresh of a session; re-anchored whenever currentLimit resets to the initial page.
95− val pageAnchorTime: Long? = null,
9675) {
9776 // Convenience accessors for backward compatibility with Screen
9877 val searchQuery: String get() = filters.searchQuery
@@ -120,29 +99,13 @@ class LogViewerViewModel @Inject constructor(
12099 @Volatile private var streamingGeneration: Int = 0
121100 @Volatile private var lastSeenTimestamp: String? = null
122101 @Volatile private var consecutiveStreamingErrors: Int = 0
123− // True while the log list is scrolled to the top. New rows prepend at the top, so when the
124− // user is already there they see them live and the "+N new" badge must stay at 0. The badge
125− // only accumulates while the user is scrolled away, and resets when they return to the top.
126− @Volatile private var viewingTop: Boolean = true
127−
128− // Identities of rows already surfaced during the current streaming session. Used to dedupe
129− // re-fetched boundary rows. Kept INDEPENDENT of the displayed `logs` list — which is capped
130− // at STREAMING_MAX_LOGS — so that under sustained high-volume streaming, boundary rows
131− // evicted from the view are still recognized as duplicates and not prepended twice.
132− // Insertion-ordered so the oldest keys are trimmed first. Touched only from the single
133− // streaming coroutine and from startStreaming (which first cancels that coroutine).
134− private val seenStreamingKeys = LinkedHashSet<String>()
135102 private var searchJob: Job? = null
136103 private var schemaJob: Job? = null
137− private var refreshJob: Job? = null
138104
139105 companion object {
140106 private const val STREAMING_BASE_INTERVAL_MS = 3000L
141107 private const val STREAMING_MAX_INTERVAL_MS = 30000L
142108 private const val STREAMING_MAX_LOGS = 1000
143− // Remember more identities than the display cap so a row evicted from the view is still
144− // deduped on a later re-fetch. 2x the display cap covers the worst realistic overlap.
145− private const val SEEN_STREAMING_KEYS_MAX = STREAMING_MAX_LOGS * 2
146109 private const val MAX_LOAD_LIMIT = 5000
147110 private const val LOAD_MORE_INCREMENT = 500
148111 private const val MAX_STREAMING_ERRORS = 5
@@ -155,13 +118,10 @@ class LogViewerViewModel @Inject constructor(
155118 internal fun computeLogKeys(logs: List<JsonObject>): List<String> {
156119 val seen = mutableMapOf<String, Int>()
157120 return logs.map { log ->
158− // Key on the full row content (same identity used for streaming dedup, see
159− // logIdentity). Keying on only p_timestamp/p_metadata/p_tags collided for rows
160− // sharing those fields but differing in body, so the positional tiebreaker made
161− // keys shift when streaming prepended rows — corrupting expanded state and
162− // letting remember(stableKey) blocks render a stale body. The trailing counter
163− // only disambiguates genuinely identical rows, whose rendering is identical too.
164− val base = log.toString()
121+ val ts = log["p_timestamp"]?.toString()
122+ val meta = log["p_metadata"]?.toString()
123+ val tag = log["p_tags"]?.toString()
124+ val base = if (ts != null) "$ts|${meta.orEmpty()}|${tag.orEmpty()}" else log.hashCode().toString()
165125 val count = seen.getOrDefault(base, 0)
166126 seen[base] = count + 1
167127 if (count == 0) base else "$base|$count"
@@ -218,11 +178,7 @@ class LogViewerViewModel @Inject constructor(
218178 }
219179
220180 fun onSearchQueryChange(query: String) {
221− // A new query invalidates the live-tail baseline and the paged-up limit, matching
222− // the time-range / custom-SQL handlers.
223− stopStreaming()
224− // Searching returns to the filter-builder query path, so drop any active custom SQL.
225− _state.update { it.copy(currentLimit = 500, filters = it.filters.copy(searchQuery = query, customSql = "", isSearching = query.isNotBlank())) }
181+ _state.update { it.copy(filters = it.filters.copy(searchQuery = query, isSearching = query.isNotBlank())) }
226182 searchJob?.cancel()
227183 searchJob = viewModelScope.launch {
228184 delay(300)
@@ -231,17 +187,6 @@ class LogViewerViewModel @Inject constructor(
231187 }
232188 }
233189
234− /**
235− * Run the current search immediately (e.g. the IME "Search" action). Cancels the pending
236− * debounce so it can't fire a second, redundant refresh ~300ms later.
237− */
238− fun submitSearch() {
239− searchJob?.cancel()
240− _state.update { it.copy(filters = it.filters.copy(isSearching = false)) }
241− stopStreaming()
242− refresh()
243− }
244−
245190 fun addFilter(column: String, operator: String, value: String) {
246191 addFilterInternal(column, operator, value)
247192 refresh()
@@ -250,33 +195,21 @@ class LogViewerViewModel @Inject constructor(
250195 private fun addFilterInternal(column: String, operator: String, value: String) {
251196 if (operator !in ALLOWED_OPERATORS) return
252197 val safeColumn = escapeIdentifier(column)
198+ val safeValue = escapeSql(value)
253199 val clause = when (operator) {
254200 "IS NULL", "IS NOT NULL" -> "\"$safeColumn\" $operator"
255− // %/_ in the user's value must match literally, so escape the LIKE wildcards
256− // and declare the escape character. Exact comparisons keep %/_ verbatim.
257− "LIKE", "ILIKE" -> "\"$safeColumn\" $operator '%${escapeLikePattern(value)}%' ESCAPE '\\'"
258− else -> "\"$safeColumn\" $operator '${escapeSql(value)}'"
259− }
260− // IS NULL / IS NOT NULL ignore the value in both the clause and the display, so store
261− // an empty value in the structured condition too (keeps saved filters clean).
262− val conditionValue = when (operator) {
263− "IS NULL", "IS NOT NULL" -> ""
264− else -> value
201+ "LIKE", "ILIKE" -> "\"$safeColumn\" $operator '%$safeValue%'"
202+ else -> "\"$safeColumn\" $operator '$safeValue'"
265203 }
266204 val display = when (operator) {
267205 "IS NULL", "IS NOT NULL" -> "$column $operator"
268206 else -> "$column $operator $value"
269207 }
270− // A new filter invalidates the live-tail baseline and the paged-up limit.
271− stopStreaming()
272208 _state.update {
273209 it.copy(
274− currentLimit = 500,
275210 filters = it.filters.copy(
276211 filterClauses = it.filters.filterClauses + clause,
277212 activeFilters = it.filters.activeFilters + display,
278− filterConditions = it.filters.filterConditions + FilterCondition(column, operator, conditionValue),
279− customSql = "",
280213 ),
281214 )
282215 }
@@ -289,93 +222,81 @@ class LogViewerViewModel @Inject constructor(
289222 if (index >= 0 && index < it.filters.filterClauses.size) {
290223 removed = true
291224 it.copy(
292− currentLimit = 500,
293225 filters = it.filters.copy(
294226 activeFilters = it.filters.activeFilters.filterIndexed { i, _ -> i != index },
295227 filterClauses = it.filters.filterClauses.filterIndexed { i, _ -> i != index },
296− filterConditions = it.filters.filterConditions.filterIndexed { i, _ -> i != index },
297− customSql = "",
298228 ),
299229 )
300230 } else {
301231 it
302232 }
303233 }
304− if (removed) {
305− // A changed filter set invalidates the live-tail baseline.
306− stopStreaming()
307− refresh()
308− }
234+ if (removed) refresh()
309235 }
310236
311237 fun clearFilters() {
312− stopStreaming()
313238 _state.update {
314− it.copy(currentLimit = 500, filters = it.filters.copy(activeFilters = emptyList(), filterClauses = emptyList(), filterConditions = emptyList(), customSql = "", searchQuery = "", isSearching = false))
239+ it.copy(filters = it.filters.copy(activeFilters = emptyList(), filterClauses = emptyList()))
315240 }
316241 refresh()
317242 }
318243
319244 fun executeCustomSql(sql: String) {
320− // Custom SQL changes the query basis, so cancel any pending search debounce that would
321− // otherwise fire a redundant refresh() ~300ms later. (Unlike the filter/time-range
322− // handlers, this path doesn't route through stopStreaming().)
323− searchJob?.cancel()
324− // Drop a trailing statement terminator before we append anything — otherwise
325− // "SELECT * FROM x;" would become "SELECT * FROM x; LIMIT 5000", a syntax error.
326− val trimmed = sql.trim().trimEnd(';').trim()
245+ val trimmed = sql.trim()
327246 if (!trimmed.uppercase().startsWith("SELECT")) {
328247 _state.update { it.copy(error = "Only SELECT queries are allowed") }
329248 return
330249 }
331− // Enforce a LIMIT to prevent OOM from unbounded queries. Only honor a *top-level*
332− // `LIMIT <n>` (optionally with OFFSET) anchored at the end of the statement — a
333− // LIMIT inside a subquery doesn't bound the outer result set, so matching it
334− // anywhere would let an unbounded outer query slip past the safety limit. Anchoring
335− // at the end also means a string literal like 'LIMIT EXCEEDED' can't disable it.
336− val hasTopLevelLimit =
337− Regex("(?i)\\bLIMIT\\s+\\d+(\\s+OFFSET\\s+\\d+)?\\s*$").containsMatchIn(trimmed)
338− val safeSql = if (!hasTopLevelLimit) {
250+ // Enforce a LIMIT to prevent OOM from unbounded queries
251+ val safeSql = if (!trimmed.uppercase().contains("LIMIT")) {
339252 "$trimmed LIMIT $MAX_LOAD_LIMIT"
340253 } else {
341254 trimmed
342255 }
343− // Store the custom SQL and let refresh() run it. Keeping it in state means
344− // pull-to-refresh / time-range changes re-run the custom query instead of
345− // silently replacing it with the default filter query.
346256 _state.update { it.copy(filters = it.filters.copy(customSql = safeSql), currentLimit = 500) }
347− refresh()
257+ viewModelScope.launch {
258+ _state.update { it.copy(isLoading = true, error = null) }
259+
260+ val (startTime, endTime) = getTimeRange()
261+ when (val result = repository.queryLogsRaw(safeSql, startTime, endTime)) {
262+ is ApiResult.Success -> {
263+ val keys = computeLogKeys(result.data)
264+ _state.update {
265+ it.copy(
266+ logs = result.data,
267+ logKeys = keys,
268+ isLoading = false,
269+ hasMore = result.data.size >= it.currentLimit,
270+ )
271+ }
272+ }
273+ is ApiResult.Error -> {
274+ _state.update {
275+ it.copy(isLoading = false, error = result.userMessage)
276+ }
277+ }
278+ }
279+ }
348280 }
349281
350282 private fun buildSearchClause(searchableColumns: List<String>, searchQuery: String): String? {
351283 if (searchQuery.isBlank()) return null
352284 if (searchableColumns.isEmpty()) return null
353− val safeSearch = escapeLikePattern(searchQuery)
285+ val safeSearch = escapeSql(searchQuery)
354286 return searchableColumns.joinToString(" OR ") {
355− "CAST(\"${escapeIdentifier(it)}\" AS VARCHAR) ILIKE '%$safeSearch%' ESCAPE '\\'"
287+ "CAST(\"${escapeIdentifier(it)}\" AS VARCHAR) ILIKE '%$safeSearch%'"
356288 }
357289 }
358290
359291 fun refresh() {
360292 if (_state.value.streamName.isEmpty()) return
361293
362− // Cancel any in-flight query so out-of-order completions can't overwrite
363− // fresh results with stale ones (filter/search/time-range/loadMore all call refresh()).
364− refreshJob?.cancel()
365− refreshJob = viewModelScope.launch {
366− // Anchor the upper time bound on the first page of a paging session (currentLimit at
367− // its initial value) and reuse it for subsequent loadMore() pages. Otherwise a relative
368− // range would recompute "now" on every page in getTimeRange(), sliding the window and
369− // duplicating/dropping rows on an actively-ingesting stream.
370− val anchor = _state.value.let { s ->
371− if (s.currentLimit <= 500) System.currentTimeMillis() else s.pageAnchorTime
372− }
294+ viewModelScope.launch {
373295 _state.update {
374296 it.copy(
375297 isLoading = true,
376298 error = null,
377299 streaming = it.streaming.copy(streamingError = null),
378− pageAnchorTime = anchor,
379300 )
380301 }
381302
@@ -383,20 +304,6 @@ class LogViewerViewModel @Inject constructor(
383304 val current = _state.value
384305 val (startTime, endTime) = getTimeRange()
385306
386− // A custom SQL query owns the whole statement (its own LIMIT, columns, ordering),
387− // so re-run it verbatim. It is not paginated via loadMore(), hence hasMore = false.
388− if (current.filters.customSql.isNotBlank()) {
389− when (val result = repository.queryLogsRaw(current.filters.customSql, startTime, endTime)) {
390− is ApiResult.Success -> {
391− _state.update { it.copy(logs = result.data, logKeys = computeLogKeys(result.data), isLoading = false, hasMore = false) }
392− }
393− is ApiResult.Error -> {
394− _state.update { it.copy(isLoading = false, error = result.userMessage) }
395− }
396− }
397− return@launch
398− }
399−
400307 // Build WHERE clause from filters + search
401308 val clauses = current.filters.filterClauses.toMutableList()
402309 if (current.filters.searchQuery.isNotBlank()) {
@@ -413,26 +320,22 @@ class LogViewerViewModel @Inject constructor(
413320
414321 val filterSql = clauses.joinToString(" AND ")
415322
416− // Over-fetch by one row so we can distinguish "exactly currentLimit rows exist"
417− // (no more pages) from "the page is full and more may exist" without firing a
418− // wasted extra query the next time the user scrolls to the bottom.
419− val requestLimit = current.currentLimit + 1
420323 when (val result = repository.queryLogs(
421324 stream = current.streamName,
422325 startTime = startTime,
423326 endTime = endTime,
424327 filterSql = filterSql,
425− limit = requestLimit,
328+ limit = current.currentLimit,
426329 )) {
427330 is ApiResult.Success -> {
428− val hasMore = result.data.size > current.currentLimit && current.currentLimit < MAX_LOAD_LIMIT
429− val logs = if (result.data.size > current.currentLimit) {
430− result.data.take(current.currentLimit)
431− } else {
432− result.data
433− }
331+ val keys = computeLogKeys(result.data)
434332 _state.update {
435− it.copy(logs = logs, logKeys = computeLogKeys(logs), isLoading = false, hasMore = hasMore)
333+ it.copy(
334+ logs = result.data,
335+ logKeys = keys,
336+ isLoading = false,
337+ hasMore = result.data.size >= it.currentLimit,
338+ )
436339 }
437340 }
438341 is ApiResult.Error -> {
@@ -444,29 +347,8 @@ class LogViewerViewModel @Inject constructor(
444347 }
445348 }
446349
447− /**
448− * Pull-to-refresh: restart the paging session from the first page. Resetting currentLimit
449− * makes refresh() re-anchor pageAnchorTime to "now", so a relative time range advances and
450− * newly-ingested rows appear. Calling refresh() alone keeps the frozen anchor once the user
451− * has paged past the first page, making pull-to-refresh look like it does nothing.
452− */
453− fun pullRefresh() {
454− if (_state.value.streamName.isEmpty()) return
455− // While live-tailing, the poller continuously prepends fresh rows; a concurrent refresh()
456− // would replace the list with a full page at the same time, racing the poller on logs/
457− // logKeys/streamingError and producing duplicated or dropped rows plus a stale "+N" badge.
458− // The displayed logs are already current under streaming, so the pull is a no-op here —
459− // mirror loadMore()'s guard rather than the stopStreaming() path the other mutators take.
460− if (_state.value.streaming.isStreaming) return
461− _state.update { it.copy(currentLimit = 500) }
462− refresh()
463− }
464−
465350 fun loadMore() {
466351 val current = _state.value
467− // While live-tailing, logs are prepended in memory; a refresh() here would replace
468− // them with a fresh page and drop the streamed rows. Pagination resumes once stopped.
469− if (current.streaming.isStreaming) return
470352 if (current.currentLimit >= MAX_LOAD_LIMIT) {
471353 _state.update { it.copy(hasMore = false) }
472354 return
@@ -489,9 +371,7 @@ class LogViewerViewModel @Inject constructor(
489371 )
490372 return Pair(start.format(dateFormatter), end.format(dateFormatter))
491373 }
492− val now = current.pageAnchorTime
493− ?.let { ZonedDateTime.ofInstant(java.time.Instant.ofEpochMilli(it), ZoneOffset.UTC) }
494− ?: ZonedDateTime.now(ZoneOffset.UTC)
374+ val now = ZonedDateTime.now(ZoneOffset.UTC)
495375 val start = now.minusMinutes(current.selectedTimeRange.minutes)
496376 return Pair(
497377 start.format(dateFormatter),
@@ -499,17 +379,6 @@ class LogViewerViewModel @Inject constructor(
499379 )
500380 }
501381
502− /**
503− * Report whether the log list is scrolled to the top. Clears the "+N new" streaming badge
504− * when the user returns to the top, since prepended rows there are seen immediately.
505− */
506− fun setViewingTop(atTop: Boolean) {
507− viewingTop = atTop
508− if (atTop && _state.value.streaming.streamingNewCount != 0) {
509− _state.update { it.copy(streaming = it.streaming.copy(streamingNewCount = 0)) }
510− }
511− }
512−
513382 fun toggleStreaming() {
514383 if (_state.value.streaming.isStreaming) {
515384 stopStreaming()
@@ -519,10 +388,6 @@ class LogViewerViewModel @Inject constructor(
519388 }
520389
521390 private fun startStreaming() {
522− // The poller builds its own default SELECT * query and cannot honor a custom SQL
523− // projection/filter, so streaming custom-query results would prepend mismatched rows.
524− // The UI disables the toggle in this state; guard here too in case it's reached anyway.
525− if (_state.value.filters.customSql.isNotBlank()) return
526391 stopStreaming()
527392 consecutiveStreamingErrors = 0
528393 val generation = ++streamingGeneration
@@ -530,12 +395,6 @@ class LogViewerViewModel @Inject constructor(
530395 it.copy(streaming = StreamingState(isStreaming = true, streamingNewCount = 0))
531396 }
532397
533− // Seed the dedup set with the rows already on screen so an initial row sitting exactly
534− // on the streaming baseline timestamp can't be re-fetched and duplicated.
535− seenStreamingKeys.clear()
536− _state.value.logs.forEach { seenStreamingKeys.add(logIdentity(it)) }
537− trimSeenStreamingKeys()
538−
539398 // Set the baseline timestamp to "now" so we only poll for new logs
540399 lastSeenTimestamp = ZonedDateTime.now(ZoneOffset.UTC).format(dateFormatter)
541400
@@ -558,13 +417,6 @@ class LogViewerViewModel @Inject constructor(
558417 }
559418
560419 fun stopStreaming() {
561− // Cancel any pending search debounce too. Every query-basis change (time range, filters,
562− // saved filter, clear, new search) routes through stopStreaming(); without this, a
563− // searchJob scheduled moments before such a change would still fire refresh() ~300ms
564− // later and redundantly re-query over the just-applied state. onSearchQueryChange()
565− // re-assigns searchJob immediately after calling stopStreaming(), so its own debounce
566− // survives.
567− searchJob?.cancel()
568420 streamingGeneration++
569421 streamingJob?.cancel()
570422 streamingJob = null
@@ -579,49 +431,6 @@ class LogViewerViewModel @Inject constructor(
579431 }
580432 }
581433
582− /**
583− * Content-based identity for a log row, used to dedupe re-fetched boundary logs.
584− * Uses the full row: two rows are duplicates only when every field matches, so
585− * distinct logs that happen to share a timestamp/metadata/tags are not dropped.
586− */
587− private fun logIdentity(log: JsonObject): String = log.toString()
588−
589− /**
590− * Parse a server-rendered p_timestamp (which may be ISO with 'Z', offset-carrying,
591− * space-separated, or offset-less UTC) and reformat it to the canonical query format
592− * (`dateFormatter`) so it's a valid inclusive startTime for the next poll. Returns null
593− * if the value can't be parsed, so the caller can keep the previous boundary.
594− */
595− private fun normalizeBoundaryTimestamp(raw: String): String? {
596− val isoLike = if (' ' in raw && 'T' !in raw) raw.replaceFirst(' ', 'T') else raw
597− val instant = try {
598− Instant.parse(isoLike)
599− } catch (_: Exception) {
600− try {
601− OffsetDateTime.parse(isoLike).toInstant()
602− } catch (_: Exception) {
603− try {
604− LocalDateTime.parse(isoLike).toInstant(ZoneOffset.UTC)
605− } catch (_: Exception) {
606− return null
607− }
608− }
609− }
610− return instant.atOffset(ZoneOffset.UTC).format(dateFormatter)
611− }
612−
613− /** Bound the dedup set, evicting the oldest identities first (insertion order). */
614− private fun trimSeenStreamingKeys() {
615− if (seenStreamingKeys.size <= SEEN_STREAMING_KEYS_MAX) return
616− val iterator = seenStreamingKeys.iterator()
617− var toRemove = seenStreamingKeys.size - SEEN_STREAMING_KEYS_MAX
618− while (toRemove > 0 && iterator.hasNext()) {
619− iterator.next()
620− iterator.remove()
621− toRemove--
622− }
623− }
624−
625434 private suspend fun pollNewLogs() {
626435 val current = _state.value
627436 if (current.streamName.isEmpty()) return
@@ -634,116 +443,45 @@ class LogViewerViewModel @Inject constructor(
634443 if (current.filters.searchQuery.isNotBlank()) {
635444 val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
636445 if (searchClause != null) {
637− // Parenthesize: the search clause is OR-joined and must not break the
638− // precedence of the AND-joined filter clauses around it.
639446 clauses.add("($searchClause)")
640− } else {
641− // Schema not loaded yet, so we can't build the search filter. Skip this
642− // poll rather than querying without it — otherwise we'd prepend rows that
643− // don't match the active search into a filtered view. Mirrors refresh(),
644− // which errors out instead of running an unfiltered query.
645− _state.update {
646− it.copy(
647− streaming = it.streaming.copy(
648− streamingError = "Search unavailable: stream schema not loaded",
649− ),
650− )
651− }
652− return
653447 }
654448 }
655449
656450 val whereClause = if (clauses.isNotEmpty()) " WHERE ${clauses.joinToString(" AND ")}" else ""
657451 val safeName = escapeIdentifier(current.streamName)
658− // Fetch up to the in-memory display cap. With a small limit (e.g. 200), a burst of
659− // more than that many logs in one interval would advance lastSeenTimestamp past the
660− // un-fetched rows and drop them permanently. Capping at STREAMING_MAX_LOGS means we
661− // never skip rows we'd actually keep (anything older is evicted by the cap anyway).
662− val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT $STREAMING_MAX_LOGS"
452+ val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT 200"
663453
664454 when (val result = repository.queryLogsRaw(sql, startTime, endTime)) {
665455 is ApiResult.Success -> {
666456 val newLogs = result.data
667457 if (newLogs.isNotEmpty()) {
668− // Advance the last-seen boundary. Rows are ordered newest-first, so
669− // normally we jump to the newest. But a full page means the LIMIT may
670− // have capped off matching rows OLDER than the newest in this batch
671− // (possible once filters/search narrow a high-volume stream) — advancing
672− // to the newest would skip them forever. In that case advance only to the
673− // oldest row we did fetch, so the next poll re-queries forward and closes
674− // the gap (re-fetched rows are deduped below).
675− val fullPage = newLogs.size >= STREAMING_MAX_LOGS
676− val boundaryLog = if (fullPage) newLogs.lastOrNull() else newLogs.firstOrNull()
677− val boundaryTimestamp = try {
678− boundaryLog?.get("p_timestamp")?.jsonPrimitive?.content
458+ // Update the last seen timestamp to the most recent log
459+ val newestTimestamp = try {
460+ newLogs.firstOrNull()
461+ ?.get("p_timestamp")?.jsonPrimitive?.content
679462 } catch (_: Exception) {
680463 null
681464 }
682− // Re-canonicalize the server's p_timestamp before reusing it as the next
683− // query's startTime. The raw value can be space-separated or offset-less,
684− // which the query API may reject or misparse — that would stall live-tail
685− // after the first batch. If it can't be parsed, leave lastSeenTimestamp as-is
686− // (the inclusive startTime simply re-fetches the window; rows are deduped).
687− var normalized = boundaryTimestamp?.let { normalizeBoundaryTimestamp(it) }
688− // On a saturated (full) page the boundary is the OLDEST fetched row so the
689− // next poll re-queries forward and closes the LIMIT gap. But if that oldest
690− // row carries the SAME timestamp we're already at — e.g. a burst of more than
691− // STREAMING_MAX_LOGS rows sharing one millisecond, or the parse failing — the
692− // boundary wouldn't move and we'd re-query the identical window forever. Fall
693− // back to the NEWEST row's timestamp to guarantee forward progress; the
694− // skipped backlog would be evicted by the display cap anyway.
695− if (fullPage && (normalized == null || normalized == lastSeenTimestamp)) {
696− val newestTimestamp = try {
697− newLogs.firstOrNull()?.get("p_timestamp")?.jsonPrimitive?.content
698− } catch (_: Exception) {
699− null
700− }
701− val newestNormalized = newestTimestamp?.let { normalizeBoundaryTimestamp(it) }
702− if (newestNormalized != null && newestNormalized != lastSeenTimestamp) {
703− normalized = newestNormalized
704− }
705− }
706− if (normalized != null) {
707− lastSeenTimestamp = normalized
465+ if (newestTimestamp != null) {
466+ lastSeenTimestamp = newestTimestamp
708467 }
709468
710− // The query's startTime is inclusive, so rows sitting on the previous
711− // boundary timestamp get re-fetched. Dedup against everything seen this
712− // session (independent of the display cap) to avoid duplicate rows and an
713− // inflated count. Computed BEFORE the state update — and the seen-set
714− // mutated here, not inside the update lambda, which may re-run.
715− val freshLogs = newLogs.filterNot { logIdentity(it) in seenStreamingKeys }
716− newLogs.forEach { seenStreamingKeys.add(logIdentity(it)) }
717− trimSeenStreamingKeys()
718−
719− if (freshLogs.isEmpty()) {
720− _state.update { it.copy(streaming = it.streaming.copy(streamingError = null)) }
721− } else {
722− _state.update { state ->
723− // Prepend new logs, cap total — avoid full intermediate list allocation
724− val remaining = (STREAMING_MAX_LOGS - freshLogs.size).coerceAtLeast(0)
725− val capped = ArrayList<JsonObject>(freshLogs.size + remaining).apply {
726− addAll(freshLogs)
727− val oldLogs = state.logs
728− addAll(oldLogs.subList(0, remaining.coerceAtMost(oldLogs.size)))
729− }
730− state.copy(
731− logs = capped,
732− logKeys = computeLogKeys(capped),
733− streaming = state.streaming.copy(
734− // Accumulate new rows while the user is scrolled away; reset
735− // to 0 when they're at the top (where prepended rows are seen
736− // live). The previous cap was against total list size, so the
737− // badge saturated at the display cap and never cleared.
738− streamingNewCount = if (viewingTop) {
739− 0
740− } else {
741− state.streaming.streamingNewCount + freshLogs.size
742− },
743− streamingError = null,
744− ),
745− )
469+ _state.update { state ->
470+ // Prepend new logs, cap total — avoid full intermediate list allocation
471+ val remaining = (STREAMING_MAX_LOGS - newLogs.size).coerceAtLeast(0)
472+ val capped = ArrayList<JsonObject>(newLogs.size + remaining).apply {
473+ addAll(newLogs)
474+ val oldLogs = state.logs
475+ addAll(oldLogs.subList(0, remaining.coerceAtMost(oldLogs.size)))
746476 }
477+ state.copy(
478+ logs = capped,
479+ logKeys = computeLogKeys(capped),
480+ streaming = state.streaming.copy(
481+ streamingNewCount = state.streaming.streamingNewCount + newLogs.size,
482+ streamingError = null,
483+ ),
484+ )
747485 }
748486 }
749487 consecutiveStreamingErrors = 0
@@ -770,37 +508,21 @@ class LogViewerViewModel @Inject constructor(
770508
771509 // --- Saved Filters (synced with Parseable server) ---
772510
773− // Serializes loadSavedFilters / saveCurrentFilter / deleteSavedFilter so they never
774− // interleave. Without this, a wholesale-overwrite load completing after a concurrent
775− // save (append) or delete (remove) would silently drop the local add/remove — a
776− // read-modify-write race across coroutines.
777− private val savedFiltersMutex = Mutex()
778−
779511 fun loadSavedFilters() {
780− // Capture the stream this load is for; if the user navigates to a different stream
781− // before it completes, bind the result to the right stream instead of clobbering the
782− // new stream's filter list with another stream's results.
783− val stream = _state.value.streamName
784512 viewModelScope.launch {
785− savedFiltersMutex.withLock {
786− _state.update { it.copy(savedFilters = it.savedFilters.copy(isLoading = true, error = null)) }
787− when (val result = repository.listFilters()) {
788− is ApiResult.Success -> {
789− if (_state.value.streamName != stream) {
790− _state.update { it.copy(savedFilters = it.savedFilters.copy(isLoading = false)) }
791− return@withLock
792− }
793− val streamFilters = result.data
794− .filter { it.streamName == stream }
795− .sortedBy { it.filterName.lowercase() }
796− _state.update {
797− it.copy(savedFilters = it.savedFilters.copy(filters = streamFilters, isLoading = false))
798− }
513+ _state.update { it.copy(savedFilters = it.savedFilters.copy(isLoading = true, error = null)) }
514+ when (val result = repository.listFilters()) {
515+ is ApiResult.Success -> {
516+ val streamFilters = result.data
517+ .filter { it.streamName == _state.value.streamName }
518+ .sortedBy { it.filterName.lowercase() }
519+ _state.update {
520+ it.copy(savedFilters = it.savedFilters.copy(filters = streamFilters, isLoading = false))
799521 }
800− is ApiResult.Error -> {
801− _state.update {
802− it.copy(savedFilters = it.savedFilters.copy(isLoading = false, error = result.userMessage))
803− }
522+ }
523+ is ApiResult.Error -> {
524+ _state.update {
525+ it.copy(savedFilters = it.savedFilters.copy(isLoading = false, error = result.userMessage))
804526 }
805527 }
806528 }
@@ -812,7 +534,6 @@ class LogViewerViewModel @Inject constructor(
812534 if (current.streamName.isEmpty()) return
813535
814536 viewModelScope.launch {
815− savedFiltersMutex.withLock {
816537 _state.update { it.copy(savedFilters = it.savedFilters.copy(isSaving = true, error = null)) }
817538
818539 val query = if (current.filters.customSql.isNotBlank()) {
@@ -821,13 +542,15 @@ class LogViewerViewModel @Inject constructor(
821542 filterQuery = current.filters.customSql,
822543 )
823544 } else {
824− val ruleGroups = current.filters.filterConditions
825− .mapIndexed { index, condition ->
545+ val ruleGroups = current.filters.activeFilters.zip(current.filters.filterClauses)
546+ .mapIndexed { index, (display, _) ->
547+ // Parse display back to field/operator/value
548+ val parts = parseFilterDisplay(display)
826549 FilterRule(
827550 id = "rule_$index",
828− field = condition.column,
829− value = condition.value,
830− operator = condition.operator,
551+ field = parts.first,
552+ value = parts.third,
553+ operator = parts.second,
831554 )
832555 }
833556 val builder = if (ruleGroups.isNotEmpty()) {
@@ -874,7 +597,6 @@ class LogViewerViewModel @Inject constructor(
874597 }
875598 }
876599 }
877− }
878600 }
879601 }
880602
@@ -884,15 +606,9 @@ class LogViewerViewModel @Inject constructor(
884606 when (filter.query.filterType) {
885607 "sql" -> {
886608 val sql = filter.query.filterQuery ?: return
887− // Reset to a clean filter state first, then let executeCustomSql validate
888− // the SQL and own the customSql state. Pre-seeding filters.customSql with
889− // the raw, unvalidated query meant that if executeCustomSql rejected it
890− // (e.g. a legacy non-SELECT saved filter, which returns early without
891− // touching state) we were left wedged in custom-SQL mode with broken SQL
892− // that every later refresh() would keep re-running.
893609 _state.update {
894610 it.copy(
895− filters = FilterState(),
611+ filters = FilterState(customSql = sql),
896612 currentLimit = 500,
897613 )
898614 }
@@ -936,21 +652,32 @@ class LogViewerViewModel @Inject constructor(
936652
937653 fun deleteSavedFilter(filterId: String) {
938654 viewModelScope.launch {
939− savedFiltersMutex.withLock {
940− when (repository.deleteFilter(filterId)) {
941− is ApiResult.Success -> {
942− _state.update {
943− it.copy(
944− savedFilters = it.savedFilters.copy(
945− filters = it.savedFilters.filters.filter { f -> f.filterId != filterId },
946− ),
947− )
948− }
655+ when (repository.deleteFilter(filterId)) {
656+ is ApiResult.Success -> {
657+ _state.update {
658+ it.copy(
659+ savedFilters = it.savedFilters.copy(
660+ filters = it.savedFilters.filters.filter { f -> f.filterId != filterId },
661+ ),
662+ )
949663 }
950− is ApiResult.Error -> { /* Silently ignore — will show on next reload */ }
951664 }
665+ is ApiResult.Error -> { /* Silently ignore — will show on next reload */ }
666+ }
667+ }
668+ }
669+
670+ /** Parse a display string like "column = value" back into (field, operator, value) */
671+ private fun parseFilterDisplay(display: String): Triple<String, String, String> {
672+ for (op in listOf("IS NOT NULL", "IS NULL", "ILIKE", "LIKE", "!=", ">=", "<=", "=", ">", "<")) {
673+ val idx = display.indexOf(" $op")
674+ if (idx >= 0) {
675+ val field = display.substring(0, idx)
676+ val value = display.substring(idx + op.length + 1).trim()
677+ return Triple(field, op, value)
952678 }
953679 }
680+ return Triple(display, "=", "")
954681 }
955682
956683 override fun onCleared() {
app/src/main/java/com/parseable/android/ui/screens/logviewer/SeverityColor.kt+8−37
@@ -65,7 +65,8 @@ private fun parseSeverityValue(raw: String, fieldName: String): LogSeverity {
6565 trimmed.toIntOrNull()?.let { num ->
6666 return when {
6767 // Syslog severity (0 = emergency .. 7 = debug)
68− fieldName.equals("syslog_severity", ignoreCase = true) -> when (num) {
68+ fieldName.equals("syslog_severity", ignoreCase = true) ||
69+ fieldName.equals("priority", ignoreCase = true) -> when (num) {
6970 0, 1, 2 -> LogSeverity.FATAL // emergency, alert, critical
7071 3 -> LogSeverity.ERROR
7172 4 -> LogSeverity.WARNING
@@ -74,50 +75,20 @@ private fun parseSeverityValue(raw: String, fieldName: String): LogSeverity {
7475 7 -> LogSeverity.DEBUG
7576 else -> LogSeverity.UNKNOWN
7677 }
77− // Syslog "priority" (PRI) is the encoded value facility*8 + severity (0..191), so the
78− // severity is the low 3 bits. For a bare 0..7 this is identity; a real PRI like 134
79− // decodes to severity 6 (info) instead of falling through to UNKNOWN.
80− fieldName.equals("priority", ignoreCase = true) -> when {
81− num < 0 || num > 191 -> LogSeverity.UNKNOWN
82− else -> when (num % 8) {
83− 0, 1, 2 -> LogSeverity.FATAL // emergency, alert, critical
84− 3 -> LogSeverity.ERROR
85− 4 -> LogSeverity.WARNING
86− 5, 6 -> LogSeverity.INFO // notice, informational
87− else -> LogSeverity.DEBUG // 7
88− }
89− }
90− // HTTP status codes. Bound every branch to the valid HTTP range (100..599):
91− // `status` is a very generic field name, so an unbounded `>= 500`/`>= 400`
92− // would mis-color non-HTTP values (e.g. status: 50000) as ERROR/WARNING.
93− fieldName.equals("status", ignoreCase = true) -> when (num) {
94− in 500..599 -> LogSeverity.ERROR
95− in 400..499 -> LogSeverity.WARNING
96− // 1xx (informational) through 3xx are non-error HTTP statuses; 1xx previously
97− // fell through to UNKNOWN.
98− in 100..399 -> LogSeverity.INFO
78+ // HTTP status codes
79+ fieldName.equals("status", ignoreCase = true) -> when {
80+ num >= 500 -> LogSeverity.ERROR
81+ num >= 400 -> LogSeverity.WARNING
82+ num in 200..399 -> LogSeverity.INFO
9983 else -> LogSeverity.UNKNOWN
10084 }
101− // Generic numeric (higher = more severe). Two scales coexist under a bare numeric
102− // "level"/"severity" field, so handle both:
103− // • Large-magnitude java.util.logging (FINEST=300 … SEVERE=1000).
104− // • Small-integer structured JSON loggers (e.g. Bunyan: trace=10 … fatal=60).
105− // Without the small-integer arm, Bunyan-style levels (10..60) would all fall into the
106− // `num > 0 -> TRACE` catch-all and every such log would render as TRACE. The >= 100
107− // boundary keeps every existing java.util.logging mapping unchanged (100..399 stayed
108− // TRACE before and still does).
85+ // Generic numeric (Java util logging style: higher = more severe)
10986 else -> when {
11087 num >= 1000 -> LogSeverity.FATAL
11188 num >= 900 -> LogSeverity.ERROR
11289 num >= 800 -> LogSeverity.WARNING
11390 num >= 700 -> LogSeverity.INFO
11491 num >= 400 -> LogSeverity.DEBUG
115− num >= 100 -> LogSeverity.TRACE
116− num >= 60 -> LogSeverity.FATAL
117− num >= 50 -> LogSeverity.ERROR
118− num >= 40 -> LogSeverity.WARNING
119− num >= 30 -> LogSeverity.INFO
120− num >= 20 -> LogSeverity.DEBUG
12192 num > 0 -> LogSeverity.TRACE
12293 else -> LogSeverity.UNKNOWN
12394 }
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoScreen.kt+86−100
@@ -7,7 +7,6 @@ import androidx.compose.material.icons.Icons
77import androidx.compose.material.icons.automirrored.filled.ArrowBack
88import androidx.compose.material.icons.filled.*
99import androidx.compose.material3.*
10−import androidx.compose.material3.pulltorefresh.PullToRefreshBox
1110import androidx.compose.runtime.*
1211import androidx.compose.ui.Alignment
1312import androidx.compose.ui.Modifier
@@ -15,7 +14,10 @@ import androidx.compose.ui.text.font.FontFamily
1514import androidx.compose.ui.text.font.FontWeight
1615import androidx.compose.ui.unit.dp
1716import androidx.hilt.navigation.compose.hiltViewModel
17+import androidx.lifecycle.Lifecycle
18+import androidx.lifecycle.compose.LocalLifecycleOwner
1819import androidx.lifecycle.compose.collectAsStateWithLifecycle
20+import androidx.lifecycle.repeatOnLifecycle
1921import com.parseable.android.data.formatBytes
2022import kotlinx.serialization.json.Json
2123import kotlinx.serialization.json.JsonObject
@@ -33,10 +35,11 @@ fun StreamInfoScreen(
3335 val state by viewModel.state.collectAsStateWithLifecycle()
3436 var showDeleteConfirmation by remember { mutableStateOf(false) }
3537
36− // Load once per stream. Reloading on every RESUME would refetch all four endpoints on
37− // each return and could re-query a stream that was just deleted (spurious error card).
38− LaunchedEffect(streamName) {
39− viewModel.load(streamName)
38+ val lifecycleOwner = LocalLifecycleOwner.current
39+ LaunchedEffect(streamName, lifecycleOwner) {
40+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
41+ viewModel.load(streamName)
42+ }
4043 }
4144
4245 LaunchedEffect(state.deleteSuccess) {
@@ -70,114 +73,97 @@ fun StreamInfoScreen(
7073 )
7174 },
7275 ) { padding ->
73− // Has any section loaded yet? Drives whether a load shows the centered first-load
74− // spinner or keeps existing content visible (so pull-to-refresh stays usable on retry).
75− val hasContent = state.stats != null || state.schema.isNotEmpty() ||
76− state.rawInfo != null || state.retention.isNotEmpty() || state.schemaFailed
77− PullToRefreshBox(
78− // Only show the pull indicator when refreshing over existing content; the very
79− // first load uses the centered spinner below, so don't show both at once.
80− isRefreshing = state.isLoading && hasContent,
81− onRefresh = viewModel::refresh,
82− modifier = Modifier
83− .fillMaxSize()
84− .padding(padding),
85− ) {
86− if (state.isLoading && !hasContent) {
87− Box(
88− modifier = Modifier.fillMaxSize(),
89− contentAlignment = Alignment.Center,
90− ) {
91− CircularProgressIndicator()
92− }
93− } else {
94− Column(
95− modifier = Modifier
96− .fillMaxSize()
97− .verticalScroll(rememberScrollState())
98− .padding(16.dp),
99− verticalArrangement = Arrangement.spacedBy(16.dp),
100− ) {
101− // Stats section
102− state.stats?.let { stats ->
103− InfoSection(title = "Statistics") {
104− // Show the current-window count when available. Fall back to "N/A"
105− // only when there's no lifetime count either — otherwise the lifetime
106− // figure is shown in its own row below, so don't duplicate it here.
107− when {
108− stats.ingestion?.count != null -> InfoRow("Event Count", stats.ingestion.count.toString())
109− stats.ingestion?.lifetimeCount == null -> InfoRow("Event Count", "N/A")
110− }
111− InfoRow("Ingestion Size", formatBytes(stats.ingestion?.size) ?: formatBytes(stats.ingestion?.lifetimeSize) ?: "N/A")
112− InfoRow("Storage Size", formatBytes(stats.storage?.size) ?: formatBytes(stats.storage?.lifetimeSize) ?: "N/A")
113− if (stats.ingestion?.lifetimeCount != null) {
114− InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
115− }
116− if (stats.ingestion?.deletedCount != null) {
117− InfoRow("Deleted Events", stats.ingestion.deletedCount.toString())
118− }
76+ if (state.isLoading) {
77+ Box(
78+ modifier = Modifier
79+ .fillMaxSize()
80+ .padding(padding),
81+ contentAlignment = Alignment.Center,
82+ ) {
83+ CircularProgressIndicator()
84+ }
85+ } else {
86+ Column(
87+ modifier = Modifier
88+ .fillMaxSize()
89+ .padding(padding)
90+ .verticalScroll(rememberScrollState())
91+ .padding(16.dp),
92+ verticalArrangement = Arrangement.spacedBy(16.dp),
93+ ) {
94+ // Stats section
95+ state.stats?.let { stats ->
96+ InfoSection(title = "Statistics") {
97+ InfoRow("Event Count", stats.ingestion?.count?.toString() ?: stats.ingestion?.lifetimeCount?.toString() ?: "N/A")
98+ InfoRow("Ingestion Size", formatBytes(stats.ingestion?.size) ?: formatBytes(stats.ingestion?.lifetimeSize) ?: "N/A")
99+ InfoRow("Storage Size", formatBytes(stats.storage?.size) ?: formatBytes(stats.storage?.lifetimeSize) ?: "N/A")
100+ if (stats.ingestion?.lifetimeCount != null) {
101+ InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
102+ }
103+ if (stats.ingestion?.deletedCount != null) {
104+ InfoRow("Deleted Events", stats.ingestion.deletedCount.toString())
119105 }
120106 }
107+ }
121108
122− // Schema section
123− if (state.schema.isNotEmpty()) {
124− InfoSection(title = "Schema (${state.schema.size} fields)") {
125− state.schema.forEach { field ->
126− InfoRow(
127− field.name,
128− field.dataType?.toString()?.trim('"') ?: "Unknown",
129− )
130− }
131− }
132− } else if (state.schemaFailed) {
133− InfoSection(title = "Schema") {
134− Text(
135− text = "Schema unavailable",
136− style = MaterialTheme.typography.bodyMedium,
137− color = MaterialTheme.colorScheme.onSurfaceVariant,
109+ // Schema section
110+ if (state.schema.isNotEmpty()) {
111+ InfoSection(title = "Schema (${state.schema.size} fields)") {
112+ state.schema.forEach { field ->
113+ InfoRow(
114+ field.name,
115+ field.dataType?.toString()?.trim('"') ?: "Unknown",
138116 )
139117 }
140118 }
119+ } else if (state.schemaFailed) {
120+ InfoSection(title = "Schema") {
121+ Text(
122+ text = "Schema unavailable",
123+ style = MaterialTheme.typography.bodyMedium,
124+ color = MaterialTheme.colorScheme.onSurfaceVariant,
125+ )
126+ }
127+ }
141128
142− // Retention section
143− if (state.retention.isNotEmpty()) {
144− InfoSection(title = "Retention") {
145− state.retention.forEach { r ->
146− InfoRow("Duration", r.duration ?: "N/A")
147− InfoRow("Action", r.action ?: "N/A")
148− if (r.description != null) {
149− InfoRow("Description", r.description)
150− }
129+ // Retention section
130+ if (state.retention.isNotEmpty()) {
131+ InfoSection(title = "Retention") {
132+ state.retention.forEach { r ->
133+ InfoRow("Duration", r.duration ?: "N/A")
134+ InfoRow("Action", r.action ?: "N/A")
135+ if (r.description != null) {
136+ InfoRow("Description", r.description)
151137 }
152138 }
153139 }
140+ }
154141
155− // Raw info section
156− state.rawInfo?.let { rawInfo ->
157− val prettyInfo = remember(rawInfo) {
158− prettyJson.encodeToString(JsonObject.serializer(), rawInfo)
159− }
160− InfoSection(title = "Stream Info") {
161− Text(
162− text = prettyInfo,
163− style = MaterialTheme.typography.bodySmall,
164− fontFamily = FontFamily.Monospace,
165− )
166− }
142+ // Raw info section
143+ state.rawInfo?.let { rawInfo ->
144+ val prettyInfo = remember(rawInfo) {
145+ prettyJson.encodeToString(JsonObject.serializer(), rawInfo)
167146 }
147+ InfoSection(title = "Stream Info") {
148+ Text(
149+ text = prettyInfo,
150+ style = MaterialTheme.typography.bodySmall,
151+ fontFamily = FontFamily.Monospace,
152+ )
153+ }
154+ }
168155
169− state.error?.let { errorText ->
170− Card(
171− colors = CardDefaults.cardColors(
172− containerColor = MaterialTheme.colorScheme.errorContainer,
173− ),
174− ) {
175− Text(
176− text = errorText,
177− modifier = Modifier.padding(16.dp),
178− color = MaterialTheme.colorScheme.onErrorContainer,
179− )
180− }
156+ state.error?.let { errorText ->
157+ Card(
158+ colors = CardDefaults.cardColors(
159+ containerColor = MaterialTheme.colorScheme.errorContainer,
160+ ),
161+ ) {
162+ Text(
163+ text = errorText,
164+ modifier = Modifier.padding(16.dp),
165+ color = MaterialTheme.colorScheme.onErrorContainer,
166+ )
181167 }
182168 }
183169 }
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoViewModel.kt+10−59
@@ -8,7 +8,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
88import kotlinx.coroutines.CancellationException
99import kotlinx.coroutines.Job
1010import kotlinx.coroutines.async
11−import kotlinx.coroutines.coroutineScope
1211import kotlinx.coroutines.flow.MutableStateFlow
1312import kotlinx.coroutines.flow.StateFlow
1413import kotlinx.coroutines.flow.asStateFlow
@@ -39,61 +38,21 @@ class StreamInfoViewModel @Inject constructor(
3938 val state: StateFlow<StreamInfoState> = _state.asStateFlow()
4039 private var loadJob: Job? = null
4140
42− /** Holds the four parallel load results so coroutineScope can return them with their types intact. */
43− private data class LoadResults(
44− val stats: ApiResult<StreamStats>,
45− val schema: ApiResult<StreamSchema>,
46− val retention: ApiResult<List<RetentionConfig>>,
47− val info: ApiResult<JsonObject>,
48− )
49−
5041 fun load(streamName: String) {
51− // Already showing (or loading) this stream — don't cancel and refetch all four
52− // endpoints on recomposition / config change. Matches LogViewerViewModel.initialize's
53− // guard; the screen loads once per stream (see StreamInfoScreen's LaunchedEffect).
54− if (_state.value.streamName == streamName) return
55− fetch(streamName)
56− }
57−
58− /**
59− * Re-fetch the currently-shown stream, bypassing load()'s identity guard. Wired to
60− * pull-to-refresh so the user can retry after a transient endpoint failure (e.g. a failed
61− * schema fetch or a delete that errored) without leaving and re-entering the screen.
62− */
63− fun refresh() {
64− val name = _state.value.streamName
65− if (name.isEmpty()) return
66− fetch(name)
67− }
68−
69− private fun fetch(streamName: String) {
7042 loadJob?.cancel()
7143 _state.update { it.copy(streamName = streamName, isLoading = true, error = null) }
7244
7345 loadJob = viewModelScope.launch {
7446 try {
75− // Wrap the parallel fetches in coroutineScope so the four async children
76− // form their own structured-concurrency scope (matching StreamsViewModel /
77− // SettingsViewModel). The repository returns ApiResult.Error rather than
78− // throwing, but if any call did throw unexpectedly this keeps the failure
79− // contained to this load instead of it propagating oddly through the launch.
80− val results = coroutineScope {
81− val statsDeferred = async { repository.getStreamStats(streamName) }
82− val schemaDeferred = async { repository.getStreamSchema(streamName) }
83− val retentionDeferred = async { repository.getStreamRetention(streamName) }
84− val infoDeferred = async { repository.getStreamInfo(streamName) }
47+ val statsDeferred = async { repository.getStreamStats(streamName) }
48+ val schemaDeferred = async { repository.getStreamSchema(streamName) }
49+ val retentionDeferred = async { repository.getStreamRetention(streamName) }
50+ val infoDeferred = async { repository.getStreamInfo(streamName) }
8551
86− LoadResults(
87− stats = statsDeferred.await(),
88− schema = schemaDeferred.await(),
89− retention = retentionDeferred.await(),
90− info = infoDeferred.await(),
91− )
92− }
93− val statsResult = results.stats
94− val schemaResult = results.schema
95− val retentionResult = results.retention
96− val infoResult = results.info
52+ val statsResult = statsDeferred.await()
53+ val schemaResult = schemaDeferred.await()
54+ val retentionResult = retentionDeferred.await()
55+ val infoResult = infoDeferred.await()
9756
9857 _state.update {
9958 it.copy(
@@ -127,17 +86,9 @@ class StreamInfoViewModel @Inject constructor(
12786
12887 fun deleteStream() {
12988 val name = _state.value.streamName
130− // Guard against a no-op or a second tap firing a duplicate DELETE while one is in flight.
131− if (name.isEmpty() || _state.value.isDeleting) return
132− // Cancel any in-flight load so its terminal update can't re-populate stats/schema for the
133− // stream we're about to delete after the DELETE completes.
134− loadJob?.cancel()
89+ if (name.isEmpty()) return
13590 viewModelScope.launch {
136− // Clear isLoading here too: cancelling loadJob above kills any in-flight fetch before
137− // its terminal update can flip isLoading back to false, so without this a load that was
138− // running when delete started would leave the pull-to-refresh spinner stuck on the
139− // error path (delete only manages isDeleting).
140− _state.update { it.copy(isDeleting = true, isLoading = false, error = null) }
91+ _state.update { it.copy(isDeleting = true, error = null) }
14192 when (val result = repository.deleteStream(name)) {
14293 is ApiResult.Success -> {
14394 _state.update { it.copy(isDeleting = false, deleteSuccess = true) }
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsScreen.kt+6−13
@@ -27,23 +27,18 @@ fun SettingsScreen(
2727 onBack: () -> Unit,
2828 onAboutClick: () -> Unit,
2929 onServerSwitched: () -> Unit,
30− onLoggedOut: () -> Unit,
3130 viewModel: SettingsViewModel = hiltViewModel(),
3231) {
3332 val state by viewModel.state.collectAsStateWithLifecycle()
33+ val switchEvent by viewModel.switchEvent.collectAsStateWithLifecycle()
3434
35− LaunchedEffect(Unit) {
36− viewModel.switchEvent.collect {
35+ LaunchedEffect(switchEvent) {
36+ if (switchEvent != null) {
37+ viewModel.consumeSwitchEvent()
3738 onServerSwitched()
3839 }
3940 }
4041
41− LaunchedEffect(Unit) {
42− viewModel.loggedOutEvent.collect {
43− onLoggedOut()
44− }
45− }
46−
4742 Scaffold(
4843 topBar = {
4944 TopAppBar(
@@ -107,10 +102,8 @@ fun SettingsScreen(
107102 )
108103 }
109104
110− // Loading indicator for server data — only on the initial load. During a
111− // pull-to-refresh the PullToRefreshBox already shows its own spinner and
112− // aboutInfo is still populated, so gating on isLoading alone double-spins.
113− if (state.isLoading && state.aboutInfo == null) {
105+ // Loading indicator for server data
106+ if (state.isLoading) {
114107 Box(
115108 modifier = Modifier
116109 .fillMaxWidth()
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsViewModel.kt+22−100
@@ -9,15 +9,12 @@ import com.parseable.android.data.repository.ParseableRepository
99import com.parseable.android.data.repository.SettingsRepository
1010import dagger.hilt.android.lifecycle.HiltViewModel
1111import kotlinx.coroutines.CancellationException
12−import kotlinx.coroutines.Job
1312import kotlinx.coroutines.async
14−import kotlinx.coroutines.channels.Channel
1513import kotlinx.coroutines.coroutineScope
1614import kotlinx.coroutines.flow.MutableStateFlow
1715import kotlinx.coroutines.flow.StateFlow
1816import kotlinx.coroutines.flow.asStateFlow
1917import kotlinx.coroutines.flow.first
20−import kotlinx.coroutines.flow.receiveAsFlow
2118import kotlinx.coroutines.flow.update
2219import kotlinx.coroutines.launch
2320import kotlinx.serialization.json.jsonPrimitive
@@ -34,7 +31,6 @@ data class SettingsState(
3431 val savedServers: List<SavedServer> = emptyList(),
3532 val activeServerId: Long? = null,
3633 val isSwitching: Boolean = false,
37− val isDeleting: Boolean = false,
3834)
3935
4036@HiltViewModel
@@ -46,25 +42,9 @@ class SettingsViewModel @Inject constructor(
4642 private val _state = MutableStateFlow(SettingsState())
4743 val state: StateFlow<SettingsState> = _state.asStateFlow()
4844
49− /**
50− * Emits the server ID after a successful switch so the screen can navigate. A [Channel]
51− * (matching the codebase's other one-shot events) so the navigation fires exactly once and
52− * is not re-delivered on rotation — a StateFlow retains its last value and would re-navigate
53− * when the screen re-collects after a config change.
54− */
55− private val _switchEvent = Channel<Long>(Channel.BUFFERED)
56− val switchEvent = _switchEvent.receiveAsFlow()
57−
58− /**
59− * Emitted when the currently-active server is deleted. The active connection has been torn
60− * down, so there is no valid session left — the screen must navigate back to login rather
61− * than leave the user on a Settings screen pointed at a deleted server. A one-shot [Channel]
62− * for the same reason as [switchEvent].
63− */
64− private val _loggedOutEvent = Channel<Unit>(Channel.BUFFERED)
65− val loggedOutEvent = _loggedOutEvent.receiveAsFlow()
66−
67− private var loadJob: Job? = null
45+ /** Emits the server ID after a successful switch so the screen can navigate. */
46+ private val _switchEvent = MutableStateFlow<Long?>(null)
47+ val switchEvent: StateFlow<Long?> = _switchEvent.asStateFlow()
6848
6949 init {
7050 load()
@@ -81,9 +61,7 @@ class SettingsViewModel @Inject constructor(
8161 }
8262
8363 fun load() {
84− // Cancel any in-flight load so a slower completion can't overwrite a newer one.
85− loadJob?.cancel()
86− loadJob = viewModelScope.launch {
64+ viewModelScope.launch {
8765 _state.update { it.copy(isLoading = true, error = null) }
8866
8967 val config = settingsRepository.serverConfig.first()
@@ -106,24 +84,24 @@ class SettingsViewModel @Inject constructor(
10684
10785 val userNames = (usersResult as? ApiResult.Success)?.data?.mapNotNull { obj ->
10886 try {
109− obj["username"]?.jsonPrimitive?.content
110− ?: obj["id"]?.jsonPrimitive?.content
87+ obj["id"]?.jsonPrimitive?.content
88+ ?: obj["username"]?.jsonPrimitive?.content
11189 } catch (_: IllegalStateException) {
11290 null
11391 }
11492 } ?: emptyList()
11593
116− // Only treat a failure of the primary server-info call (/about) as a blocking
117− // error. The user list (/user) is secondary and is commonly forbidden for
118− // restricted accounts — failing it shouldn't hide otherwise-valid server info.
119− val error = (aboutResult as? ApiResult.Error)?.userMessage
94+ val errors = listOfNotNull(
95+ (aboutResult as? ApiResult.Error)?.userMessage,
96+ (usersResult as? ApiResult.Error)?.userMessage,
97+ ).distinct().joinToString("\n").ifEmpty { null }
12098
12199 _state.update {
122100 it.copy(
123101 aboutInfo = (aboutResult as? ApiResult.Success)?.data,
124102 users = userNames,
125103 isLoading = false,
126− error = error,
104+ error = errors,
127105 )
128106 }
129107 } catch (e: CancellationException) {
@@ -138,80 +116,24 @@ class SettingsViewModel @Inject constructor(
138116 }
139117
140118 fun switchToServer(serverId: Long) {
141− // Guard against a second tap launching a concurrent switch that would send a
142− // duplicate _switchEvent and navigate twice, and against racing an in-flight delete
143− // (both mutate the active connection and fire conflicting one-shot nav events).
144− if (_state.value.isSwitching || _state.value.isDeleting) return
145119 viewModelScope.launch {
146− _state.update { it.copy(isSwitching = true, error = null) }
147− try {
148− val config = settingsRepository.switchToServer(serverId)
149− if (config != null) {
150− repository.configure(config)
151− _switchEvent.send(serverId)
152− } else {
153− // Surface the failure instead of silently dropping the spinner — a null
154− // result means the saved credentials couldn't be loaded for this server.
155− _state.update {
156− it.copy(
157− error = "Couldn't switch servers — the saved credentials for this server are missing or couldn't be read.",
158− )
159− }
160− }
161− } catch (e: CancellationException) {
162− throw e
163− } catch (e: Exception) {
164− _state.update {
165− it.copy(error = e.message ?: "Couldn't switch servers.")
166− }
167− } finally {
168− // Always clear the flag. Without finally, an exception or cancellation would
169− // leave isSwitching = true, and the early-return guard above would then make
170− // server-switching permanently impossible for this ViewModel instance.
171− _state.update { it.copy(isSwitching = false) }
120+ _state.update { it.copy(isSwitching = true) }
121+ val config = settingsRepository.switchToServer(serverId)
122+ if (config != null) {
123+ repository.configure(config)
124+ _switchEvent.value = serverId
172125 }
126+ _state.update { it.copy(isSwitching = false) }
173127 }
174128 }
175129
130+ fun consumeSwitchEvent() {
131+ _switchEvent.value = null
132+ }
133+
176134 fun deleteServer(serverId: Long) {
177− // Don't delete while a switch or another delete is in flight: all mutate the active
178− // connection and can fire conflicting one-shot navigation events (_loggedOutEvent vs
179− // _switchEvent), or send _loggedOutEvent twice on a double-tap of the active server.
180− // The UI also disables the buttons during a switch, but guard here too. isDeleting is
181− // set inside the launch (viewModelScope is Main.immediate, so it runs synchronously
182− // before this call returns) and cleared in finally, mirroring switchToServer.
183− if (_state.value.isSwitching || _state.value.isDeleting) return
184135 viewModelScope.launch {
185− _state.update { it.copy(isDeleting = true) }
186− try {
187− val wasActive = settingsRepository.deleteServer(serverId)
188− if (wasActive) {
189− // The active connection was torn down, so the Server Connection / Server Info
190− // cards are now showing a deleted server. Clear them for the transition frame,
191− // then navigate back to login — there is no valid session to keep using and the
192− // in-memory api client still points at the deleted server, so any fetch from
193− // here would hit it.
194− _state.update {
195− it.copy(
196− serverUrl = "",
197− username = "",
198− aboutInfo = null,
199− users = emptyList(),
200− )
201− }
202− _loggedOutEvent.send(Unit)
203− }
204− } catch (e: CancellationException) {
205− throw e
206− } catch (e: Exception) {
207− // Surface the failure instead of silently swallowing it, so the user knows the
208− // server wasn't removed and can retry.
209− _state.update { it.copy(error = e.message ?: "Couldn't remove server.") }
210− } finally {
211− // Always clear the flag, even on exception/cancellation, or the guard above would
212− // make deletion permanently impossible for this ViewModel instance.
213− _state.update { it.copy(isDeleting = false) }
214− }
136+ settingsRepository.deleteServer(serverId)
215137 }
216138 }
217139}
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsScreen.kt+15−20
@@ -10,13 +10,15 @@ import androidx.compose.material.icons.filled.*
1010import androidx.compose.material3.*
1111import androidx.compose.material3.pulltorefresh.PullToRefreshBox
1212import androidx.compose.runtime.*
13−import androidx.compose.runtime.saveable.rememberSaveable
1413import androidx.compose.ui.Alignment
1514import androidx.compose.ui.Modifier
15+import androidx.lifecycle.compose.LocalLifecycleOwner
1616import androidx.compose.ui.text.font.FontWeight
1717import androidx.compose.ui.unit.dp
1818import androidx.hilt.navigation.compose.hiltViewModel
19+import androidx.lifecycle.Lifecycle
1920import androidx.lifecycle.compose.collectAsStateWithLifecycle
21+import androidx.lifecycle.repeatOnLifecycle
2022import kotlinx.coroutines.flow.collectLatest
2123import kotlinx.coroutines.launch
2224
@@ -32,21 +34,16 @@ fun StreamsScreen(
3234 val state by viewModel.state.collectAsStateWithLifecycle()
3335 val scope = rememberCoroutineScope()
3436 var showLogoutConfirmation by remember { mutableStateOf(false) }
35− var searchQuery by rememberSaveable { mutableStateOf("") }
37+ var searchQuery by remember { mutableStateOf("") }
3638 val snackbarHostState = remember { SnackbarHostState() }
3739
3840 val listState = rememberLazyListState()
3941
40− // Scroll to top only when a refresh actually completes (isLoading transitions true -> false
41− // while composed). Keying solely on isLoading also fired on return-to-screen and on the
42− // initial composition, yanking the list to the top and discarding the user's restored
43− // scroll position. Tracking the previous value limits the jump to genuine refresh events.
44− var wasLoading by remember { mutableStateOf(false) }
42+ // Scroll to top when a refresh completes (isLoading transitions to false)
4543 LaunchedEffect(state.isLoading) {
46− if (wasLoading && !state.isLoading) {
44+ if (!state.isLoading) {
4745 listState.scrollToItem(0)
4846 }
49− wasLoading = state.isLoading
5047 }
5148
5249 val filteredStreams = remember(state.streams, searchQuery, state.favoriteNames) {
@@ -58,11 +55,14 @@ fun StreamsScreen(
5855 }
5956 // Partition by favorites instead of full sort for O(n) vs O(n log n)
6057 val (favs, rest) = filtered.partition { it.name in favorites }
61− // `favorites` iterates most-recently-favorited first (DAO ORDER BY addedAt DESC
62− // preserved through LinkedHashSet); order favs to match so recency is reflected.
63− val favRank = favorites.withIndex().associate { (i, name) -> name to i }
64− val sortedFavs = favs.sortedBy { favRank[it.name] ?: Int.MAX_VALUE }
65− sortedFavs + rest
58+ favs + rest
59+ }
60+
61+ val lifecycleOwner = LocalLifecycleOwner.current
62+ LaunchedEffect(lifecycleOwner) {
63+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
64+ viewModel.refresh()
65+ }
6666 }
6767
6868 LaunchedEffect(Unit) {
@@ -157,12 +157,7 @@ fun StreamsScreen(
157157 }
158158 } else {
159159 Column {
160− // Keep the search field visible whenever a query is active, even if the
161− // total stream count has since dropped to <=5 (e.g. after a refresh or a
162− // server-side deletion). Otherwise the field would vanish while the stale
163− // query keeps filtering `filteredStreams`, stranding the user with no way
164− // to clear it.
165− if (state.streams.size > 5 || searchQuery.isNotEmpty()) {
160+ if (state.streams.size > 5) {
166161 OutlinedTextField(
167162 value = searchQuery,
168163 onValueChange = { searchQuery = it },
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsViewModel.kt+10−52
@@ -22,9 +22,7 @@ import kotlinx.coroutines.flow.asStateFlow
2222import kotlinx.coroutines.flow.receiveAsFlow
2323import kotlinx.coroutines.flow.update
2424import kotlinx.coroutines.launch
25−import kotlinx.coroutines.sync.Mutex
2625import kotlinx.coroutines.sync.Semaphore
27−import kotlinx.coroutines.sync.withLock
2826import kotlinx.coroutines.sync.withPermit
2927import javax.inject.Inject
3028
@@ -63,40 +61,22 @@ class StreamsViewModel @Inject constructor(
6361 _state.update { it.copy(favoriteNames = names.toSet()) }
6462 }
6563 }
66− // Load once when the ViewModel is created. The screen no longer refreshes on every
67− // RESUME, so returning via back-navigation reuses the already-loaded data.
68− refresh()
6964 }
7065
71− // Serializes toggles so two rapid taps can't both read the same pre-update state and
72− // take the same branch (which, with idempotent DAO ops, would leave the favorite in the
73− // wrong final state — e.g. tap-twice nets to "favorited" instead of cancelling out).
74− private val favoriteMutex = Mutex()
75−
7666 fun toggleFavorite(streamName: String) {
7767 viewModelScope.launch {
78− favoriteMutex.withLock {
79− // Decide from the committed DB state, not the async-mirrored favoriteNames.
80− if (favoriteDao.isFavoriteNow(streamName)) {
81− favoriteDao.deleteByName(streamName)
82− _snackbarEvent.send("Removed from favorites")
83− } else {
84− favoriteDao.insert(FavoriteStream(streamName = streamName))
85− _snackbarEvent.send("Added to favorites")
86− }
68+ if (streamName in _state.value.favoriteNames) {
69+ favoriteDao.deleteByName(streamName)
70+ _snackbarEvent.send("Removed from favorites")
71+ } else {
72+ favoriteDao.insert(FavoriteStream(streamName = streamName))
73+ _snackbarEvent.send("Added to favorites")
8774 }
8875 }
8976 }
9077
91− private var refreshJob: Job? = null
92−
9378 fun refresh() {
94− // Cancel any in-flight refresh so out-of-order completions can't overwrite a newer
95− // result with a stale one (the init load and a pull-to-refresh, or two quick pulls,
96− // can otherwise race and leave the older stream list showing). Mirrors the refreshJob
97− // guard in LogViewerViewModel.
98− refreshJob?.cancel()
99− refreshJob = viewModelScope.launch {
79+ viewModelScope.launch {
10080 _state.update { it.copy(isLoading = true, error = null) }
10181
10282 try {
@@ -113,14 +93,9 @@ class StreamsViewModel @Inject constructor(
11393
11494 when (streamsResult) {
11595 is ApiResult.Success -> {
116− // Prune cached stats/failures for streams that no longer exist so
117− // deleted or recreated streams don't show stale numbers.
118− val names = streamsResult.data.mapTo(HashSet()) { it.name }
11996 _state.update {
12097 it.copy(
12198 streams = streamsResult.data.sortedBy { s -> s.name.lowercase() },
122− streamStats = it.streamStats.filterKeys { name -> name in names },
123− failedStats = it.failedStats.filterTo(mutableSetOf()) { name -> name in names },
12499 isLoading = false,
125100 )
126101 }
@@ -149,14 +124,9 @@ class StreamsViewModel @Inject constructor(
149124
150125 private val statsSemaphore = Semaphore(8)
151126 private var statsJob: Job? = null
152− // Per-stream retry jobs, tracked so a new bulk refresh can cancel any in-flight
153− // retry — otherwise a late retry result could overwrite freshly-loaded stats.
154− private val retryJobs = mutableMapOf<String, Job>()
155127
156128 private fun loadStreamStats(streams: List<LogStream>) {
157129 statsJob?.cancel()
158− retryJobs.values.forEach { it.cancel() }
159− retryJobs.clear()
160130 statsJob = viewModelScope.launch {
161131 streams.forEach { stream ->
162132 launch {
@@ -169,20 +139,12 @@ class StreamsViewModel @Inject constructor(
169139 }
170140
171141 fun retryStats(streamName: String) {
172− retryJobs[streamName]?.cancel()
173− val job = viewModelScope.launch {
142+ viewModelScope.launch {
174143 _state.update { it.copy(failedStats = it.failedStats - streamName) }
175144 statsSemaphore.withPermit {
176145 loadSingleStreamStats(streamName)
177146 }
178147 }
179− retryJobs[streamName] = job
180− // Drop the entry once the retry finishes so the map doesn't accumulate completed
181− // jobs for the ViewModel's lifetime. Guard with an identity check so we never remove
182− // a newer retry that has since replaced this one for the same stream.
183− job.invokeOnCompletion {
184− if (retryJobs[streamName] === job) retryJobs.remove(streamName)
185− }
186148 }
187149
188150 private suspend fun loadSingleStreamStats(streamName: String) {
@@ -198,10 +160,7 @@ class StreamsViewModel @Inject constructor(
198160 ?: formatBytes(stats.storage?.lifetimeSize),
199161 )
200162 _state.update {
201− // Drop late results for streams a concurrent refresh has already pruned,
202− // so an in-flight retry can't resurrect stats for a deleted stream.
203− if (it.streams.none { s -> s.name == streamName }) it
204− else it.copy(
163+ it.copy(
205164 streamStats = it.streamStats + (streamName to ui),
206165 failedStats = it.failedStats - streamName,
207166 )
@@ -209,8 +168,7 @@ class StreamsViewModel @Inject constructor(
209168 }
210169 is ApiResult.Error -> {
211170 _state.update {
212− if (it.streams.none { s -> s.name == streamName }) it
213− else it.copy(failedStats = it.failedStats + streamName)
171+ it.copy(failedStats = it.failedStats + streamName)
214172 }
215173 }
216174 }
app/src/test/java/com/parseable/android/AlertsViewModelTest.kt−4
@@ -24,10 +24,6 @@ class AlertsViewModelTest {
2424 fun setup() {
2525 Dispatchers.setMain(testDispatcher)
2626 repository = mockk(relaxed = true)
27− // The ViewModel loads once in init { refresh() }. Give that load a deterministic
28− // empty-success default so the initial state is clean; tests that exercise refresh
29− // override this stub and call refresh() again.
30− coEvery { repository.listAlerts() } returns ApiResult.Success(emptyList())
3127 viewModel = AlertsViewModel(repository)
3228 }
3329
app/src/test/java/com/parseable/android/EscapeSqlTest.kt−27
@@ -1,7 +1,6 @@
11package com.parseable.android
22
33import com.parseable.android.data.escapeIdentifier
4−import com.parseable.android.data.escapeLikePattern
54import com.parseable.android.data.escapeSql
65import org.junit.Assert.assertEquals
76import org.junit.Test
@@ -53,32 +52,6 @@ class EscapeSqlTest {
5352 assertEquals("''; DROP TABLE users --", escapeSql("'; DROP TABLE users --"))
5453 }
5554
56− @Test
57− fun `escapeLikePattern escapes percent wildcard`() {
58− assertEquals("100\\%", escapeLikePattern("100%"))
59− }
60−
61− @Test
62− fun `escapeLikePattern escapes underscore wildcard`() {
63− assertEquals("a\\_b", escapeLikePattern("a_b"))
64− }
65−
66− @Test
67− fun `escapeLikePattern escapes backslash before wildcards`() {
68− // Input backslash becomes a doubled backslash; %/_ each gain one escape.
69− assertEquals("\\\\\\%\\_", escapeLikePattern("\\%_"))
70− }
71−
72− @Test
73− fun `escapeLikePattern also escapes single quotes`() {
74− assertEquals("it''s 50\\%", escapeLikePattern("it's 50%"))
75− }
76−
77− @Test
78− fun `escapeLikePattern leaves plain text untouched`() {
79− assertEquals("hello world", escapeLikePattern("hello world"))
80− }
81−
8255 @Test
8356 fun `escapeIdentifier doubles double quotes`() {
8457 assertEquals("foo\"\"bar", escapeIdentifier("foo\"bar"))
app/src/test/java/com/parseable/android/FormatTimestampTest.kt−14
@@ -32,20 +32,6 @@ class FormatTimestampTest {
3232 }
3333 }
3434
35− @Test
36− fun `treats offset-less timestamp as UTC and converts to local`() {
37− val original = TimeZone.getDefault()
38− try {
39− TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"))
40− // No offset/zone: should be interpreted as UTC (Parseable stores UTC),
41− // then converted to local (June = EDT = UTC-4).
42− val result = formatTimestamp("2024-06-15T18:30:45.000")
43− assertEquals("Jun 15 14:30:45.000", result)
44− } finally {
45− TimeZone.setDefault(original)
46− }
47− }
48−
4935 @Test
5036 fun `returns raw string for unparseable timestamp`() {
5137 val result = formatTimestamp("not-a-timestamp")
app/src/test/java/com/parseable/android/LogViewerViewModelTest.kt−54
@@ -185,24 +185,6 @@ class LogViewerViewModelTest {
185185 assertEquals(500, state.currentLimit) // Reset
186186 }
187187
188− @Test
189− fun `pullRefresh is a no-op while streaming`() {
190− viewModel.initialize("test")
191− // Page out so currentLimit is no longer at its initial 500.
192− viewModel.loadMore()
193− assertEquals(1000, viewModel.state.value.currentLimit)
194−
195− viewModel.toggleStreaming()
196− assertTrue(viewModel.state.value.isStreaming)
197−
198− // While live-tailing, pullRefresh must not run refresh() (which would reset the limit
199− // to 500 and race the streaming poller). Streaming stays on and the limit is untouched.
200− viewModel.pullRefresh()
201−
202− assertTrue(viewModel.state.value.isStreaming)
203− assertEquals(1000, viewModel.state.value.currentLimit)
204− }
205−
206188 @Test
207189 fun `toggleStreaming starts and stops`() {
208190 viewModel.initialize("test")
@@ -274,42 +256,6 @@ class LogViewerViewModelTest {
274256 }
275257 }
276258
277− @Test
278− fun `executeCustomSql strips trailing semicolon before appending LIMIT`() {
279− coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
280−
281− viewModel.initialize("test")
282− // Without stripping the ';', the appended LIMIT would land after the terminator
283− // ("...test_stream; LIMIT 5000") and produce invalid SQL.
284− viewModel.executeCustomSql("SELECT * FROM test_stream;")
285−
286− coVerify {
287− repository.queryLogsRaw(
288− match { it.trim() == "SELECT * FROM test_stream LIMIT 5000" },
289− any(),
290− any(),
291− )
292− }
293− }
294−
295− @Test
296− fun `executeCustomSql adds outer LIMIT when only a subquery has one`() {
297− coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
298−
299− viewModel.initialize("test")
300− // The inner LIMIT doesn't bound the outer result set, so the safety LIMIT must still
301− // be appended.
302− viewModel.executeCustomSql("SELECT * FROM (SELECT * FROM test_stream LIMIT 10) t")
303−
304− coVerify {
305− repository.queryLogsRaw(
306− match { it.contains("LIMIT 10") && it.trimEnd().endsWith("LIMIT 5000") },
307− any(),
308− any(),
309− )
310− }
311− }
312−
313259 @Test
314260 fun `addFilter escapes column names`() {
315261 viewModel.initialize("test")
app/src/test/java/com/parseable/android/LoginViewModelTest.kt+4−28
@@ -9,8 +9,6 @@ import io.mockk.*
99import kotlinx.coroutines.Dispatchers
1010import kotlinx.coroutines.ExperimentalCoroutinesApi
1111import kotlinx.coroutines.flow.flowOf
12−import kotlinx.coroutines.flow.toList
13−import kotlinx.coroutines.launch
1412import kotlinx.coroutines.test.*
1513import org.junit.After
1614import org.junit.Assert.*
@@ -39,17 +37,6 @@ class LoginViewModelTest {
3937 Dispatchers.resetMain()
4038 }
4139
42− /**
43− * Start collecting the one-shot login-success events into a list (on the eager
44− * UnconfinedTestDispatcher) before the action under test, so the success emission is observed.
45− * The collector lives in backgroundScope, which runTest auto-cancels.
46− */
47− private fun TestScope.collectSuccessEvents(): MutableList<Unit> {
48− val events = mutableListOf<Unit>()
49− backgroundScope.launch(testDispatcher) { viewModel.loginSuccessEvent.toList(events) }
50− return events
51− }
52−
5340 @Test
5441 fun `onLogin validates required fields`() {
5542 viewModel.onLogin()
@@ -78,11 +65,6 @@ class LoginViewModelTest {
7865 fun `onLogin succeeds with valid credentials`() = runTest {
7966 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
8067 coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo(version = "1.0"))
81− // Reach the success branch deterministically: the login-success event only fires when the
82− // credentials were durably saved.
83− coEvery { settingsRepository.saveServerConfig(any()) } returns true
84−
85− val events = collectSuccessEvents()
8668
8769 viewModel.onServerUrlChange("https://test.parseable.com")
8870 viewModel.onUsernameChange("admin")
@@ -90,7 +72,7 @@ class LoginViewModelTest {
9072 viewModel.onLogin()
9173
9274 val state = viewModel.state.value
93− assertEquals(1, events.size)
75+ assertTrue(state.loginSuccess)
9476 assertFalse(state.isLoading)
9577 coVerify { settingsRepository.saveServerConfig(any()) }
9678 }
@@ -99,15 +81,13 @@ class LoginViewModelTest {
9981 fun `onLogin fails when connection fails`() = runTest {
10082 coEvery { repository.testConnection() } returns ApiResult.Error("Connection refused", 0)
10183
102− val events = collectSuccessEvents()
103−
10484 viewModel.onServerUrlChange("https://bad.server")
10585 viewModel.onUsernameChange("admin")
10686 viewModel.onPasswordChange("password")
10787 viewModel.onLogin()
10888
10989 val state = viewModel.state.value
110− assertEquals(0, events.size)
90+ assertFalse(state.loginSuccess)
11191 assertNotNull(state.error)
11292 assertTrue(state.error!!.contains("Connection failed"))
11393 }
@@ -117,15 +97,13 @@ class LoginViewModelTest {
11797 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
11898 coEvery { repository.verifyServer() } returns ApiResult.Error("not parseable", 404)
11999
120− val events = collectSuccessEvents()
121−
122100 viewModel.onServerUrlChange("https://test.com")
123101 viewModel.onUsernameChange("admin")
124102 viewModel.onPasswordChange("password")
125103 viewModel.onLogin()
126104
127105 val state = viewModel.state.value
128− assertEquals(0, events.size)
106+ assertFalse(state.loginSuccess)
129107 assertNotNull(state.error)
130108 assertTrue(state.error!!.contains("doesn't appear to be Parseable"))
131109 }
@@ -135,15 +113,13 @@ class LoginViewModelTest {
135113 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
136114 coEvery { repository.verifyServer() } returns ApiResult.Error("Unauthorized", 401)
137115
138− val events = collectSuccessEvents()
139−
140116 viewModel.onServerUrlChange("https://test.parseable.com")
141117 viewModel.onUsernameChange("admin")
142118 viewModel.onPasswordChange("wrongpass")
143119 viewModel.onLogin()
144120
145121 val state = viewModel.state.value
146− assertEquals(0, events.size)
122+ assertFalse(state.loginSuccess)
147123 assertNotNull(state.error)
148124 assertTrue(state.error!!.contains("Invalid credentials"))
149125 assertFalse(state.error!!.contains("doesn't appear to be Parseable"))
app/src/test/java/com/parseable/android/PrivateHostTest.ktdeleted−73
@@ -1,73 +0,0 @@
1−package com.parseable.android
2−
3−import com.parseable.android.ui.screens.login.extractHost
4−import com.parseable.android.ui.screens.login.isPrivateHost
5−import org.junit.Assert.assertEquals
6−import org.junit.Assert.assertFalse
7−import org.junit.Assert.assertTrue
8−import org.junit.Test
9−
10−class PrivateHostTest {
11−
12− @Test
13− fun `extractHost strips scheme port and path`() {
14− assertEquals("192.168.1.5", extractHost("http://192.168.1.5:8000/path"))
15− assertEquals("example.com", extractHost("https://example.com"))
16− assertEquals("example.com", extractHost("example.com:9000"))
17− }
18−
19− @Test
20− fun `extractHost handles bracketed ipv6`() {
21− assertEquals("::1", extractHost("http://[::1]:8000"))
22− assertEquals("fe80::1", extractHost("[fe80::1]"))
23− }
24−
25− @Test
26− fun `extractHost leaves bare ipv6 intact`() {
27− assertEquals("fe80::1", extractHost("fe80::1"))
28− }
29−
30− @Test
31− fun `private ipv4 ranges are detected`() {
32− assertTrue(isPrivateHost("10.0.0.1"))
33− assertTrue(isPrivateHost("127.0.0.1"))
34− assertTrue(isPrivateHost("192.168.1.10"))
35− assertTrue(isPrivateHost("172.16.0.1"))
36− assertTrue(isPrivateHost("172.31.255.255"))
37− }
38−
39− @Test
40− fun `public ipv4 in the 172 gap is not private`() {
41− assertFalse(isPrivateHost("172.15.0.1"))
42− assertFalse(isPrivateHost("172.32.0.1"))
43− assertFalse(isPrivateHost("8.8.8.8"))
44− }
45−
46− @Test
47− fun `public hostnames sharing a private prefix are not private`() {
48− // The old prefix-string check wrongly classified these as private, hiding the
49− // plaintext-HTTP warning.
50− assertFalse(isPrivateHost("10.example.com"))
51− assertFalse(isPrivateHost("192.168.evil.com"))
52− }
53−
54− @Test
55− fun `localhost is private`() {
56− assertTrue(isPrivateHost("localhost"))
57− assertTrue(isPrivateHost("api.localhost"))
58− assertTrue(isPrivateHost(""))
59− }
60−
61− @Test
62− fun `ipv6 loopback and local ranges are private`() {
63− assertTrue(isPrivateHost("::1"))
64− assertTrue(isPrivateHost("fe80::1"))
65− assertTrue(isPrivateHost("fd00::1"))
66− assertTrue(isPrivateHost("fc00::1"))
67− }
68−
69− @Test
70− fun `public ipv6 is not private`() {
71− assertFalse(isPrivateHost("2001:4860:4860::8888"))
72− }
73−}
app/src/test/java/com/parseable/android/SettingsViewModelTest.kt+6−10
@@ -107,35 +107,31 @@ class SettingsViewModelTest {
107107 }
108108
109109 @Test
110− fun `load ignores users failure when about succeeds`() = runTest {
110+ fun `load sets error when users fail`() = runTest {
111111 coEvery { settingsRepository.serverConfig } returns flowOf(null)
112− coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo(version = "1.2.0"))
112+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
113113 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
114114
115115 val viewModel = SettingsViewModel(settingsRepository, repository)
116116
117− // The user list (/user) is secondary and is commonly forbidden for restricted accounts,
118− // so its failure must not surface a blocking error or hide otherwise-valid server info.
119117 val state = viewModel.state.value
120− assertNull(state.error)
121− assertEquals("1.2.0", state.aboutInfo?.version)
118+ assertNotNull(state.error)
122119 assertTrue(state.users.isEmpty())
123120 }
124121
125122 @Test
126− fun `load surfaces only the about error, not the users error`() = runTest {
123+ fun `load consolidates errors from both about and users`() = runTest {
127124 coEvery { settingsRepository.serverConfig } returns flowOf(null)
128125 coEvery { repository.getAbout() } returns ApiResult.Error("Server error", 500)
129126 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
130127
131128 val viewModel = SettingsViewModel(settingsRepository, repository)
132129
133− // Only the primary server-info call (/about) is treated as blocking; the secondary
134− // /user failure stays silent.
135130 val state = viewModel.state.value
136131 assertNotNull(state.error)
132+ // Both error messages should be present
137133 assertTrue(state.error!!.contains("Server error"))
138− assertFalse(state.error!!.contains("Permission denied"))
134+ assertTrue(state.error!!.contains("Permission denied"))
139135 }
140136
141137 @Test
app/src/test/java/com/parseable/android/SeverityDetectionTest.kt−13
@@ -128,7 +128,6 @@ class SeverityDetectionTest {
128128
129129 @Test
130130 fun `HTTP status code mapping`() {
131− assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "100")))
132131 assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "200")))
133132 assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "301")))
134133 assertEquals(LogSeverity.WARNING, detectSeverity(logWith("status" to "404")))
@@ -146,18 +145,6 @@ class SeverityDetectionTest {
146145 assertEquals(LogSeverity.TRACE, detectSeverity(logWith("level" to "100")))
147146 }
148147
149− @Test
150− fun `small-integer (Bunyan-style) numeric level mapping`() {
151− // Structured JSON loggers like Bunyan emit a numeric level field (trace=10 .. fatal=60,
152− // higher = more severe). These previously all collapsed into TRACE.
153− assertEquals(LogSeverity.FATAL, detectSeverity(logWith("level" to "60")))
154− assertEquals(LogSeverity.ERROR, detectSeverity(logWith("level" to "50")))
155− assertEquals(LogSeverity.WARNING, detectSeverity(logWith("level" to "40")))
156− assertEquals(LogSeverity.INFO, detectSeverity(logWith("level" to "30")))
157− assertEquals(LogSeverity.DEBUG, detectSeverity(logWith("level" to "20")))
158− assertEquals(LogSeverity.TRACE, detectSeverity(logWith("level" to "10")))
159− }
160−
161148 // --- Priority among fields ---
162149
163150 @Test
app/src/test/java/com/parseable/android/StreamsViewModelTest.kt−3
@@ -143,9 +143,6 @@ class StreamsViewModelTest {
143143 @Test
144144 fun `toggleFavorite removes when already favorited`() = runTest {
145145 every { favoriteDao.getAllNames() } returns flowOf(listOf("existing"))
146− // toggleFavorite decides insert-vs-delete from the committed DB state (isFavoriteNow),
147− // not the async-mirrored favoriteNames flow, so stub that as the source of truth.
148− coEvery { favoriteDao.isFavoriteNow("existing") } returns true
149146
150147 viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
151148 viewModel.toggleFavorite("existing")