git.iko.soy/parseable-android
on master help sign in
.dockerignorenew file+15
since splitsnapshots
unifiedsplit
@@ -0,0 +1,15 @@
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/
.github/workflows/build-release.yml+7
@@ -12,6 +12,8 @@ concurrency:
12
12
13permissions:
13permissions:
14 contents: write
14 contents: write
15+ id-token: write
16+ attestations: write
15
17
16jobs:
18jobs:
17 build:
19 build:
@@ -60,6 +62,11 @@ jobs:
60 name: parseable-android-release
62 name: parseable-android-release
61 path: app/build/outputs/apk/release/*.apk
63 path: app/build/outputs/apk/release/*.apk
62
64
65+ - name: Attest release APK
66+ uses: actions/attest-build-provenance@v2
67+ with:
68+ subject-path: app/build/outputs/apk/release/*.apk
69+
63 - name: Create Draft Release
70 - name: Create Draft Release
64 if: startsWith(github.ref, 'refs/tags/v')
71 if: startsWith(github.ref, 'refs/tags/v')
65 uses: softprops/action-gh-release@v2
72 uses: softprops/action-gh-release@v2
.github/workflows/ci.yml+7
@@ -79,6 +79,8 @@ jobs:
79 cancel-in-progress: false
79 cancel-in-progress: false
80 permissions:
80 permissions:
81 contents: write
81 contents: write
82+ id-token: write
83+ attestations: write
82
84
83 steps:
85 steps:
84 - name: Checkout code
86 - name: Checkout code
@@ -124,6 +126,11 @@ jobs:
124 mv "$apk" "app/build/outputs/apk/release/parseable-android-${VERSION_NAME}.apk"
126 mv "$apk" "app/build/outputs/apk/release/parseable-android-${VERSION_NAME}.apk"
125 done
127 done
126
128
129+ - name: Attest release APK
130+ uses: actions/attest-build-provenance@v2
131+ with:
132+ subject-path: app/build/outputs/apk/release/*.apk
133+
127 - name: Create release
134 - name: Create release
128 uses: softprops/action-gh-release@v2
135 uses: softprops/action-gh-release@v2
129 with:
136 with:
Dockerfilenew file+72
@@ -0,0 +1,72 @@
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+21−1
@@ -51,6 +51,17 @@ class MainActivity : ComponentActivity() {
51 repository.configure(savedConfig)
51 repository.configure(savedConfig)
52 }
52 }
53 startDestination = if (savedConfig != null) Routes.STREAMS else Routes.LOGIN
53 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+ }
54 }
65 }
55
66
56 val dest = startDestination
67 val dest = startDestination
@@ -61,8 +72,17 @@ class MainActivity : ComponentActivity() {
61 LaunchedEffect(navController) {
72 LaunchedEffect(navController) {
62 repository.authErrors
73 repository.authErrors
63 .onEach {
74 .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
64 settingsRepository.clearConfig()
82 settingsRepository.clearConfig()
65− errorHandler.showError("Session expired. Please log in again.")
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.
66 navController.navigate(Routes.login(sessionExpired = true)) {
86 navController.navigate(Routes.login(sessionExpired = true)) {
67 popUpTo(0) { inclusive = true }
87 popUpTo(0) { inclusive = true }
68 }
88 }
app/src/main/java/com/parseable/android/data/FormatUtils.kt+19−2
@@ -2,7 +2,10 @@ package com.parseable.android.data
2
2
3import java.util.Locale
3import java.util.Locale
4
4
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.
5−private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB")
8+private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
6
9
7/**
10/**
8 * Converts a raw byte-count string (e.g. "123456789") to a human-readable
11 * Converts a raw byte-count string (e.g. "123456789") to a human-readable
@@ -12,14 +15,28 @@ private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB")
12fun formatBytes(raw: String?): String? {
15fun formatBytes(raw: String?): String? {
13 if (raw == null) return null
16 if (raw == null) return null
14 val bytes = raw.trim().toDoubleOrNull() ?: return raw
17 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
15 if (bytes < 0) return raw
21 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.
16− if (bytes < 1024) return String.format(Locale.US, "%.0f B", bytes)
26+ if (bytes < 1023.5) return String.format(Locale.US, "%.0f B", bytes)
17 var value = bytes
27 var value = bytes
18 var unitIndex = 0
28 var unitIndex = 0
19 while (value >= 1024 && unitIndex < BYTE_UNITS.size - 1) {
29 while (value >= 1024 && unitIndex < BYTE_UNITS.size - 1) {
20 value /= 1024
30 value /= 1024
21 unitIndex++
31 unitIndex++
22 }
32 }
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+ }
23 return if (value >= 100) {
40 return if (value >= 100) {
24 String.format(Locale.US, "%.0f %s", value, BYTE_UNITS[unitIndex])
41 String.format(Locale.US, "%.0f %s", value, BYTE_UNITS[unitIndex])
25 } else if (value >= 10) {
42 } else if (value >= 10) {
app/src/main/java/com/parseable/android/data/SqlUtils.kt+16
@@ -5,3 +5,19 @@ fun escapeSql(value: String): String = value.replace("'", "''")
5
5
6/** Escape a SQL identifier (table/column name) for use inside double quotes. */
6/** Escape a SQL identifier (table/column name) for use inside double quotes. */
7fun escapeIdentifier(name: String): String = name.replace("\"", "\"\"")
7fun 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+57−14
@@ -1,26 +1,69 @@
1package com.parseable.android.data
1package com.parseable.android.data
2
2
3import java.time.Instant
3import java.time.Instant
4+import java.time.LocalDateTime
5+import java.time.OffsetDateTime
4import java.time.ZoneId
6import java.time.ZoneId
7+import java.time.ZoneOffset
5import java.time.ZonedDateTime
8import java.time.ZonedDateTime
6import java.time.format.DateTimeFormatter
9import java.time.format.DateTimeFormatter
10+import java.util.Locale
7
11
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.
8−private val displayFormatter = DateTimeFormatter.ofPattern("MMM dd HH:mm:ss.SSS")
14+private val displayFormatter = DateTimeFormatter.ofPattern("MMM dd HH:mm:ss.SSS", Locale.US)
9
15
10fun formatTimestamp(raw: String): String {
16fun formatTimestamp(raw: String): String {
11− return try {
17+ // Parseable/DataFusion sometimes renders p_timestamp with a space between the date and
12− val instant = Instant.parse(raw)
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)
13− val local = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
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)
14− local.format(displayFormatter)
50+ return offset.atZoneSameInstant(ZoneId.systemDefault()).format(displayFormatter)
15 } catch (_: Exception) {
51 } catch (_: Exception) {
16− // Try alternate format with +00:00 suffix
52+ // Fall through to the naive (offset-less) parser.
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− }
25 }
53 }
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
26}
69}
app/src/main/java/com/parseable/android/data/api/ParseableApiClient.kt+88−31
@@ -77,11 +77,18 @@ class ParseableApiClient @Inject constructor() {
77
77
78 fun configure(serverConfig: ServerConfig) {
78 fun configure(serverConfig: ServerConfig) {
79 val url = serverConfig.serverUrl.trimEnd('/')
79 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 {
80− val credentials = "${serverConfig.username}:${serverConfig.password}"
85+ val credentials = "${serverConfig.username}:${serverConfig.password}"
81− val auth = "Basic " + android.util.Base64.encodeToString(
86+ "Basic " + android.util.Base64.encodeToString(
87+ // Explicit UTF-8 per RFC 7617 rather than the platform default charset.
82− credentials.toByteArray(),
88+ credentials.toByteArray(Charsets.UTF_8),
83− android.util.Base64.NO_WRAP
89+ android.util.Base64.NO_WRAP
84− )
90+ )
91+ }
85 config = ClientConfig(
92 config = ClientConfig(
86 baseUrl = url,
93 baseUrl = url,
87 authHeader = auth,
94 authHeader = auth,
@@ -96,30 +103,48 @@ class ParseableApiClient @Inject constructor() {
96 }
103 }
97
104
98 fun shutdown() {
105 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.
99 sharedPool.evictAll()
110 sharedPool.evictAll()
100− sharedDispatcher.executorService.shutdown()
101 }
111 }
102
112
103 private fun encodePathSegment(segment: String): String =
113 private fun encodePathSegment(segment: String): String =
104 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
114 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
105
115
106 private fun buildRequest(path: String): Request.Builder {
116 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.
107 val snapshot = config
122 val snapshot = config
123+ val selectedClient = if (snapshot.allowInsecure) insecureClient else secureClient
108− return Request.Builder()
124+ val builder = Request.Builder()
109 .url("${snapshot.baseUrl}$path")
125 .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()) {
110− .header("Authorization", snapshot.authHeader)
130+ builder.header("Authorization", snapshot.authHeader)
131+ }
132+ return builder
111 }
133 }
112
134
113 private suspend fun executeRequest(request: Request): ApiResult<String> =
135 private suspend fun executeRequest(request: Request): ApiResult<String> =
114 withContext(Dispatchers.IO) {
136 withContext(Dispatchers.IO) {
115 try {
137 try {
138+ val callClient = request.tag(OkHttpClient::class.java) ?: client
116− client.newCall(request).execute().use { response ->
139+ callClient.newCall(request).execute().use { response ->
117 val body = response.body?.string() ?: ""
140 val body = response.body?.string() ?: ""
118 if (response.isSuccessful) {
141 if (response.isSuccessful) {
119 ApiResult.Success(body)
142 ApiResult.Success(body)
120 } else {
143 } else {
121 ApiResult.Error(
144 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.
122− message = body.ifEmpty { response.message },
147+ message = body.ifBlank { response.message },
123 code = response.code
148 code = response.code
124 )
149 )
125 }
150 }
@@ -127,7 +152,7 @@ class ParseableApiClient @Inject constructor() {
127 } catch (e: SocketTimeoutException) {
152 } catch (e: SocketTimeoutException) {
128 ApiResult.Error(message = "Connection timed out")
153 ApiResult.Error(message = "Connection timed out")
129 } catch (e: UnknownHostException) {
154 } catch (e: UnknownHostException) {
130− ApiResult.Error(message = "Unable to resolve host \"${e.message}\"")
155+ ApiResult.Error(message = "Unable to resolve host")
131 } catch (e: ConnectException) {
156 } catch (e: ConnectException) {
132 ApiResult.Error(message = "Connection refused. Is the server running?")
157 ApiResult.Error(message = "Connection refused. Is the server running?")
133 } catch (e: SSLException) {
158 } catch (e: SSLException) {
@@ -140,13 +165,21 @@ class ParseableApiClient @Inject constructor() {
140 }
165 }
141 }
166 }
142
167
143− private fun <T> parseResponse(body: String, description: String, parse: () -> T): ApiResult<T> {
168+ private fun <T> parseResponse(description: String, parse: () -> T): ApiResult<T> {
144 return try {
169 return try {
145 ApiResult.Success(parse())
170 ApiResult.Success(parse())
146 } catch (e: SerializationException) {
171 } catch (e: SerializationException) {
147− ApiResult.Error("Invalid response format from server")
172+ ApiResult.Error("Invalid response format from server", code = ApiResult.PARSE_ERROR_CODE)
148 } catch (e: IllegalArgumentException) {
173 } catch (e: IllegalArgumentException) {
149− ApiResult.Error("Unexpected response format for $description")
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)
150 }
183 }
151 }
184 }
152
185
@@ -156,7 +189,7 @@ class ParseableApiClient @Inject constructor() {
156 suspend fun getAbout(): ApiResult<AboutInfo> {
189 suspend fun getAbout(): ApiResult<AboutInfo> {
157 val request = buildRequest("/api/v1/about").get().build()
190 val request = buildRequest("/api/v1/about").get().build()
158 return when (val result = executeRequest(request)) {
191 return when (val result = executeRequest(request)) {
159− is ApiResult.Success -> parseResponse(result.data, "about info") {
192+ is ApiResult.Success -> parseResponse("about info") {
160 json.decodeFromString<AboutInfo>(result.data)
193 json.decodeFromString<AboutInfo>(result.data)
161 }
194 }
162 is ApiResult.Error -> result
195 is ApiResult.Error -> result
@@ -197,7 +230,7 @@ class ParseableApiClient @Inject constructor() {
197 }
230 }
198 ApiResult.Success(streams)
231 ApiResult.Success(streams)
199 } catch (e2: Exception) {
232 } catch (e2: Exception) {
200− ApiResult.Error("Failed to parse streams: ${e2.message}")
233+ ApiResult.Error("Failed to parse streams: ${e2.message}", code = ApiResult.PARSE_ERROR_CODE)
201 }
234 }
202 }
235 }
203 }
236 }
@@ -212,7 +245,7 @@ class ParseableApiClient @Inject constructor() {
212 val encoded = encodePathSegment(stream)
245 val encoded = encodePathSegment(stream)
213 val request = buildRequest("/api/v1/logstream/$encoded/schema").get().build()
246 val request = buildRequest("/api/v1/logstream/$encoded/schema").get().build()
214 return when (val result = executeRequest(request)) {
247 return when (val result = executeRequest(request)) {
215− is ApiResult.Success -> parseResponse(result.data, "schema") {
248+ is ApiResult.Success -> parseResponse("schema") {
216 json.decodeFromString<StreamSchema>(result.data)
249 json.decodeFromString<StreamSchema>(result.data)
217 }
250 }
218 is ApiResult.Error -> result
251 is ApiResult.Error -> result
@@ -226,7 +259,7 @@ class ParseableApiClient @Inject constructor() {
226 val encoded = encodePathSegment(stream)
259 val encoded = encodePathSegment(stream)
227 val request = buildRequest("/api/v1/logstream/$encoded/stats").get().build()
260 val request = buildRequest("/api/v1/logstream/$encoded/stats").get().build()
228 return when (val result = executeRequest(request)) {
261 return when (val result = executeRequest(request)) {
229− is ApiResult.Success -> parseResponse(result.data, "stats") {
262+ is ApiResult.Success -> parseResponse("stats") {
230 json.decodeFromString<StreamStats>(result.data)
263 json.decodeFromString<StreamStats>(result.data)
231 }
264 }
232 is ApiResult.Error -> result
265 is ApiResult.Error -> result
@@ -240,7 +273,7 @@ class ParseableApiClient @Inject constructor() {
240 val encoded = encodePathSegment(stream)
273 val encoded = encodePathSegment(stream)
241 val request = buildRequest("/api/v1/logstream/$encoded/info").get().build()
274 val request = buildRequest("/api/v1/logstream/$encoded/info").get().build()
242 return when (val result = executeRequest(request)) {
275 return when (val result = executeRequest(request)) {
243− is ApiResult.Success -> parseResponse(result.data, "stream info") {
276+ is ApiResult.Success -> parseResponse("stream info") {
244 val element = json.parseToJsonElement(result.data)
277 val element = json.parseToJsonElement(result.data)
245 (element as? JsonObject)
278 (element as? JsonObject)
246 ?: throw IllegalArgumentException("Expected JSON object, got ${element::class.simpleName}")
279 ?: throw IllegalArgumentException("Expected JSON object, got ${element::class.simpleName}")
@@ -260,8 +293,15 @@ class ParseableApiClient @Inject constructor() {
260 try {
293 try {
261 ApiResult.Success(json.decodeFromString<List<RetentionConfig>>(result.data))
294 ApiResult.Success(json.decodeFromString<List<RetentionConfig>>(result.data))
262 } catch (_: Exception) {
295 } catch (_: Exception) {
263− parseResponse(result.data, "retention config") {
296+ parseResponse("retention config") {
264− listOf(json.decodeFromString<RetentionConfig>(result.data))
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+ }
265 }
305 }
266 }
306 }
267 }
307 }
@@ -291,7 +331,7 @@ class ParseableApiClient @Inject constructor() {
291 .build()
331 .build()
292
332
293 return when (val result = executeRequest(request)) {
333 return when (val result = executeRequest(request)) {
294− is ApiResult.Success -> parseResponse(result.data, "query results") {
334+ is ApiResult.Success -> parseResponse("query results") {
295 val element = json.parseToJsonElement(result.data)
335 val element = json.parseToJsonElement(result.data)
296 val elements = (element as? JsonArray)
336 val elements = (element as? JsonArray)
297 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
337 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
@@ -316,11 +356,14 @@ class ParseableApiClient @Inject constructor() {
316 suspend fun listAlerts(): ApiResult<List<Alert>> {
356 suspend fun listAlerts(): ApiResult<List<Alert>> {
317 val request = buildRequest("/api/v1/alerts").get().build()
357 val request = buildRequest("/api/v1/alerts").get().build()
318 return when (val result = executeRequest(request)) {
358 return when (val result = executeRequest(request)) {
319− is ApiResult.Success -> parseResponse(result.data, "alerts") {
359+ is ApiResult.Success -> parseResponse("alerts") {
320 val element = json.parseToJsonElement(result.data)
360 val element = json.parseToJsonElement(result.data)
321 val alertsArray = when (element) {
361 val alertsArray = when (element) {
322 is JsonArray -> element
362 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.
323− is JsonObject -> element["alerts"]?.jsonArray ?: JsonArray(emptyList())
366+ is JsonObject -> (element["alerts"] as? JsonArray) ?: JsonArray(emptyList())
324 else -> JsonArray(emptyList())
367 else -> JsonArray(emptyList())
325 }
368 }
326 alertsArray.mapNotNull { alertElement ->
369 alertsArray.mapNotNull { alertElement ->
@@ -355,7 +398,7 @@ class ParseableApiClient @Inject constructor() {
355 .post(requestBody)
398 .post(requestBody)
356 .build()
399 .build()
357 return when (val result = executeRequest(request)) {
400 return when (val result = executeRequest(request)) {
358− is ApiResult.Success -> parseResponse(result.data, "alert") {
401+ is ApiResult.Success -> parseResponse("alert") {
359 json.decodeFromString<Alert>(result.data)
402 json.decodeFromString<Alert>(result.data)
360 }
403 }
361 is ApiResult.Error -> result
404 is ApiResult.Error -> result
@@ -368,7 +411,7 @@ class ParseableApiClient @Inject constructor() {
368 suspend fun listUsers(): ApiResult<List<JsonObject>> {
411 suspend fun listUsers(): ApiResult<List<JsonObject>> {
369 val request = buildRequest("/api/v1/user").get().build()
412 val request = buildRequest("/api/v1/user").get().build()
370 return when (val result = executeRequest(request)) {
413 return when (val result = executeRequest(request)) {
371− is ApiResult.Success -> parseResponse(result.data, "users") {
414+ is ApiResult.Success -> parseResponse("users") {
372 val element = json.parseToJsonElement(result.data)
415 val element = json.parseToJsonElement(result.data)
373 val elements = (element as? JsonArray)
416 val elements = (element as? JsonArray)
374 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
417 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
@@ -384,8 +427,22 @@ class ParseableApiClient @Inject constructor() {
384 suspend fun listFilters(): ApiResult<List<SavedFilter>> {
427 suspend fun listFilters(): ApiResult<List<SavedFilter>> {
385 val request = buildRequest("/api/v1/filters").get().build()
428 val request = buildRequest("/api/v1/filters").get().build()
386 return when (val result = executeRequest(request)) {
429 return when (val result = executeRequest(request)) {
387− is ApiResult.Success -> parseResponse(result.data, "filters") {
430+ is ApiResult.Success -> parseResponse("filters") {
388− json.decodeFromString<List<SavedFilter>>(result.data)
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+ }
389 }
446 }
390 is ApiResult.Error -> result
447 is ApiResult.Error -> result
391 }
448 }
@@ -398,7 +455,7 @@ class ParseableApiClient @Inject constructor() {
398 val encoded = encodePathSegment(filterId)
455 val encoded = encodePathSegment(filterId)
399 val request = buildRequest("/api/v1/filters/$encoded").get().build()
456 val request = buildRequest("/api/v1/filters/$encoded").get().build()
400 return when (val result = executeRequest(request)) {
457 return when (val result = executeRequest(request)) {
401− is ApiResult.Success -> parseResponse(result.data, "filter") {
458+ is ApiResult.Success -> parseResponse("filter") {
402 json.decodeFromString<SavedFilter>(result.data)
459 json.decodeFromString<SavedFilter>(result.data)
403 }
460 }
404 is ApiResult.Error -> result
461 is ApiResult.Error -> result
@@ -415,7 +472,7 @@ class ParseableApiClient @Inject constructor() {
415 .post(requestBody)
472 .post(requestBody)
416 .build()
473 .build()
417 return when (val result = executeRequest(request)) {
474 return when (val result = executeRequest(request)) {
418− is ApiResult.Success -> parseResponse(result.data, "filter") {
475+ is ApiResult.Success -> parseResponse("filter") {
419 json.decodeFromString<SavedFilter>(result.data)
476 json.decodeFromString<SavedFilter>(result.data)
420 }
477 }
421 is ApiResult.Error -> result
478 is ApiResult.Error -> result
@@ -433,7 +490,7 @@ class ParseableApiClient @Inject constructor() {
433 .put(requestBody)
490 .put(requestBody)
434 .build()
491 .build()
435 return when (val result = executeRequest(request)) {
492 return when (val result = executeRequest(request)) {
436− is ApiResult.Success -> parseResponse(result.data, "filter") {
493+ is ApiResult.Success -> parseResponse("filter") {
437 json.decodeFromString<SavedFilter>(result.data)
494 json.decodeFromString<SavedFilter>(result.data)
438 }
495 }
439 is ApiResult.Error -> result
496 is ApiResult.Error -> result
app/src/main/java/com/parseable/android/data/local/FavoriteStreamDao.kt+11−1
@@ -12,10 +12,14 @@ interface FavoriteStreamDao {
12 @Query("SELECT * FROM favorite_streams ORDER BY addedAt DESC")
12 @Query("SELECT * FROM favorite_streams ORDER BY addedAt DESC")
13 fun getAll(): Flow<List<FavoriteStream>>
13 fun getAll(): Flow<List<FavoriteStream>>
14
14
15+ // Ordered most-recently-favorited first so the UI can show favorites in recency order
16+ // (toSet() downstream preserves this iteration order via LinkedHashSet).
15 @Query("SELECT streamName FROM favorite_streams ORDER BY addedAt DESC")
17 @Query("SELECT streamName FROM favorite_streams ORDER BY addedAt DESC")
16 fun getAllNames(): Flow<List<String>>
18 fun getAllNames(): Flow<List<String>>
17
19
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.
18− @Insert(onConflict = OnConflictStrategy.REPLACE)
22+ @Insert(onConflict = OnConflictStrategy.IGNORE)
19 suspend fun insert(favorite: FavoriteStream)
23 suspend fun insert(favorite: FavoriteStream)
20
24
21 @Delete
25 @Delete
@@ -26,4 +30,10 @@ interface FavoriteStreamDao {
26
30
27 @Query("SELECT EXISTS(SELECT 1 FROM favorite_streams WHERE streamName = :name)")
31 @Query("SELECT EXISTS(SELECT 1 FROM favorite_streams WHERE streamName = :name)")
28 fun isFavorite(name: String): Flow<Boolean>
32 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
29}
39}
app/src/main/java/com/parseable/android/data/local/ParseableDatabase.kt+1−1
@@ -5,7 +5,7 @@ import androidx.room.RoomDatabase
5
5
6@Database(
6@Database(
7 entities = [FavoriteStream::class, SavedServer::class],
7 entities = [FavoriteStream::class, SavedServer::class],
8− version = 2,
8+ version = 3,
9 exportSchema = true,
9 exportSchema = true,
10)
10)
11abstract class ParseableDatabase : RoomDatabase() {
11abstract class ParseableDatabase : RoomDatabase() {
app/src/main/java/com/parseable/android/data/local/SavedServer.kt+8−1
@@ -1,9 +1,16 @@
1package com.parseable.android.data.local
1package com.parseable.android.data.local
2
2
3import androidx.room.Entity
3import androidx.room.Entity
4+import androidx.room.Index
4import androidx.room.PrimaryKey
5import androidx.room.PrimaryKey
5
6
7+@Entity(
6−@Entity(tableName = "saved_servers")
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+)
7data class SavedServer(
14data class SavedServer(
8 @PrimaryKey(autoGenerate = true)
15 @PrimaryKey(autoGenerate = true)
9 val id: Long = 0,
16 val id: Long = 0,
app/src/main/java/com/parseable/android/data/model/Models.kt+57−2
@@ -1,9 +1,43 @@
1package com.parseable.android.data.model
1package com.parseable.android.data.model
2
2
3+import kotlinx.serialization.KSerializer
3import kotlinx.serialization.SerialName
4import kotlinx.serialization.SerialName
4import kotlinx.serialization.Serializable
5import 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
5import kotlinx.serialization.json.JsonElement
12import kotlinx.serialization.json.JsonElement
13+import kotlinx.serialization.json.JsonNull
6import kotlinx.serialization.json.JsonObject
14import 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+}
7
41
8/**
42/**
9 * Server connection configuration stored in DataStore.
43 * Server connection configuration stored in DataStore.
@@ -47,25 +81,31 @@ data class StreamStats(
47@Serializable
81@Serializable
48data class IngestionStats(
82data class IngestionStats(
49 val count: Long? = null,
83 val count: Long? = null,
84+ @Serializable(with = FlexibleStringSerializer::class)
50 val size: String? = null,
85 val size: String? = null,
51 val format: String? = null,
86 val format: String? = null,
52 @SerialName("lifetime_count")
87 @SerialName("lifetime_count")
53 val lifetimeCount: Long? = null,
88 val lifetimeCount: Long? = null,
54 @SerialName("lifetime_size")
89 @SerialName("lifetime_size")
90+ @Serializable(with = FlexibleStringSerializer::class)
55 val lifetimeSize: String? = null,
91 val lifetimeSize: String? = null,
56 @SerialName("deleted_count")
92 @SerialName("deleted_count")
57 val deletedCount: Long? = null,
93 val deletedCount: Long? = null,
58 @SerialName("deleted_size")
94 @SerialName("deleted_size")
95+ @Serializable(with = FlexibleStringSerializer::class)
59 val deletedSize: String? = null,
96 val deletedSize: String? = null,
60)
97)
61
98
62@Serializable
99@Serializable
63data class StorageStats(
100data class StorageStats(
101+ @Serializable(with = FlexibleStringSerializer::class)
64 val size: String? = null,
102 val size: String? = null,
65 val format: String? = null,
103 val format: String? = null,
66 @SerialName("lifetime_size")
104 @SerialName("lifetime_size")
105+ @Serializable(with = FlexibleStringSerializer::class)
67 val lifetimeSize: String? = null,
106 val lifetimeSize: String? = null,
68 @SerialName("deleted_size")
107 @SerialName("deleted_size")
108+ @Serializable(with = FlexibleStringSerializer::class)
69 val deletedSize: String? = null,
109 val deletedSize: String? = null,
70)
110)
71
111
@@ -152,11 +192,14 @@ data class Alert(
152 val datasets: List<String> = emptyList(),
192 val datasets: List<String> = emptyList(),
153) {
193) {
154 /** Best-effort display name from either response format. */
194 /** Best-effort display name from either response format. */
155− val displayName: String get() = title ?: name ?: "Unnamed Alert"
195+ val displayName: String
196+ get() = title?.takeIf { it.isNotBlank() }
197+ ?: name?.takeIf { it.isNotBlank() }
198+ ?: "Unnamed Alert"
156
199
157 /** Whether the alert is active, derived from either response format. */
200 /** Whether the alert is active, derived from either response format. */
158 val isEnabled: Boolean get() = when {
201 val isEnabled: Boolean get() = when {
159− state != null -> state != "Disabled"
202+ state != null -> !state.equals("Disabled", ignoreCase = true)
160 enabled != null -> enabled
203 enabled != null -> enabled
161 else -> true
204 else -> true
162 }
205 }
@@ -178,6 +221,8 @@ sealed class ApiResult<out T> {
178 val isUnauthorized: Boolean get() = code == 401
221 val isUnauthorized: Boolean get() = code == 401
179 val isNotFound: Boolean get() = code == 404
222 val isNotFound: Boolean get() = code == 404
180 val isServerError: Boolean get() = code in 500..599
223 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.
181 val isNetworkError: Boolean get() = code == 0
226 val isNetworkError: Boolean get() = code == 0
182
227
183 val userMessage: String get() = when {
228 val userMessage: String get() = when {
@@ -195,4 +240,14 @@ sealed class ApiResult<out T> {
195 else -> message
240 else -> message
196 }
241 }
197 }
242 }
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+ }
198}
253}
app/src/main/java/com/parseable/android/data/repository/ParseableRepository.kt+15−4
@@ -71,12 +71,23 @@ class ParseableRepository @Inject constructor(
71 * Does NOT trigger [checkAuth] so the global auth-error handler won't fire during login.
71 * Does NOT trigger [checkAuth] so the global auth-error handler won't fire during login.
72 */
72 */
73 suspend fun verifyServer(): ApiResult<AboutInfo> {
73 suspend fun verifyServer(): ApiResult<AboutInfo> {
74− return when (val result = apiClient.getAbout()) {
74+ return when (val aboutResult = apiClient.getAbout()) {
75 is ApiResult.Success -> {
75 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 -> {
76− aboutCache = CacheEntry(result.data)
84+ aboutCache = CacheEntry(aboutResult.data)
77− result
85+ aboutResult
86+ }
87+ is ApiResult.Error -> authProbe
88+ }
78 }
89 }
79− is ApiResult.Error -> result
90+ is ApiResult.Error -> aboutResult
80 }
91 }
81 }
92 }
82
93
app/src/main/java/com/parseable/android/data/repository/SettingsRepository.kt+139−21
@@ -73,8 +73,17 @@ class SettingsRepository @Inject constructor(
73 Timber.e(e, "Failed to read DataStore")
73 Timber.e(e, "Failed to read DataStore")
74 emit(emptyPreferences())
74 emit(emptyPreferences())
75 }
75 }
76− .map { prefs ->
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.
77 configMutex.withLock {
85 configMutex.withLock {
86+ val prefs = context.dataStore.data.first()
78 val url = prefs[serverUrlKey] ?: return@withLock null
87 val url = prefs[serverUrlKey] ?: return@withLock null
79 val user = prefs[usernameKey] ?: return@withLock null
88 val user = prefs[usernameKey] ?: return@withLock null
80 val pass = try {
89 val pass = try {
@@ -103,19 +112,33 @@ class SettingsRepository @Inject constructor(
103
112
104 suspend fun getSavedPassword(): ServerConfig? = serverConfig.first()
113 suspend fun getSavedPassword(): ServerConfig? = serverConfig.first()
105
114
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+ */
106− suspend fun saveServerConfig(config: ServerConfig) {
120+ suspend fun saveServerConfig(config: ServerConfig): Boolean {
107− configMutex.withLock {
121+ return configMutex.withLock {
108 try {
122 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+ }
109 context.dataStore.edit { prefs ->
133 context.dataStore.edit { prefs ->
110 prefs[serverUrlKey] = config.serverUrl
134 prefs[serverUrlKey] = config.serverUrl
111 prefs[usernameKey] = config.username
135 prefs[usernameKey] = config.username
112 prefs[useTlsKey] = config.useTls
136 prefs[useTlsKey] = config.useTls
113 }
137 }
114− withContext(Dispatchers.IO) {
138+ true
115− encryptedPrefs.edit().putString("password", config.password).apply()
116− }
117 } catch (e: Exception) {
139 } catch (e: Exception) {
118 Timber.e(e, "Failed to save server config")
140 Timber.e(e, "Failed to save server config")
141+ false
119 }
142 }
120 }
143 }
121 }
144 }
@@ -130,7 +153,9 @@ class SettingsRepository @Inject constructor(
130 prefs.remove(activeServerIdKey)
153 prefs.remove(activeServerIdKey)
131 }
154 }
132 withContext(Dispatchers.IO) {
155 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.
133− encryptedPrefs.edit().remove("password").apply()
158+ encryptedPrefs.edit().remove("password").commit()
134 }
159 }
135 } catch (e: Exception) {
160 } catch (e: Exception) {
136 Timber.e(e, "Failed to clear config")
161 Timber.e(e, "Failed to clear config")
@@ -145,12 +170,15 @@ class SettingsRepository @Inject constructor(
145 /**
170 /**
146 * Persist the current server connection as a saved server entry.
171 * Persist the current server connection as a saved server entry.
147 * If a server with the same URL and username already exists, updates it.
172 * If a server with the same URL and username already exists, updates it.
148− * Returns the server ID.
173+ * Returns the server ID, or null if the credentials could not be persisted.
149 */
174 */
150− suspend fun saveServer(config: ServerConfig): Long {
175+ suspend fun saveServer(config: ServerConfig): Long? {
151 return configMutex.withLock {
176 return configMutex.withLock {
152 val existing = savedServerDao.findByUrlAndUsername(config.serverUrl, config.username)
177 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.
153− val passwordKey = existing?.passwordKey ?: "server_pwd_${System.currentTimeMillis()}"
181+ val passwordKey = existing?.passwordKey ?: "server_pwd_${java.util.UUID.randomUUID()}"
154 val server = SavedServer(
182 val server = SavedServer(
155 id = existing?.id ?: 0,
183 id = existing?.id ?: 0,
156 serverUrl = config.serverUrl,
184 serverUrl = config.serverUrl,
@@ -159,8 +187,19 @@ class SettingsRepository @Inject constructor(
159 passwordKey = passwordKey,
187 passwordKey = passwordKey,
160 addedAt = existing?.addedAt ?: System.currentTimeMillis(),
188 addedAt = existing?.addedAt ?: System.currentTimeMillis(),
161 )
189 )
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).
162− withContext(Dispatchers.IO) {
194+ val passwordSaved = withContext(Dispatchers.IO) {
163− encryptedPrefs.edit().putString(passwordKey, config.password).apply()
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
164 }
203 }
165 val id = savedServerDao.insert(server)
204 val id = savedServerDao.insert(server)
166
205
@@ -195,6 +234,16 @@ class SettingsRepository @Inject constructor(
195 useTls = server.useTls,
234 useTls = server.useTls,
196 )
235 )
197
236
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+ }
198 // Write to active config
247 // Write to active config
199 context.dataStore.edit { prefs ->
248 context.dataStore.edit { prefs ->
200 prefs[serverUrlKey] = config.serverUrl
249 prefs[serverUrlKey] = config.serverUrl
@@ -202,9 +251,6 @@ class SettingsRepository @Inject constructor(
202 prefs[useTlsKey] = config.useTls
251 prefs[useTlsKey] = config.useTls
203 prefs[activeServerIdKey] = serverId
252 prefs[activeServerIdKey] = serverId
204 }
253 }
205− withContext(Dispatchers.IO) {
206− encryptedPrefs.edit().putString("password", config.password).apply()
207− }
208 config
254 config
209 }
255 }
210 }
256 }
@@ -212,20 +258,92 @@ class SettingsRepository @Inject constructor(
212 /**
258 /**
213 * Remove a saved server and its stored password.
259 * Remove a saved server and its stored password.
214 * If the deleted server was the active one, the active server ID is cleared.
260 * 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.
215 */
263 */
216− suspend fun deleteServer(serverId: Long) {
264+ suspend fun deleteServer(serverId: Long): Boolean {
217− configMutex.withLock {
265+ return configMutex.withLock {
218− val server = savedServerDao.getById(serverId) ?: return@withLock
266+ val server = savedServerDao.getById(serverId) ?: return@withLock false
219 withContext(Dispatchers.IO) {
267 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.
220− encryptedPrefs.edit().remove(server.passwordKey).apply()
270+ encryptedPrefs.edit().remove(server.passwordKey).commit()
221 }
271 }
222 savedServerDao.deleteById(serverId)
272 savedServerDao.deleteById(serverId)
223
273
224− // Clear active server reference if it pointed at the deleted server
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).
225 val currentActiveId = context.dataStore.data.first()[activeServerIdKey]
279 val currentActiveId = context.dataStore.data.first()[activeServerIdKey]
226− if (currentActiveId == serverId) {
280+ val wasActive = currentActiveId == serverId
281+ if (wasActive) {
227 context.dataStore.edit { prefs ->
282 context.dataStore.edit { prefs ->
228 prefs.remove(activeServerIdKey)
283 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")
229 }
347 }
230 }
348 }
231 }
349 }
app/src/main/java/com/parseable/android/di/AppModule.kt+53
@@ -2,6 +2,8 @@ package com.parseable.android.di
2
2
3import android.content.Context
3import android.content.Context
4import androidx.room.Room
4import androidx.room.Room
5+import androidx.room.migration.Migration
6+import androidx.sqlite.db.SupportSQLiteDatabase
5import com.parseable.android.data.local.FavoriteStreamDao
7import com.parseable.android.data.local.FavoriteStreamDao
6import com.parseable.android.data.local.ParseableDatabase
8import com.parseable.android.data.local.ParseableDatabase
7import com.parseable.android.data.local.SavedServerDao
9import com.parseable.android.data.local.SavedServerDao
@@ -16,10 +18,61 @@ import javax.inject.Singleton
16@InstallIn(SingletonComponent::class)
18@InstallIn(SingletonComponent::class)
17object AppModule {
19object AppModule {
18
20
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+
19 @Provides
66 @Provides
20 @Singleton
67 @Singleton
21 fun provideDatabase(@ApplicationContext context: Context): ParseableDatabase =
68 fun provideDatabase(@ApplicationContext context: Context): ParseableDatabase =
22 Room.databaseBuilder(context, ParseableDatabase::class.java, "parseable.db")
69 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)
23 .fallbackToDestructiveMigration()
76 .fallbackToDestructiveMigration()
24 .build()
77 .build()
25
78
app/src/main/java/com/parseable/android/ui/navigation/NavGraph.kt+28−10
@@ -16,7 +16,6 @@ import com.parseable.android.ui.screens.logviewer.StreamInfoScreen
16import com.parseable.android.ui.screens.about.AboutScreen
16import com.parseable.android.ui.screens.about.AboutScreen
17import com.parseable.android.ui.screens.settings.SettingsScreen
17import com.parseable.android.ui.screens.settings.SettingsScreen
18import com.parseable.android.ui.screens.streams.StreamsScreen
18import com.parseable.android.ui.screens.streams.StreamsScreen
19−import java.net.URLDecoder
20import java.net.URLEncoder
19import java.net.URLEncoder
21
20
22object Routes {
21object Routes {
@@ -31,9 +30,16 @@ object Routes {
31 fun login(sessionExpired: Boolean = false) =
30 fun login(sessionExpired: Boolean = false) =
32 if (sessionExpired) "login?sessionExpired=true" else LOGIN
31 if (sessionExpired) "login?sessionExpired=true" else LOGIN
33 fun logViewer(streamName: String) =
32 fun logViewer(streamName: String) =
34− "log_viewer/${URLEncoder.encode(streamName, "UTF-8")}"
33+ "log_viewer/${encodeArg(streamName)}"
35 fun streamInfo(streamName: String) =
34 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) =
36− "stream_info/${URLEncoder.encode(streamName, "UTF-8")}"
42+ URLEncoder.encode(value, "UTF-8").replace("+", "%20")
37}
43}
38
44
39@Composable
45@Composable
@@ -114,9 +120,8 @@ fun ParseableNavGraph(
114 }
120 }
115 return@composable
121 return@composable
116 }
122 }
117− val streamName = URLDecoder.decode(
123+ // Navigation already URI-decodes captured path args, so use the value as-is.
118− backStackEntry.arguments?.getString("streamName") ?: "", "UTF-8"
124+ val streamName = backStackEntry.arguments?.getString("streamName") ?: ""
119− )
120 LogViewerScreen(
125 LogViewerScreen(
121 streamName = streamName,
126 streamName = streamName,
122 onBack = { navController.popBackStack() },
127 onBack = { navController.popBackStack() },
@@ -132,14 +137,22 @@ fun ParseableNavGraph(
132 route = Routes.STREAM_INFO,
137 route = Routes.STREAM_INFO,
133 arguments = listOf(navArgument("streamName") { type = NavType.StringType }),
138 arguments = listOf(navArgument("streamName") { type = NavType.StringType }),
134 ) { backStackEntry ->
139 ) { backStackEntry ->
135− val streamName = URLDecoder.decode(
140+ // Navigation already URI-decodes captured path args, so use the value as-is.
136− backStackEntry.arguments?.getString("streamName") ?: "", "UTF-8"
141+ val streamName = backStackEntry.arguments?.getString("streamName") ?: ""
137− )
138 StreamInfoScreen(
142 StreamInfoScreen(
139 streamName = streamName,
143 streamName = streamName,
140 onBack = { navController.popBackStack() },
144 onBack = { navController.popBackStack() },
141 onStreamDeleted = {
145 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.
142− navController.popBackStack(Routes.STREAMS, inclusive = false)
150+ val popped = navController.popBackStack(Routes.STREAMS, inclusive = false)
151+ if (!popped) {
152+ navController.navigate(Routes.STREAMS) {
153+ popUpTo(0) { inclusive = true }
154+ }
155+ }
143 },
156 },
144 )
157 )
145 }
158 }
@@ -182,6 +195,11 @@ fun ParseableNavGraph(
182 popUpTo(0) { inclusive = true }
195 popUpTo(0) { inclusive = true }
183 }
196 }
184 },
197 },
198+ onLoggedOut = {
199+ navController.navigate(Routes.LOGIN) {
200+ popUpTo(0) { inclusive = true }
201+ }
202+ },
185 )
203 )
186 }
204 }
187
205
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+43−13
@@ -6,6 +6,7 @@ import com.parseable.android.data.model.Alert
6import com.parseable.android.data.model.ApiResult
6import com.parseable.android.data.model.ApiResult
7import com.parseable.android.data.repository.ParseableRepository
7import com.parseable.android.data.repository.ParseableRepository
8import dagger.hilt.android.lifecycle.HiltViewModel
8import dagger.hilt.android.lifecycle.HiltViewModel
9+import kotlinx.coroutines.Job
9import kotlinx.coroutines.flow.MutableStateFlow
10import kotlinx.coroutines.flow.MutableStateFlow
10import kotlinx.coroutines.flow.StateFlow
11import kotlinx.coroutines.flow.StateFlow
11import kotlinx.coroutines.flow.asStateFlow
12import kotlinx.coroutines.flow.asStateFlow
@@ -28,19 +29,40 @@ class AlertsViewModel @Inject constructor(
28 private val _state = MutableStateFlow(AlertsState())
29 private val _state = MutableStateFlow(AlertsState())
29 val state: StateFlow<AlertsState> = _state.asStateFlow()
30 val state: StateFlow<AlertsState> = _state.asStateFlow()
30
31
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+
31 fun refresh() {
39 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()
32− viewModelScope.launch {
43+ refreshJob = viewModelScope.launch {
33 _state.update { it.copy(isLoading = true, error = null) }
44 _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() {
34− when (val result = repository.listAlerts()) {
56+ when (val result = repository.listAlerts()) {
35− is ApiResult.Success -> {
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.
36− val sorted = result.data.sortedBy { a ->
61+ val sorted = result.data.sortedBy { it.displayName.lowercase() }
37− (a.name ?: a.title ?: "").lowercase()
38− }
39− _state.update { it.copy(alerts = sorted, isLoading = false) }
62+ _state.update { it.copy(alerts = sorted, isLoading = false) }
40− }
63+ }
41− is ApiResult.Error -> {
64+ is ApiResult.Error -> {
42− _state.update { it.copy(isLoading = false, error = result.userMessage) }
65+ _state.update { it.copy(isLoading = false, error = result.userMessage) }
43− }
44 }
66 }
45 }
67 }
46 }
68 }
@@ -53,11 +75,19 @@ class AlertsViewModel @Inject constructor(
53 _state.update { it.copy(alertToDelete = null) }
75 _state.update { it.copy(alertToDelete = null) }
54 }
76 }
55
77
78+ private var deleteJob: Job? = null
79+
56 fun deleteAlert(alertId: String) {
80 fun deleteAlert(alertId: String) {
81+ // Guard against a double-tap on the confirm button firing two concurrent DELETEs.
82+ if (deleteJob?.isActive == true) return
57− viewModelScope.launch {
83+ deleteJob = viewModelScope.launch {
58 _state.update { it.copy(alertToDelete = null, isLoading = true, error = null) }
84 _state.update { it.copy(alertToDelete = null, isLoading = true, error = null) }
59 when (val result = repository.deleteAlert(alertId)) {
85 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.
60− is ApiResult.Success -> refresh()
90+ is ApiResult.Success -> loadAlerts()
61 is ApiResult.Error -> {
91 is ApiResult.Error -> {
62 _state.update { it.copy(isLoading = false, error = result.userMessage) }
92 _state.update { it.copy(isLoading = false, error = result.userMessage) }
63 }
93 }
app/src/main/java/com/parseable/android/ui/screens/login/LoginScreen.kt+55−8
@@ -40,8 +40,8 @@ fun LoginScreen(
40 var passwordVisible by rememberSaveable { mutableStateOf(false) }
40 var passwordVisible by rememberSaveable { mutableStateOf(false) }
41 val errorHandler = LocalErrorHandler.current
41 val errorHandler = LocalErrorHandler.current
42
42
43− LaunchedEffect(state.loginSuccess) {
43+ LaunchedEffect(Unit) {
44− if (state.loginSuccess) {
44+ viewModel.loginSuccessEvent.collect {
45 onLoginSuccess()
45 onLoginSuccess()
46 }
46 }
47 }
47 }
@@ -178,12 +178,7 @@ fun LoginScreen(
178
178
179 if (state.allowInsecure) {
179 if (state.allowInsecure) {
180 val isPrivateNetwork = remember(state.serverUrl) {
180 val isPrivateNetwork = remember(state.serverUrl) {
181− val host = state.serverUrl
181+ isPrivateHost(extractHost(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])\\..*"))
187 }
182 }
188 if (!isPrivateNetwork) {
183 if (!isPrivateNetwork) {
189 Card(
184 Card(
@@ -255,3 +250,55 @@ fun LoginScreen(
255 }
250 }
256 }
251 }
257}
252}
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+88−12
@@ -7,10 +7,12 @@ import com.parseable.android.data.model.ServerConfig
7import com.parseable.android.data.repository.ParseableRepository
7import com.parseable.android.data.repository.ParseableRepository
8import com.parseable.android.data.repository.SettingsRepository
8import com.parseable.android.data.repository.SettingsRepository
9import dagger.hilt.android.lifecycle.HiltViewModel
9import dagger.hilt.android.lifecycle.HiltViewModel
10+import kotlinx.coroutines.channels.Channel
10import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.StateFlow
12import kotlinx.coroutines.flow.StateFlow
12import kotlinx.coroutines.flow.asStateFlow
13import kotlinx.coroutines.flow.asStateFlow
13import kotlinx.coroutines.flow.first
14import kotlinx.coroutines.flow.first
15+import kotlinx.coroutines.flow.receiveAsFlow
14import kotlinx.coroutines.flow.update
16import kotlinx.coroutines.flow.update
15import kotlinx.coroutines.launch
17import kotlinx.coroutines.launch
16import javax.inject.Inject
18import javax.inject.Inject
@@ -22,7 +24,6 @@ data class LoginState(
22 val allowInsecure: Boolean = false,
24 val allowInsecure: Boolean = false,
23 val isLoading: Boolean = false,
25 val isLoading: Boolean = false,
24 val error: String? = null,
26 val error: String? = null,
25− val loginSuccess: Boolean = false,
26 val serverUrlError: String? = null,
27 val serverUrlError: String? = null,
27 val usernameError: String? = null,
28 val usernameError: String? = null,
28 val passwordError: String? = null,
29 val passwordError: String? = null,
@@ -38,6 +39,15 @@ class LoginViewModel @Inject constructor(
38 private val _state = MutableStateFlow(LoginState())
39 private val _state = MutableStateFlow(LoginState())
39 val state: StateFlow<LoginState> = _state.asStateFlow()
40 val state: StateFlow<LoginState> = _state.asStateFlow()
40
41
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+
41 init {
51 init {
42 viewModelScope.launch {
52 viewModelScope.launch {
43 val config = settingsRepository.serverConfig.first()
53 val config = settingsRepository.serverConfig.first()
@@ -55,9 +65,39 @@ class LoginViewModel @Inject constructor(
55 }
65 }
56
66
57 fun loginWithSavedCredentials() {
67 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) }
58 viewModelScope.launch {
73 viewModelScope.launch {
59− val config = settingsRepository.getSavedPassword() ?: return@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+ }
60− performLogin(config.password)
100+ performLogin(config.serverUrl, config.username, config.password, !config.useTls)
61 }
101 }
62 }
102 }
63
103
@@ -80,6 +120,15 @@ class LoginViewModel @Inject constructor(
80 fun onLogin() {
120 fun onLogin() {
81 val current = _state.value
121 val current = _state.value
82
122
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+
83 // Validate
132 // Validate
84 var hasError = false
133 var hasError = false
85 var urlError: String? = null
134 var urlError: String? = null
@@ -110,19 +159,34 @@ class LoginViewModel @Inject constructor(
110 return
159 return
111 }
160 }
112
161
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.
113 viewModelScope.launch {
168 viewModelScope.launch {
114− performLogin(current.password)
169+ performLogin(current.serverUrl, current.username, current.password, current.allowInsecure)
115 }
170 }
116 }
171 }
117
172
118− private suspend fun performLogin(password: String) {
173+ private suspend fun performLogin(
119− val current = _state.value
174+ serverUrl: String,
175+ username: String,
176+ password: String,
177+ allowInsecure: Boolean,
178+ ) {
120 _state.update { it.copy(isLoading = true, error = null) }
179 _state.update { it.copy(isLoading = true, error = null) }
121
180
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.
122− var url = current.serverUrl.trim()
186+ var url = serverUrl.trim().trimEnd('/')
123 val urlLower = url.lowercase()
187 val urlLower = url.lowercase()
124 if (!urlLower.startsWith("http://") && !urlLower.startsWith("https://")) {
188 if (!urlLower.startsWith("http://") && !urlLower.startsWith("https://")) {
125− url = if (current.allowInsecure) "http://$url" else "https://$url"
189+ url = if (allowInsecure) "http://$url" else "https://$url"
126 }
190 }
127
191
128 val isValidUrl = try {
192 val isValidUrl = try {
@@ -143,7 +207,7 @@ class LoginViewModel @Inject constructor(
143
207
144 val config = ServerConfig(
208 val config = ServerConfig(
145 serverUrl = url,
209 serverUrl = url,
146− username = current.username.trim(),
210+ username = username.trim(),
147 password = password,
211 password = password,
148 useTls = url.lowercase().startsWith("https"),
212 useTls = url.lowercase().startsWith("https"),
149 )
213 )
@@ -156,9 +220,21 @@ class LoginViewModel @Inject constructor(
156 // Uses verifyServer() to avoid triggering the global auth-error handler.
220 // Uses verifyServer() to avoid triggering the global auth-error handler.
157 when (val aboutResult = repository.verifyServer()) {
221 when (val aboutResult = repository.verifyServer()) {
158 is ApiResult.Success -> {
222 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.
159− settingsRepository.saveServerConfig(config)
226+ if (settingsRepository.saveServerConfig(config)) {
160− settingsRepository.saveServer(config)
227+ settingsRepository.saveServer(config)
161− _state.update { it.copy(isLoading = false, loginSuccess = true, password = "") }
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+ }
162 }
238 }
163 is ApiResult.Error -> {
239 is ApiResult.Error -> {
164 val errorMsg = if (aboutResult.isUnauthorized) {
240 val errorMsg = if (aboutResult.isUnauthorized) {
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.kt+82−33
@@ -101,6 +101,10 @@ fun LogViewerScreen(
101 actions = {
101 actions = {
102 StreamingToggleButton(
102 StreamingToggleButton(
103 isStreaming = state.isStreaming,
103 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(),
104 onClick = viewModel::toggleStreaming,
108 onClick = viewModel::toggleStreaming,
105 )
109 )
106 IconButton(onClick = { onStreamInfo(streamName) }) {
110 IconButton(onClick = { onStreamInfo(streamName) }) {
@@ -167,7 +171,9 @@ fun LogViewerScreen(
167 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
171 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
168 keyboardActions = KeyboardActions(onSearch = {
172 keyboardActions = KeyboardActions(onSearch = {
169 keyboardController?.hide()
173 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.
170− viewModel.refresh()
176+ viewModel.submitSearch()
171 }),
177 }),
172 modifier = Modifier
178 modifier = Modifier
173 .fillMaxWidth()
179 .fillMaxWidth()
@@ -281,9 +287,41 @@ fun LogViewerScreen(
281 // Log entries
287 // Log entries
282 PullToRefreshBox(
288 PullToRefreshBox(
283 isRefreshing = state.isLoading,
289 isRefreshing = state.isLoading,
284− onRefresh = viewModel::refresh,
290+ onRefresh = viewModel::pullRefresh,
285 modifier = Modifier.fillMaxSize(),
291 modifier = Modifier.fillMaxSize(),
286 ) {
292 ) {
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+
287 if (state.error != null && state.logs.isEmpty()) {
325 if (state.error != null && state.logs.isEmpty()) {
288 Box(
326 Box(
289 modifier = Modifier.fillMaxSize(),
327 modifier = Modifier.fillMaxSize(),
@@ -350,24 +388,6 @@ fun LogViewerScreen(
350 }
388 }
351 }
389 }
352 } else {
390 } 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−
371 LazyColumn(
391 LazyColumn(
372 state = listState,
392 state = listState,
373 contentPadding = PaddingValues(8.dp),
393 contentPadding = PaddingValues(8.dp),
@@ -380,19 +400,24 @@ fun LogViewerScreen(
380 val key = state.logKeys.getOrElse(index) { "log_$index" }
400 val key = state.logKeys.getOrElse(index) { "log_$index" }
381 LogEntryCard(
401 LogEntryCard(
382 logEntry = logEntry,
402 logEntry = logEntry,
403+ stableKey = key,
383 isExpanded = expandedLogKey == key,
404 isExpanded = expandedLogKey == key,
384 onClick = {
405 onClick = {
385 if (expandedLogKey == key) {
406 if (expandedLogKey == key) {
386 // Collapsing: if the top of the entry is scrolled
407 // Collapsing: if the top of the entry is scrolled
387 // above the viewport, jump to it so the user sees
408 // above the viewport, jump to it so the user sees
388 // the collapsed card instead of a random position.
409 // 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.
389 val itemInfo = listState.layoutInfo.visibleItemsInfo
413 val itemInfo = listState.layoutInfo.visibleItemsInfo
390− .firstOrNull { it.index == index }
414+ .firstOrNull { it.key == key }
391 val needsScroll = itemInfo == null || itemInfo.offset < 0
415 val needsScroll = itemInfo == null || itemInfo.offset < 0
392 expandedLogKey = null
416 expandedLogKey = null
393 if (needsScroll) {
417 if (needsScroll) {
418+ val target = state.logKeys.indexOf(key).takeIf { it >= 0 } ?: index
394 scope.launch {
419 scope.launch {
395− listState.animateScrollToItem(index)
420+ listState.animateScrollToItem(target)
396 }
421 }
397 }
422 }
398 } else {
423 } else {
@@ -509,10 +534,18 @@ fun LogViewerScreen(
509 if (!dir.mkdirs() && !dir.isDirectory) {
534 if (!dir.mkdirs() && !dir.isDirectory) {
510 throw java.io.IOException("Failed to create shared_logs directory")
535 throw java.io.IOException("Failed to create shared_logs directory")
511 }
536 }
512− // Clean up previous exports
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
513− dir.listFiles()?.forEach { old -> old.delete() }
544+ dir.listFiles()?.forEach { old ->
545+ if (old.lastModified() < cutoff) old.delete()
546+ }
514 val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
547 val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
515− val file = java.io.File(dir, "logs_$safeFileName.json")
548+ val file = java.io.File(dir, "logs_${safeFileName}_${System.currentTimeMillis()}.json")
516 file.bufferedWriter().use { writer ->
549 file.bufferedWriter().use { writer ->
517 // Use compact JSON for large exports to reduce memory/file size
550 // Use compact JSON for large exports to reduce memory/file size
518 val encoder = if (logs.size > 1000) Json else prettyJson
551 val encoder = if (logs.size > 1000) Json else prettyJson
@@ -574,17 +607,18 @@ fun LogViewerScreen(
574 val start = dateRangePickerState.selectedStartDateMillis
607 val start = dateRangePickerState.selectedStartDateMillis
575 val end = dateRangePickerState.selectedEndDateMillis
608 val end = dateRangePickerState.selectedEndDateMillis
576 if (start != null && end != null) {
609 if (start != null && end != null) {
577− // DateRangePicker returns midnight UTC for the selected date.
610+ // DateRangePicker returns midnight UTC for the selected date, so the
578− // Adjust to cover the full end day in the user's local timezone:
611+ // calendar date must be read back in UTC — reading it in the local zone
579− // Convert to local date, get end-of-day, convert back to UTC millis.
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.
580 val localZone = java.time.ZoneId.systemDefault()
614 val localZone = java.time.ZoneId.systemDefault()
581 val endLocalDate = java.time.Instant.ofEpochMilli(end)
615 val endLocalDate = java.time.Instant.ofEpochMilli(end)
582− .atZone(localZone).toLocalDate()
616+ .atZone(java.time.ZoneOffset.UTC).toLocalDate()
583 val endOfDayUtc = endLocalDate.plusDays(1)
617 val endOfDayUtc = endLocalDate.plusDays(1)
584 .atStartOfDay(localZone)
618 .atStartOfDay(localZone)
585 .toInstant().toEpochMilli() - 1
619 .toInstant().toEpochMilli() - 1
586 val startLocalDate = java.time.Instant.ofEpochMilli(start)
620 val startLocalDate = java.time.Instant.ofEpochMilli(start)
587− .atZone(localZone).toLocalDate()
621+ .atZone(java.time.ZoneOffset.UTC).toLocalDate()
588 val startUtc = startLocalDate
622 val startUtc = startLocalDate
589 .atStartOfDay(localZone)
623 .atStartOfDay(localZone)
590 .toInstant().toEpochMilli()
624 .toInstant().toEpochMilli()
@@ -667,9 +701,10 @@ private fun TimeRangeBar(
667@Composable
701@Composable
668private fun StreamingToggleButton(
702private fun StreamingToggleButton(
669 isStreaming: Boolean,
703 isStreaming: Boolean,
704+ enabled: Boolean,
670 onClick: () -> Unit,
705 onClick: () -> Unit,
671) {
706) {
672− IconButton(onClick = onClick) {
707+ IconButton(onClick = onClick, enabled = enabled || isStreaming) {
673 if (isStreaming) {
708 if (isStreaming) {
674 val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
709 val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
675 val alpha by infiniteTransition.animateFloat(
710 val alpha by infiniteTransition.animateFloat(
@@ -700,6 +735,7 @@ private fun StreamingToggleButton(
700@Composable
735@Composable
701fun LogEntryCard(
736fun LogEntryCard(
702 logEntry: JsonObject,
737 logEntry: JsonObject,
738+ stableKey: String,
703 isExpanded: Boolean,
739 isExpanded: Boolean,
704 onClick: () -> Unit,
740 onClick: () -> Unit,
705 onCopied: () -> Unit = {},
741 onCopied: () -> Unit = {},
@@ -718,7 +754,9 @@ fun LogEntryCard(
718 ) {
754 ) {
719 Column(modifier = Modifier.padding(12.dp)) {
755 Column(modifier = Modifier.padding(12.dp)) {
720 // Timestamp row
756 // 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.
721− val rawTimestamp = remember(logEntry) {
759+ val rawTimestamp = remember(stableKey) {
722 try {
760 try {
723 logEntry["p_timestamp"]?.jsonPrimitive?.content
761 logEntry["p_timestamp"]?.jsonPrimitive?.content
724 ?: logEntry["datetime"]?.jsonPrimitive?.content
762 ?: logEntry["datetime"]?.jsonPrimitive?.content
@@ -743,7 +781,7 @@ fun LogEntryCard(
743
781
744 if (!isExpanded) {
782 if (!isExpanded) {
745 // Compact view: show first meaningful fields
783 // Compact view: show first meaningful fields
746− val preview = remember(logEntry) {
784+ val preview = remember(stableKey) {
747 logEntry.entries
785 logEntry.entries
748 .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
786 .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
749 .take(3)
787 .take(3)
@@ -858,6 +896,17 @@ fun FilterBottomSheet(
858 onApplyFilter: (column: String, operator: String, value: String) -> Unit,
896 onApplyFilter: (column: String, operator: String, value: String) -> Unit,
859) {
897) {
860 var selectedColumn by remember { mutableStateOf(columns.firstOrNull() ?: "") }
898 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+ }
861 var selectedOperator by remember { mutableStateOf("=") }
910 var selectedOperator by remember { mutableStateOf("=") }
862 var filterValue by remember { mutableStateOf("") }
911 var filterValue by remember { mutableStateOf("") }
863 var columnDropdownExpanded by remember { mutableStateOf(false) }
912 var columnDropdownExpanded by remember { mutableStateOf(false) }
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerViewModel.kt+392−115
@@ -3,6 +3,7 @@ package com.parseable.android.ui.screens.logviewer
3import androidx.lifecycle.ViewModel
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5import com.parseable.android.data.escapeIdentifier
5import com.parseable.android.data.escapeIdentifier
6+import com.parseable.android.data.escapeLikePattern
6import com.parseable.android.data.escapeSql
7import com.parseable.android.data.escapeSql
7import com.parseable.android.data.model.*
8import com.parseable.android.data.model.*
8import com.parseable.android.data.repository.ParseableRepository
9import com.parseable.android.data.repository.ParseableRepository
@@ -15,8 +16,13 @@ import kotlinx.coroutines.flow.asStateFlow
15import kotlinx.coroutines.flow.update
16import kotlinx.coroutines.flow.update
16import kotlinx.coroutines.isActive
17import kotlinx.coroutines.isActive
17import kotlinx.coroutines.launch
18import kotlinx.coroutines.launch
19+import kotlinx.coroutines.sync.Mutex
20+import kotlinx.coroutines.sync.withLock
18import kotlinx.serialization.json.JsonObject
21import kotlinx.serialization.json.JsonObject
19import kotlinx.serialization.json.jsonPrimitive
22import kotlinx.serialization.json.jsonPrimitive
23+import java.time.Instant
24+import java.time.LocalDateTime
25+import java.time.OffsetDateTime
20import java.time.ZoneOffset
26import java.time.ZoneOffset
21import java.time.ZonedDateTime
27import java.time.ZonedDateTime
22import java.time.format.DateTimeFormatter
28import java.time.format.DateTimeFormatter
@@ -34,10 +40,21 @@ enum class TimeRange(val label: String, val minutes: Long) {
34 CUSTOM("Custom", 0),
40 CUSTOM("Custom", 0),
35}
41}
36
42
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+
37data class FilterState(
50data class FilterState(
38 val searchQuery: String = "",
51 val searchQuery: String = "",
39 val activeFilters: List<String> = emptyList(),
52 val activeFilters: List<String> = emptyList(),
40 val filterClauses: List<String> = emptyList(),
53 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(),
41 val customSql: String = "",
58 val customSql: String = "",
42 val isSearching: Boolean = false,
59 val isSearching: Boolean = false,
43)
60)
@@ -72,6 +89,10 @@ data class LogViewerState(
72 val hasMore: Boolean = false,
89 val hasMore: Boolean = false,
73 val customStartTime: Long? = null,
90 val customStartTime: Long? = null,
74 val customEndTime: Long? = null,
91 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,
75) {
96) {
76 // Convenience accessors for backward compatibility with Screen
97 // Convenience accessors for backward compatibility with Screen
77 val searchQuery: String get() = filters.searchQuery
98 val searchQuery: String get() = filters.searchQuery
@@ -99,13 +120,29 @@ class LogViewerViewModel @Inject constructor(
99 @Volatile private var streamingGeneration: Int = 0
120 @Volatile private var streamingGeneration: Int = 0
100 @Volatile private var lastSeenTimestamp: String? = null
121 @Volatile private var lastSeenTimestamp: String? = null
101 @Volatile private var consecutiveStreamingErrors: Int = 0
122 @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>()
102 private var searchJob: Job? = null
135 private var searchJob: Job? = null
103 private var schemaJob: Job? = null
136 private var schemaJob: Job? = null
137+ private var refreshJob: Job? = null
104
138
105 companion object {
139 companion object {
106 private const val STREAMING_BASE_INTERVAL_MS = 3000L
140 private const val STREAMING_BASE_INTERVAL_MS = 3000L
107 private const val STREAMING_MAX_INTERVAL_MS = 30000L
141 private const val STREAMING_MAX_INTERVAL_MS = 30000L
108 private const val STREAMING_MAX_LOGS = 1000
142 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
109 private const val MAX_LOAD_LIMIT = 5000
146 private const val MAX_LOAD_LIMIT = 5000
110 private const val LOAD_MORE_INCREMENT = 500
147 private const val LOAD_MORE_INCREMENT = 500
111 private const val MAX_STREAMING_ERRORS = 5
148 private const val MAX_STREAMING_ERRORS = 5
@@ -118,10 +155,13 @@ class LogViewerViewModel @Inject constructor(
118 internal fun computeLogKeys(logs: List<JsonObject>): List<String> {
155 internal fun computeLogKeys(logs: List<JsonObject>): List<String> {
119 val seen = mutableMapOf<String, Int>()
156 val seen = mutableMapOf<String, Int>()
120 return logs.map { log ->
157 return logs.map { log ->
121− val ts = log["p_timestamp"]?.toString()
158+ // Key on the full row content (same identity used for streaming dedup, see
122− val meta = log["p_metadata"]?.toString()
159+ // logIdentity). Keying on only p_timestamp/p_metadata/p_tags collided for rows
123− val tag = log["p_tags"]?.toString()
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.
124− val base = if (ts != null) "$ts|${meta.orEmpty()}|${tag.orEmpty()}" else log.hashCode().toString()
164+ val base = log.toString()
125 val count = seen.getOrDefault(base, 0)
165 val count = seen.getOrDefault(base, 0)
126 seen[base] = count + 1
166 seen[base] = count + 1
127 if (count == 0) base else "$base|$count"
167 if (count == 0) base else "$base|$count"
@@ -178,7 +218,11 @@ class LogViewerViewModel @Inject constructor(
178 }
218 }
179
219
180 fun onSearchQueryChange(query: String) {
220 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.
181− _state.update { it.copy(filters = it.filters.copy(searchQuery = query, isSearching = query.isNotBlank())) }
225+ _state.update { it.copy(currentLimit = 500, filters = it.filters.copy(searchQuery = query, customSql = "", isSearching = query.isNotBlank())) }
182 searchJob?.cancel()
226 searchJob?.cancel()
183 searchJob = viewModelScope.launch {
227 searchJob = viewModelScope.launch {
184 delay(300)
228 delay(300)
@@ -187,28 +231,55 @@ class LogViewerViewModel @Inject constructor(
187 }
231 }
188 }
232 }
189
233
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+
190 fun addFilter(column: String, operator: String, value: String) {
245 fun addFilter(column: String, operator: String, value: String) {
246+ addFilterInternal(column, operator, value)
247+ refresh()
248+ }
249+
250+ private fun addFilterInternal(column: String, operator: String, value: String) {
191 if (operator !in ALLOWED_OPERATORS) return
251 if (operator !in ALLOWED_OPERATORS) return
192 val safeColumn = escapeIdentifier(column)
252 val safeColumn = escapeIdentifier(column)
193− val safeValue = escapeSql(value)
194 val clause = when (operator) {
253 val clause = when (operator) {
195 "IS NULL", "IS NOT NULL" -> "\"$safeColumn\" $operator"
254 "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.
196− "LIKE", "ILIKE" -> "\"$safeColumn\" $operator '%$safeValue%'"
257+ "LIKE", "ILIKE" -> "\"$safeColumn\" $operator '%${escapeLikePattern(value)}%' ESCAPE '\\'"
197− else -> "\"$safeColumn\" $operator '$safeValue'"
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
198 }
265 }
199 val display = when (operator) {
266 val display = when (operator) {
200 "IS NULL", "IS NOT NULL" -> "$column $operator"
267 "IS NULL", "IS NOT NULL" -> "$column $operator"
201 else -> "$column $operator $value"
268 else -> "$column $operator $value"
202 }
269 }
270+ // A new filter invalidates the live-tail baseline and the paged-up limit.
271+ stopStreaming()
203 _state.update {
272 _state.update {
204 it.copy(
273 it.copy(
274+ currentLimit = 500,
205 filters = it.filters.copy(
275 filters = it.filters.copy(
206 filterClauses = it.filters.filterClauses + clause,
276 filterClauses = it.filters.filterClauses + clause,
207 activeFilters = it.filters.activeFilters + display,
277 activeFilters = it.filters.activeFilters + display,
278+ filterConditions = it.filters.filterConditions + FilterCondition(column, operator, conditionValue),
279+ customSql = "",
208 ),
280 ),
209 )
281 )
210 }
282 }
211− refresh()
212 }
283 }
213
284
214 fun removeFilter(display: String) {
285 fun removeFilter(display: String) {
@@ -218,81 +289,93 @@ class LogViewerViewModel @Inject constructor(
218 if (index >= 0 && index < it.filters.filterClauses.size) {
289 if (index >= 0 && index < it.filters.filterClauses.size) {
219 removed = true
290 removed = true
220 it.copy(
291 it.copy(
292+ currentLimit = 500,
221 filters = it.filters.copy(
293 filters = it.filters.copy(
222 activeFilters = it.filters.activeFilters.filterIndexed { i, _ -> i != index },
294 activeFilters = it.filters.activeFilters.filterIndexed { i, _ -> i != index },
223 filterClauses = it.filters.filterClauses.filterIndexed { i, _ -> i != index },
295 filterClauses = it.filters.filterClauses.filterIndexed { i, _ -> i != index },
296+ filterConditions = it.filters.filterConditions.filterIndexed { i, _ -> i != index },
297+ customSql = "",
224 ),
298 ),
225 )
299 )
226 } else {
300 } else {
227 it
301 it
228 }
302 }
229 }
303 }
230− if (removed) refresh()
304+ if (removed) {
305+ // A changed filter set invalidates the live-tail baseline.
306+ stopStreaming()
307+ refresh()
308+ }
231 }
309 }
232
310
233 fun clearFilters() {
311 fun clearFilters() {
312+ stopStreaming()
234 _state.update {
313 _state.update {
235− it.copy(filters = it.filters.copy(activeFilters = emptyList(), filterClauses = emptyList()))
314+ it.copy(currentLimit = 500, filters = it.filters.copy(activeFilters = emptyList(), filterClauses = emptyList(), filterConditions = emptyList(), customSql = "", searchQuery = "", isSearching = false))
236 }
315 }
237 refresh()
316 refresh()
238 }
317 }
239
318
240 fun executeCustomSql(sql: String) {
319 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.
241− val trimmed = sql.trim()
326+ val trimmed = sql.trim().trimEnd(';').trim()
242 if (!trimmed.uppercase().startsWith("SELECT")) {
327 if (!trimmed.uppercase().startsWith("SELECT")) {
243 _state.update { it.copy(error = "Only SELECT queries are allowed") }
328 _state.update { it.copy(error = "Only SELECT queries are allowed") }
244 return
329 return
245 }
330 }
246− // Enforce a LIMIT to prevent OOM from unbounded queries
331+ // Enforce a LIMIT to prevent OOM from unbounded queries. Only honor a *top-level*
247− val safeSql = if (!trimmed.uppercase().contains("LIMIT")) {
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) {
248 "$trimmed LIMIT $MAX_LOAD_LIMIT"
339 "$trimmed LIMIT $MAX_LOAD_LIMIT"
249 } else {
340 } else {
250 trimmed
341 trimmed
251 }
342 }
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.
252 _state.update { it.copy(filters = it.filters.copy(customSql = safeSql), currentLimit = 500) }
346 _state.update { it.copy(filters = it.filters.copy(customSql = safeSql), currentLimit = 500) }
253− viewModelScope.launch {
347+ refresh()
254− _state.update { it.copy(isLoading = true, error = null) }
255−
256− val (startTime, endTime) = getTimeRange()
257− when (val result = repository.queryLogsRaw(safeSql, startTime, endTime)) {
258− is ApiResult.Success -> {
259− val keys = computeLogKeys(result.data)
260− _state.update {
261− it.copy(
262− logs = result.data,
263− logKeys = keys,
264− isLoading = false,
265− hasMore = result.data.size >= it.currentLimit,
266− )
267− }
268− }
269− is ApiResult.Error -> {
270− _state.update {
271− it.copy(isLoading = false, error = result.userMessage)
272− }
273− }
274− }
275− }
276 }
348 }
277
349
278 private fun buildSearchClause(searchableColumns: List<String>, searchQuery: String): String? {
350 private fun buildSearchClause(searchableColumns: List<String>, searchQuery: String): String? {
279 if (searchQuery.isBlank()) return null
351 if (searchQuery.isBlank()) return null
280 if (searchableColumns.isEmpty()) return null
352 if (searchableColumns.isEmpty()) return null
281− val safeSearch = escapeSql(searchQuery)
353+ val safeSearch = escapeLikePattern(searchQuery)
282 return searchableColumns.joinToString(" OR ") {
354 return searchableColumns.joinToString(" OR ") {
283− "\"${escapeIdentifier(it)}\" ILIKE '%$safeSearch%'"
355+ "CAST(\"${escapeIdentifier(it)}\" AS VARCHAR) ILIKE '%$safeSearch%' ESCAPE '\\'"
284 }
356 }
285 }
357 }
286
358
287 fun refresh() {
359 fun refresh() {
288 if (_state.value.streamName.isEmpty()) return
360 if (_state.value.streamName.isEmpty()) return
289
361
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()
290− viewModelScope.launch {
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+ }
291 _state.update {
373 _state.update {
292 it.copy(
374 it.copy(
293 isLoading = true,
375 isLoading = true,
294 error = null,
376 error = null,
295 streaming = it.streaming.copy(streamingError = null),
377 streaming = it.streaming.copy(streamingError = null),
378+ pageAnchorTime = anchor,
296 )
379 )
297 }
380 }
298
381
@@ -300,6 +383,20 @@ class LogViewerViewModel @Inject constructor(
300 val current = _state.value
383 val current = _state.value
301 val (startTime, endTime) = getTimeRange()
384 val (startTime, endTime) = getTimeRange()
302
385
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+
303 // Build WHERE clause from filters + search
400 // Build WHERE clause from filters + search
304 val clauses = current.filters.filterClauses.toMutableList()
401 val clauses = current.filters.filterClauses.toMutableList()
305 if (current.filters.searchQuery.isNotBlank()) {
402 if (current.filters.searchQuery.isNotBlank()) {
@@ -316,22 +413,26 @@ class LogViewerViewModel @Inject constructor(
316
413
317 val filterSql = clauses.joinToString(" AND ")
414 val filterSql = clauses.joinToString(" AND ")
318
415
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
319 when (val result = repository.queryLogs(
420 when (val result = repository.queryLogs(
320 stream = current.streamName,
421 stream = current.streamName,
321 startTime = startTime,
422 startTime = startTime,
322 endTime = endTime,
423 endTime = endTime,
323 filterSql = filterSql,
424 filterSql = filterSql,
324− limit = current.currentLimit,
425+ limit = requestLimit,
325 )) {
426 )) {
326 is ApiResult.Success -> {
427 is ApiResult.Success -> {
327− val keys = computeLogKeys(result.data)
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+ }
328 _state.update {
434 _state.update {
329− it.copy(
435+ it.copy(logs = logs, logKeys = computeLogKeys(logs), isLoading = false, hasMore = hasMore)
330− logs = result.data,
331− logKeys = keys,
332− isLoading = false,
333− hasMore = result.data.size >= it.currentLimit,
334− )
335 }
436 }
336 }
437 }
337 is ApiResult.Error -> {
438 is ApiResult.Error -> {
@@ -343,8 +444,29 @@ class LogViewerViewModel @Inject constructor(
343 }
444 }
344 }
445 }
345
446
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+
346 fun loadMore() {
465 fun loadMore() {
347 val current = _state.value
466 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
348 if (current.currentLimit >= MAX_LOAD_LIMIT) {
470 if (current.currentLimit >= MAX_LOAD_LIMIT) {
349 _state.update { it.copy(hasMore = false) }
471 _state.update { it.copy(hasMore = false) }
350 return
472 return
@@ -367,7 +489,9 @@ class LogViewerViewModel @Inject constructor(
367 )
489 )
368 return Pair(start.format(dateFormatter), end.format(dateFormatter))
490 return Pair(start.format(dateFormatter), end.format(dateFormatter))
369 }
491 }
492+ val now = current.pageAnchorTime
493+ ?.let { ZonedDateTime.ofInstant(java.time.Instant.ofEpochMilli(it), ZoneOffset.UTC) }
370− val now = ZonedDateTime.now(ZoneOffset.UTC)
494+ ?: ZonedDateTime.now(ZoneOffset.UTC)
371 val start = now.minusMinutes(current.selectedTimeRange.minutes)
495 val start = now.minusMinutes(current.selectedTimeRange.minutes)
372 return Pair(
496 return Pair(
373 start.format(dateFormatter),
497 start.format(dateFormatter),
@@ -375,6 +499,17 @@ class LogViewerViewModel @Inject constructor(
375 )
499 )
376 }
500 }
377
501
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+
378 fun toggleStreaming() {
513 fun toggleStreaming() {
379 if (_state.value.streaming.isStreaming) {
514 if (_state.value.streaming.isStreaming) {
380 stopStreaming()
515 stopStreaming()
@@ -384,6 +519,10 @@ class LogViewerViewModel @Inject constructor(
384 }
519 }
385
520
386 private fun startStreaming() {
521 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
387 stopStreaming()
526 stopStreaming()
388 consecutiveStreamingErrors = 0
527 consecutiveStreamingErrors = 0
389 val generation = ++streamingGeneration
528 val generation = ++streamingGeneration
@@ -391,6 +530,12 @@ class LogViewerViewModel @Inject constructor(
391 it.copy(streaming = StreamingState(isStreaming = true, streamingNewCount = 0))
530 it.copy(streaming = StreamingState(isStreaming = true, streamingNewCount = 0))
392 }
531 }
393
532
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+
394 // Set the baseline timestamp to "now" so we only poll for new logs
539 // Set the baseline timestamp to "now" so we only poll for new logs
395 lastSeenTimestamp = ZonedDateTime.now(ZoneOffset.UTC).format(dateFormatter)
540 lastSeenTimestamp = ZonedDateTime.now(ZoneOffset.UTC).format(dateFormatter)
396
541
@@ -413,6 +558,13 @@ class LogViewerViewModel @Inject constructor(
413 }
558 }
414
559
415 fun stopStreaming() {
560 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()
416 streamingGeneration++
568 streamingGeneration++
417 streamingJob?.cancel()
569 streamingJob?.cancel()
418 streamingJob = null
570 streamingJob = null
@@ -427,6 +579,49 @@ class LogViewerViewModel @Inject constructor(
427 }
579 }
428 }
580 }
429
581
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+
430 private suspend fun pollNewLogs() {
625 private suspend fun pollNewLogs() {
431 val current = _state.value
626 val current = _state.value
432 if (current.streamName.isEmpty()) return
627 if (current.streamName.isEmpty()) return
@@ -439,45 +634,116 @@ class LogViewerViewModel @Inject constructor(
439 if (current.filters.searchQuery.isNotBlank()) {
634 if (current.filters.searchQuery.isNotBlank()) {
440 val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
635 val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
441 if (searchClause != null) {
636 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.
442− clauses.add(searchClause)
639+ 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
443 }
653 }
444 }
654 }
445
655
446 val whereClause = if (clauses.isNotEmpty()) " WHERE ${clauses.joinToString(" AND ")}" else ""
656 val whereClause = if (clauses.isNotEmpty()) " WHERE ${clauses.joinToString(" AND ")}" else ""
447 val safeName = escapeIdentifier(current.streamName)
657 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).
448− val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT 200"
662+ val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT $STREAMING_MAX_LOGS"
449
663
450 when (val result = repository.queryLogsRaw(sql, startTime, endTime)) {
664 when (val result = repository.queryLogsRaw(sql, startTime, endTime)) {
451 is ApiResult.Success -> {
665 is ApiResult.Success -> {
452 val newLogs = result.data
666 val newLogs = result.data
453 if (newLogs.isNotEmpty()) {
667 if (newLogs.isNotEmpty()) {
454− // Update the last seen timestamp to the most recent log
668+ // Advance the last-seen boundary. Rows are ordered newest-first, so
455− val newestTimestamp = try {
669+ // normally we jump to the newest. But a full page means the LIMIT may
456− newLogs.firstOrNull()
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 {
457− ?.get("p_timestamp")?.jsonPrimitive?.content
678+ boundaryLog?.get("p_timestamp")?.jsonPrimitive?.content
458 } catch (_: Exception) {
679 } catch (_: Exception) {
459 null
680 null
460 }
681 }
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)) {
461− if (newestTimestamp != null) {
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) {
462− lastSeenTimestamp = newestTimestamp
707+ lastSeenTimestamp = normalized
463 }
708 }
464
709
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 {
465− _state.update { state ->
722+ _state.update { state ->
466− // Prepend new logs, cap total — avoid full intermediate list allocation
723+ // Prepend new logs, cap total — avoid full intermediate list allocation
467− val remaining = (STREAMING_MAX_LOGS - newLogs.size).coerceAtLeast(0)
724+ val remaining = (STREAMING_MAX_LOGS - freshLogs.size).coerceAtLeast(0)
468− val capped = ArrayList<JsonObject>(newLogs.size + remaining).apply {
725+ val capped = ArrayList<JsonObject>(freshLogs.size + remaining).apply {
469− addAll(newLogs)
726+ addAll(freshLogs)
470− val oldLogs = state.logs
727+ val oldLogs = state.logs
471− addAll(oldLogs.subList(0, remaining.coerceAtMost(oldLogs.size)))
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+ )
472 }
746 }
473− state.copy(
474− logs = capped,
475− logKeys = computeLogKeys(capped),
476− streaming = state.streaming.copy(
477− streamingNewCount = state.streaming.streamingNewCount + newLogs.size,
478− streamingError = null,
479− ),
480− )
481 }
747 }
482 }
748 }
483 consecutiveStreamingErrors = 0
749 consecutiveStreamingErrors = 0
@@ -504,21 +770,37 @@ class LogViewerViewModel @Inject constructor(
504
770
505 // --- Saved Filters (synced with Parseable server) ---
771 // --- Saved Filters (synced with Parseable server) ---
506
772
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+
507 fun loadSavedFilters() {
779 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
508 viewModelScope.launch {
784 viewModelScope.launch {
785+ savedFiltersMutex.withLock {
509− _state.update { it.copy(savedFilters = it.savedFilters.copy(isLoading = true, error = null)) }
786+ _state.update { it.copy(savedFilters = it.savedFilters.copy(isLoading = true, error = null)) }
510− when (val result = repository.listFilters()) {
787+ when (val result = repository.listFilters()) {
511− is ApiResult.Success -> {
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+ }
512− val streamFilters = result.data
793+ val streamFilters = result.data
513− .filter { it.streamName == _state.value.streamName }
794+ .filter { it.streamName == stream }
514− .sortedBy { it.filterName.lowercase() }
795+ .sortedBy { it.filterName.lowercase() }
515− _state.update {
796+ _state.update {
516− it.copy(savedFilters = it.savedFilters.copy(filters = streamFilters, isLoading = false))
797+ it.copy(savedFilters = it.savedFilters.copy(filters = streamFilters, isLoading = false))
798+ }
517 }
799 }
518− }
519− is ApiResult.Error -> {
800+ is ApiResult.Error -> {
520− _state.update {
801+ _state.update {
521− it.copy(savedFilters = it.savedFilters.copy(isLoading = false, error = result.userMessage))
802+ it.copy(savedFilters = it.savedFilters.copy(isLoading = false, error = result.userMessage))
803+ }
522 }
804 }
523 }
805 }
524 }
806 }
@@ -530,6 +812,7 @@ class LogViewerViewModel @Inject constructor(
530 if (current.streamName.isEmpty()) return
812 if (current.streamName.isEmpty()) return
531
813
532 viewModelScope.launch {
814 viewModelScope.launch {
815+ savedFiltersMutex.withLock {
533 _state.update { it.copy(savedFilters = it.savedFilters.copy(isSaving = true, error = null)) }
816 _state.update { it.copy(savedFilters = it.savedFilters.copy(isSaving = true, error = null)) }
534
817
535 val query = if (current.filters.customSql.isNotBlank()) {
818 val query = if (current.filters.customSql.isNotBlank()) {
@@ -538,15 +821,13 @@ class LogViewerViewModel @Inject constructor(
538 filterQuery = current.filters.customSql,
821 filterQuery = current.filters.customSql,
539 )
822 )
540 } else {
823 } else {
541− val ruleGroups = current.filters.activeFilters.zip(current.filters.filterClauses)
824+ val ruleGroups = current.filters.filterConditions
542− .mapIndexed { index, (display, _) ->
825+ .mapIndexed { index, condition ->
543− // Parse display back to field/operator/value
544− val parts = parseFilterDisplay(display)
545 FilterRule(
826 FilterRule(
546 id = "rule_$index",
827 id = "rule_$index",
547− field = parts.first,
828+ field = condition.column,
548− value = parts.third,
829+ value = condition.value,
549− operator = parts.second,
830+ operator = condition.operator,
550 )
831 )
551 }
832 }
552 val builder = if (ruleGroups.isNotEmpty()) {
833 val builder = if (ruleGroups.isNotEmpty()) {
@@ -593,6 +874,7 @@ class LogViewerViewModel @Inject constructor(
593 }
874 }
594 }
875 }
595 }
876 }
877+ }
596 }
878 }
597 }
879 }
598
880
@@ -602,9 +884,15 @@ class LogViewerViewModel @Inject constructor(
602 when (filter.query.filterType) {
884 when (filter.query.filterType) {
603 "sql" -> {
885 "sql" -> {
604 val sql = filter.query.filterQuery ?: return
886 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.
605 _state.update {
893 _state.update {
606 it.copy(
894 it.copy(
607− filters = FilterState(customSql = sql),
895+ filters = FilterState(),
608 currentLimit = 500,
896 currentLimit = 500,
609 )
897 )
610 }
898 }
@@ -640,7 +928,7 @@ class LogViewerViewModel @Inject constructor(
640 for (group in builder.rules) {
928 for (group in builder.rules) {
641 for (rule in group.rules) {
929 for (rule in group.rules) {
642 if (rule.field.isNotBlank()) {
930 if (rule.field.isNotBlank()) {
643− addFilter(rule.field, rule.operator, rule.value)
931+ addFilterInternal(rule.field, rule.operator, rule.value)
644 }
932 }
645 }
933 }
646 }
934 }
@@ -648,32 +936,21 @@ class LogViewerViewModel @Inject constructor(
648
936
649 fun deleteSavedFilter(filterId: String) {
937 fun deleteSavedFilter(filterId: String) {
650 viewModelScope.launch {
938 viewModelScope.launch {
939+ savedFiltersMutex.withLock {
651− when (repository.deleteFilter(filterId)) {
940+ when (repository.deleteFilter(filterId)) {
652− is ApiResult.Success -> {
941+ is ApiResult.Success -> {
653− _state.update {
942+ _state.update {
654− it.copy(
943+ it.copy(
655− savedFilters = it.savedFilters.copy(
944+ savedFilters = it.savedFilters.copy(
656− filters = it.savedFilters.filters.filter { f -> f.filterId != filterId },
945+ filters = it.savedFilters.filters.filter { f -> f.filterId != filterId },
657− ),
946+ ),
658− )
947+ )
948+ }
659 }
949 }
950+ is ApiResult.Error -> { /* Silently ignore — will show on next reload */ }
660 }
951 }
661− is ApiResult.Error -> { /* Silently ignore — will show on next reload */ }
662− }
663− }
664− }
665−
666− /** Parse a display string like "column = value" back into (field, operator, value) */
667− private fun parseFilterDisplay(display: String): Triple<String, String, String> {
668− for (op in listOf("IS NOT NULL", "IS NULL", "ILIKE", "LIKE", "!=", ">=", "<=", "=", ">", "<")) {
669− val idx = display.indexOf(" $op")
670− if (idx >= 0) {
671− val field = display.substring(0, idx)
672− val value = display.substring(idx + op.length + 1).trim()
673− return Triple(field, op, value)
674 }
952 }
675 }
953 }
676− return Triple(display, "=", "")
677 }
954 }
678
955
679 override fun onCleared() {
956 override fun onCleared() {
app/src/main/java/com/parseable/android/ui/screens/logviewer/SeverityColor.kt+37−8
@@ -65,8 +65,7 @@ private fun parseSeverityValue(raw: String, fieldName: String): LogSeverity {
65 trimmed.toIntOrNull()?.let { num ->
65 trimmed.toIntOrNull()?.let { num ->
66 return when {
66 return when {
67 // Syslog severity (0 = emergency .. 7 = debug)
67 // Syslog severity (0 = emergency .. 7 = debug)
68− fieldName.equals("syslog_severity", ignoreCase = true) ||
68+ fieldName.equals("syslog_severity", ignoreCase = true) -> when (num) {
69− fieldName.equals("priority", ignoreCase = true) -> when (num) {
70 0, 1, 2 -> LogSeverity.FATAL // emergency, alert, critical
69 0, 1, 2 -> LogSeverity.FATAL // emergency, alert, critical
71 3 -> LogSeverity.ERROR
70 3 -> LogSeverity.ERROR
72 4 -> LogSeverity.WARNING
71 4 -> LogSeverity.WARNING
@@ -75,20 +74,50 @@ private fun parseSeverityValue(raw: String, fieldName: String): LogSeverity {
75 7 -> LogSeverity.DEBUG
74 7 -> LogSeverity.DEBUG
76 else -> LogSeverity.UNKNOWN
75 else -> LogSeverity.UNKNOWN
77 }
76 }
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+ }
78− // HTTP status codes
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.
79− fieldName.equals("status", ignoreCase = true) -> when {
93+ fieldName.equals("status", ignoreCase = true) -> when (num) {
80− num >= 500 -> LogSeverity.ERROR
94+ in 500..599 -> LogSeverity.ERROR
81− num >= 400 -> LogSeverity.WARNING
95+ in 400..499 -> LogSeverity.WARNING
96+ // 1xx (informational) through 3xx are non-error HTTP statuses; 1xx previously
97+ // fell through to UNKNOWN.
82− num in 200..399 -> LogSeverity.INFO
98+ in 100..399 -> LogSeverity.INFO
83 else -> LogSeverity.UNKNOWN
99 else -> LogSeverity.UNKNOWN
84 }
100 }
85− // Generic numeric (Java util logging style: higher = more severe)
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).
86 else -> when {
109 else -> when {
87 num >= 1000 -> LogSeverity.FATAL
110 num >= 1000 -> LogSeverity.FATAL
88 num >= 900 -> LogSeverity.ERROR
111 num >= 900 -> LogSeverity.ERROR
89 num >= 800 -> LogSeverity.WARNING
112 num >= 800 -> LogSeverity.WARNING
90 num >= 700 -> LogSeverity.INFO
113 num >= 700 -> LogSeverity.INFO
91 num >= 400 -> LogSeverity.DEBUG
114 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
92 num > 0 -> LogSeverity.TRACE
121 num > 0 -> LogSeverity.TRACE
93 else -> LogSeverity.UNKNOWN
122 else -> LogSeverity.UNKNOWN
94 }
123 }
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoScreen.kt+100−86
@@ -7,6 +7,7 @@ import androidx.compose.material.icons.Icons
7import androidx.compose.material.icons.automirrored.filled.ArrowBack
7import androidx.compose.material.icons.automirrored.filled.ArrowBack
8import androidx.compose.material.icons.filled.*
8import androidx.compose.material.icons.filled.*
9import androidx.compose.material3.*
9import androidx.compose.material3.*
10+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
10import androidx.compose.runtime.*
11import androidx.compose.runtime.*
11import androidx.compose.ui.Alignment
12import androidx.compose.ui.Alignment
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.Modifier
@@ -14,10 +15,7 @@ import androidx.compose.ui.text.font.FontFamily
14import androidx.compose.ui.text.font.FontWeight
15import androidx.compose.ui.text.font.FontWeight
15import androidx.compose.ui.unit.dp
16import androidx.compose.ui.unit.dp
16import androidx.hilt.navigation.compose.hiltViewModel
17import androidx.hilt.navigation.compose.hiltViewModel
17−import androidx.lifecycle.Lifecycle
18−import androidx.lifecycle.compose.LocalLifecycleOwner
19import androidx.lifecycle.compose.collectAsStateWithLifecycle
18import androidx.lifecycle.compose.collectAsStateWithLifecycle
20−import androidx.lifecycle.repeatOnLifecycle
21import com.parseable.android.data.formatBytes
19import com.parseable.android.data.formatBytes
22import kotlinx.serialization.json.Json
20import kotlinx.serialization.json.Json
23import kotlinx.serialization.json.JsonObject
21import kotlinx.serialization.json.JsonObject
@@ -35,11 +33,10 @@ fun StreamInfoScreen(
35 val state by viewModel.state.collectAsStateWithLifecycle()
33 val state by viewModel.state.collectAsStateWithLifecycle()
36 var showDeleteConfirmation by remember { mutableStateOf(false) }
34 var showDeleteConfirmation by remember { mutableStateOf(false) }
37
35
38− val lifecycleOwner = LocalLifecycleOwner.current
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).
39− LaunchedEffect(streamName, lifecycleOwner) {
38+ LaunchedEffect(streamName) {
40− lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
41− viewModel.load(streamName)
39+ viewModel.load(streamName)
42− }
43 }
40 }
44
41
45 LaunchedEffect(state.deleteSuccess) {
42 LaunchedEffect(state.deleteSuccess) {
@@ -73,97 +70,114 @@ fun StreamInfoScreen(
73 )
70 )
74 },
71 },
75 ) { padding ->
72 ) { 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+ ) {
76− if (state.isLoading) {
86+ if (state.isLoading && !hasContent) {
77− Box(
87+ Box(
78− modifier = Modifier
88+ modifier = Modifier.fillMaxSize(),
79− .fillMaxSize()
80− .padding(padding),
81− contentAlignment = Alignment.Center,
89+ contentAlignment = Alignment.Center,
82− ) {
90+ ) {
83− CircularProgressIndicator()
91+ CircularProgressIndicator()
84− }
92+ }
85− } else {
93+ } else {
86− Column(
94+ Column(
87− modifier = Modifier
95+ modifier = Modifier
88− .fillMaxSize()
96+ .fillMaxSize()
89− .padding(padding)
90− .verticalScroll(rememberScrollState())
97+ .verticalScroll(rememberScrollState())
91− .padding(16.dp),
98+ .padding(16.dp),
92− verticalArrangement = Arrangement.spacedBy(16.dp),
99+ verticalArrangement = Arrangement.spacedBy(16.dp),
93− ) {
100+ ) {
94− // Stats section
101+ // Stats section
95− state.stats?.let { stats ->
102+ state.stats?.let { stats ->
96− InfoSection(title = "Statistics") {
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 {
97− InfoRow("Event Count", stats.ingestion?.count?.toString() ?: stats.ingestion?.lifetimeCount?.toString() ?: "N/A")
108+ stats.ingestion?.count != null -> InfoRow("Event Count", stats.ingestion.count.toString())
109+ stats.ingestion?.lifetimeCount == null -> InfoRow("Event Count", "N/A")
110+ }
98− InfoRow("Ingestion Size", formatBytes(stats.ingestion?.size) ?: formatBytes(stats.ingestion?.lifetimeSize) ?: "N/A")
111+ 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")
112+ InfoRow("Storage Size", formatBytes(stats.storage?.size) ?: formatBytes(stats.storage?.lifetimeSize) ?: "N/A")
100− if (stats.ingestion?.lifetimeCount != null) {
113+ if (stats.ingestion?.lifetimeCount != null) {
101− InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
114+ InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
102− }
115+ }
103− if (stats.ingestion?.deletedCount != null) {
116+ if (stats.ingestion?.deletedCount != null) {
104− InfoRow("Deleted Events", stats.ingestion.deletedCount.toString())
117+ InfoRow("Deleted Events", stats.ingestion.deletedCount.toString())
118+ }
105 }
119 }
106 }
120 }
107− }
108
121
109− // Schema section
122+ // Schema section
110− if (state.schema.isNotEmpty()) {
123+ if (state.schema.isNotEmpty()) {
111− InfoSection(title = "Schema (${state.schema.size} fields)") {
124+ InfoSection(title = "Schema (${state.schema.size} fields)") {
112− state.schema.forEach { field ->
125+ state.schema.forEach { field ->
113− InfoRow(
126+ InfoRow(
114− field.name,
127+ field.name,
115− field.dataType?.toString()?.trim('"') ?: "Unknown",
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,
116 )
138 )
117 }
139 }
118 }
140 }
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− }
128
141
129− // Retention section
142+ // Retention section
130− if (state.retention.isNotEmpty()) {
143+ if (state.retention.isNotEmpty()) {
131− InfoSection(title = "Retention") {
144+ InfoSection(title = "Retention") {
132− state.retention.forEach { r ->
145+ state.retention.forEach { r ->
133− InfoRow("Duration", r.duration ?: "N/A")
146+ InfoRow("Duration", r.duration ?: "N/A")
134− InfoRow("Action", r.action ?: "N/A")
147+ InfoRow("Action", r.action ?: "N/A")
135− if (r.description != null) {
148+ if (r.description != null) {
136− InfoRow("Description", r.description)
149+ InfoRow("Description", r.description)
150+ }
137 }
151 }
138 }
152 }
139 }
153 }
140− }
141
154
142− // Raw info section
155+ // Raw info section
143− state.rawInfo?.let { rawInfo ->
156+ state.rawInfo?.let { rawInfo ->
144− val prettyInfo = remember(rawInfo) {
157+ val prettyInfo = remember(rawInfo) {
145− prettyJson.encodeToString(JsonObject.serializer(), rawInfo)
158+ prettyJson.encodeToString(JsonObject.serializer(), rawInfo)
146− }
159+ }
147− InfoSection(title = "Stream Info") {
160+ InfoSection(title = "Stream Info") {
148− Text(
161+ Text(
149− text = prettyInfo,
162+ text = prettyInfo,
150− style = MaterialTheme.typography.bodySmall,
163+ style = MaterialTheme.typography.bodySmall,
151− fontFamily = FontFamily.Monospace,
164+ fontFamily = FontFamily.Monospace,
152− )
165+ )
166+ }
153 }
167 }
154− }
155
168
156− state.error?.let { errorText ->
169+ state.error?.let { errorText ->
157− Card(
170+ Card(
158− colors = CardDefaults.cardColors(
171+ colors = CardDefaults.cardColors(
159− containerColor = MaterialTheme.colorScheme.errorContainer,
172+ containerColor = MaterialTheme.colorScheme.errorContainer,
160− ),
173+ ),
161− ) {
174+ ) {
162− Text(
175+ Text(
163− text = errorText,
176+ text = errorText,
164− modifier = Modifier.padding(16.dp),
177+ modifier = Modifier.padding(16.dp),
165− color = MaterialTheme.colorScheme.onErrorContainer,
178+ color = MaterialTheme.colorScheme.onErrorContainer,
166− )
179+ )
180+ }
167 }
181 }
168 }
182 }
169 }
183 }
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoViewModel.kt+59−10
@@ -8,6 +8,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
8import kotlinx.coroutines.CancellationException
8import kotlinx.coroutines.CancellationException
9import kotlinx.coroutines.Job
9import kotlinx.coroutines.Job
10import kotlinx.coroutines.async
10import kotlinx.coroutines.async
11+import kotlinx.coroutines.coroutineScope
11import kotlinx.coroutines.flow.MutableStateFlow
12import kotlinx.coroutines.flow.MutableStateFlow
12import kotlinx.coroutines.flow.StateFlow
13import kotlinx.coroutines.flow.StateFlow
13import kotlinx.coroutines.flow.asStateFlow
14import kotlinx.coroutines.flow.asStateFlow
@@ -38,21 +39,61 @@ class StreamInfoViewModel @Inject constructor(
38 val state: StateFlow<StreamInfoState> = _state.asStateFlow()
39 val state: StateFlow<StreamInfoState> = _state.asStateFlow()
39 private var loadJob: Job? = null
40 private var loadJob: Job? = null
40
41
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+
41 fun load(streamName: String) {
50 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) {
42 loadJob?.cancel()
70 loadJob?.cancel()
43 _state.update { it.copy(streamName = streamName, isLoading = true, error = null) }
71 _state.update { it.copy(streamName = streamName, isLoading = true, error = null) }
44
72
45 loadJob = viewModelScope.launch {
73 loadJob = viewModelScope.launch {
46 try {
74 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 {
47− val statsDeferred = async { repository.getStreamStats(streamName) }
81+ val statsDeferred = async { repository.getStreamStats(streamName) }
48− val schemaDeferred = async { repository.getStreamSchema(streamName) }
82+ val schemaDeferred = async { repository.getStreamSchema(streamName) }
49− val retentionDeferred = async { repository.getStreamRetention(streamName) }
83+ val retentionDeferred = async { repository.getStreamRetention(streamName) }
50− val infoDeferred = async { repository.getStreamInfo(streamName) }
84+ val infoDeferred = async { repository.getStreamInfo(streamName) }
51
85
86+ LoadResults(
87+ stats = statsDeferred.await(),
88+ schema = schemaDeferred.await(),
89+ retention = retentionDeferred.await(),
90+ info = infoDeferred.await(),
91+ )
92+ }
52− val statsResult = statsDeferred.await()
93+ val statsResult = results.stats
53− val schemaResult = schemaDeferred.await()
94+ val schemaResult = results.schema
54− val retentionResult = retentionDeferred.await()
95+ val retentionResult = results.retention
55− val infoResult = infoDeferred.await()
96+ val infoResult = results.info
56
97
57 _state.update {
98 _state.update {
58 it.copy(
99 it.copy(
@@ -86,9 +127,17 @@ class StreamInfoViewModel @Inject constructor(
86
127
87 fun deleteStream() {
128 fun deleteStream() {
88 val name = _state.value.streamName
129 val name = _state.value.streamName
130+ // Guard against a no-op or a second tap firing a duplicate DELETE while one is in flight.
89− if (name.isEmpty()) return
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()
90 viewModelScope.launch {
135 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).
91− _state.update { it.copy(isDeleting = true, error = null) }
140+ _state.update { it.copy(isDeleting = true, isLoading = false, error = null) }
92 when (val result = repository.deleteStream(name)) {
141 when (val result = repository.deleteStream(name)) {
93 is ApiResult.Success -> {
142 is ApiResult.Success -> {
94 _state.update { it.copy(isDeleting = false, deleteSuccess = true) }
143 _state.update { it.copy(isDeleting = false, deleteSuccess = true) }
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsScreen.kt+13−6
@@ -27,18 +27,23 @@ fun SettingsScreen(
27 onBack: () -> Unit,
27 onBack: () -> Unit,
28 onAboutClick: () -> Unit,
28 onAboutClick: () -> Unit,
29 onServerSwitched: () -> Unit,
29 onServerSwitched: () -> Unit,
30+ onLoggedOut: () -> Unit,
30 viewModel: SettingsViewModel = hiltViewModel(),
31 viewModel: SettingsViewModel = hiltViewModel(),
31) {
32) {
32 val state by viewModel.state.collectAsStateWithLifecycle()
33 val state by viewModel.state.collectAsStateWithLifecycle()
33− val switchEvent by viewModel.switchEvent.collectAsStateWithLifecycle()
34
34
35− LaunchedEffect(switchEvent) {
35+ LaunchedEffect(Unit) {
36− if (switchEvent != null) {
36+ viewModel.switchEvent.collect {
37− viewModel.consumeSwitchEvent()
38 onServerSwitched()
37 onServerSwitched()
39 }
38 }
40 }
39 }
41
40
41+ LaunchedEffect(Unit) {
42+ viewModel.loggedOutEvent.collect {
43+ onLoggedOut()
44+ }
45+ }
46+
42 Scaffold(
47 Scaffold(
43 topBar = {
48 topBar = {
44 TopAppBar(
49 TopAppBar(
@@ -102,8 +107,10 @@ fun SettingsScreen(
102 )
107 )
103 }
108 }
104
109
105− // Loading indicator for server data
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.
106− if (state.isLoading) {
113+ if (state.isLoading && state.aboutInfo == null) {
107 Box(
114 Box(
108 modifier = Modifier
115 modifier = Modifier
109 .fillMaxWidth()
116 .fillMaxWidth()
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsViewModel.kt+100−22
@@ -9,12 +9,15 @@ import com.parseable.android.data.repository.ParseableRepository
9import com.parseable.android.data.repository.SettingsRepository
9import com.parseable.android.data.repository.SettingsRepository
10import dagger.hilt.android.lifecycle.HiltViewModel
10import dagger.hilt.android.lifecycle.HiltViewModel
11import kotlinx.coroutines.CancellationException
11import kotlinx.coroutines.CancellationException
12+import kotlinx.coroutines.Job
12import kotlinx.coroutines.async
13import kotlinx.coroutines.async
14+import kotlinx.coroutines.channels.Channel
13import kotlinx.coroutines.coroutineScope
15import kotlinx.coroutines.coroutineScope
14import kotlinx.coroutines.flow.MutableStateFlow
16import kotlinx.coroutines.flow.MutableStateFlow
15import kotlinx.coroutines.flow.StateFlow
17import kotlinx.coroutines.flow.StateFlow
16import kotlinx.coroutines.flow.asStateFlow
18import kotlinx.coroutines.flow.asStateFlow
17import kotlinx.coroutines.flow.first
19import kotlinx.coroutines.flow.first
20+import kotlinx.coroutines.flow.receiveAsFlow
18import kotlinx.coroutines.flow.update
21import kotlinx.coroutines.flow.update
19import kotlinx.coroutines.launch
22import kotlinx.coroutines.launch
20import kotlinx.serialization.json.jsonPrimitive
23import kotlinx.serialization.json.jsonPrimitive
@@ -31,6 +34,7 @@ data class SettingsState(
31 val savedServers: List<SavedServer> = emptyList(),
34 val savedServers: List<SavedServer> = emptyList(),
32 val activeServerId: Long? = null,
35 val activeServerId: Long? = null,
33 val isSwitching: Boolean = false,
36 val isSwitching: Boolean = false,
37+ val isDeleting: Boolean = false,
34)
38)
35
39
36@HiltViewModel
40@HiltViewModel
@@ -42,9 +46,25 @@ class SettingsViewModel @Inject constructor(
42 private val _state = MutableStateFlow(SettingsState())
46 private val _state = MutableStateFlow(SettingsState())
43 val state: StateFlow<SettingsState> = _state.asStateFlow()
47 val state: StateFlow<SettingsState> = _state.asStateFlow()
44
48
49+ /**
45− /** Emits the server ID after a successful switch so the screen can navigate. */
50+ * Emits the server ID after a successful switch so the screen can navigate. A [Channel]
46− private val _switchEvent = MutableStateFlow<Long?>(null)
51+ * (matching the codebase's other one-shot events) so the navigation fires exactly once and
47− val switchEvent: StateFlow<Long?> = _switchEvent.asStateFlow()
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
48
68
49 init {
69 init {
50 load()
70 load()
@@ -61,7 +81,9 @@ class SettingsViewModel @Inject constructor(
61 }
81 }
62
82
63 fun load() {
83 fun load() {
84+ // Cancel any in-flight load so a slower completion can't overwrite a newer one.
85+ loadJob?.cancel()
64− viewModelScope.launch {
86+ loadJob = viewModelScope.launch {
65 _state.update { it.copy(isLoading = true, error = null) }
87 _state.update { it.copy(isLoading = true, error = null) }
66
88
67 val config = settingsRepository.serverConfig.first()
89 val config = settingsRepository.serverConfig.first()
@@ -84,24 +106,24 @@ class SettingsViewModel @Inject constructor(
84
106
85 val userNames = (usersResult as? ApiResult.Success)?.data?.mapNotNull { obj ->
107 val userNames = (usersResult as? ApiResult.Success)?.data?.mapNotNull { obj ->
86 try {
108 try {
87− obj["id"]?.jsonPrimitive?.content
109+ obj["username"]?.jsonPrimitive?.content
88− ?: obj["username"]?.jsonPrimitive?.content
110+ ?: obj["id"]?.jsonPrimitive?.content
89 } catch (_: IllegalStateException) {
111 } catch (_: IllegalStateException) {
90 null
112 null
91 }
113 }
92 } ?: emptyList()
114 } ?: emptyList()
93
115
94− val errors = listOfNotNull(
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.
95− (aboutResult as? ApiResult.Error)?.userMessage,
119+ val error = (aboutResult as? ApiResult.Error)?.userMessage
96− (usersResult as? ApiResult.Error)?.userMessage,
97− ).distinct().joinToString("\n").ifEmpty { null }
98
120
99 _state.update {
121 _state.update {
100 it.copy(
122 it.copy(
101 aboutInfo = (aboutResult as? ApiResult.Success)?.data,
123 aboutInfo = (aboutResult as? ApiResult.Success)?.data,
102 users = userNames,
124 users = userNames,
103 isLoading = false,
125 isLoading = false,
104− error = errors,
126+ error = error,
105 )
127 )
106 }
128 }
107 } catch (e: CancellationException) {
129 } catch (e: CancellationException) {
@@ -116,24 +138,80 @@ class SettingsViewModel @Inject constructor(
116 }
138 }
117
139
118 fun switchToServer(serverId: Long) {
140 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
119 viewModelScope.launch {
145 viewModelScope.launch {
120− _state.update { it.copy(isSwitching = true) }
146+ _state.update { it.copy(isSwitching = true, error = null) }
147+ try {
121− val config = settingsRepository.switchToServer(serverId)
148+ val config = settingsRepository.switchToServer(serverId)
122− if (config != null) {
149+ if (config != null) {
123− repository.configure(config)
150+ repository.configure(config)
124− _switchEvent.value = serverId
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) }
125 }
172 }
126− _state.update { it.copy(isSwitching = false) }
127 }
173 }
128 }
174 }
129
175
130− fun consumeSwitchEvent() {
131− _switchEvent.value = null
132− }
133−
134 fun deleteServer(serverId: Long) {
176 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
135 viewModelScope.launch {
184 viewModelScope.launch {
185+ _state.update { it.copy(isDeleting = true) }
186+ try {
136− settingsRepository.deleteServer(serverId)
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+ }
137 }
215 }
138 }
216 }
139}
217}
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsScreen.kt+20−15
@@ -10,15 +10,13 @@ import androidx.compose.material.icons.filled.*
10import androidx.compose.material3.*
10import androidx.compose.material3.*
11import androidx.compose.material3.pulltorefresh.PullToRefreshBox
11import androidx.compose.material3.pulltorefresh.PullToRefreshBox
12import androidx.compose.runtime.*
12import androidx.compose.runtime.*
13+import androidx.compose.runtime.saveable.rememberSaveable
13import androidx.compose.ui.Alignment
14import androidx.compose.ui.Alignment
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.Modifier
15−import androidx.lifecycle.compose.LocalLifecycleOwner
16import androidx.compose.ui.text.font.FontWeight
16import androidx.compose.ui.text.font.FontWeight
17import androidx.compose.ui.unit.dp
17import androidx.compose.ui.unit.dp
18import androidx.hilt.navigation.compose.hiltViewModel
18import androidx.hilt.navigation.compose.hiltViewModel
19−import androidx.lifecycle.Lifecycle
20import androidx.lifecycle.compose.collectAsStateWithLifecycle
19import androidx.lifecycle.compose.collectAsStateWithLifecycle
21−import androidx.lifecycle.repeatOnLifecycle
22import kotlinx.coroutines.flow.collectLatest
20import kotlinx.coroutines.flow.collectLatest
23import kotlinx.coroutines.launch
21import kotlinx.coroutines.launch
24
22
@@ -34,16 +32,21 @@ fun StreamsScreen(
34 val state by viewModel.state.collectAsStateWithLifecycle()
32 val state by viewModel.state.collectAsStateWithLifecycle()
35 val scope = rememberCoroutineScope()
33 val scope = rememberCoroutineScope()
36 var showLogoutConfirmation by remember { mutableStateOf(false) }
34 var showLogoutConfirmation by remember { mutableStateOf(false) }
37− var searchQuery by remember { mutableStateOf("") }
35+ var searchQuery by rememberSaveable { mutableStateOf("") }
38 val snackbarHostState = remember { SnackbarHostState() }
36 val snackbarHostState = remember { SnackbarHostState() }
39
37
40 val listState = rememberLazyListState()
38 val listState = rememberLazyListState()
41
39
42− // Scroll to top when a refresh completes (isLoading transitions to false)
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) }
43 LaunchedEffect(state.isLoading) {
45 LaunchedEffect(state.isLoading) {
44− if (!state.isLoading) {
46+ if (wasLoading && !state.isLoading) {
45 listState.scrollToItem(0)
47 listState.scrollToItem(0)
46 }
48 }
49+ wasLoading = state.isLoading
47 }
50 }
48
51
49 val filteredStreams = remember(state.streams, searchQuery, state.favoriteNames) {
52 val filteredStreams = remember(state.streams, searchQuery, state.favoriteNames) {
@@ -55,14 +58,11 @@ fun StreamsScreen(
55 }
58 }
56 // Partition by favorites instead of full sort for O(n) vs O(n log n)
59 // Partition by favorites instead of full sort for O(n) vs O(n log n)
57 val (favs, rest) = filtered.partition { it.name in favorites }
60 val (favs, rest) = filtered.partition { it.name in favorites }
58− favs + rest
61+ // `favorites` iterates most-recently-favorited first (DAO ORDER BY addedAt DESC
59− }
62+ // preserved through LinkedHashSet); order favs to match so recency is reflected.
60−
63+ val favRank = favorites.withIndex().associate { (i, name) -> name to i }
61− val lifecycleOwner = LocalLifecycleOwner.current
64+ val sortedFavs = favs.sortedBy { favRank[it.name] ?: Int.MAX_VALUE }
62− LaunchedEffect(lifecycleOwner) {
65+ sortedFavs + rest
63− lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
64− viewModel.refresh()
65− }
66 }
66 }
67
67
68 LaunchedEffect(Unit) {
68 LaunchedEffect(Unit) {
@@ -157,7 +157,12 @@ fun StreamsScreen(
157 }
157 }
158 } else {
158 } else {
159 Column {
159 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.
160− if (state.streams.size > 5) {
165+ if (state.streams.size > 5 || searchQuery.isNotEmpty()) {
161 OutlinedTextField(
166 OutlinedTextField(
162 value = searchQuery,
167 value = searchQuery,
163 onValueChange = { searchQuery = it },
168 onValueChange = { searchQuery = it },
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsViewModel.kt+52−10
@@ -22,7 +22,9 @@ import kotlinx.coroutines.flow.asStateFlow
22import kotlinx.coroutines.flow.receiveAsFlow
22import kotlinx.coroutines.flow.receiveAsFlow
23import kotlinx.coroutines.flow.update
23import kotlinx.coroutines.flow.update
24import kotlinx.coroutines.launch
24import kotlinx.coroutines.launch
25+import kotlinx.coroutines.sync.Mutex
25import kotlinx.coroutines.sync.Semaphore
26import kotlinx.coroutines.sync.Semaphore
27+import kotlinx.coroutines.sync.withLock
26import kotlinx.coroutines.sync.withPermit
28import kotlinx.coroutines.sync.withPermit
27import javax.inject.Inject
29import javax.inject.Inject
28
30
@@ -61,22 +63,40 @@ class StreamsViewModel @Inject constructor(
61 _state.update { it.copy(favoriteNames = names.toSet()) }
63 _state.update { it.copy(favoriteNames = names.toSet()) }
62 }
64 }
63 }
65 }
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()
64 }
69 }
65
70
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+
66 fun toggleFavorite(streamName: String) {
76 fun toggleFavorite(streamName: String) {
67 viewModelScope.launch {
77 viewModelScope.launch {
68− if (streamName in _state.value.favoriteNames) {
78+ favoriteMutex.withLock {
79+ // Decide from the committed DB state, not the async-mirrored favoriteNames.
80+ if (favoriteDao.isFavoriteNow(streamName)) {
69− favoriteDao.deleteByName(streamName)
81+ favoriteDao.deleteByName(streamName)
70− _snackbarEvent.send("Removed from favorites")
82+ _snackbarEvent.send("Removed from favorites")
71− } else {
83+ } else {
72− favoriteDao.insert(FavoriteStream(streamName = streamName))
84+ favoriteDao.insert(FavoriteStream(streamName = streamName))
73− _snackbarEvent.send("Added to favorites")
85+ _snackbarEvent.send("Added to favorites")
86+ }
74 }
87 }
75 }
88 }
76 }
89 }
77
90
91+ private var refreshJob: Job? = null
92+
78 fun refresh() {
93 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()
79− viewModelScope.launch {
99+ refreshJob = viewModelScope.launch {
80 _state.update { it.copy(isLoading = true, error = null) }
100 _state.update { it.copy(isLoading = true, error = null) }
81
101
82 try {
102 try {
@@ -93,9 +113,14 @@ class StreamsViewModel @Inject constructor(
93
113
94 when (streamsResult) {
114 when (streamsResult) {
95 is ApiResult.Success -> {
115 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 }
96 _state.update {
119 _state.update {
97 it.copy(
120 it.copy(
98 streams = streamsResult.data.sortedBy { s -> s.name.lowercase() },
121 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 },
99 isLoading = false,
124 isLoading = false,
100 )
125 )
101 }
126 }
@@ -124,9 +149,14 @@ class StreamsViewModel @Inject constructor(
124
149
125 private val statsSemaphore = Semaphore(8)
150 private val statsSemaphore = Semaphore(8)
126 private var statsJob: Job? = null
151 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>()
127
155
128 private fun loadStreamStats(streams: List<LogStream>) {
156 private fun loadStreamStats(streams: List<LogStream>) {
129 statsJob?.cancel()
157 statsJob?.cancel()
158+ retryJobs.values.forEach { it.cancel() }
159+ retryJobs.clear()
130 statsJob = viewModelScope.launch {
160 statsJob = viewModelScope.launch {
131 streams.forEach { stream ->
161 streams.forEach { stream ->
132 launch {
162 launch {
@@ -139,12 +169,20 @@ class StreamsViewModel @Inject constructor(
139 }
169 }
140
170
141 fun retryStats(streamName: String) {
171 fun retryStats(streamName: String) {
172+ retryJobs[streamName]?.cancel()
142− viewModelScope.launch {
173+ val job = viewModelScope.launch {
143 _state.update { it.copy(failedStats = it.failedStats - streamName) }
174 _state.update { it.copy(failedStats = it.failedStats - streamName) }
144 statsSemaphore.withPermit {
175 statsSemaphore.withPermit {
145 loadSingleStreamStats(streamName)
176 loadSingleStreamStats(streamName)
146 }
177 }
147 }
178 }
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+ }
148 }
186 }
149
187
150 private suspend fun loadSingleStreamStats(streamName: String) {
188 private suspend fun loadSingleStreamStats(streamName: String) {
@@ -160,7 +198,10 @@ class StreamsViewModel @Inject constructor(
160 ?: formatBytes(stats.storage?.lifetimeSize),
198 ?: formatBytes(stats.storage?.lifetimeSize),
161 )
199 )
162 _state.update {
200 _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
163− it.copy(
204+ else it.copy(
164 streamStats = it.streamStats + (streamName to ui),
205 streamStats = it.streamStats + (streamName to ui),
165 failedStats = it.failedStats - streamName,
206 failedStats = it.failedStats - streamName,
166 )
207 )
@@ -168,7 +209,8 @@ class StreamsViewModel @Inject constructor(
168 }
209 }
169 is ApiResult.Error -> {
210 is ApiResult.Error -> {
170 _state.update {
211 _state.update {
212+ if (it.streams.none { s -> s.name == streamName }) it
171− it.copy(failedStats = it.failedStats + streamName)
213+ else it.copy(failedStats = it.failedStats + streamName)
172 }
214 }
173 }
215 }
174 }
216 }
app/src/test/java/com/parseable/android/AlertsViewModelTest.kt+4
@@ -24,6 +24,10 @@ class AlertsViewModelTest {
24 fun setup() {
24 fun setup() {
25 Dispatchers.setMain(testDispatcher)
25 Dispatchers.setMain(testDispatcher)
26 repository = mockk(relaxed = true)
26 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())
27 viewModel = AlertsViewModel(repository)
31 viewModel = AlertsViewModel(repository)
28 }
32 }
29
33
app/src/test/java/com/parseable/android/EscapeSqlTest.kt+27
@@ -1,6 +1,7 @@
1package com.parseable.android
1package com.parseable.android
2
2
3import com.parseable.android.data.escapeIdentifier
3import com.parseable.android.data.escapeIdentifier
4+import com.parseable.android.data.escapeLikePattern
4import com.parseable.android.data.escapeSql
5import com.parseable.android.data.escapeSql
5import org.junit.Assert.assertEquals
6import org.junit.Assert.assertEquals
6import org.junit.Test
7import org.junit.Test
@@ -52,6 +53,32 @@ class EscapeSqlTest {
52 assertEquals("''; DROP TABLE users --", escapeSql("'; DROP TABLE users --"))
53 assertEquals("''; DROP TABLE users --", escapeSql("'; DROP TABLE users --"))
53 }
54 }
54
55
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+
55 @Test
82 @Test
56 fun `escapeIdentifier doubles double quotes`() {
83 fun `escapeIdentifier doubles double quotes`() {
57 assertEquals("foo\"\"bar", escapeIdentifier("foo\"bar"))
84 assertEquals("foo\"\"bar", escapeIdentifier("foo\"bar"))
app/src/test/java/com/parseable/android/FormatTimestampTest.kt+14
@@ -32,6 +32,20 @@ class FormatTimestampTest {
32 }
32 }
33 }
33 }
34
34
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+
35 @Test
49 @Test
36 fun `returns raw string for unparseable timestamp`() {
50 fun `returns raw string for unparseable timestamp`() {
37 val result = formatTimestamp("not-a-timestamp")
51 val result = formatTimestamp("not-a-timestamp")
app/src/test/java/com/parseable/android/LogViewerViewModelTest.kt+57−3
@@ -164,9 +164,9 @@ class LogViewerViewModelTest {
164 startTime = any(),
164 startTime = any(),
165 endTime = any(),
165 endTime = any(),
166 filterSql = match {
166 filterSql = match {
167− it.contains("\"message\" ILIKE '%error%'") &&
167+ it.contains("CAST(\"message\" AS VARCHAR) ILIKE '%error%'") &&
168− it.contains("\"level\" ILIKE '%error%'") &&
168+ it.contains("CAST(\"level\" AS VARCHAR) ILIKE '%error%'") &&
169− !it.contains("p_timestamp ILIKE") // Internal columns excluded
169+ !it.contains("p_timestamp") // Internal columns excluded
170 },
170 },
171 limit = any(),
171 limit = any(),
172 )
172 )
@@ -185,6 +185,24 @@ class LogViewerViewModelTest {
185 assertEquals(500, state.currentLimit) // Reset
185 assertEquals(500, state.currentLimit) // Reset
186 }
186 }
187
187
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+
188 @Test
206 @Test
189 fun `toggleStreaming starts and stops`() {
207 fun `toggleStreaming starts and stops`() {
190 viewModel.initialize("test")
208 viewModel.initialize("test")
@@ -256,6 +274,42 @@ class LogViewerViewModelTest {
256 }
274 }
257 }
275 }
258
276
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+
259 @Test
313 @Test
260 fun `addFilter escapes column names`() {
314 fun `addFilter escapes column names`() {
261 viewModel.initialize("test")
315 viewModel.initialize("test")
app/src/test/java/com/parseable/android/LoginViewModelTest.kt+28−4
@@ -9,6 +9,8 @@ import io.mockk.*
9import kotlinx.coroutines.Dispatchers
9import kotlinx.coroutines.Dispatchers
10import kotlinx.coroutines.ExperimentalCoroutinesApi
10import kotlinx.coroutines.ExperimentalCoroutinesApi
11import kotlinx.coroutines.flow.flowOf
11import kotlinx.coroutines.flow.flowOf
12+import kotlinx.coroutines.flow.toList
13+import kotlinx.coroutines.launch
12import kotlinx.coroutines.test.*
14import kotlinx.coroutines.test.*
13import org.junit.After
15import org.junit.After
14import org.junit.Assert.*
16import org.junit.Assert.*
@@ -37,6 +39,17 @@ class LoginViewModelTest {
37 Dispatchers.resetMain()
39 Dispatchers.resetMain()
38 }
40 }
39
41
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+
40 @Test
53 @Test
41 fun `onLogin validates required fields`() {
54 fun `onLogin validates required fields`() {
42 viewModel.onLogin()
55 viewModel.onLogin()
@@ -65,6 +78,11 @@ class LoginViewModelTest {
65 fun `onLogin succeeds with valid credentials`() = runTest {
78 fun `onLogin succeeds with valid credentials`() = runTest {
66 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
79 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
67 coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo(version = "1.0"))
80 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()
68
86
69 viewModel.onServerUrlChange("https://test.parseable.com")
87 viewModel.onServerUrlChange("https://test.parseable.com")
70 viewModel.onUsernameChange("admin")
88 viewModel.onUsernameChange("admin")
@@ -72,7 +90,7 @@ class LoginViewModelTest {
72 viewModel.onLogin()
90 viewModel.onLogin()
73
91
74 val state = viewModel.state.value
92 val state = viewModel.state.value
75− assertTrue(state.loginSuccess)
93+ assertEquals(1, events.size)
76 assertFalse(state.isLoading)
94 assertFalse(state.isLoading)
77 coVerify { settingsRepository.saveServerConfig(any()) }
95 coVerify { settingsRepository.saveServerConfig(any()) }
78 }
96 }
@@ -81,13 +99,15 @@ class LoginViewModelTest {
81 fun `onLogin fails when connection fails`() = runTest {
99 fun `onLogin fails when connection fails`() = runTest {
82 coEvery { repository.testConnection() } returns ApiResult.Error("Connection refused", 0)
100 coEvery { repository.testConnection() } returns ApiResult.Error("Connection refused", 0)
83
101
102+ val events = collectSuccessEvents()
103+
84 viewModel.onServerUrlChange("https://bad.server")
104 viewModel.onServerUrlChange("https://bad.server")
85 viewModel.onUsernameChange("admin")
105 viewModel.onUsernameChange("admin")
86 viewModel.onPasswordChange("password")
106 viewModel.onPasswordChange("password")
87 viewModel.onLogin()
107 viewModel.onLogin()
88
108
89 val state = viewModel.state.value
109 val state = viewModel.state.value
90− assertFalse(state.loginSuccess)
110+ assertEquals(0, events.size)
91 assertNotNull(state.error)
111 assertNotNull(state.error)
92 assertTrue(state.error!!.contains("Connection failed"))
112 assertTrue(state.error!!.contains("Connection failed"))
93 }
113 }
@@ -97,13 +117,15 @@ class LoginViewModelTest {
97 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
117 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
98 coEvery { repository.verifyServer() } returns ApiResult.Error("not parseable", 404)
118 coEvery { repository.verifyServer() } returns ApiResult.Error("not parseable", 404)
99
119
120+ val events = collectSuccessEvents()
121+
100 viewModel.onServerUrlChange("https://test.com")
122 viewModel.onServerUrlChange("https://test.com")
101 viewModel.onUsernameChange("admin")
123 viewModel.onUsernameChange("admin")
102 viewModel.onPasswordChange("password")
124 viewModel.onPasswordChange("password")
103 viewModel.onLogin()
125 viewModel.onLogin()
104
126
105 val state = viewModel.state.value
127 val state = viewModel.state.value
106− assertFalse(state.loginSuccess)
128+ assertEquals(0, events.size)
107 assertNotNull(state.error)
129 assertNotNull(state.error)
108 assertTrue(state.error!!.contains("doesn't appear to be Parseable"))
130 assertTrue(state.error!!.contains("doesn't appear to be Parseable"))
109 }
131 }
@@ -113,13 +135,15 @@ class LoginViewModelTest {
113 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
135 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
114 coEvery { repository.verifyServer() } returns ApiResult.Error("Unauthorized", 401)
136 coEvery { repository.verifyServer() } returns ApiResult.Error("Unauthorized", 401)
115
137
138+ val events = collectSuccessEvents()
139+
116 viewModel.onServerUrlChange("https://test.parseable.com")
140 viewModel.onServerUrlChange("https://test.parseable.com")
117 viewModel.onUsernameChange("admin")
141 viewModel.onUsernameChange("admin")
118 viewModel.onPasswordChange("wrongpass")
142 viewModel.onPasswordChange("wrongpass")
119 viewModel.onLogin()
143 viewModel.onLogin()
120
144
121 val state = viewModel.state.value
145 val state = viewModel.state.value
122− assertFalse(state.loginSuccess)
146+ assertEquals(0, events.size)
123 assertNotNull(state.error)
147 assertNotNull(state.error)
124 assertTrue(state.error!!.contains("Invalid credentials"))
148 assertTrue(state.error!!.contains("Invalid credentials"))
125 assertFalse(state.error!!.contains("doesn't appear to be Parseable"))
149 assertFalse(state.error!!.contains("doesn't appear to be Parseable"))
app/src/test/java/com/parseable/android/PrivateHostTest.ktnew file+73
@@ -0,0 +1,73 @@
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+10−6
@@ -107,31 +107,35 @@ class SettingsViewModelTest {
107 }
107 }
108
108
109 @Test
109 @Test
110− fun `load sets error when users fail`() = runTest {
110+ fun `load ignores users failure when about succeeds`() = runTest {
111 coEvery { settingsRepository.serverConfig } returns flowOf(null)
111 coEvery { settingsRepository.serverConfig } returns flowOf(null)
112− coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
112+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo(version = "1.2.0"))
113 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
113 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
114
114
115 val viewModel = SettingsViewModel(settingsRepository, repository)
115 val viewModel = SettingsViewModel(settingsRepository, repository)
116
116
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.
117 val state = viewModel.state.value
119 val state = viewModel.state.value
118− assertNotNull(state.error)
120+ assertNull(state.error)
121+ assertEquals("1.2.0", state.aboutInfo?.version)
119 assertTrue(state.users.isEmpty())
122 assertTrue(state.users.isEmpty())
120 }
123 }
121
124
122 @Test
125 @Test
123− fun `load consolidates errors from both about and users`() = runTest {
126+ fun `load surfaces only the about error, not the users error`() = runTest {
124 coEvery { settingsRepository.serverConfig } returns flowOf(null)
127 coEvery { settingsRepository.serverConfig } returns flowOf(null)
125 coEvery { repository.getAbout() } returns ApiResult.Error("Server error", 500)
128 coEvery { repository.getAbout() } returns ApiResult.Error("Server error", 500)
126 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
129 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
127
130
128 val viewModel = SettingsViewModel(settingsRepository, repository)
131 val viewModel = SettingsViewModel(settingsRepository, repository)
129
132
133+ // Only the primary server-info call (/about) is treated as blocking; the secondary
134+ // /user failure stays silent.
130 val state = viewModel.state.value
135 val state = viewModel.state.value
131 assertNotNull(state.error)
136 assertNotNull(state.error)
132− // Both error messages should be present
133 assertTrue(state.error!!.contains("Server error"))
137 assertTrue(state.error!!.contains("Server error"))
134− assertTrue(state.error!!.contains("Permission denied"))
138+ assertFalse(state.error!!.contains("Permission denied"))
135 }
139 }
136
140
137 @Test
141 @Test
app/src/test/java/com/parseable/android/SeverityDetectionTest.kt+13
@@ -128,6 +128,7 @@ class SeverityDetectionTest {
128
128
129 @Test
129 @Test
130 fun `HTTP status code mapping`() {
130 fun `HTTP status code mapping`() {
131+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "100")))
131 assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "200")))
132 assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "200")))
132 assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "301")))
133 assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "301")))
133 assertEquals(LogSeverity.WARNING, detectSeverity(logWith("status" to "404")))
134 assertEquals(LogSeverity.WARNING, detectSeverity(logWith("status" to "404")))
@@ -145,6 +146,18 @@ class SeverityDetectionTest {
145 assertEquals(LogSeverity.TRACE, detectSeverity(logWith("level" to "100")))
146 assertEquals(LogSeverity.TRACE, detectSeverity(logWith("level" to "100")))
146 }
147 }
147
148
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+
148 // --- Priority among fields ---
161 // --- Priority among fields ---
149
162
150 @Test
163 @Test
app/src/test/java/com/parseable/android/StreamsViewModelTest.kt+3
@@ -143,6 +143,9 @@ class StreamsViewModelTest {
143 @Test
143 @Test
144 fun `toggleFavorite removes when already favorited`() = runTest {
144 fun `toggleFavorite removes when already favorited`() = runTest {
145 every { favoriteDao.getAllNames() } returns flowOf(listOf("existing"))
145 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
146
149
147 viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
150 viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
148 viewModel.toggleFavorite("existing")
151 viewModel.toggleFavorite("existing")