git.iko.soy/parseable-android
on master help sign in
54 files changed+2655−493
since splitsnapshots
unifiedsplit
.dockerignore+15 .github/workflows/build-release.yml+18 .github/workflows/ci.yml+29 .gitignore+2 Dockerfile+72 app/build.gradle.kts+12−1 app/src/main/AndroidManifest.xml+1−1 app/src/main/java/com/parseable/android/MainActivity.kt+21−1 app/src/main/java/com/parseable/android/data/FormatUtils.kt+19−2 app/src/main/java/com/parseable/android/data/SqlUtils.kt+16 app/src/main/java/com/parseable/android/data/TimestampUtils.kt+57−14 app/src/main/java/com/parseable/android/data/api/ParseableApiClient.kt+89−32 app/src/main/java/com/parseable/android/data/local/FavoriteStreamDao.kt+12−2 app/src/main/java/com/parseable/android/data/local/ParseableDatabase.kt+3−2 app/src/main/java/com/parseable/android/data/local/SavedServer.kt+23 app/src/main/java/com/parseable/android/data/local/SavedServerDao.kt+25 app/src/main/java/com/parseable/android/data/model/Models.kt+57−2 app/src/main/java/com/parseable/android/data/repository/ParseableRepository.kt+15−4 app/src/main/java/com/parseable/android/data/repository/SettingsRepository.kt+238−8 app/src/main/java/com/parseable/android/di/AppModule.kt+59 app/src/main/java/com/parseable/android/ui/navigation/NavGraph.kt+54−10 app/src/main/java/com/parseable/android/ui/screens/about/AboutScreen.kt+178 app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsScreen.kt app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsViewModel.kt+43−10 app/src/main/java/com/parseable/android/ui/screens/login/LoginScreen.kt+64−8 app/src/main/java/com/parseable/android/ui/screens/login/LoginViewModel.kt+88−11 app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.kt+213−71 app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerViewModel.kt+415−113 app/src/main/java/com/parseable/android/ui/screens/logviewer/SeverityColor.kt+37−8 app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoScreen.kt+100−86 app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoViewModel.kt+59−10 app/src/main/java/com/parseable/android/ui/screens/settings/SettingsScreen.kt+193−2 app/src/main/java/com/parseable/android/ui/screens/settings/SettingsViewModel.kt+126−8 app/src/main/java/com/parseable/android/ui/screens/streams/StreamsScreen.kt+20−15 app/src/main/java/com/parseable/android/ui/screens/streams/StreamsViewModel.kt+53−11 app/src/main/res/drawable/ic_launcher_background.xml−10 app/src/main/res/drawable/ic_launcher_foreground.xml−15 app/src/main/res/drawable/ic_launcher_monochrome.xml−11 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml−6 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml−6 app/src/main/res/mipmap-hdpi/ic_launcher.webp app/src/main/res/mipmap-mdpi/ic_launcher.webp app/src/main/res/mipmap-xhdpi/ic_launcher.webp app/src/main/res/mipmap-xxhdpi/ic_launcher.webp app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp app/src/test/java/com/parseable/android/AlertsViewModelTest.kt+4 app/src/test/java/com/parseable/android/EscapeSqlTest.kt+27 app/src/test/java/com/parseable/android/FormatTimestampTest.kt+14 app/src/test/java/com/parseable/android/LogViewerViewModelTest.kt+57−3 app/src/test/java/com/parseable/android/LoginViewModelTest.kt+28−4 app/src/test/java/com/parseable/android/PrivateHostTest.kt+73 app/src/test/java/com/parseable/android/SettingsViewModelTest.kt+10−6 app/src/test/java/com/parseable/android/SeverityDetectionTest.kt+13 app/src/test/java/com/parseable/android/StreamsViewModelTest.kt+3
.dockerignorenew file+15
@@ -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+18
@@ -12,6 +12,8 @@ concurrency:
1212
1313permissions:
1414 contents: write
15+ id-token: write
16+ attestations: write
1517
1618jobs:
1719 build:
@@ -22,6 +24,12 @@ jobs:
2224 - name: Checkout code
2325 uses: actions/checkout@v4
2426
27+ - name: Decode release keystore
28+ if: env.RELEASE_KEYSTORE_BASE64 != ''
29+ env:
30+ RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
31+ run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > app/release.keystore
32+
2533 - name: Set up JDK 17
2634 uses: actions/setup-java@v4
2735 with:
@@ -38,6 +46,11 @@ jobs:
3846 run: gradle wrapper --gradle-version 8.9
3947
4048 - name: Build release APK
49+ env:
50+ RELEASE_KEYSTORE_FILE: ${{ secrets.RELEASE_KEYSTORE_BASE64 != '' && 'release.keystore' || '' }}
51+ RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
52+ RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
53+ RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
4154 run: ./gradlew assembleRelease
4255
4356 - name: Run unit tests
@@ -49,6 +62,11 @@ jobs:
4962 name: parseable-android-release
5063 path: app/build/outputs/apk/release/*.apk
5164
65+ - name: Attest release APK
66+ uses: actions/attest-build-provenance@v2
67+ with:
68+ subject-path: app/build/outputs/apk/release/*.apk
69+
5270 - name: Create Draft Release
5371 if: startsWith(github.ref, 'refs/tags/v')
5472 uses: softprops/action-gh-release@v2
.github/workflows/ci.yml+29
@@ -18,6 +18,12 @@ jobs:
1818 - name: Checkout code
1919 uses: actions/checkout@v4
2020
21+ - name: Decode release keystore
22+ if: env.RELEASE_KEYSTORE_BASE64 != ''
23+ env:
24+ RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
25+ run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > app/release.keystore
26+
2127 - name: Set up JDK 17
2228 uses: actions/setup-java@v4
2329 with:
@@ -34,6 +40,11 @@ jobs:
3440 run: gradle wrapper --gradle-version 8.9
3541
3642 - name: Build release APK
43+ env:
44+ RELEASE_KEYSTORE_FILE: ${{ secrets.RELEASE_KEYSTORE_BASE64 != '' && 'release.keystore' || '' }}
45+ RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
46+ RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
47+ RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
3748 run: ./gradlew assembleRelease
3849
3950 - name: Run unit tests
@@ -68,11 +79,19 @@ jobs:
6879 cancel-in-progress: false
6980 permissions:
7081 contents: write
82+ id-token: write
83+ attestations: write
7184
7285 steps:
7386 - name: Checkout code
7487 uses: actions/checkout@v4
7588
89+ - name: Decode release keystore
90+ if: env.RELEASE_KEYSTORE_BASE64 != ''
91+ env:
92+ RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
93+ run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > app/release.keystore
94+
7695 - name: Set version from commit timestamp
7796 run: |
7897 VERSION_NAME=$(TZ=UTC git log -1 --format='%cd' --date=format-local:'%Y.%m.%d.%H%M')
@@ -94,6 +113,11 @@ jobs:
94113 run: gradle wrapper --gradle-version 8.9
95114
96115 - name: Build release APK
116+ env:
117+ RELEASE_KEYSTORE_FILE: ${{ secrets.RELEASE_KEYSTORE_BASE64 != '' && 'release.keystore' || '' }}
118+ RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
119+ RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
120+ RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
97121 run: ./gradlew assembleRelease
98122
99123 - name: Rename APK with version
@@ -102,6 +126,11 @@ jobs:
102126 mv "$apk" "app/build/outputs/apk/release/parseable-android-${VERSION_NAME}.apk"
103127 done
104128
129+ - name: Attest release APK
130+ uses: actions/attest-build-provenance@v2
131+ with:
132+ subject-path: app/build/outputs/apk/release/*.apk
133+
105134 - name: Create release
106135 uses: softprops/action-gh-release@v2
107136 with:
.gitignore+2
@@ -10,3 +10,5 @@
1010local.properties
1111app/build/
1212gradle/wrapper/gradle-wrapper.jar
13+*.keystore
14+*.jks
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/build.gradle.kts+12−1
@@ -32,11 +32,22 @@ android {
3232 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
3333 }
3434
35+ signingConfigs {
36+ if (System.getenv("RELEASE_KEYSTORE_FILE") != null) {
37+ create("release") {
38+ storeFile = file(System.getenv("RELEASE_KEYSTORE_FILE"))
39+ storePassword = System.getenv("RELEASE_KEYSTORE_PASSWORD")
40+ keyAlias = System.getenv("RELEASE_KEY_ALIAS")
41+ keyPassword = System.getenv("RELEASE_KEY_PASSWORD")
42+ }
43+ }
44+ }
45+
3546 buildTypes {
3647 release {
3748 isMinifyEnabled = true
3849 isShrinkResources = true
39− signingConfig = signingConfigs.getByName("debug")
50+ signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
4051 proguardFiles(
4152 getDefaultProguardFile("proguard-android-optimize.txt"),
4253 "proguard-rules.pro"
app/src/main/AndroidManifest.xml+1−1
@@ -10,7 +10,7 @@
1010 android:allowBackup="false"
1111 android:icon="@mipmap/ic_launcher"
1212 android:label="@string/app_name"
13− android:roundIcon="@mipmap/ic_launcher_round"
13+ android:roundIcon="@mipmap/ic_launcher"
1414 android:supportsRtl="true"
1515 android:theme="@style/Theme.ParseableAndroid"
1616 android:networkSecurityConfig="@xml/network_security_config"
app/src/main/java/com/parseable/android/MainActivity.kt+21−1
@@ -51,6 +51,17 @@ class MainActivity : ComponentActivity() {
5151 repository.configure(savedConfig)
5252 }
5353 startDestination = if (savedConfig != null) Routes.STREAMS else Routes.LOGIN
54+
55+ // Best-effort startup reconciliation for state a row-collapsing migration
56+ // (raw SQL) can't reach: re-link a dangling active_server_id to its
57+ // surviving row, then drop password entries orphaned by the collapse.
58+ // Runs after startDestination so it never blocks first paint.
59+ try {
60+ settingsRepository.reconcileActiveServerId()
61+ settingsRepository.cleanupOrphanedServerPasswords()
62+ } catch (_: Exception) {
63+ // Reconciliation/cleanup are non-critical; ignore failures.
64+ }
5465 }
5566
5667 val dest = startDestination
@@ -61,8 +72,17 @@ class MainActivity : ComponentActivity() {
6172 LaunchedEffect(navController) {
6273 repository.authErrors
6374 .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
6482 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.
6686 navController.navigate(Routes.login(sessionExpired = true)) {
6787 popUpTo(0) { inclusive = true }
6888 }
app/src/main/java/com/parseable/android/data/FormatUtils.kt+19−2
@@ -2,7 +2,10 @@ package com.parseable.android.data
22
33import java.util.Locale
44
5−private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB")
5+// "EB" is included so PB is never the last unit: the promotion guard below (which only fires
6+// when a higher unit exists) then handles a PB value in [1023.5, 1024) — promoting it to "1.00 EB"
7+// instead of rendering "1024 PB". The remaining unhandled top-unit case sits at ~1.2e24 bytes.
8+private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
69
710/**
811 * 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")
1215fun formatBytes(raw: String?): String? {
1316 if (raw == null) return null
1417 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
1521 if (bytes < 0) return raw
16− if (bytes < 1024) return String.format(Locale.US, "%.0f B", bytes)
22+ // Use 1023.5, not 1024, as the cutoff: "%.0f" rounds half-up, so a byte count in
23+ // [1023.5, 1024) would render as "1024 B" — a value that should promote to "1.00 KB".
24+ // Such inputs fall through to the unit-scaling path, where the promotion guard below
25+ // (value >= 1023.5) handles them.
26+ if (bytes < 1023.5) return String.format(Locale.US, "%.0f B", bytes)
1727 var value = bytes
1828 var unitIndex = 0
1929 while (value >= 1024 && unitIndex < BYTE_UNITS.size - 1) {
2030 value /= 1024
2131 unitIndex++
2232 }
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+ }
2340 return if (value >= 100) {
2441 String.format(Locale.US, "%.0f %s", value, BYTE_UNITS[unitIndex])
2542 } 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("'", "''")
55
66/** Escape a SQL identifier (table/column name) for use inside double quotes. */
77fun escapeIdentifier(name: String): String = name.replace("\"", "\"\"")
8+
9+/**
10+ * Escape a user-supplied substring for use as the body of a LIKE/ILIKE pattern.
11+ *
12+ * Besides the single-quote escaping every string literal needs, this neutralizes the
13+ * LIKE wildcards `%` and `_` (and the escape character `\` itself) so that searching or
14+ * filtering for a literal `%` or `_` matches that character instead of acting as a
15+ * wildcard. The backslash must be escaped first so the backslashes added for `%`/`_`
16+ * aren't doubled. Callers must pair the result with an `ESCAPE '\'` clause.
17+ */
18+fun escapeLikePattern(value: String): String =
19+ value
20+ .replace("\\", "\\\\")
21+ .replace("%", "\\%")
22+ .replace("_", "\\_")
23+ .replace("'", "''")
app/src/main/java/com/parseable/android/data/TimestampUtils.kt+57−14
@@ -1,26 +1,69 @@
11package com.parseable.android.data
22
33import java.time.Instant
4+import java.time.LocalDateTime
5+import java.time.OffsetDateTime
46import java.time.ZoneId
7+import java.time.ZoneOffset
58import java.time.ZonedDateTime
69import java.time.format.DateTimeFormatter
10+import java.util.Locale
711
8−private val displayFormatter = DateTimeFormatter.ofPattern("MMM dd HH:mm:ss.SSS")
12+// Pin to Locale.US so the MMM month token is always rendered as a stable English
13+// abbreviation (e.g. "Jun") regardless of the device's locale.
14+private val displayFormatter = DateTimeFormatter.ofPattern("MMM dd HH:mm:ss.SSS", Locale.US)
915
1016fun formatTimestamp(raw: String): String {
11− return try {
12− val instant = Instant.parse(raw)
13− val local = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
14− local.format(displayFormatter)
17+ // Parseable/DataFusion sometimes renders p_timestamp with a space between the date and
18+ // time ("2026-06-20 12:00:00.000") instead of the ISO 'T', and may append a zone/offset
19+ // after another space ("... +00:00", "... UTC"). Normalize both so the parsers below
20+ // accept them; values already in canonical ISO form are left untouched.
21+ var iso = raw.trim()
22+ // Convert the FIRST space (date↔time separator) to 'T', unless a 'T' already precedes it
23+ // (in which case that space sits before a zone token, handled next).
24+ val firstSpace = iso.indexOf(' ')
25+ if (firstSpace != -1 && 'T' !in iso.substring(0, firstSpace + 1)) {
26+ iso = iso.substring(0, firstSpace) + "T" + iso.substring(firstSpace + 1)
27+ }
28+ // Drop a space sitting between the time and a trailing offset/zone token:
29+ // "...12:00:00 +00:00" → "...12:00:00+00:00", "...12:00:00 UTC" → "...12:00:00UTC".
30+ iso = iso.replace(Regex("(?<=\\d)\\s+(?=[+\\-Z]|UTC|GMT)"), "")
31+ // Map a textual "UTC"/"GMT" suffix to the ISO 'Z' the parsers understand (GMT is exactly
32+ // UTC, so this is a safe rewrite). Other textual zones (e.g. "PST") are ambiguous, so they
33+ // are left untouched and fall through to the raw-string passthrough rather than risk
34+ // displaying a wrong local time.
35+ if (iso.endsWith("UTC")) iso = iso.removeSuffix("UTC") + "Z"
36+ else if (iso.endsWith("GMT")) iso = iso.removeSuffix("GMT") + "Z"
37+
38+ // Fast path: an ISO-8601 instant in UTC (e.g. "2026-06-20T12:00:00Z").
39+ try {
40+ val instant = Instant.parse(iso)
41+ return ZonedDateTime.ofInstant(instant, ZoneId.systemDefault()).format(displayFormatter)
42+ } catch (_: Exception) {
43+ // Fall through to the offset-aware parser.
44+ }
45+
46+ // General path: any ISO-8601 timestamp carrying an explicit offset, including
47+ // "+00:00" and non-UTC offsets like "+05:30" that Instant.parse rejects.
48+ try {
49+ val offset = OffsetDateTime.parse(iso)
50+ return offset.atZoneSameInstant(ZoneId.systemDefault()).format(displayFormatter)
1551 } catch (_: Exception) {
16− // Try alternate format with +00:00 suffix
17− try {
18− val normalized = raw.replace("+00:00", "Z")
19− val instant = Instant.parse(normalized)
20− val local = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
21− local.format(displayFormatter)
22− } catch (_: Exception) {
23− raw
24− }
52+ // Fall through to the naive (offset-less) parser.
2553 }
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
2669}
app/src/main/java/com/parseable/android/data/api/ParseableApiClient.kt+89−32
@@ -39,7 +39,7 @@ class ParseableApiClient @Inject constructor() {
3939 coerceInputValues = true
4040 }
4141
42− private val sharedPool = okhttp3.ConnectionPool(5, 5, TimeUnit.MINUTES)
42+ private val sharedPool = okhttp3.ConnectionPool(16, 5, TimeUnit.MINUTES)
4343 private val sharedDispatcher = okhttp3.Dispatcher()
4444
4545 private val secureClient: OkHttpClient = buildClient(allowInsecure = false)
@@ -77,11 +77,18 @@ class ParseableApiClient @Inject constructor() {
7777
7878 fun configure(serverConfig: ServerConfig) {
7979 val url = serverConfig.serverUrl.trimEnd('/')
80− val credentials = "${serverConfig.username}:${serverConfig.password}"
81− val auth = "Basic " + android.util.Base64.encodeToString(
82− credentials.toByteArray(),
83− android.util.Base64.NO_WRAP
84− )
80+ // Leave the auth header empty for blank credentials so isConfigured() reports
81+ // false instead of silently sending a bogus "Basic <user:>" header.
82+ val auth = if (serverConfig.username.isBlank() && serverConfig.password.isBlank()) {
83+ ""
84+ } else {
85+ val credentials = "${serverConfig.username}:${serverConfig.password}"
86+ "Basic " + android.util.Base64.encodeToString(
87+ // Explicit UTF-8 per RFC 7617 rather than the platform default charset.
88+ credentials.toByteArray(Charsets.UTF_8),
89+ android.util.Base64.NO_WRAP
90+ )
91+ }
8592 config = ClientConfig(
8693 baseUrl = url,
8794 authHeader = auth,
@@ -96,30 +103,48 @@ class ParseableApiClient @Inject constructor() {
96103 }
97104
98105 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.
99110 sharedPool.evictAll()
100− sharedDispatcher.executorService.shutdown()
101111 }
102112
103113 private fun encodePathSegment(segment: String): String =
104114 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
105115
106116 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.
107122 val snapshot = config
108− return Request.Builder()
123+ val selectedClient = if (snapshot.allowInsecure) insecureClient else secureClient
124+ val builder = Request.Builder()
109125 .url("${snapshot.baseUrl}$path")
110− .header("Authorization", snapshot.authHeader)
126+ .tag(OkHttpClient::class.java, selectedClient)
127+ // Don't send a literal empty "Authorization:" header for unconfigured calls
128+ // (e.g. pre-login connectivity probes) — strict servers/proxies may reject it.
129+ if (snapshot.authHeader.isNotEmpty()) {
130+ builder.header("Authorization", snapshot.authHeader)
131+ }
132+ return builder
111133 }
112134
113135 private suspend fun executeRequest(request: Request): ApiResult<String> =
114136 withContext(Dispatchers.IO) {
115137 try {
116− client.newCall(request).execute().use { response ->
138+ val callClient = request.tag(OkHttpClient::class.java) ?: client
139+ callClient.newCall(request).execute().use { response ->
117140 val body = response.body?.string() ?: ""
118141 if (response.isSuccessful) {
119142 ApiResult.Success(body)
120143 } else {
121144 ApiResult.Error(
122− message = body.ifEmpty { response.message },
145+ // ifBlank, not ifEmpty: a whitespace-only error body would otherwise
146+ // be shown verbatim as a blank message instead of the status reason.
147+ message = body.ifBlank { response.message },
123148 code = response.code
124149 )
125150 }
@@ -127,7 +152,7 @@ class ParseableApiClient @Inject constructor() {
127152 } catch (e: SocketTimeoutException) {
128153 ApiResult.Error(message = "Connection timed out")
129154 } catch (e: UnknownHostException) {
130− ApiResult.Error(message = "Unable to resolve host \"${e.message}\"")
155+ ApiResult.Error(message = "Unable to resolve host")
131156 } catch (e: ConnectException) {
132157 ApiResult.Error(message = "Connection refused. Is the server running?")
133158 } catch (e: SSLException) {
@@ -140,13 +165,21 @@ class ParseableApiClient @Inject constructor() {
140165 }
141166 }
142167
143− private fun <T> parseResponse(body: String, description: String, parse: () -> T): ApiResult<T> {
168+ private fun <T> parseResponse(description: String, parse: () -> T): ApiResult<T> {
144169 return try {
145170 ApiResult.Success(parse())
146171 } catch (e: SerializationException) {
147− ApiResult.Error("Invalid response format from server")
172+ ApiResult.Error("Invalid response format from server", code = ApiResult.PARSE_ERROR_CODE)
148173 } 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)
150183 }
151184 }
152185
@@ -156,7 +189,7 @@ class ParseableApiClient @Inject constructor() {
156189 suspend fun getAbout(): ApiResult<AboutInfo> {
157190 val request = buildRequest("/api/v1/about").get().build()
158191 return when (val result = executeRequest(request)) {
159− is ApiResult.Success -> parseResponse(result.data, "about info") {
192+ is ApiResult.Success -> parseResponse("about info") {
160193 json.decodeFromString<AboutInfo>(result.data)
161194 }
162195 is ApiResult.Error -> result
@@ -197,7 +230,7 @@ class ParseableApiClient @Inject constructor() {
197230 }
198231 ApiResult.Success(streams)
199232 } 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)
201234 }
202235 }
203236 }
@@ -212,7 +245,7 @@ class ParseableApiClient @Inject constructor() {
212245 val encoded = encodePathSegment(stream)
213246 val request = buildRequest("/api/v1/logstream/$encoded/schema").get().build()
214247 return when (val result = executeRequest(request)) {
215− is ApiResult.Success -> parseResponse(result.data, "schema") {
248+ is ApiResult.Success -> parseResponse("schema") {
216249 json.decodeFromString<StreamSchema>(result.data)
217250 }
218251 is ApiResult.Error -> result
@@ -226,7 +259,7 @@ class ParseableApiClient @Inject constructor() {
226259 val encoded = encodePathSegment(stream)
227260 val request = buildRequest("/api/v1/logstream/$encoded/stats").get().build()
228261 return when (val result = executeRequest(request)) {
229− is ApiResult.Success -> parseResponse(result.data, "stats") {
262+ is ApiResult.Success -> parseResponse("stats") {
230263 json.decodeFromString<StreamStats>(result.data)
231264 }
232265 is ApiResult.Error -> result
@@ -240,7 +273,7 @@ class ParseableApiClient @Inject constructor() {
240273 val encoded = encodePathSegment(stream)
241274 val request = buildRequest("/api/v1/logstream/$encoded/info").get().build()
242275 return when (val result = executeRequest(request)) {
243− is ApiResult.Success -> parseResponse(result.data, "stream info") {
276+ is ApiResult.Success -> parseResponse("stream info") {
244277 val element = json.parseToJsonElement(result.data)
245278 (element as? JsonObject)
246279 ?: throw IllegalArgumentException("Expected JSON object, got ${element::class.simpleName}")
@@ -260,8 +293,15 @@ class ParseableApiClient @Inject constructor() {
260293 try {
261294 ApiResult.Success(json.decodeFromString<List<RetentionConfig>>(result.data))
262295 } catch (_: Exception) {
263− parseResponse(result.data, "retention config") {
264− listOf(json.decodeFromString<RetentionConfig>(result.data))
296+ parseResponse("retention config") {
297+ val single = json.decodeFromString<RetentionConfig>(result.data)
298+ // An empty object ({}) decodes into an all-null config — that means
299+ // "no retention configured" (zero rules), not one empty rule.
300+ if (single.description == null && single.duration == null && single.action == null) {
301+ emptyList()
302+ } else {
303+ listOf(single)
304+ }
265305 }
266306 }
267307 }
@@ -291,7 +331,7 @@ class ParseableApiClient @Inject constructor() {
291331 .build()
292332
293333 return when (val result = executeRequest(request)) {
294− is ApiResult.Success -> parseResponse(result.data, "query results") {
334+ is ApiResult.Success -> parseResponse("query results") {
295335 val element = json.parseToJsonElement(result.data)
296336 val elements = (element as? JsonArray)
297337 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
@@ -316,11 +356,14 @@ class ParseableApiClient @Inject constructor() {
316356 suspend fun listAlerts(): ApiResult<List<Alert>> {
317357 val request = buildRequest("/api/v1/alerts").get().build()
318358 return when (val result = executeRequest(request)) {
319− is ApiResult.Success -> parseResponse(result.data, "alerts") {
359+ is ApiResult.Success -> parseResponse("alerts") {
320360 val element = json.parseToJsonElement(result.data)
321361 val alertsArray = when (element) {
322362 is JsonArray -> element
323− is JsonObject -> element["alerts"]?.jsonArray ?: JsonArray(emptyList())
363+ // Use a safe cast, not the `.jsonArray` accessor: a present-but-non-array
364+ // value (e.g. {"alerts": null} or an object) makes `.jsonArray` throw, which
365+ // would surface as a parse error instead of degrading to an empty list.
366+ is JsonObject -> (element["alerts"] as? JsonArray) ?: JsonArray(emptyList())
324367 else -> JsonArray(emptyList())
325368 }
326369 alertsArray.mapNotNull { alertElement ->
@@ -355,7 +398,7 @@ class ParseableApiClient @Inject constructor() {
355398 .post(requestBody)
356399 .build()
357400 return when (val result = executeRequest(request)) {
358− is ApiResult.Success -> parseResponse(result.data, "alert") {
401+ is ApiResult.Success -> parseResponse("alert") {
359402 json.decodeFromString<Alert>(result.data)
360403 }
361404 is ApiResult.Error -> result
@@ -368,7 +411,7 @@ class ParseableApiClient @Inject constructor() {
368411 suspend fun listUsers(): ApiResult<List<JsonObject>> {
369412 val request = buildRequest("/api/v1/user").get().build()
370413 return when (val result = executeRequest(request)) {
371− is ApiResult.Success -> parseResponse(result.data, "users") {
414+ is ApiResult.Success -> parseResponse("users") {
372415 val element = json.parseToJsonElement(result.data)
373416 val elements = (element as? JsonArray)
374417 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
@@ -384,8 +427,22 @@ class ParseableApiClient @Inject constructor() {
384427 suspend fun listFilters(): ApiResult<List<SavedFilter>> {
385428 val request = buildRequest("/api/v1/filters").get().build()
386429 return when (val result = executeRequest(request)) {
387− is ApiResult.Success -> parseResponse(result.data, "filters") {
388− json.decodeFromString<List<SavedFilter>>(result.data)
430+ is ApiResult.Success -> parseResponse("filters") {
431+ val element = json.parseToJsonElement(result.data)
432+ val array = (element as? JsonArray)
433+ ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
434+ array.mapNotNull { filterElement ->
435+ try {
436+ val filter = json.decodeFromJsonElement<SavedFilter>(filterElement)
437+ // coerceInputValues turns a server-null identity field into "". Skip such
438+ // entries rather than surface a nameless/stream-less ghost filter (and so
439+ // one malformed entry doesn't fail the whole list).
440+ if (filter.filterName.isBlank() || filter.streamName.isBlank()) null else filter
441+ } catch (e: Exception) {
442+ Timber.w(e, "Skipping malformed filter entry")
443+ null
444+ }
445+ }
389446 }
390447 is ApiResult.Error -> result
391448 }
@@ -398,7 +455,7 @@ class ParseableApiClient @Inject constructor() {
398455 val encoded = encodePathSegment(filterId)
399456 val request = buildRequest("/api/v1/filters/$encoded").get().build()
400457 return when (val result = executeRequest(request)) {
401− is ApiResult.Success -> parseResponse(result.data, "filter") {
458+ is ApiResult.Success -> parseResponse("filter") {
402459 json.decodeFromString<SavedFilter>(result.data)
403460 }
404461 is ApiResult.Error -> result
@@ -415,7 +472,7 @@ class ParseableApiClient @Inject constructor() {
415472 .post(requestBody)
416473 .build()
417474 return when (val result = executeRequest(request)) {
418− is ApiResult.Success -> parseResponse(result.data, "filter") {
475+ is ApiResult.Success -> parseResponse("filter") {
419476 json.decodeFromString<SavedFilter>(result.data)
420477 }
421478 is ApiResult.Error -> result
@@ -433,7 +490,7 @@ class ParseableApiClient @Inject constructor() {
433490 .put(requestBody)
434491 .build()
435492 return when (val result = executeRequest(request)) {
436− is ApiResult.Success -> parseResponse(result.data, "filter") {
493+ is ApiResult.Success -> parseResponse("filter") {
437494 json.decodeFromString<SavedFilter>(result.data)
438495 }
439496 is ApiResult.Error -> result
app/src/main/java/com/parseable/android/data/local/FavoriteStreamDao.kt+12−2
@@ -12,10 +12,14 @@ interface FavoriteStreamDao {
1212 @Query("SELECT * FROM favorite_streams ORDER BY addedAt DESC")
1313 fun getAll(): Flow<List<FavoriteStream>>
1414
15− @Query("SELECT streamName FROM favorite_streams")
15+ // Ordered most-recently-favorited first so the UI can show favorites in recency order
16+ // (toSet() downstream preserves this iteration order via LinkedHashSet).
17+ @Query("SELECT streamName FROM favorite_streams ORDER BY addedAt DESC")
1618 fun getAllNames(): Flow<List<String>>
1719
18− @Insert(onConflict = OnConflictStrategy.REPLACE)
20+ // IGNORE (not REPLACE): re-favoriting an existing stream must keep its original
21+ // addedAt so the getAll() "ORDER BY addedAt DESC" ordering stays stable.
22+ @Insert(onConflict = OnConflictStrategy.IGNORE)
1923 suspend fun insert(favorite: FavoriteStream)
2024
2125 @Delete
@@ -26,4 +30,10 @@ interface FavoriteStreamDao {
2630
2731 @Query("SELECT EXISTS(SELECT 1 FROM favorite_streams WHERE streamName = :name)")
2832 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
2939}
app/src/main/java/com/parseable/android/data/local/ParseableDatabase.kt+3−2
@@ -4,10 +4,11 @@ import androidx.room.Database
44import androidx.room.RoomDatabase
55
66@Database(
7− entities = [FavoriteStream::class],
8− version = 1,
7+ entities = [FavoriteStream::class, SavedServer::class],
8+ version = 3,
99 exportSchema = true,
1010)
1111abstract class ParseableDatabase : RoomDatabase() {
1212 abstract fun favoriteStreamDao(): FavoriteStreamDao
13+ abstract fun savedServerDao(): SavedServerDao
1314}
app/src/main/java/com/parseable/android/data/local/SavedServer.ktnew file+23
@@ -0,0 +1,23 @@
1+package com.parseable.android.data.local
2+
3+import androidx.room.Entity
4+import androidx.room.Index
5+import androidx.room.PrimaryKey
6+
7+@Entity(
8+ tableName = "saved_servers",
9+ // One logical server per (url, username). The dedupe in SettingsRepository.saveServer
10+ // relies on this invariant; enforcing it in the schema means REPLACE actually upserts on
11+ // the natural key instead of silently inserting a duplicate row if the read-back misses.
12+ indices = [Index(value = ["serverUrl", "username"], unique = true)],
13+)
14+data class SavedServer(
15+ @PrimaryKey(autoGenerate = true)
16+ val id: Long = 0,
17+ val serverUrl: String,
18+ val username: String,
19+ val useTls: Boolean = true,
20+ /** Key into EncryptedSharedPreferences for this server's password. */
21+ val passwordKey: String,
22+ val addedAt: Long = System.currentTimeMillis(),
23+)
app/src/main/java/com/parseable/android/data/local/SavedServerDao.ktnew file+25
@@ -0,0 +1,25 @@
1+package com.parseable.android.data.local
2+
3+import androidx.room.Dao
4+import androidx.room.Insert
5+import androidx.room.OnConflictStrategy
6+import androidx.room.Query
7+import kotlinx.coroutines.flow.Flow
8+
9+@Dao
10+interface SavedServerDao {
11+ @Query("SELECT * FROM saved_servers ORDER BY addedAt DESC")
12+ fun getAll(): Flow<List<SavedServer>>
13+
14+ @Query("SELECT * FROM saved_servers WHERE id = :id")
15+ suspend fun getById(id: Long): SavedServer?
16+
17+ @Query("SELECT * FROM saved_servers WHERE serverUrl = :url AND username = :username LIMIT 1")
18+ suspend fun findByUrlAndUsername(url: String, username: String): SavedServer?
19+
20+ @Insert(onConflict = OnConflictStrategy.REPLACE)
21+ suspend fun insert(server: SavedServer): Long
22+
23+ @Query("DELETE FROM saved_servers WHERE id = :id")
24+ suspend fun deleteById(id: Long)
25+}
app/src/main/java/com/parseable/android/data/model/Models.kt+57−2
@@ -1,9 +1,43 @@
11package com.parseable.android.data.model
22
3+import kotlinx.serialization.KSerializer
34import kotlinx.serialization.SerialName
45import 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
512import kotlinx.serialization.json.JsonElement
13+import kotlinx.serialization.json.JsonNull
614import 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+}
741
842/**
943 * Server connection configuration stored in DataStore.
@@ -47,25 +81,31 @@ data class StreamStats(
4781@Serializable
4882data class IngestionStats(
4983 val count: Long? = null,
84+ @Serializable(with = FlexibleStringSerializer::class)
5085 val size: String? = null,
5186 val format: String? = null,
5287 @SerialName("lifetime_count")
5388 val lifetimeCount: Long? = null,
5489 @SerialName("lifetime_size")
90+ @Serializable(with = FlexibleStringSerializer::class)
5591 val lifetimeSize: String? = null,
5692 @SerialName("deleted_count")
5793 val deletedCount: Long? = null,
5894 @SerialName("deleted_size")
95+ @Serializable(with = FlexibleStringSerializer::class)
5996 val deletedSize: String? = null,
6097)
6198
6299@Serializable
63100data class StorageStats(
101+ @Serializable(with = FlexibleStringSerializer::class)
64102 val size: String? = null,
65103 val format: String? = null,
66104 @SerialName("lifetime_size")
105+ @Serializable(with = FlexibleStringSerializer::class)
67106 val lifetimeSize: String? = null,
68107 @SerialName("deleted_size")
108+ @Serializable(with = FlexibleStringSerializer::class)
69109 val deletedSize: String? = null,
70110)
71111
@@ -152,11 +192,14 @@ data class Alert(
152192 val datasets: List<String> = emptyList(),
153193) {
154194 /** 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"
156199
157200 /** Whether the alert is active, derived from either response format. */
158201 val isEnabled: Boolean get() = when {
159− state != null -> state != "Disabled"
202+ state != null -> !state.equals("Disabled", ignoreCase = true)
160203 enabled != null -> enabled
161204 else -> true
162205 }
@@ -178,6 +221,8 @@ sealed class ApiResult<out T> {
178221 val isUnauthorized: Boolean get() = code == 401
179222 val isNotFound: Boolean get() = code == 404
180223 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.
181226 val isNetworkError: Boolean get() = code == 0
182227
183228 val userMessage: String get() = when {
@@ -195,4 +240,14 @@ sealed class ApiResult<out T> {
195240 else -> message
196241 }
197242 }
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+ }
198253}
app/src/main/java/com/parseable/android/data/repository/ParseableRepository.kt+15−4
@@ -71,12 +71,23 @@ class ParseableRepository @Inject constructor(
7171 * Does NOT trigger [checkAuth] so the global auth-error handler won't fire during login.
7272 */
7373 suspend fun verifyServer(): ApiResult<AboutInfo> {
74− return when (val result = apiClient.getAbout()) {
74+ return when (val aboutResult = apiClient.getAbout()) {
7575 is ApiResult.Success -> {
76− aboutCache = CacheEntry(result.data)
77− result
76+ // GET /about is not auth-gated on Parseable, so a success here only proves
77+ // the server is reachable and is Parseable — NOT that the credentials are
78+ // valid. Probe an auth-required endpoint (logstream listing, which the app
79+ // needs anyway) so wrong credentials are rejected at login instead of being
80+ // persisted and only failing on the first real request. Call apiClient
81+ // directly to avoid checkAuth firing the global auth-error handler mid-login.
82+ when (val authProbe = apiClient.listStreams()) {
83+ is ApiResult.Success -> {
84+ aboutCache = CacheEntry(aboutResult.data)
85+ aboutResult
86+ }
87+ is ApiResult.Error -> authProbe
88+ }
7889 }
79− is ApiResult.Error -> result
90+ is ApiResult.Error -> aboutResult
8091 }
8192 }
8293
app/src/main/java/com/parseable/android/data/repository/SettingsRepository.kt+238−8
@@ -8,6 +8,8 @@ import androidx.datastore.preferences.core.*
88import androidx.datastore.preferences.preferencesDataStore
99import androidx.security.crypto.EncryptedSharedPreferences
1010import androidx.security.crypto.MasterKey
11+import com.parseable.android.data.local.SavedServer
12+import com.parseable.android.data.local.SavedServerDao
1113import com.parseable.android.data.model.ServerConfig
1214import dagger.hilt.android.qualifiers.ApplicationContext
1315import kotlinx.coroutines.Dispatchers
@@ -27,10 +29,12 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
2729@Singleton
2830class SettingsRepository @Inject constructor(
2931 @ApplicationContext private val context: Context,
32+ private val savedServerDao: SavedServerDao,
3033) {
3134 private val serverUrlKey = stringPreferencesKey("server_url")
3235 private val usernameKey = stringPreferencesKey("username")
3336 private val useTlsKey = booleanPreferencesKey("use_tls")
37+ private val activeServerIdKey = longPreferencesKey("active_server_id")
3438
3539 /** Guards concurrent reads/writes across DataStore and EncryptedSharedPreferences. */
3640 private val configMutex = Mutex()
@@ -62,13 +66,24 @@ class SettingsRepository @Inject constructor(
6266 )
6367 }
6468
69+ // ── Active server config (current connection) ──────────────────────
70+
6571 val serverConfig: Flow<ServerConfig?> = context.dataStore.data
6672 .catch { e ->
6773 Timber.e(e, "Failed to read DataStore")
6874 emit(emptyPreferences())
6975 }
70− .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.
7185 configMutex.withLock {
86+ val prefs = context.dataStore.data.first()
7287 val url = prefs[serverUrlKey] ?: return@withLock null
7388 val user = prefs[usernameKey] ?: return@withLock null
7489 val pass = try {
@@ -87,21 +102,43 @@ class SettingsRepository @Inject constructor(
87102 }
88103 .flowOn(Dispatchers.IO)
89104
105+ val activeServerId: Flow<Long?> = context.dataStore.data
106+ .catch { e ->
107+ Timber.e(e, "Failed to read DataStore")
108+ emit(emptyPreferences())
109+ }
110+ .map { prefs -> prefs[activeServerIdKey] }
111+ .flowOn(Dispatchers.IO)
112+
90113 suspend fun getSavedPassword(): ServerConfig? = serverConfig.first()
91114
92− suspend fun saveServerConfig(config: ServerConfig) {
93− configMutex.withLock {
115+ /**
116+ * Persist the server config. Returns true only if the credentials were durably saved.
117+ * Callers (e.g. login) must not report success when this returns false — otherwise the
118+ * session would appear logged in but vanish on the next launch.
119+ */
120+ suspend fun saveServerConfig(config: ServerConfig): Boolean {
121+ return configMutex.withLock {
94122 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+ }
95133 context.dataStore.edit { prefs ->
96134 prefs[serverUrlKey] = config.serverUrl
97135 prefs[usernameKey] = config.username
98136 prefs[useTlsKey] = config.useTls
99137 }
100− withContext(Dispatchers.IO) {
101− encryptedPrefs.edit().putString("password", config.password).apply()
102− }
138+ true
103139 } catch (e: Exception) {
104140 Timber.e(e, "Failed to save server config")
141+ false
105142 }
106143 }
107144 }
@@ -109,13 +146,206 @@ class SettingsRepository @Inject constructor(
109146 suspend fun clearConfig() {
110147 configMutex.withLock {
111148 try {
112− context.dataStore.edit { it.clear() }
149+ context.dataStore.edit { prefs ->
150+ prefs.remove(serverUrlKey)
151+ prefs.remove(usernameKey)
152+ prefs.remove(useTlsKey)
153+ prefs.remove(activeServerIdKey)
154+ }
113155 withContext(Dispatchers.IO) {
114− encryptedPrefs.edit().clear().apply()
156+ // commit() (not apply()) so the cleared password is durably gone before we
157+ // return — a queued async clear could survive into the next session.
158+ encryptedPrefs.edit().remove("password").commit()
115159 }
116160 } catch (e: Exception) {
117161 Timber.e(e, "Failed to clear config")
118162 }
119163 }
120164 }
165+
166+ // ── Multi-server management ────────────────────────────────────────
167+
168+ val savedServers: Flow<List<SavedServer>> = savedServerDao.getAll()
169+
170+ /**
171+ * Persist the current server connection as a saved server entry.
172+ * If a server with the same URL and username already exists, updates it.
173+ * Returns the server ID, or null if the credentials could not be persisted.
174+ */
175+ suspend fun saveServer(config: ServerConfig): Long? {
176+ return configMutex.withLock {
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.
181+ val passwordKey = existing?.passwordKey ?: "server_pwd_${java.util.UUID.randomUUID()}"
182+ val server = SavedServer(
183+ id = existing?.id ?: 0,
184+ serverUrl = config.serverUrl,
185+ username = config.username,
186+ useTls = config.useTls,
187+ passwordKey = passwordKey,
188+ addedAt = existing?.addedAt ?: System.currentTimeMillis(),
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).
194+ val passwordSaved = withContext(Dispatchers.IO) {
195+ encryptedPrefs.edit().putString(passwordKey, config.password).commit()
196+ }
197+ if (!passwordSaved) {
198+ // Return null rather than existing?.id: reusing the existing id would look like a
199+ // successful update to the caller, while the new password was never written and a
200+ // later switchToServer would silently load the stale credentials.
201+ Timber.e("Failed to persist saved-server credentials securely; aborting saveServer")
202+ return@withLock null
203+ }
204+ val id = savedServerDao.insert(server)
205+
206+ // Update active server ID
207+ context.dataStore.edit { prefs ->
208+ prefs[activeServerIdKey] = id
209+ }
210+ id
211+ }
212+ }
213+
214+ /**
215+ * Switch to a previously saved server. Loads its credentials into the
216+ * active config (DataStore + encrypted password).
217+ */
218+ suspend fun switchToServer(serverId: Long): ServerConfig? {
219+ return configMutex.withLock {
220+ val server = savedServerDao.getById(serverId) ?: return@withLock null
221+ val password = withContext(Dispatchers.IO) {
222+ try {
223+ encryptedPrefs.getString(server.passwordKey, null)
224+ } catch (e: Exception) {
225+ Timber.e(e, "Failed to read server password")
226+ null
227+ }
228+ } ?: return@withLock null
229+
230+ val config = ServerConfig(
231+ serverUrl = server.serverUrl,
232+ username = server.username,
233+ password = password,
234+ useTls = server.useTls,
235+ )
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+ }
247+ // Write to active config
248+ context.dataStore.edit { prefs ->
249+ prefs[serverUrlKey] = config.serverUrl
250+ prefs[usernameKey] = config.username
251+ prefs[useTlsKey] = config.useTls
252+ prefs[activeServerIdKey] = serverId
253+ }
254+ config
255+ }
256+ }
257+
258+ /**
259+ * Remove a saved server and its stored password.
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.
263+ */
264+ suspend fun deleteServer(serverId: Long): Boolean {
265+ return configMutex.withLock {
266+ val server = savedServerDao.getById(serverId) ?: return@withLock false
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.
270+ encryptedPrefs.edit().remove(server.passwordKey).commit()
271+ }
272+ savedServerDao.deleteById(serverId)
273+
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).
279+ val currentActiveId = context.dataStore.data.first()[activeServerIdKey]
280+ val wasActive = currentActiveId == serverId
281+ if (wasActive) {
282+ context.dataStore.edit { prefs ->
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")
347+ }
348+ }
349+ }
350+ }
121351}
app/src/main/java/com/parseable/android/di/AppModule.kt+59
@@ -2,8 +2,11 @@ package com.parseable.android.di
22
33import android.content.Context
44import androidx.room.Room
5+import androidx.room.migration.Migration
6+import androidx.sqlite.db.SupportSQLiteDatabase
57import com.parseable.android.data.local.FavoriteStreamDao
68import com.parseable.android.data.local.ParseableDatabase
9+import com.parseable.android.data.local.SavedServerDao
710import dagger.Module
811import dagger.Provides
912import dagger.hilt.InstallIn
@@ -15,10 +18,61 @@ import javax.inject.Singleton
1518@InstallIn(SingletonComponent::class)
1619object AppModule {
1720
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+
1866 @Provides
1967 @Singleton
2068 fun provideDatabase(@ApplicationContext context: Context): ParseableDatabase =
2169 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)
2276 .fallbackToDestructiveMigration()
2377 .build()
2478
@@ -26,4 +80,9 @@ object AppModule {
2680 @Singleton
2781 fun provideFavoriteStreamDao(db: ParseableDatabase): FavoriteStreamDao =
2882 db.favoriteStreamDao()
83+
84+ @Provides
85+ @Singleton
86+ fun provideSavedServerDao(db: ParseableDatabase): SavedServerDao =
87+ db.savedServerDao()
2988}
app/src/main/java/com/parseable/android/ui/navigation/NavGraph.kt+54−10
@@ -13,9 +13,9 @@ import com.parseable.android.ui.screens.alerts.AlertsScreen
1313import com.parseable.android.ui.screens.login.LoginScreen
1414import com.parseable.android.ui.screens.logviewer.LogViewerScreen
1515import com.parseable.android.ui.screens.logviewer.StreamInfoScreen
16+import com.parseable.android.ui.screens.about.AboutScreen
1617import com.parseable.android.ui.screens.settings.SettingsScreen
1718import com.parseable.android.ui.screens.streams.StreamsScreen
18−import java.net.URLDecoder
1919import java.net.URLEncoder
2020
2121object Routes {
@@ -25,13 +25,21 @@ object Routes {
2525 const val STREAM_INFO = "stream_info/{streamName}"
2626 const val ALERTS = "alerts"
2727 const val SETTINGS = "settings"
28+ const val ABOUT = "about"
2829
2930 fun login(sessionExpired: Boolean = false) =
3031 if (sessionExpired) "login?sessionExpired=true" else LOGIN
3132 fun logViewer(streamName: String) =
32− "log_viewer/${URLEncoder.encode(streamName, "UTF-8")}"
33+ "log_viewer/${encodeArg(streamName)}"
3334 fun streamInfo(streamName: String) =
34− "stream_info/${URLEncoder.encode(streamName, "UTF-8")}"
35+ "stream_info/${encodeArg(streamName)}"
36+
37+ // URLEncoder maps space to "+", but Navigation decodes captured path args with Uri.decode,
38+ // which only understands "%20" and leaves "+" untouched (it would survive as a literal
39+ // "+"). Normalize to "%20" so the name Navigation hands back matches the original. Do NOT
40+ // decode again at the call site — Navigation already does that one decode for us.
41+ private fun encodeArg(value: String) =
42+ URLEncoder.encode(value, "UTF-8").replace("+", "%20")
3543}
3644
3745@Composable
@@ -112,9 +120,8 @@ fun ParseableNavGraph(
112120 }
113121 return@composable
114122 }
115− val streamName = URLDecoder.decode(
116− backStackEntry.arguments?.getString("streamName") ?: "", "UTF-8"
117− )
123+ // Navigation already URI-decodes captured path args, so use the value as-is.
124+ val streamName = backStackEntry.arguments?.getString("streamName") ?: ""
118125 LogViewerScreen(
119126 streamName = streamName,
120127 onBack = { navController.popBackStack() },
@@ -130,14 +137,22 @@ fun ParseableNavGraph(
130137 route = Routes.STREAM_INFO,
131138 arguments = listOf(navArgument("streamName") { type = NavType.StringType }),
132139 ) { backStackEntry ->
133− val streamName = URLDecoder.decode(
134− backStackEntry.arguments?.getString("streamName") ?: "", "UTF-8"
135− )
140+ // Navigation already URI-decodes captured path args, so use the value as-is.
141+ val streamName = backStackEntry.arguments?.getString("streamName") ?: ""
136142 StreamInfoScreen(
137143 streamName = streamName,
138144 onBack = { navController.popBackStack() },
139145 onStreamDeleted = {
140− navController.popBackStack(Routes.STREAMS, inclusive = false)
146+ // popBackStack no-ops (returns false) when there's no streams entry on
147+ // the back stack — e.g. when the user arrived via the deep link. In that
148+ // case navigate to streams fresh so we don't strand them on the info
149+ // screen for a now-deleted stream.
150+ val popped = navController.popBackStack(Routes.STREAMS, inclusive = false)
151+ if (!popped) {
152+ navController.navigate(Routes.STREAMS) {
153+ popUpTo(0) { inclusive = true }
154+ }
155+ }
141156 },
142157 )
143158 }
@@ -170,6 +185,35 @@ fun ParseableNavGraph(
170185 }
171186 SettingsScreen(
172187 onBack = { navController.popBackStack() },
188+ onAboutClick = {
189+ navController.navigate(Routes.ABOUT) {
190+ launchSingleTop = true
191+ }
192+ },
193+ onServerSwitched = {
194+ navController.navigate(Routes.STREAMS) {
195+ popUpTo(0) { inclusive = true }
196+ }
197+ },
198+ onLoggedOut = {
199+ navController.navigate(Routes.LOGIN) {
200+ popUpTo(0) { inclusive = true }
201+ }
202+ },
203+ )
204+ }
205+
206+ composable(Routes.ABOUT) {
207+ if (!repository.isConfigured) {
208+ LaunchedEffect(Unit) {
209+ navController.navigate(Routes.LOGIN) {
210+ popUpTo(0) { inclusive = true }
211+ }
212+ }
213+ return@composable
214+ }
215+ AboutScreen(
216+ onBack = { navController.popBackStack() },
173217 )
174218 }
175219 }
app/src/main/java/com/parseable/android/ui/screens/about/AboutScreen.ktnew file+178
@@ -0,0 +1,178 @@
1+package com.parseable.android.ui.screens.about
2+
3+import android.content.Intent
4+import android.net.Uri
5+import androidx.compose.foundation.Image
6+import androidx.compose.foundation.layout.*
7+import androidx.compose.foundation.rememberScrollState
8+import androidx.compose.foundation.verticalScroll
9+import androidx.compose.material.icons.Icons
10+import androidx.compose.material.icons.automirrored.filled.ArrowBack
11+import androidx.compose.material.icons.filled.*
12+import androidx.compose.material3.*
13+import androidx.compose.runtime.Composable
14+import androidx.compose.ui.Alignment
15+import androidx.compose.ui.Modifier
16+import androidx.compose.ui.platform.LocalContext
17+import androidx.compose.ui.res.painterResource
18+import androidx.compose.ui.text.font.FontWeight
19+import androidx.compose.ui.unit.dp
20+import com.parseable.android.BuildConfig
21+import com.parseable.android.R
22+
23+private const val GITHUB_REPO_URL = "https://github.com/parseablehq/parseable-android"
24+private const val PARSEABLE_WEBSITE_URL = "https://www.parseable.com"
25+
26+@OptIn(ExperimentalMaterial3Api::class)
27+@Composable
28+fun AboutScreen(
29+ onBack: () -> Unit,
30+) {
31+ val context = LocalContext.current
32+
33+ Scaffold(
34+ topBar = {
35+ TopAppBar(
36+ title = { Text("About") },
37+ navigationIcon = {
38+ IconButton(onClick = onBack) {
39+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
40+ }
41+ },
42+ )
43+ },
44+ ) { padding ->
45+ Column(
46+ modifier = Modifier
47+ .fillMaxSize()
48+ .padding(padding)
49+ .verticalScroll(rememberScrollState())
50+ .padding(16.dp),
51+ verticalArrangement = Arrangement.spacedBy(16.dp),
52+ ) {
53+ // App header
54+ Card(
55+ modifier = Modifier.fillMaxWidth(),
56+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
57+ ) {
58+ Column(
59+ modifier = Modifier
60+ .fillMaxWidth()
61+ .padding(24.dp),
62+ horizontalAlignment = Alignment.CenterHorizontally,
63+ ) {
64+ Image(
65+ painter = painterResource(id = R.mipmap.ic_launcher),
66+ contentDescription = "Parseable logo",
67+ modifier = Modifier.size(80.dp),
68+ )
69+ Spacer(modifier = Modifier.height(12.dp))
70+ Text(
71+ text = "Parseable",
72+ style = MaterialTheme.typography.headlineSmall,
73+ fontWeight = FontWeight.Bold,
74+ )
75+ Text(
76+ text = "Android Client",
77+ style = MaterialTheme.typography.titleMedium,
78+ color = MaterialTheme.colorScheme.onSurfaceVariant,
79+ )
80+ Spacer(modifier = Modifier.height(8.dp))
81+ Text(
82+ text = "Version ${BuildConfig.VERSION_NAME}",
83+ style = MaterialTheme.typography.bodyMedium,
84+ color = MaterialTheme.colorScheme.onSurfaceVariant,
85+ )
86+ }
87+ }
88+
89+ // Description
90+ Card(
91+ modifier = Modifier.fillMaxWidth(),
92+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
93+ ) {
94+ Column(modifier = Modifier.padding(16.dp)) {
95+ Row(verticalAlignment = Alignment.CenterVertically) {
96+ Icon(
97+ Icons.Filled.Info,
98+ contentDescription = null,
99+ tint = MaterialTheme.colorScheme.primary,
100+ )
101+ Spacer(modifier = Modifier.width(8.dp))
102+ Text(
103+ text = "About",
104+ style = MaterialTheme.typography.titleMedium,
105+ fontWeight = FontWeight.Bold,
106+ )
107+ }
108+ Spacer(modifier = Modifier.height(12.dp))
109+ Text(
110+ text = "Parseable is an open-source log analytics platform. " +
111+ "This app connects to a Parseable server instance and lets you " +
112+ "browse, search, filter, and live-tail log data.",
113+ style = MaterialTheme.typography.bodyMedium,
114+ )
115+ }
116+ }
117+
118+ // Links
119+ Card(
120+ modifier = Modifier.fillMaxWidth(),
121+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
122+ ) {
123+ Column(modifier = Modifier.padding(16.dp)) {
124+ Row(verticalAlignment = Alignment.CenterVertically) {
125+ Icon(
126+ Icons.Filled.Link,
127+ contentDescription = null,
128+ tint = MaterialTheme.colorScheme.primary,
129+ )
130+ Spacer(modifier = Modifier.width(8.dp))
131+ Text(
132+ text = "Links",
133+ style = MaterialTheme.typography.titleMedium,
134+ fontWeight = FontWeight.Bold,
135+ )
136+ }
137+ Spacer(modifier = Modifier.height(12.dp))
138+
139+ OutlinedButton(
140+ onClick = {
141+ context.startActivity(
142+ Intent(Intent.ACTION_VIEW, Uri.parse(GITHUB_REPO_URL))
143+ )
144+ },
145+ modifier = Modifier.fillMaxWidth(),
146+ ) {
147+ Icon(
148+ Icons.Filled.Code,
149+ contentDescription = null,
150+ modifier = Modifier.size(18.dp),
151+ )
152+ Spacer(modifier = Modifier.width(8.dp))
153+ Text("GitHub Repository")
154+ }
155+
156+ Spacer(modifier = Modifier.height(8.dp))
157+
158+ OutlinedButton(
159+ onClick = {
160+ context.startActivity(
161+ Intent(Intent.ACTION_VIEW, Uri.parse(PARSEABLE_WEBSITE_URL))
162+ )
163+ },
164+ modifier = Modifier.fillMaxWidth(),
165+ ) {
166+ Icon(
167+ Icons.Filled.Language,
168+ contentDescription = null,
169+ modifier = Modifier.size(18.dp),
170+ )
171+ Spacer(modifier = Modifier.width(8.dp))
172+ Text("Parseable Website")
173+ }
174+ }
175+ }
176+ }
177+ }
178+}
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−10
@@ -6,6 +6,7 @@ import com.parseable.android.data.model.Alert
66import com.parseable.android.data.model.ApiResult
77import com.parseable.android.data.repository.ParseableRepository
88import dagger.hilt.android.lifecycle.HiltViewModel
9+import kotlinx.coroutines.Job
910import kotlinx.coroutines.flow.MutableStateFlow
1011import kotlinx.coroutines.flow.StateFlow
1112import kotlinx.coroutines.flow.asStateFlow
@@ -28,16 +29,40 @@ class AlertsViewModel @Inject constructor(
2829 private val _state = MutableStateFlow(AlertsState())
2930 val state: StateFlow<AlertsState> = _state.asStateFlow()
3031
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+
3139 fun refresh() {
32− viewModelScope.launch {
40+ // Cancel any in-flight load so out-of-order completions can't overwrite a newer alert
41+ // list with a stale one (init load, pull-to-refresh and deleteAlert->refresh can overlap).
42+ refreshJob?.cancel()
43+ refreshJob = viewModelScope.launch {
3344 _state.update { it.copy(isLoading = true, error = null) }
34− when (val result = repository.listAlerts()) {
35− is ApiResult.Success -> {
36− _state.update { it.copy(alerts = result.data, isLoading = false) }
37− }
38− is ApiResult.Error -> {
39− _state.update { it.copy(isLoading = false, error = result.userMessage) }
40− }
45+ loadAlerts()
46+ }
47+ }
48+
49+ /**
50+ * Fetches the alert list and writes a terminal state update (clearing isLoading). Callers set
51+ * isLoading = true before invoking. Extracted so deleteAlert can reload within its own
52+ * (never-cancelled) job rather than delegating to refresh(), whose refreshJob can be cancelled
53+ * by a concurrent refresh before it clears the spinner.
54+ */
55+ private suspend fun loadAlerts() {
56+ when (val result = repository.listAlerts()) {
57+ is ApiResult.Success -> {
58+ // Sort by the same field the UI shows (displayName = title ?: name), otherwise
59+ // alerts that carry both fields, or a mix of per-stream and global formats,
60+ // render in an order that doesn't match their visible labels.
61+ val sorted = result.data.sortedBy { it.displayName.lowercase() }
62+ _state.update { it.copy(alerts = sorted, isLoading = false) }
63+ }
64+ is ApiResult.Error -> {
65+ _state.update { it.copy(isLoading = false, error = result.userMessage) }
4166 }
4267 }
4368 }
@@ -50,11 +75,19 @@ class AlertsViewModel @Inject constructor(
5075 _state.update { it.copy(alertToDelete = null) }
5176 }
5277
78+ private var deleteJob: Job? = null
79+
5380 fun deleteAlert(alertId: String) {
54− viewModelScope.launch {
81+ // Guard against a double-tap on the confirm button firing two concurrent DELETEs.
82+ if (deleteJob?.isActive == true) return
83+ deleteJob = viewModelScope.launch {
5584 _state.update { it.copy(alertToDelete = null, isLoading = true, error = null) }
5685 when (val result = repository.deleteAlert(alertId)) {
57− is ApiResult.Success -> refresh()
86+ // Reload inline rather than via refresh(): refresh() runs on the cancellable
87+ // refreshJob, so a pull-to-refresh landing right after could cancel it before it
88+ // clears isLoading, leaving the spinner stuck. This job is never cancelled by a
89+ // refresh, so its terminal state update always runs.
90+ is ApiResult.Success -> loadAlerts()
5891 is ApiResult.Error -> {
5992 _state.update { it.copy(isLoading = false, error = result.userMessage) }
6093 }
app/src/main/java/com/parseable/android/ui/screens/login/LoginScreen.kt+64−8
@@ -1,5 +1,6 @@
11package com.parseable.android.ui.screens.login
22
3+import androidx.compose.foundation.Image
34import androidx.compose.foundation.layout.*
45import androidx.compose.foundation.rememberScrollState
56import androidx.compose.foundation.text.KeyboardActions
@@ -15,7 +16,9 @@ import androidx.compose.ui.Alignment
1516import androidx.compose.ui.Modifier
1617import androidx.compose.ui.focus.FocusDirection
1718import androidx.compose.ui.platform.LocalFocusManager
19+import androidx.compose.ui.res.painterResource
1820import androidx.compose.ui.text.font.FontWeight
21+import com.parseable.android.R
1922import com.parseable.android.ui.LocalErrorHandler
2023import androidx.compose.ui.text.input.ImeAction
2124import androidx.compose.ui.text.input.KeyboardType
@@ -37,8 +40,8 @@ fun LoginScreen(
3740 var passwordVisible by rememberSaveable { mutableStateOf(false) }
3841 val errorHandler = LocalErrorHandler.current
3942
40− LaunchedEffect(state.loginSuccess) {
41− if (state.loginSuccess) {
43+ LaunchedEffect(Unit) {
44+ viewModel.loginSuccessEvent.collect {
4245 onLoginSuccess()
4346 }
4447 }
@@ -59,6 +62,12 @@ fun LoginScreen(
5962 horizontalAlignment = Alignment.CenterHorizontally,
6063 verticalArrangement = Arrangement.Center,
6164 ) {
65+ Image(
66+ painter = painterResource(id = R.mipmap.ic_launcher),
67+ contentDescription = "Parseable logo",
68+ modifier = Modifier.size(120.dp),
69+ )
70+
6271 Text(
6372 text = "Parseable",
6473 style = MaterialTheme.typography.headlineLarge,
@@ -169,12 +178,7 @@ fun LoginScreen(
169178
170179 if (state.allowInsecure) {
171180 val isPrivateNetwork = remember(state.serverUrl) {
172− val host = state.serverUrl
173− .removePrefix("http://").removePrefix("https://")
174− .substringBefore("/").substringBefore(":")
175− host.isBlank() || host == "localhost" || host.startsWith("127.") ||
176− host.startsWith("10.") || host.startsWith("192.168.") ||
177− host.matches(Regex("^172\\.(1[6-9]|2[0-9]|3[01])\\..*"))
181+ isPrivateHost(extractHost(state.serverUrl))
178182 }
179183 if (!isPrivateNetwork) {
180184 Card(
@@ -246,3 +250,55 @@ fun LoginScreen(
246250 }
247251 }
248252}
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−11
@@ -7,10 +7,12 @@ import com.parseable.android.data.model.ServerConfig
77import com.parseable.android.data.repository.ParseableRepository
88import com.parseable.android.data.repository.SettingsRepository
99import dagger.hilt.android.lifecycle.HiltViewModel
10+import kotlinx.coroutines.channels.Channel
1011import kotlinx.coroutines.flow.MutableStateFlow
1112import kotlinx.coroutines.flow.StateFlow
1213import kotlinx.coroutines.flow.asStateFlow
1314import kotlinx.coroutines.flow.first
15+import kotlinx.coroutines.flow.receiveAsFlow
1416import kotlinx.coroutines.flow.update
1517import kotlinx.coroutines.launch
1618import javax.inject.Inject
@@ -22,7 +24,6 @@ data class LoginState(
2224 val allowInsecure: Boolean = false,
2325 val isLoading: Boolean = false,
2426 val error: String? = null,
25− val loginSuccess: Boolean = false,
2627 val serverUrlError: String? = null,
2728 val usernameError: String? = null,
2829 val passwordError: String? = null,
@@ -38,6 +39,15 @@ class LoginViewModel @Inject constructor(
3839 private val _state = MutableStateFlow(LoginState())
3940 val state: StateFlow<LoginState> = _state.asStateFlow()
4041
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+
4151 init {
4252 viewModelScope.launch {
4353 val config = settingsRepository.serverConfig.first()
@@ -55,9 +65,39 @@ class LoginViewModel @Inject constructor(
5565 }
5666
5767 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) }
5873 viewModelScope.launch {
59− val config = settingsRepository.getSavedPassword() ?: return@launch
60− performLogin(config.password)
74+ val config = settingsRepository.getSavedPassword()
75+ if (config == null) {
76+ // URL/username are present but the saved password can't be read (e.g. the
77+ // Keystore entry was lost). Surface this instead of silently stopping the
78+ // spinner, and hide the now-useless "use saved credentials" button.
79+ _state.update {
80+ it.copy(
81+ isLoading = false,
82+ hasSavedCredentials = false,
83+ error = "Saved credentials could not be read. Please log in again.",
84+ )
85+ }
86+ return@launch
87+ }
88+ // Restore the saved URL/username so the saved password is paired with the
89+ // server/user it was saved for, even if the user edited those fields.
90+ _state.update {
91+ it.copy(
92+ serverUrl = config.serverUrl,
93+ username = config.username,
94+ allowInsecure = !config.useTls,
95+ serverUrlError = null,
96+ usernameError = null,
97+ passwordError = null,
98+ )
99+ }
100+ performLogin(config.serverUrl, config.username, config.password, !config.useTls)
61101 }
62102 }
63103
@@ -80,6 +120,15 @@ class LoginViewModel @Inject constructor(
80120 fun onLogin() {
81121 val current = _state.value
82122
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+
83132 // Validate
84133 var hasError = false
85134 var urlError: String? = null
@@ -110,19 +159,34 @@ class LoginViewModel @Inject constructor(
110159 return
111160 }
112161
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.
113168 viewModelScope.launch {
114− performLogin(current.password)
169+ performLogin(current.serverUrl, current.username, current.password, current.allowInsecure)
115170 }
116171 }
117172
118− private suspend fun performLogin(password: String) {
119− val current = _state.value
173+ private suspend fun performLogin(
174+ serverUrl: String,
175+ username: String,
176+ password: String,
177+ allowInsecure: Boolean,
178+ ) {
120179 _state.update { it.copy(isLoading = true, error = null) }
121180
122− var url = current.serverUrl.trim()
181+ // Strip trailing slashes so the persisted URL matches the canonical form the API
182+ // client uses (ParseableApiClient.configure also does trimEnd('/')). Without this,
183+ // "http://host:8000" and "http://host:8000/" persist as distinct strings, defeating
184+ // saveServer's (serverUrl, username) dedup and the UNIQUE index — the same server
185+ // ends up as two saved-server rows.
186+ var url = serverUrl.trim().trimEnd('/')
123187 val urlLower = url.lowercase()
124188 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"
126190 }
127191
128192 val isValidUrl = try {
@@ -143,7 +207,7 @@ class LoginViewModel @Inject constructor(
143207
144208 val config = ServerConfig(
145209 serverUrl = url,
146− username = current.username.trim(),
210+ username = username.trim(),
147211 password = password,
148212 useTls = url.lowercase().startsWith("https"),
149213 )
@@ -156,8 +220,21 @@ class LoginViewModel @Inject constructor(
156220 // Uses verifyServer() to avoid triggering the global auth-error handler.
157221 when (val aboutResult = repository.verifyServer()) {
158222 is ApiResult.Success -> {
159− settingsRepository.saveServerConfig(config)
160− _state.update { it.copy(isLoading = false, loginSuccess = true, password = "") }
223+ // Only report success if the credentials were durably persisted.
224+ // Otherwise the app would navigate in and clear the typed password,
225+ // yet bounce back to login on next launch with nothing to restore.
226+ if (settingsRepository.saveServerConfig(config)) {
227+ settingsRepository.saveServer(config)
228+ _state.update { it.copy(isLoading = false, password = "") }
229+ _loginSuccessEvent.send(Unit)
230+ } else {
231+ _state.update {
232+ it.copy(
233+ isLoading = false,
234+ error = "Connected, but your credentials couldn't be saved securely on this device. Please try again.",
235+ )
236+ }
237+ }
161238 }
162239 is ApiResult.Error -> {
163240 val errorMsg = if (aboutResult.isUnauthorized) {
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.kt+213−71
@@ -101,6 +101,10 @@ fun LogViewerScreen(
101101 actions = {
102102 StreamingToggleButton(
103103 isStreaming = state.isStreaming,
104+ // Live-tail re-derives its own default SELECT * query in the poller, so
105+ // it can't honor a custom SQL projection/filter. Disable the toggle while
106+ // a custom query is active to avoid prepending mismatched rows.
107+ enabled = state.customSql.isBlank(),
104108 onClick = viewModel::toggleStreaming,
105109 )
106110 IconButton(onClick = { onStreamInfo(streamName) }) {
@@ -167,7 +171,9 @@ fun LogViewerScreen(
167171 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
168172 keyboardActions = KeyboardActions(onSearch = {
169173 keyboardController?.hide()
170− viewModel.refresh()
174+ // Runs the search now and cancels the pending debounce (which stops live-tail
175+ // and refreshes) so we don't fire a second, redundant refresh ~300ms later.
176+ viewModel.submitSearch()
171177 }),
172178 modifier = Modifier
173179 .fillMaxWidth()
@@ -281,9 +287,41 @@ fun LogViewerScreen(
281287 // Log entries
282288 PullToRefreshBox(
283289 isRefreshing = state.isLoading,
284− onRefresh = viewModel::refresh,
290+ onRefresh = viewModel::pullRefresh,
285291 modifier = Modifier.fillMaxSize(),
286292 ) {
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+
287325 if (state.error != null && state.logs.isEmpty()) {
288326 Box(
289327 modifier = Modifier.fillMaxSize(),
@@ -350,29 +388,6 @@ fun LogViewerScreen(
350388 }
351389 }
352390 } 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− // Pre-compute stable keys so each log is assigned exactly one key
372− val logKeys = remember(state.logs) {
373− stableLogKeys(state.logs)
374− }
375−
376391 LazyColumn(
377392 state = listState,
378393 contentPadding = PaddingValues(8.dp),
@@ -380,14 +395,34 @@ fun LogViewerScreen(
380395 ) {
381396 itemsIndexed(
382397 state.logs,
383− key = { index, _ -> logKeys.getOrElse(index) { "log_$index" } },
398+ key = { index, _ -> state.logKeys.getOrElse(index) { "log_$index" } },
384399 ) { index, logEntry ->
385− val key = logKeys.getOrElse(index) { "log_$index" }
400+ val key = state.logKeys.getOrElse(index) { "log_$index" }
386401 LogEntryCard(
387402 logEntry = logEntry,
403+ stableKey = key,
388404 isExpanded = expandedLogKey == key,
389405 onClick = {
390− expandedLogKey = if (expandedLogKey == key) null else key
406+ if (expandedLogKey == key) {
407+ // Collapsing: if the top of the entry is scrolled
408+ // above the viewport, jump to it so the user sees
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.
413+ val itemInfo = listState.layoutInfo.visibleItemsInfo
414+ .firstOrNull { it.key == key }
415+ val needsScroll = itemInfo == null || itemInfo.offset < 0
416+ expandedLogKey = null
417+ if (needsScroll) {
418+ val target = state.logKeys.indexOf(key).takeIf { it >= 0 } ?: index
419+ scope.launch {
420+ listState.animateScrollToItem(target)
421+ }
422+ }
423+ } else {
424+ expandedLogKey = key
425+ }
391426 },
392427 clipboardManager = clipboard,
393428 onCopied = {
@@ -398,6 +433,12 @@ fun LogViewerScreen(
398433 )
399434 }
400435 },
436+ onFilterInclude = { column, value ->
437+ viewModel.addFilter(column, "=", value)
438+ },
439+ onFilterExclude = { column, value ->
440+ viewModel.addFilter(column, "!=", value)
441+ },
401442 )
402443 }
403444
@@ -450,6 +491,7 @@ fun LogViewerScreen(
450491 if (showFilterSheet) {
451492 FilterBottomSheet(
452493 columns = state.columns,
494+ logs = state.logs,
453495 onDismiss = { showFilterSheet = false },
454496 onApplyFilter = { column, operator, value ->
455497 viewModel.addFilter(column, operator, value)
@@ -492,10 +534,18 @@ fun LogViewerScreen(
492534 if (!dir.mkdirs() && !dir.isDirectory) {
493535 throw java.io.IOException("Failed to create shared_logs directory")
494536 }
495− // Clean up previous exports
496− dir.listFiles()?.forEach { old -> old.delete() }
537+ // Clean up STALE exports only. A share chooser opened moments
538+ // ago may still be reading a recent file via its FileProvider
539+ // URI; deleting every file (as before) could pull one out from
540+ // under an in-flight share. Keep anything written in the last
541+ // hour and give each export a unique name so concurrent shares
542+ // never collide.
543+ val cutoff = System.currentTimeMillis() - 60 * 60 * 1000L
544+ dir.listFiles()?.forEach { old ->
545+ if (old.lastModified() < cutoff) old.delete()
546+ }
497547 val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
498− val file = java.io.File(dir, "logs_$safeFileName.json")
548+ val file = java.io.File(dir, "logs_${safeFileName}_${System.currentTimeMillis()}.json")
499549 file.bufferedWriter().use { writer ->
500550 // Use compact JSON for large exports to reduce memory/file size
501551 val encoder = if (logs.size > 1000) Json else prettyJson
@@ -557,17 +607,18 @@ fun LogViewerScreen(
557607 val start = dateRangePickerState.selectedStartDateMillis
558608 val end = dateRangePickerState.selectedEndDateMillis
559609 if (start != null && end != null) {
560− // DateRangePicker returns midnight UTC for the selected date.
561− // Adjust to cover the full end day in the user's local timezone:
562− // Convert to local date, get end-of-day, convert back to UTC millis.
610+ // DateRangePicker returns midnight UTC for the selected date, so the
611+ // calendar date must be read back in UTC — reading it in the local zone
612+ // would roll west-of-UTC users to the previous day. Once we have the
613+ // tapped date, build the boundaries to cover the full day locally.
563614 val localZone = java.time.ZoneId.systemDefault()
564615 val endLocalDate = java.time.Instant.ofEpochMilli(end)
565− .atZone(localZone).toLocalDate()
616+ .atZone(java.time.ZoneOffset.UTC).toLocalDate()
566617 val endOfDayUtc = endLocalDate.plusDays(1)
567618 .atStartOfDay(localZone)
568619 .toInstant().toEpochMilli() - 1
569620 val startLocalDate = java.time.Instant.ofEpochMilli(start)
570− .atZone(localZone).toLocalDate()
621+ .atZone(java.time.ZoneOffset.UTC).toLocalDate()
571622 val startUtc = startLocalDate
572623 .atStartOfDay(localZone)
573624 .toInstant().toEpochMilli()
@@ -650,9 +701,10 @@ private fun TimeRangeBar(
650701@Composable
651702private fun StreamingToggleButton(
652703 isStreaming: Boolean,
704+ enabled: Boolean,
653705 onClick: () -> Unit,
654706) {
655− IconButton(onClick = onClick) {
707+ IconButton(onClick = onClick, enabled = enabled || isStreaming) {
656708 if (isStreaming) {
657709 val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
658710 val alpha by infiniteTransition.animateFloat(
@@ -683,9 +735,12 @@ private fun StreamingToggleButton(
683735@Composable
684736fun LogEntryCard(
685737 logEntry: JsonObject,
738+ stableKey: String,
686739 isExpanded: Boolean,
687740 onClick: () -> Unit,
688741 onCopied: () -> Unit = {},
742+ onFilterInclude: (column: String, value: String) -> Unit = { _, _ -> },
743+ onFilterExclude: (column: String, value: String) -> Unit = { _, _ -> },
689744 clipboardManager: android.content.ClipboardManager,
690745) {
691746 val severity = remember(logEntry) { detectSeverity(logEntry) }
@@ -699,7 +754,9 @@ fun LogEntryCard(
699754 ) {
700755 Column(modifier = Modifier.padding(12.dp)) {
701756 // Timestamp row
702− val rawTimestamp = remember(logEntry) {
757+ // Key the derived values on the content-stable log key (not the JsonObject
758+ // instance), so live-tail re-emissions of unchanged rows don't re-parse them.
759+ val rawTimestamp = remember(stableKey) {
703760 try {
704761 logEntry["p_timestamp"]?.jsonPrimitive?.content
705762 ?: logEntry["datetime"]?.jsonPrimitive?.content
@@ -724,7 +781,7 @@ fun LogEntryCard(
724781
725782 if (!isExpanded) {
726783 // Compact view: show first meaningful fields
727− val preview = remember(logEntry) {
784+ val preview = remember(stableKey) {
728785 logEntry.entries
729786 .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
730787 .take(3)
@@ -766,25 +823,55 @@ fun LogEntryCard(
766823 }
767824 HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
768825 logEntry.entries.forEach { (key, value) ->
826+ val displayValue = formatJsonValue(value)
769827 Row(
770828 modifier = Modifier
771829 .fillMaxWidth()
772830 .padding(vertical = 2.dp),
831+ verticalAlignment = Alignment.CenterVertically,
773832 ) {
774− Text(
775− text = "$key: ",
776− style = MaterialTheme.typography.bodySmall,
777− fontWeight = FontWeight.Bold,
778− fontFamily = FontFamily.Monospace,
779− color = MaterialTheme.colorScheme.primary,
780− fontSize = 12.sp,
781− )
782− Text(
783− text = formatJsonValue(value),
784− style = MaterialTheme.typography.bodySmall,
785− fontFamily = FontFamily.Monospace,
786− fontSize = 12.sp,
787− )
833+ Column(modifier = Modifier.weight(1f)) {
834+ Row {
835+ Text(
836+ text = "$key: ",
837+ style = MaterialTheme.typography.bodySmall,
838+ fontWeight = FontWeight.Bold,
839+ fontFamily = FontFamily.Monospace,
840+ color = MaterialTheme.colorScheme.primary,
841+ fontSize = 12.sp,
842+ )
843+ Text(
844+ text = displayValue,
845+ style = MaterialTheme.typography.bodySmall,
846+ fontFamily = FontFamily.Monospace,
847+ fontSize = 12.sp,
848+ )
849+ }
850+ }
851+ // Filter include (=) button
852+ IconButton(
853+ onClick = { onFilterInclude(key, displayValue) },
854+ modifier = Modifier.size(28.dp),
855+ ) {
856+ Icon(
857+ Icons.Filled.AddCircle,
858+ contentDescription = "Filter for $key = $displayValue",
859+ modifier = Modifier.size(16.dp),
860+ tint = MaterialTheme.colorScheme.primary,
861+ )
862+ }
863+ // Filter exclude (!=) button
864+ IconButton(
865+ onClick = { onFilterExclude(key, displayValue) },
866+ modifier = Modifier.size(28.dp),
867+ ) {
868+ Icon(
869+ Icons.Filled.RemoveCircle,
870+ contentDescription = "Filter out $key = $displayValue",
871+ modifier = Modifier.size(16.dp),
872+ tint = MaterialTheme.colorScheme.error,
873+ )
874+ }
788875 }
789876 }
790877 }
@@ -792,24 +879,6 @@ fun LogEntryCard(
792879 }
793880}
794881
795−/**
796− * Produces deterministic keys for a list of log entries so that expanded state survives
797− * list mutations (e.g., new logs prepended during streaming). Keys are content-based
798− * using p_timestamp + p_metadata + p_tags; duplicate content gets a disambiguation suffix.
799− */
800−private fun stableLogKeys(logs: List<JsonObject>): List<String> {
801− val seen = mutableMapOf<String, Int>()
802− return logs.map { log ->
803− val ts = log["p_timestamp"]?.toString()
804− val meta = log["p_metadata"]?.toString()
805− val tag = log["p_tags"]?.toString()
806− val base = if (ts != null) "$ts|${meta.orEmpty()}|${tag.orEmpty()}" else log.hashCode().toString()
807− val count = seen.getOrDefault(base, 0)
808− seen[base] = count + 1
809− if (count == 0) base else "$base|$count"
810− }
811−}
812−
813882private fun formatJsonValue(element: JsonElement): String {
814883 return when (element) {
815884 is JsonPrimitive -> element.content
@@ -822,10 +891,22 @@ private fun formatJsonValue(element: JsonElement): String {
822891@Composable
823892fun FilterBottomSheet(
824893 columns: List<String>,
894+ logs: List<JsonObject> = emptyList(),
825895 onDismiss: () -> Unit,
826896 onApplyFilter: (column: String, operator: String, value: String) -> Unit,
827897) {
828898 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+ }
829910 var selectedOperator by remember { mutableStateOf("=") }
830911 var filterValue by remember { mutableStateOf("") }
831912 var columnDropdownExpanded by remember { mutableStateOf(false) }
@@ -833,6 +914,28 @@ fun FilterBottomSheet(
833914
834915 val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
835916
917+ // Extract distinct values for the selected column from current logs
918+ val columnValues = remember(selectedColumn, logs) {
919+ if (selectedColumn.isBlank() || logs.isEmpty()) {
920+ emptyList()
921+ } else {
922+ logs.asSequence()
923+ .mapNotNull { entry ->
924+ try {
925+ entry[selectedColumn]?.let { element ->
926+ formatJsonValue(element).takeIf { it != "null" && it.isNotBlank() }
927+ }
928+ } catch (_: Exception) {
929+ null
930+ }
931+ }
932+ .distinct()
933+ .take(50)
934+ .sorted()
935+ .toList()
936+ }
937+ }
938+
836939 ModalBottomSheet(onDismissRequest = onDismiss) {
837940 Column(
838941 modifier = Modifier.padding(16.dp),
@@ -868,6 +971,7 @@ fun FilterBottomSheet(
868971 text = { Text(column) },
869972 onClick = {
870973 selectedColumn = column
974+ filterValue = ""
871975 columnDropdownExpanded = false
872976 },
873977 )
@@ -936,6 +1040,44 @@ fun FilterBottomSheet(
9361040 }
9371041 }),
9381042 )
1043+
1044+ // Show existing values as selectable suggestions
1045+ if (columnValues.isNotEmpty() && selectedOperator in listOf("=", "!=")) {
1046+ Text(
1047+ text = "Existing values:",
1048+ style = MaterialTheme.typography.labelMedium,
1049+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1050+ )
1051+ // Filter suggestions by typed text
1052+ val filteredValues = remember(columnValues, filterValue) {
1053+ if (filterValue.isBlank()) columnValues
1054+ else columnValues.filter {
1055+ it.contains(filterValue, ignoreCase = true)
1056+ }
1057+ }
1058+ Row(
1059+ modifier = Modifier
1060+ .fillMaxWidth()
1061+ .horizontalScroll(rememberScrollState()),
1062+ horizontalArrangement = Arrangement.spacedBy(6.dp),
1063+ ) {
1064+ filteredValues.forEach { value ->
1065+ SuggestionChip(
1066+ onClick = {
1067+ onApplyFilter(selectedColumn, selectedOperator, value)
1068+ },
1069+ label = {
1070+ Text(
1071+ text = if (value.length > 40) value.take(40) + "\u2026" else value,
1072+ maxLines = 1,
1073+ overflow = TextOverflow.Ellipsis,
1074+ fontSize = 12.sp,
1075+ )
1076+ },
1077+ )
1078+ }
1079+ }
1080+ }
9391081 }
9401082
9411083 val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerViewModel.kt+415−113
@@ -3,6 +3,7 @@ package com.parseable.android.ui.screens.logviewer
33import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
55import com.parseable.android.data.escapeIdentifier
6+import com.parseable.android.data.escapeLikePattern
67import com.parseable.android.data.escapeSql
78import com.parseable.android.data.model.*
89import com.parseable.android.data.repository.ParseableRepository
@@ -15,8 +16,13 @@ import kotlinx.coroutines.flow.asStateFlow
1516import kotlinx.coroutines.flow.update
1617import kotlinx.coroutines.isActive
1718import kotlinx.coroutines.launch
19+import kotlinx.coroutines.sync.Mutex
20+import kotlinx.coroutines.sync.withLock
1821import kotlinx.serialization.json.JsonObject
1922import kotlinx.serialization.json.jsonPrimitive
23+import java.time.Instant
24+import java.time.LocalDateTime
25+import java.time.OffsetDateTime
2026import java.time.ZoneOffset
2127import java.time.ZonedDateTime
2228import java.time.format.DateTimeFormatter
@@ -34,10 +40,21 @@ enum class TimeRange(val label: String, val minutes: Long) {
3440 CUSTOM("Custom", 0),
3541}
3642
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+
3750data class FilterState(
3851 val searchQuery: String = "",
3952 val activeFilters: List<String> = emptyList(),
4053 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(),
4158 val customSql: String = "",
4259 val isSearching: Boolean = false,
4360)
@@ -59,7 +76,9 @@ data class SavedFiltersState(
5976data class LogViewerState(
6077 val streamName: String = "",
6178 val logs: List<JsonObject> = emptyList(),
79+ val logKeys: List<String> = emptyList(),
6280 val columns: List<String> = emptyList(),
81+ val searchableColumns: List<String> = emptyList(),
6382 val isLoading: Boolean = false,
6483 val error: String? = null,
6584 val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
@@ -70,6 +89,10 @@ data class LogViewerState(
7089 val hasMore: Boolean = false,
7190 val customStartTime: Long? = null,
7291 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,
7396) {
7497 // Convenience accessors for backward compatibility with Screen
7598 val searchQuery: String get() = filters.searchQuery
@@ -97,17 +120,53 @@ class LogViewerViewModel @Inject constructor(
97120 @Volatile private var streamingGeneration: Int = 0
98121 @Volatile private var lastSeenTimestamp: String? = null
99122 @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>()
100135 private var searchJob: Job? = null
101136 private var schemaJob: Job? = null
137+ private var refreshJob: Job? = null
102138
103139 companion object {
104140 private const val STREAMING_BASE_INTERVAL_MS = 3000L
105141 private const val STREAMING_MAX_INTERVAL_MS = 30000L
106142 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
107146 private const val MAX_LOAD_LIMIT = 5000
108147 private const val LOAD_MORE_INCREMENT = 500
109148 private const val MAX_STREAMING_ERRORS = 5
110149 private val ALLOWED_OPERATORS = setOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
150+
151+ /**
152+ * Produces deterministic keys for a list of log entries so that expanded state
153+ * survives list mutations (e.g., new logs prepended during streaming).
154+ */
155+ internal fun computeLogKeys(logs: List<JsonObject>): List<String> {
156+ val seen = mutableMapOf<String, Int>()
157+ return logs.map { log ->
158+ // Key on the full row content (same identity used for streaming dedup, see
159+ // logIdentity). Keying on only p_timestamp/p_metadata/p_tags collided for rows
160+ // sharing those fields but differing in body, so the positional tiebreaker made
161+ // keys shift when streaming prepended rows — corrupting expanded state and
162+ // letting remember(stableKey) blocks render a stale body. The trailing counter
163+ // only disambiguates genuinely identical rows, whose rendering is identical too.
164+ val base = log.toString()
165+ val count = seen.getOrDefault(base, 0)
166+ seen[base] = count + 1
167+ if (count == 0) base else "$base|$count"
168+ }
169+ }
111170 }
112171
113172 fun initialize(streamName: String) {
@@ -123,8 +182,9 @@ class LogViewerViewModel @Inject constructor(
123182 schemaJob = viewModelScope.launch {
124183 when (val result = repository.getStreamSchema(streamName)) {
125184 is ApiResult.Success -> {
126− val columns = result.data.fields.map { it.name }
127− _state.update { it.copy(columns = columns) }
185+ val columns = result.data.fields.map { it.name }.sorted()
186+ val searchable = columns.filter { !it.startsWith("p_") }
187+ _state.update { it.copy(columns = columns, searchableColumns = searchable) }
128188 }
129189 is ApiResult.Error -> { /* Schema loading failure is non-fatal */ }
130190 }
@@ -158,7 +218,11 @@ class LogViewerViewModel @Inject constructor(
158218 }
159219
160220 fun onSearchQueryChange(query: String) {
161− _state.update { it.copy(filters = it.filters.copy(searchQuery = query, isSearching = query.isNotBlank())) }
221+ // A new query invalidates the live-tail baseline and the paged-up limit, matching
222+ // the time-range / custom-SQL handlers.
223+ stopStreaming()
224+ // Searching returns to the filter-builder query path, so drop any active custom SQL.
225+ _state.update { it.copy(currentLimit = 500, filters = it.filters.copy(searchQuery = query, customSql = "", isSearching = query.isNotBlank())) }
162226 searchJob?.cancel()
163227 searchJob = viewModelScope.launch {
164228 delay(300)
@@ -167,28 +231,55 @@ class LogViewerViewModel @Inject constructor(
167231 }
168232 }
169233
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+
170245 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) {
171251 if (operator !in ALLOWED_OPERATORS) return
172252 val safeColumn = escapeIdentifier(column)
173− val safeValue = escapeSql(value)
174253 val clause = when (operator) {
175254 "IS NULL", "IS NOT NULL" -> "\"$safeColumn\" $operator"
176− "LIKE", "ILIKE" -> "\"$safeColumn\" $operator '%$safeValue%'"
177− else -> "\"$safeColumn\" $operator '$safeValue'"
255+ // %/_ in the user's value must match literally, so escape the LIKE wildcards
256+ // and declare the escape character. Exact comparisons keep %/_ verbatim.
257+ "LIKE", "ILIKE" -> "\"$safeColumn\" $operator '%${escapeLikePattern(value)}%' ESCAPE '\\'"
258+ else -> "\"$safeColumn\" $operator '${escapeSql(value)}'"
259+ }
260+ // IS NULL / IS NOT NULL ignore the value in both the clause and the display, so store
261+ // an empty value in the structured condition too (keeps saved filters clean).
262+ val conditionValue = when (operator) {
263+ "IS NULL", "IS NOT NULL" -> ""
264+ else -> value
178265 }
179266 val display = when (operator) {
180267 "IS NULL", "IS NOT NULL" -> "$column $operator"
181268 else -> "$column $operator $value"
182269 }
270+ // A new filter invalidates the live-tail baseline and the paged-up limit.
271+ stopStreaming()
183272 _state.update {
184273 it.copy(
274+ currentLimit = 500,
185275 filters = it.filters.copy(
186276 filterClauses = it.filters.filterClauses + clause,
187277 activeFilters = it.filters.activeFilters + display,
278+ filterConditions = it.filters.filterConditions + FilterCondition(column, operator, conditionValue),
279+ customSql = "",
188280 ),
189281 )
190282 }
191− refresh()
192283 }
193284
194285 fun removeFilter(display: String) {
@@ -198,81 +289,93 @@ class LogViewerViewModel @Inject constructor(
198289 if (index >= 0 && index < it.filters.filterClauses.size) {
199290 removed = true
200291 it.copy(
292+ currentLimit = 500,
201293 filters = it.filters.copy(
202294 activeFilters = it.filters.activeFilters.filterIndexed { i, _ -> i != index },
203295 filterClauses = it.filters.filterClauses.filterIndexed { i, _ -> i != index },
296+ filterConditions = it.filters.filterConditions.filterIndexed { i, _ -> i != index },
297+ customSql = "",
204298 ),
205299 )
206300 } else {
207301 it
208302 }
209303 }
210− if (removed) refresh()
304+ if (removed) {
305+ // A changed filter set invalidates the live-tail baseline.
306+ stopStreaming()
307+ refresh()
308+ }
211309 }
212310
213311 fun clearFilters() {
312+ stopStreaming()
214313 _state.update {
215− 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))
216315 }
217316 refresh()
218317 }
219318
220319 fun executeCustomSql(sql: String) {
221− val trimmed = sql.trim()
320+ // Custom SQL changes the query basis, so cancel any pending search debounce that would
321+ // otherwise fire a redundant refresh() ~300ms later. (Unlike the filter/time-range
322+ // handlers, this path doesn't route through stopStreaming().)
323+ searchJob?.cancel()
324+ // Drop a trailing statement terminator before we append anything — otherwise
325+ // "SELECT * FROM x;" would become "SELECT * FROM x; LIMIT 5000", a syntax error.
326+ val trimmed = sql.trim().trimEnd(';').trim()
222327 if (!trimmed.uppercase().startsWith("SELECT")) {
223328 _state.update { it.copy(error = "Only SELECT queries are allowed") }
224329 return
225330 }
226− // Enforce a LIMIT to prevent OOM from unbounded queries
227− val safeSql = if (!trimmed.uppercase().contains("LIMIT")) {
331+ // Enforce a LIMIT to prevent OOM from unbounded queries. Only honor a *top-level*
332+ // `LIMIT <n>` (optionally with OFFSET) anchored at the end of the statement — a
333+ // LIMIT inside a subquery doesn't bound the outer result set, so matching it
334+ // anywhere would let an unbounded outer query slip past the safety limit. Anchoring
335+ // at the end also means a string literal like 'LIMIT EXCEEDED' can't disable it.
336+ val hasTopLevelLimit =
337+ Regex("(?i)\\bLIMIT\\s+\\d+(\\s+OFFSET\\s+\\d+)?\\s*$").containsMatchIn(trimmed)
338+ val safeSql = if (!hasTopLevelLimit) {
228339 "$trimmed LIMIT $MAX_LOAD_LIMIT"
229340 } else {
230341 trimmed
231342 }
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.
232346 _state.update { it.copy(filters = it.filters.copy(customSql = safeSql), currentLimit = 500) }
233− viewModelScope.launch {
234− _state.update { it.copy(isLoading = true, error = null) }
235−
236− val (startTime, endTime) = getTimeRange()
237− when (val result = repository.queryLogsRaw(safeSql, startTime, endTime)) {
238− is ApiResult.Success -> {
239− _state.update {
240− it.copy(
241− logs = result.data,
242− isLoading = false,
243− hasMore = result.data.size >= it.currentLimit,
244− )
245− }
246− }
247− is ApiResult.Error -> {
248− _state.update {
249− it.copy(isLoading = false, error = result.userMessage)
250− }
251− }
252− }
253− }
347+ refresh()
254348 }
255349
256− private fun buildSearchClause(columns: List<String>, searchQuery: String): String? {
350+ private fun buildSearchClause(searchableColumns: List<String>, searchQuery: String): String? {
257351 if (searchQuery.isBlank()) return null
258− val safeSearch = escapeSql(searchQuery)
259− // Search across ALL non-internal columns (not just 5)
260− val searchFields = columns.filter { !it.startsWith("p_") }
261− if (searchFields.isEmpty()) return null
262− return searchFields.joinToString(" OR ") {
263− "\"${escapeIdentifier(it)}\" ILIKE '%$safeSearch%'"
352+ if (searchableColumns.isEmpty()) return null
353+ val safeSearch = escapeLikePattern(searchQuery)
354+ return searchableColumns.joinToString(" OR ") {
355+ "CAST(\"${escapeIdentifier(it)}\" AS VARCHAR) ILIKE '%$safeSearch%' ESCAPE '\\'"
264356 }
265357 }
266358
267359 fun refresh() {
268360 if (_state.value.streamName.isEmpty()) return
269361
270− viewModelScope.launch {
362+ // Cancel any in-flight query so out-of-order completions can't overwrite
363+ // fresh results with stale ones (filter/search/time-range/loadMore all call refresh()).
364+ refreshJob?.cancel()
365+ refreshJob = viewModelScope.launch {
366+ // Anchor the upper time bound on the first page of a paging session (currentLimit at
367+ // its initial value) and reuse it for subsequent loadMore() pages. Otherwise a relative
368+ // range would recompute "now" on every page in getTimeRange(), sliding the window and
369+ // duplicating/dropping rows on an actively-ingesting stream.
370+ val anchor = _state.value.let { s ->
371+ if (s.currentLimit <= 500) System.currentTimeMillis() else s.pageAnchorTime
372+ }
271373 _state.update {
272374 it.copy(
273375 isLoading = true,
274376 error = null,
275377 streaming = it.streaming.copy(streamingError = null),
378+ pageAnchorTime = anchor,
276379 )
277380 }
278381
@@ -280,10 +383,24 @@ class LogViewerViewModel @Inject constructor(
280383 val current = _state.value
281384 val (startTime, endTime) = getTimeRange()
282385
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+
283400 // Build WHERE clause from filters + search
284401 val clauses = current.filters.filterClauses.toMutableList()
285402 if (current.filters.searchQuery.isNotBlank()) {
286− val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
403+ val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
287404 if (searchClause != null) {
288405 clauses.add("($searchClause)")
289406 } else {
@@ -296,20 +413,26 @@ class LogViewerViewModel @Inject constructor(
296413
297414 val filterSql = clauses.joinToString(" AND ")
298415
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
299420 when (val result = repository.queryLogs(
300421 stream = current.streamName,
301422 startTime = startTime,
302423 endTime = endTime,
303424 filterSql = filterSql,
304− limit = current.currentLimit,
425+ limit = requestLimit,
305426 )) {
306427 is ApiResult.Success -> {
428+ val hasMore = result.data.size > current.currentLimit && current.currentLimit < MAX_LOAD_LIMIT
429+ val logs = if (result.data.size > current.currentLimit) {
430+ result.data.take(current.currentLimit)
431+ } else {
432+ result.data
433+ }
307434 _state.update {
308− it.copy(
309− logs = result.data,
310− isLoading = false,
311− hasMore = result.data.size >= it.currentLimit,
312− )
435+ it.copy(logs = logs, logKeys = computeLogKeys(logs), isLoading = false, hasMore = hasMore)
313436 }
314437 }
315438 is ApiResult.Error -> {
@@ -321,8 +444,29 @@ class LogViewerViewModel @Inject constructor(
321444 }
322445 }
323446
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+
324465 fun loadMore() {
325466 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
326470 if (current.currentLimit >= MAX_LOAD_LIMIT) {
327471 _state.update { it.copy(hasMore = false) }
328472 return
@@ -345,7 +489,9 @@ class LogViewerViewModel @Inject constructor(
345489 )
346490 return Pair(start.format(dateFormatter), end.format(dateFormatter))
347491 }
348− val now = ZonedDateTime.now(ZoneOffset.UTC)
492+ val now = current.pageAnchorTime
493+ ?.let { ZonedDateTime.ofInstant(java.time.Instant.ofEpochMilli(it), ZoneOffset.UTC) }
494+ ?: ZonedDateTime.now(ZoneOffset.UTC)
349495 val start = now.minusMinutes(current.selectedTimeRange.minutes)
350496 return Pair(
351497 start.format(dateFormatter),
@@ -353,6 +499,17 @@ class LogViewerViewModel @Inject constructor(
353499 )
354500 }
355501
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+
356513 fun toggleStreaming() {
357514 if (_state.value.streaming.isStreaming) {
358515 stopStreaming()
@@ -362,6 +519,10 @@ class LogViewerViewModel @Inject constructor(
362519 }
363520
364521 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
365526 stopStreaming()
366527 consecutiveStreamingErrors = 0
367528 val generation = ++streamingGeneration
@@ -369,6 +530,12 @@ class LogViewerViewModel @Inject constructor(
369530 it.copy(streaming = StreamingState(isStreaming = true, streamingNewCount = 0))
370531 }
371532
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+
372539 // Set the baseline timestamp to "now" so we only poll for new logs
373540 lastSeenTimestamp = ZonedDateTime.now(ZoneOffset.UTC).format(dateFormatter)
374541
@@ -391,6 +558,13 @@ class LogViewerViewModel @Inject constructor(
391558 }
392559
393560 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()
394568 streamingGeneration++
395569 streamingJob?.cancel()
396570 streamingJob = null
@@ -405,6 +579,49 @@ class LogViewerViewModel @Inject constructor(
405579 }
406580 }
407581
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+
408625 private suspend fun pollNewLogs() {
409626 val current = _state.value
410627 if (current.streamName.isEmpty()) return
@@ -415,46 +632,118 @@ class LogViewerViewModel @Inject constructor(
415632 // Build WHERE clause from active filters + search
416633 val clauses = current.filters.filterClauses.toMutableList()
417634 if (current.filters.searchQuery.isNotBlank()) {
418− val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
635+ val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
419636 if (searchClause != null) {
420− clauses.add(searchClause)
637+ // Parenthesize: the search clause is OR-joined and must not break the
638+ // precedence of the AND-joined filter clauses around it.
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
421653 }
422654 }
423655
424656 val whereClause = if (clauses.isNotEmpty()) " WHERE ${clauses.joinToString(" AND ")}" else ""
425657 val safeName = escapeIdentifier(current.streamName)
426− val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT 200"
658+ // Fetch up to the in-memory display cap. With a small limit (e.g. 200), a burst of
659+ // more than that many logs in one interval would advance lastSeenTimestamp past the
660+ // un-fetched rows and drop them permanently. Capping at STREAMING_MAX_LOGS means we
661+ // never skip rows we'd actually keep (anything older is evicted by the cap anyway).
662+ val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT $STREAMING_MAX_LOGS"
427663
428664 when (val result = repository.queryLogsRaw(sql, startTime, endTime)) {
429665 is ApiResult.Success -> {
430666 val newLogs = result.data
431667 if (newLogs.isNotEmpty()) {
432− // Update the last seen timestamp to the most recent log
433− val newestTimestamp = try {
434− newLogs.firstOrNull()
435− ?.get("p_timestamp")?.jsonPrimitive?.content
668+ // Advance the last-seen boundary. Rows are ordered newest-first, so
669+ // normally we jump to the newest. But a full page means the LIMIT may
670+ // have capped off matching rows OLDER than the newest in this batch
671+ // (possible once filters/search narrow a high-volume stream) — advancing
672+ // to the newest would skip them forever. In that case advance only to the
673+ // oldest row we did fetch, so the next poll re-queries forward and closes
674+ // the gap (re-fetched rows are deduped below).
675+ val fullPage = newLogs.size >= STREAMING_MAX_LOGS
676+ val boundaryLog = if (fullPage) newLogs.lastOrNull() else newLogs.firstOrNull()
677+ val boundaryTimestamp = try {
678+ boundaryLog?.get("p_timestamp")?.jsonPrimitive?.content
436679 } catch (_: Exception) {
437680 null
438681 }
439− if (newestTimestamp != null) {
440− lastSeenTimestamp = newestTimestamp
682+ // Re-canonicalize the server's p_timestamp before reusing it as the next
683+ // query's startTime. The raw value can be space-separated or offset-less,
684+ // which the query API may reject or misparse — that would stall live-tail
685+ // after the first batch. If it can't be parsed, leave lastSeenTimestamp as-is
686+ // (the inclusive startTime simply re-fetches the window; rows are deduped).
687+ var normalized = boundaryTimestamp?.let { normalizeBoundaryTimestamp(it) }
688+ // On a saturated (full) page the boundary is the OLDEST fetched row so the
689+ // next poll re-queries forward and closes the LIMIT gap. But if that oldest
690+ // row carries the SAME timestamp we're already at — e.g. a burst of more than
691+ // STREAMING_MAX_LOGS rows sharing one millisecond, or the parse failing — the
692+ // boundary wouldn't move and we'd re-query the identical window forever. Fall
693+ // back to the NEWEST row's timestamp to guarantee forward progress; the
694+ // skipped backlog would be evicted by the display cap anyway.
695+ if (fullPage && (normalized == null || normalized == lastSeenTimestamp)) {
696+ val newestTimestamp = try {
697+ newLogs.firstOrNull()?.get("p_timestamp")?.jsonPrimitive?.content
698+ } catch (_: Exception) {
699+ null
700+ }
701+ val newestNormalized = newestTimestamp?.let { normalizeBoundaryTimestamp(it) }
702+ if (newestNormalized != null && newestNormalized != lastSeenTimestamp) {
703+ normalized = newestNormalized
704+ }
705+ }
706+ if (normalized != null) {
707+ lastSeenTimestamp = normalized
441708 }
442709
443− _state.update { state ->
444− // Prepend new logs, cap total
445− val combined = newLogs + state.logs
446− val capped = if (combined.size > STREAMING_MAX_LOGS) {
447− combined.take(STREAMING_MAX_LOGS)
448− } else {
449− combined
710+ // The query's startTime is inclusive, so rows sitting on the previous
711+ // boundary timestamp get re-fetched. Dedup against everything seen this
712+ // session (independent of the display cap) to avoid duplicate rows and an
713+ // inflated count. Computed BEFORE the state update — and the seen-set
714+ // mutated here, not inside the update lambda, which may re-run.
715+ val freshLogs = newLogs.filterNot { logIdentity(it) in seenStreamingKeys }
716+ newLogs.forEach { seenStreamingKeys.add(logIdentity(it)) }
717+ trimSeenStreamingKeys()
718+
719+ if (freshLogs.isEmpty()) {
720+ _state.update { it.copy(streaming = it.streaming.copy(streamingError = null)) }
721+ } else {
722+ _state.update { state ->
723+ // Prepend new logs, cap total — avoid full intermediate list allocation
724+ val remaining = (STREAMING_MAX_LOGS - freshLogs.size).coerceAtLeast(0)
725+ val capped = ArrayList<JsonObject>(freshLogs.size + remaining).apply {
726+ addAll(freshLogs)
727+ val oldLogs = state.logs
728+ addAll(oldLogs.subList(0, remaining.coerceAtMost(oldLogs.size)))
729+ }
730+ state.copy(
731+ logs = capped,
732+ logKeys = computeLogKeys(capped),
733+ streaming = state.streaming.copy(
734+ // Accumulate new rows while the user is scrolled away; reset
735+ // to 0 when they're at the top (where prepended rows are seen
736+ // live). The previous cap was against total list size, so the
737+ // badge saturated at the display cap and never cleared.
738+ streamingNewCount = if (viewingTop) {
739+ 0
740+ } else {
741+ state.streaming.streamingNewCount + freshLogs.size
742+ },
743+ streamingError = null,
744+ ),
745+ )
450746 }
451− state.copy(
452− logs = capped,
453− streaming = state.streaming.copy(
454− streamingNewCount = state.streaming.streamingNewCount + newLogs.size,
455− streamingError = null,
456− ),
457− )
458747 }
459748 }
460749 consecutiveStreamingErrors = 0
@@ -481,19 +770,37 @@ class LogViewerViewModel @Inject constructor(
481770
482771 // --- Saved Filters (synced with Parseable server) ---
483772
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+
484779 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
485784 viewModelScope.launch {
486− _state.update { it.copy(savedFilters = it.savedFilters.copy(isLoading = true, error = null)) }
487− when (val result = repository.listFilters()) {
488− is ApiResult.Success -> {
489− val streamFilters = result.data.filter { it.streamName == _state.value.streamName }
490− _state.update {
491− it.copy(savedFilters = it.savedFilters.copy(filters = streamFilters, isLoading = false))
785+ savedFiltersMutex.withLock {
786+ _state.update { it.copy(savedFilters = it.savedFilters.copy(isLoading = true, error = null)) }
787+ when (val result = repository.listFilters()) {
788+ is ApiResult.Success -> {
789+ if (_state.value.streamName != stream) {
790+ _state.update { it.copy(savedFilters = it.savedFilters.copy(isLoading = false)) }
791+ return@withLock
792+ }
793+ val streamFilters = result.data
794+ .filter { it.streamName == stream }
795+ .sortedBy { it.filterName.lowercase() }
796+ _state.update {
797+ it.copy(savedFilters = it.savedFilters.copy(filters = streamFilters, isLoading = false))
798+ }
492799 }
493− }
494− is ApiResult.Error -> {
495− _state.update {
496− it.copy(savedFilters = it.savedFilters.copy(isLoading = false, error = result.userMessage))
800+ is ApiResult.Error -> {
801+ _state.update {
802+ it.copy(savedFilters = it.savedFilters.copy(isLoading = false, error = result.userMessage))
803+ }
497804 }
498805 }
499806 }
@@ -505,6 +812,7 @@ class LogViewerViewModel @Inject constructor(
505812 if (current.streamName.isEmpty()) return
506813
507814 viewModelScope.launch {
815+ savedFiltersMutex.withLock {
508816 _state.update { it.copy(savedFilters = it.savedFilters.copy(isSaving = true, error = null)) }
509817
510818 val query = if (current.filters.customSql.isNotBlank()) {
@@ -513,15 +821,13 @@ class LogViewerViewModel @Inject constructor(
513821 filterQuery = current.filters.customSql,
514822 )
515823 } else {
516− val ruleGroups = current.filters.activeFilters.zip(current.filters.filterClauses)
517− .mapIndexed { index, (display, _) ->
518− // Parse display back to field/operator/value
519− val parts = parseFilterDisplay(display)
824+ val ruleGroups = current.filters.filterConditions
825+ .mapIndexed { index, condition ->
520826 FilterRule(
521827 id = "rule_$index",
522− field = parts.first,
523− value = parts.third,
524− operator = parts.second,
828+ field = condition.column,
829+ value = condition.value,
830+ operator = condition.operator,
525831 )
526832 }
527833 val builder = if (ruleGroups.isNotEmpty()) {
@@ -568,6 +874,7 @@ class LogViewerViewModel @Inject constructor(
568874 }
569875 }
570876 }
877+ }
571878 }
572879 }
573880
@@ -577,9 +884,15 @@ class LogViewerViewModel @Inject constructor(
577884 when (filter.query.filterType) {
578885 "sql" -> {
579886 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.
580893 _state.update {
581894 it.copy(
582− filters = FilterState(customSql = sql),
895+ filters = FilterState(),
583896 currentLimit = 500,
584897 )
585898 }
@@ -615,7 +928,7 @@ class LogViewerViewModel @Inject constructor(
615928 for (group in builder.rules) {
616929 for (rule in group.rules) {
617930 if (rule.field.isNotBlank()) {
618− addFilter(rule.field, rule.operator, rule.value)
931+ addFilterInternal(rule.field, rule.operator, rule.value)
619932 }
620933 }
621934 }
@@ -623,32 +936,21 @@ class LogViewerViewModel @Inject constructor(
623936
624937 fun deleteSavedFilter(filterId: String) {
625938 viewModelScope.launch {
626− when (repository.deleteFilter(filterId)) {
627− is ApiResult.Success -> {
628− _state.update {
629− it.copy(
630− savedFilters = it.savedFilters.copy(
631− filters = it.savedFilters.filters.filter { f -> f.filterId != filterId },
632− ),
633− )
939+ savedFiltersMutex.withLock {
940+ when (repository.deleteFilter(filterId)) {
941+ is ApiResult.Success -> {
942+ _state.update {
943+ it.copy(
944+ savedFilters = it.savedFilters.copy(
945+ filters = it.savedFilters.filters.filter { f -> f.filterId != filterId },
946+ ),
947+ )
948+ }
634949 }
950+ is ApiResult.Error -> { /* Silently ignore — will show on next reload */ }
635951 }
636− is ApiResult.Error -> { /* Silently ignore — will show on next reload */ }
637− }
638− }
639− }
640−
641− /** Parse a display string like "column = value" back into (field, operator, value) */
642− private fun parseFilterDisplay(display: String): Triple<String, String, String> {
643− for (op in listOf("IS NOT NULL", "IS NULL", "ILIKE", "LIKE", "!=", ">=", "<=", "=", ">", "<")) {
644− val idx = display.indexOf(" $op")
645− if (idx >= 0) {
646− val field = display.substring(0, idx)
647− val value = display.substring(idx + op.length + 1).trim()
648− return Triple(field, op, value)
649952 }
650953 }
651− return Triple(display, "=", "")
652954 }
653955
654956 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 {
6565 trimmed.toIntOrNull()?.let { num ->
6666 return when {
6767 // Syslog severity (0 = emergency .. 7 = debug)
68− fieldName.equals("syslog_severity", ignoreCase = true) ||
69− fieldName.equals("priority", ignoreCase = true) -> when (num) {
68+ fieldName.equals("syslog_severity", ignoreCase = true) -> when (num) {
7069 0, 1, 2 -> LogSeverity.FATAL // emergency, alert, critical
7170 3 -> LogSeverity.ERROR
7271 4 -> LogSeverity.WARNING
@@ -75,20 +74,50 @@ private fun parseSeverityValue(raw: String, fieldName: String): LogSeverity {
7574 7 -> LogSeverity.DEBUG
7675 else -> LogSeverity.UNKNOWN
7776 }
78− // HTTP status codes
79− fieldName.equals("status", ignoreCase = true) -> when {
80− num >= 500 -> LogSeverity.ERROR
81− num >= 400 -> LogSeverity.WARNING
82− num in 200..399 -> LogSeverity.INFO
77+ // Syslog "priority" (PRI) is the encoded value facility*8 + severity (0..191), so the
78+ // severity is the low 3 bits. For a bare 0..7 this is identity; a real PRI like 134
79+ // decodes to severity 6 (info) instead of falling through to UNKNOWN.
80+ fieldName.equals("priority", ignoreCase = true) -> when {
81+ num < 0 || num > 191 -> LogSeverity.UNKNOWN
82+ else -> when (num % 8) {
83+ 0, 1, 2 -> LogSeverity.FATAL // emergency, alert, critical
84+ 3 -> LogSeverity.ERROR
85+ 4 -> LogSeverity.WARNING
86+ 5, 6 -> LogSeverity.INFO // notice, informational
87+ else -> LogSeverity.DEBUG // 7
88+ }
89+ }
90+ // HTTP status codes. Bound every branch to the valid HTTP range (100..599):
91+ // `status` is a very generic field name, so an unbounded `>= 500`/`>= 400`
92+ // would mis-color non-HTTP values (e.g. status: 50000) as ERROR/WARNING.
93+ fieldName.equals("status", ignoreCase = true) -> when (num) {
94+ in 500..599 -> LogSeverity.ERROR
95+ in 400..499 -> LogSeverity.WARNING
96+ // 1xx (informational) through 3xx are non-error HTTP statuses; 1xx previously
97+ // fell through to UNKNOWN.
98+ in 100..399 -> LogSeverity.INFO
8399 else -> LogSeverity.UNKNOWN
84100 }
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).
86109 else -> when {
87110 num >= 1000 -> LogSeverity.FATAL
88111 num >= 900 -> LogSeverity.ERROR
89112 num >= 800 -> LogSeverity.WARNING
90113 num >= 700 -> LogSeverity.INFO
91114 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
92121 num > 0 -> LogSeverity.TRACE
93122 else -> LogSeverity.UNKNOWN
94123 }
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoScreen.kt+100−86
@@ -7,6 +7,7 @@ import androidx.compose.material.icons.Icons
77import androidx.compose.material.icons.automirrored.filled.ArrowBack
88import androidx.compose.material.icons.filled.*
99import androidx.compose.material3.*
10+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
1011import androidx.compose.runtime.*
1112import androidx.compose.ui.Alignment
1213import androidx.compose.ui.Modifier
@@ -14,10 +15,7 @@ import androidx.compose.ui.text.font.FontFamily
1415import androidx.compose.ui.text.font.FontWeight
1516import androidx.compose.ui.unit.dp
1617import androidx.hilt.navigation.compose.hiltViewModel
17−import androidx.lifecycle.Lifecycle
18−import androidx.lifecycle.compose.LocalLifecycleOwner
1918import androidx.lifecycle.compose.collectAsStateWithLifecycle
20−import androidx.lifecycle.repeatOnLifecycle
2119import com.parseable.android.data.formatBytes
2220import kotlinx.serialization.json.Json
2321import kotlinx.serialization.json.JsonObject
@@ -35,11 +33,10 @@ fun StreamInfoScreen(
3533 val state by viewModel.state.collectAsStateWithLifecycle()
3634 var showDeleteConfirmation by remember { mutableStateOf(false) }
3735
38− val lifecycleOwner = LocalLifecycleOwner.current
39− LaunchedEffect(streamName, lifecycleOwner) {
40− lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
41− viewModel.load(streamName)
42− }
36+ // Load once per stream. Reloading on every RESUME would refetch all four endpoints on
37+ // each return and could re-query a stream that was just deleted (spurious error card).
38+ LaunchedEffect(streamName) {
39+ viewModel.load(streamName)
4340 }
4441
4542 LaunchedEffect(state.deleteSuccess) {
@@ -73,97 +70,114 @@ fun StreamInfoScreen(
7370 )
7471 },
7572 ) { padding ->
76− if (state.isLoading) {
77− Box(
78− modifier = Modifier
79− .fillMaxSize()
80− .padding(padding),
81− contentAlignment = Alignment.Center,
82− ) {
83− CircularProgressIndicator()
84− }
85− } else {
86− Column(
87− modifier = Modifier
88− .fillMaxSize()
89− .padding(padding)
90− .verticalScroll(rememberScrollState())
91− .padding(16.dp),
92− verticalArrangement = Arrangement.spacedBy(16.dp),
93− ) {
94− // Stats section
95− state.stats?.let { stats ->
96− InfoSection(title = "Statistics") {
97− InfoRow("Event Count", stats.ingestion?.count?.toString() ?: stats.ingestion?.lifetimeCount?.toString() ?: "N/A")
98− InfoRow("Ingestion Size", formatBytes(stats.ingestion?.size) ?: formatBytes(stats.ingestion?.lifetimeSize) ?: "N/A")
99− InfoRow("Storage Size", formatBytes(stats.storage?.size) ?: formatBytes(stats.storage?.lifetimeSize) ?: "N/A")
100− if (stats.ingestion?.lifetimeCount != null) {
101− InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
102− }
103− if (stats.ingestion?.deletedCount != null) {
104− InfoRow("Deleted Events", stats.ingestion.deletedCount.toString())
73+ // Has any section loaded yet? Drives whether a load shows the centered first-load
74+ // spinner or keeps existing content visible (so pull-to-refresh stays usable on retry).
75+ val hasContent = state.stats != null || state.schema.isNotEmpty() ||
76+ state.rawInfo != null || state.retention.isNotEmpty() || state.schemaFailed
77+ PullToRefreshBox(
78+ // Only show the pull indicator when refreshing over existing content; the very
79+ // first load uses the centered spinner below, so don't show both at once.
80+ isRefreshing = state.isLoading && hasContent,
81+ onRefresh = viewModel::refresh,
82+ modifier = Modifier
83+ .fillMaxSize()
84+ .padding(padding),
85+ ) {
86+ if (state.isLoading && !hasContent) {
87+ Box(
88+ modifier = Modifier.fillMaxSize(),
89+ contentAlignment = Alignment.Center,
90+ ) {
91+ CircularProgressIndicator()
92+ }
93+ } else {
94+ Column(
95+ modifier = Modifier
96+ .fillMaxSize()
97+ .verticalScroll(rememberScrollState())
98+ .padding(16.dp),
99+ verticalArrangement = Arrangement.spacedBy(16.dp),
100+ ) {
101+ // Stats section
102+ state.stats?.let { stats ->
103+ InfoSection(title = "Statistics") {
104+ // Show the current-window count when available. Fall back to "N/A"
105+ // only when there's no lifetime count either — otherwise the lifetime
106+ // figure is shown in its own row below, so don't duplicate it here.
107+ when {
108+ stats.ingestion?.count != null -> InfoRow("Event Count", stats.ingestion.count.toString())
109+ stats.ingestion?.lifetimeCount == null -> InfoRow("Event Count", "N/A")
110+ }
111+ InfoRow("Ingestion Size", formatBytes(stats.ingestion?.size) ?: formatBytes(stats.ingestion?.lifetimeSize) ?: "N/A")
112+ InfoRow("Storage Size", formatBytes(stats.storage?.size) ?: formatBytes(stats.storage?.lifetimeSize) ?: "N/A")
113+ if (stats.ingestion?.lifetimeCount != null) {
114+ InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
115+ }
116+ if (stats.ingestion?.deletedCount != null) {
117+ InfoRow("Deleted Events", stats.ingestion.deletedCount.toString())
118+ }
105119 }
106120 }
107− }
108121
109− // Schema section
110− if (state.schema.isNotEmpty()) {
111− InfoSection(title = "Schema (${state.schema.size} fields)") {
112− state.schema.forEach { field ->
113− InfoRow(
114− field.name,
115− field.dataType?.toString()?.trim('"') ?: "Unknown",
122+ // Schema section
123+ if (state.schema.isNotEmpty()) {
124+ InfoSection(title = "Schema (${state.schema.size} fields)") {
125+ state.schema.forEach { field ->
126+ InfoRow(
127+ field.name,
128+ field.dataType?.toString()?.trim('"') ?: "Unknown",
129+ )
130+ }
131+ }
132+ } else if (state.schemaFailed) {
133+ InfoSection(title = "Schema") {
134+ Text(
135+ text = "Schema unavailable",
136+ style = MaterialTheme.typography.bodyMedium,
137+ color = MaterialTheme.colorScheme.onSurfaceVariant,
116138 )
117139 }
118140 }
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− }
128141
129− // Retention section
130− if (state.retention.isNotEmpty()) {
131− InfoSection(title = "Retention") {
132− state.retention.forEach { r ->
133− InfoRow("Duration", r.duration ?: "N/A")
134− InfoRow("Action", r.action ?: "N/A")
135− if (r.description != null) {
136− InfoRow("Description", r.description)
142+ // Retention section
143+ if (state.retention.isNotEmpty()) {
144+ InfoSection(title = "Retention") {
145+ state.retention.forEach { r ->
146+ InfoRow("Duration", r.duration ?: "N/A")
147+ InfoRow("Action", r.action ?: "N/A")
148+ if (r.description != null) {
149+ InfoRow("Description", r.description)
150+ }
137151 }
138152 }
139153 }
140− }
141154
142− // Raw info section
143− state.rawInfo?.let { rawInfo ->
144− val prettyInfo = remember(rawInfo) {
145− prettyJson.encodeToString(JsonObject.serializer(), rawInfo)
146− }
147− InfoSection(title = "Stream Info") {
148− Text(
149− text = prettyInfo,
150− style = MaterialTheme.typography.bodySmall,
151− fontFamily = FontFamily.Monospace,
152− )
155+ // Raw info section
156+ state.rawInfo?.let { rawInfo ->
157+ val prettyInfo = remember(rawInfo) {
158+ prettyJson.encodeToString(JsonObject.serializer(), rawInfo)
159+ }
160+ InfoSection(title = "Stream Info") {
161+ Text(
162+ text = prettyInfo,
163+ style = MaterialTheme.typography.bodySmall,
164+ fontFamily = FontFamily.Monospace,
165+ )
166+ }
153167 }
154− }
155168
156− state.error?.let { errorText ->
157− Card(
158− colors = CardDefaults.cardColors(
159− containerColor = MaterialTheme.colorScheme.errorContainer,
160− ),
161− ) {
162− Text(
163− text = errorText,
164− modifier = Modifier.padding(16.dp),
165− color = MaterialTheme.colorScheme.onErrorContainer,
166− )
169+ state.error?.let { errorText ->
170+ Card(
171+ colors = CardDefaults.cardColors(
172+ containerColor = MaterialTheme.colorScheme.errorContainer,
173+ ),
174+ ) {
175+ Text(
176+ text = errorText,
177+ modifier = Modifier.padding(16.dp),
178+ color = MaterialTheme.colorScheme.onErrorContainer,
179+ )
180+ }
167181 }
168182 }
169183 }
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoViewModel.kt+59−10
@@ -8,6 +8,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
88import kotlinx.coroutines.CancellationException
99import kotlinx.coroutines.Job
1010import kotlinx.coroutines.async
11+import kotlinx.coroutines.coroutineScope
1112import kotlinx.coroutines.flow.MutableStateFlow
1213import kotlinx.coroutines.flow.StateFlow
1314import kotlinx.coroutines.flow.asStateFlow
@@ -38,21 +39,61 @@ class StreamInfoViewModel @Inject constructor(
3839 val state: StateFlow<StreamInfoState> = _state.asStateFlow()
3940 private var loadJob: Job? = null
4041
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+
4150 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) {
4270 loadJob?.cancel()
4371 _state.update { it.copy(streamName = streamName, isLoading = true, error = null) }
4472
4573 loadJob = viewModelScope.launch {
4674 try {
47− val statsDeferred = async { repository.getStreamStats(streamName) }
48− val schemaDeferred = async { repository.getStreamSchema(streamName) }
49− val retentionDeferred = async { repository.getStreamRetention(streamName) }
50− val infoDeferred = async { repository.getStreamInfo(streamName) }
75+ // Wrap the parallel fetches in coroutineScope so the four async children
76+ // form their own structured-concurrency scope (matching StreamsViewModel /
77+ // SettingsViewModel). The repository returns ApiResult.Error rather than
78+ // throwing, but if any call did throw unexpectedly this keeps the failure
79+ // contained to this load instead of it propagating oddly through the launch.
80+ val results = coroutineScope {
81+ val statsDeferred = async { repository.getStreamStats(streamName) }
82+ val schemaDeferred = async { repository.getStreamSchema(streamName) }
83+ val retentionDeferred = async { repository.getStreamRetention(streamName) }
84+ val infoDeferred = async { repository.getStreamInfo(streamName) }
5185
52− val statsResult = statsDeferred.await()
53− val schemaResult = schemaDeferred.await()
54− val retentionResult = retentionDeferred.await()
55− val infoResult = infoDeferred.await()
86+ LoadResults(
87+ stats = statsDeferred.await(),
88+ schema = schemaDeferred.await(),
89+ retention = retentionDeferred.await(),
90+ info = infoDeferred.await(),
91+ )
92+ }
93+ val statsResult = results.stats
94+ val schemaResult = results.schema
95+ val retentionResult = results.retention
96+ val infoResult = results.info
5697
5798 _state.update {
5899 it.copy(
@@ -86,9 +127,17 @@ class StreamInfoViewModel @Inject constructor(
86127
87128 fun deleteStream() {
88129 val name = _state.value.streamName
89− if (name.isEmpty()) return
130+ // Guard against a no-op or a second tap firing a duplicate DELETE while one is in flight.
131+ if (name.isEmpty() || _state.value.isDeleting) return
132+ // Cancel any in-flight load so its terminal update can't re-populate stats/schema for the
133+ // stream we're about to delete after the DELETE completes.
134+ loadJob?.cancel()
90135 viewModelScope.launch {
91− _state.update { it.copy(isDeleting = true, error = null) }
136+ // Clear isLoading here too: cancelling loadJob above kills any in-flight fetch before
137+ // its terminal update can flip isLoading back to false, so without this a load that was
138+ // running when delete started would leave the pull-to-refresh spinner stuck on the
139+ // error path (delete only manages isDeleting).
140+ _state.update { it.copy(isDeleting = true, isLoading = false, error = null) }
92141 when (val result = repository.deleteStream(name)) {
93142 is ApiResult.Success -> {
94143 _state.update { it.copy(isDeleting = false, deleteSuccess = true) }
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsScreen.kt+193−2
@@ -12,10 +12,12 @@ import androidx.compose.runtime.*
1212import androidx.compose.ui.Alignment
1313import androidx.compose.ui.Modifier
1414import androidx.compose.ui.text.font.FontWeight
15+import androidx.compose.ui.text.style.TextOverflow
1516import androidx.compose.ui.unit.dp
1617import androidx.hilt.navigation.compose.hiltViewModel
1718import androidx.lifecycle.compose.collectAsStateWithLifecycle
1819import com.parseable.android.BuildConfig
20+import com.parseable.android.data.local.SavedServer
1921import kotlinx.serialization.json.contentOrNull
2022import kotlinx.serialization.json.jsonPrimitive
2123
@@ -23,10 +25,25 @@ import kotlinx.serialization.json.jsonPrimitive
2325@Composable
2426fun SettingsScreen(
2527 onBack: () -> Unit,
28+ onAboutClick: () -> Unit,
29+ onServerSwitched: () -> Unit,
30+ onLoggedOut: () -> Unit,
2631 viewModel: SettingsViewModel = hiltViewModel(),
2732) {
2833 val state by viewModel.state.collectAsStateWithLifecycle()
2934
35+ LaunchedEffect(Unit) {
36+ viewModel.switchEvent.collect {
37+ onServerSwitched()
38+ }
39+ }
40+
41+ LaunchedEffect(Unit) {
42+ viewModel.loggedOutEvent.collect {
43+ onLoggedOut()
44+ }
45+ }
46+
3047 Scaffold(
3148 topBar = {
3249 TopAppBar(
@@ -79,8 +96,21 @@ fun SettingsScreen(
7996 }
8097 }
8198
82− // Loading indicator for server data
83− if (state.isLoading) {
99+ // Saved servers
100+ if (state.savedServers.size > 1) {
101+ SavedServersCard(
102+ servers = state.savedServers,
103+ activeServerId = state.activeServerId,
104+ isSwitching = state.isSwitching,
105+ onSwitch = viewModel::switchToServer,
106+ onDelete = viewModel::deleteServer,
107+ )
108+ }
109+
110+ // Loading indicator for server data — only on the initial load. During a
111+ // pull-to-refresh the PullToRefreshBox already shows its own spinner and
112+ // aboutInfo is still populated, so gating on isLoading alone double-spins.
113+ if (state.isLoading && state.aboutInfo == null) {
84114 Box(
85115 modifier = Modifier
86116 .fillMaxWidth()
@@ -218,6 +248,167 @@ fun SettingsScreen(
218248 SettingsRow("Platform", "Android")
219249 }
220250 }
251+
252+ // About
253+ Card(
254+ onClick = onAboutClick,
255+ modifier = Modifier.fillMaxWidth(),
256+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
257+ ) {
258+ Row(
259+ modifier = Modifier
260+ .fillMaxWidth()
261+ .padding(16.dp),
262+ verticalAlignment = Alignment.CenterVertically,
263+ ) {
264+ Icon(
265+ Icons.Filled.Info,
266+ contentDescription = "About",
267+ tint = MaterialTheme.colorScheme.primary,
268+ )
269+ Spacer(modifier = Modifier.width(8.dp))
270+ Text(
271+ text = "About",
272+ style = MaterialTheme.typography.titleMedium,
273+ fontWeight = FontWeight.Bold,
274+ modifier = Modifier.weight(1f),
275+ )
276+ Icon(
277+ Icons.Filled.ChevronRight,
278+ contentDescription = null,
279+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
280+ )
281+ }
282+ }
283+ }
284+ }
285+ }
286+}
287+
288+@Composable
289+private fun SavedServersCard(
290+ servers: List<SavedServer>,
291+ activeServerId: Long?,
292+ isSwitching: Boolean,
293+ onSwitch: (Long) -> Unit,
294+ onDelete: (Long) -> Unit,
295+) {
296+ var serverToDelete by remember { mutableStateOf<SavedServer?>(null) }
297+
298+ Card(
299+ modifier = Modifier.fillMaxWidth(),
300+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
301+ ) {
302+ Column(modifier = Modifier.padding(16.dp)) {
303+ Row(verticalAlignment = Alignment.CenterVertically) {
304+ Icon(
305+ Icons.Filled.SwapHoriz,
306+ contentDescription = "Saved servers",
307+ tint = MaterialTheme.colorScheme.primary,
308+ )
309+ Spacer(modifier = Modifier.width(8.dp))
310+ Text(
311+ text = "Saved Servers (${servers.size})",
312+ style = MaterialTheme.typography.titleMedium,
313+ fontWeight = FontWeight.Bold,
314+ )
315+ if (isSwitching) {
316+ Spacer(modifier = Modifier.width(8.dp))
317+ CircularProgressIndicator(
318+ modifier = Modifier.size(16.dp),
319+ strokeWidth = 2.dp,
320+ )
321+ }
322+ }
323+ Spacer(modifier = Modifier.height(12.dp))
324+ servers.forEach { server ->
325+ val isActive = server.id == activeServerId
326+ SavedServerRow(
327+ server = server,
328+ isActive = isActive,
329+ enabled = !isSwitching,
330+ onSwitch = { onSwitch(server.id) },
331+ onDelete = { serverToDelete = server },
332+ )
333+ }
334+ }
335+ }
336+
337+ serverToDelete?.let { server ->
338+ AlertDialog(
339+ onDismissRequest = { serverToDelete = null },
340+ title = { Text("Remove Server") },
341+ text = {
342+ Text("Remove ${server.username}@${server.serverUrl} from saved servers?")
343+ },
344+ confirmButton = {
345+ Button(
346+ onClick = {
347+ onDelete(server.id)
348+ serverToDelete = null
349+ },
350+ colors = ButtonDefaults.buttonColors(
351+ containerColor = MaterialTheme.colorScheme.error,
352+ ),
353+ ) {
354+ Text("Remove")
355+ }
356+ },
357+ dismissButton = {
358+ TextButton(onClick = { serverToDelete = null }) {
359+ Text("Cancel")
360+ }
361+ },
362+ )
363+ }
364+}
365+
366+@Composable
367+private fun SavedServerRow(
368+ server: SavedServer,
369+ isActive: Boolean,
370+ enabled: Boolean,
371+ onSwitch: () -> Unit,
372+ onDelete: () -> Unit,
373+) {
374+ Row(
375+ modifier = Modifier
376+ .fillMaxWidth()
377+ .padding(vertical = 4.dp),
378+ verticalAlignment = Alignment.CenterVertically,
379+ ) {
380+ Icon(
381+ if (isActive) Icons.Filled.RadioButtonChecked else Icons.Filled.RadioButtonUnchecked,
382+ contentDescription = if (isActive) "Active" else "Inactive",
383+ tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
384+ modifier = Modifier.size(20.dp),
385+ )
386+ Spacer(modifier = Modifier.width(8.dp))
387+ Column(modifier = Modifier.weight(1f)) {
388+ Text(
389+ text = server.serverUrl,
390+ style = MaterialTheme.typography.bodyMedium,
391+ fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
392+ maxLines = 1,
393+ overflow = TextOverflow.Ellipsis,
394+ )
395+ Text(
396+ text = server.username,
397+ style = MaterialTheme.typography.bodySmall,
398+ color = MaterialTheme.colorScheme.onSurfaceVariant,
399+ )
400+ }
401+ if (!isActive) {
402+ TextButton(onClick = onSwitch, enabled = enabled) {
403+ Text("Switch")
404+ }
405+ IconButton(onClick = onDelete, enabled = enabled) {
406+ Icon(
407+ Icons.Filled.Delete,
408+ contentDescription = "Remove server",
409+ tint = MaterialTheme.colorScheme.error,
410+ modifier = Modifier.size(20.dp),
411+ )
221412 }
222413 }
223414 }
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsViewModel.kt+126−8
@@ -2,18 +2,22 @@ package com.parseable.android.ui.screens.settings
22
33import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
5+import com.parseable.android.data.local.SavedServer
56import com.parseable.android.data.model.AboutInfo
67import com.parseable.android.data.model.ApiResult
78import com.parseable.android.data.repository.ParseableRepository
89import com.parseable.android.data.repository.SettingsRepository
910import dagger.hilt.android.lifecycle.HiltViewModel
1011import kotlinx.coroutines.CancellationException
12+import kotlinx.coroutines.Job
1113import kotlinx.coroutines.async
14+import kotlinx.coroutines.channels.Channel
1215import kotlinx.coroutines.coroutineScope
1316import kotlinx.coroutines.flow.MutableStateFlow
1417import kotlinx.coroutines.flow.StateFlow
1518import kotlinx.coroutines.flow.asStateFlow
1619import kotlinx.coroutines.flow.first
20+import kotlinx.coroutines.flow.receiveAsFlow
1721import kotlinx.coroutines.flow.update
1822import kotlinx.coroutines.launch
1923import kotlinx.serialization.json.jsonPrimitive
@@ -27,6 +31,10 @@ data class SettingsState(
2731 val users: List<String> = emptyList(),
2832 val isLoading: Boolean = false,
2933 val error: String? = null,
34+ val savedServers: List<SavedServer> = emptyList(),
35+ val activeServerId: Long? = null,
36+ val isSwitching: Boolean = false,
37+ val isDeleting: Boolean = false,
3038)
3139
3240@HiltViewModel
@@ -38,12 +46,44 @@ class SettingsViewModel @Inject constructor(
3846 private val _state = MutableStateFlow(SettingsState())
3947 val state: StateFlow<SettingsState> = _state.asStateFlow()
4048
49+ /**
50+ * Emits the server ID after a successful switch so the screen can navigate. A [Channel]
51+ * (matching the codebase's other one-shot events) so the navigation fires exactly once and
52+ * is not re-delivered on rotation — a StateFlow retains its last value and would re-navigate
53+ * when the screen re-collects after a config change.
54+ */
55+ private val _switchEvent = Channel<Long>(Channel.BUFFERED)
56+ val switchEvent = _switchEvent.receiveAsFlow()
57+
58+ /**
59+ * Emitted when the currently-active server is deleted. The active connection has been torn
60+ * down, so there is no valid session left — the screen must navigate back to login rather
61+ * than leave the user on a Settings screen pointed at a deleted server. A one-shot [Channel]
62+ * for the same reason as [switchEvent].
63+ */
64+ private val _loggedOutEvent = Channel<Unit>(Channel.BUFFERED)
65+ val loggedOutEvent = _loggedOutEvent.receiveAsFlow()
66+
67+ private var loadJob: Job? = null
68+
4169 init {
4270 load()
71+ viewModelScope.launch {
72+ settingsRepository.savedServers.collect { servers ->
73+ _state.update { it.copy(savedServers = servers) }
74+ }
75+ }
76+ viewModelScope.launch {
77+ settingsRepository.activeServerId.collect { id ->
78+ _state.update { it.copy(activeServerId = id) }
79+ }
80+ }
4381 }
4482
4583 fun load() {
46− viewModelScope.launch {
84+ // Cancel any in-flight load so a slower completion can't overwrite a newer one.
85+ loadJob?.cancel()
86+ loadJob = viewModelScope.launch {
4787 _state.update { it.copy(isLoading = true, error = null) }
4888
4989 val config = settingsRepository.serverConfig.first()
@@ -66,24 +106,24 @@ class SettingsViewModel @Inject constructor(
66106
67107 val userNames = (usersResult as? ApiResult.Success)?.data?.mapNotNull { obj ->
68108 try {
69− obj["id"]?.jsonPrimitive?.content
70− ?: obj["username"]?.jsonPrimitive?.content
109+ obj["username"]?.jsonPrimitive?.content
110+ ?: obj["id"]?.jsonPrimitive?.content
71111 } catch (_: IllegalStateException) {
72112 null
73113 }
74114 } ?: emptyList()
75115
76− val errors = listOfNotNull(
77− (aboutResult as? ApiResult.Error)?.userMessage,
78− (usersResult as? ApiResult.Error)?.userMessage,
79− ).distinct().joinToString("\n").ifEmpty { null }
116+ // Only treat a failure of the primary server-info call (/about) as a blocking
117+ // error. The user list (/user) is secondary and is commonly forbidden for
118+ // restricted accounts — failing it shouldn't hide otherwise-valid server info.
119+ val error = (aboutResult as? ApiResult.Error)?.userMessage
80120
81121 _state.update {
82122 it.copy(
83123 aboutInfo = (aboutResult as? ApiResult.Success)?.data,
84124 users = userNames,
85125 isLoading = false,
86− error = errors,
126+ error = error,
87127 )
88128 }
89129 } catch (e: CancellationException) {
@@ -96,4 +136,82 @@ class SettingsViewModel @Inject constructor(
96136 }
97137 }
98138 }
139+
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
145+ viewModelScope.launch {
146+ _state.update { it.copy(isSwitching = true, error = null) }
147+ try {
148+ val config = settingsRepository.switchToServer(serverId)
149+ if (config != null) {
150+ repository.configure(config)
151+ _switchEvent.send(serverId)
152+ } else {
153+ // Surface the failure instead of silently dropping the spinner — a null
154+ // result means the saved credentials couldn't be loaded for this server.
155+ _state.update {
156+ it.copy(
157+ error = "Couldn't switch servers — the saved credentials for this server are missing or couldn't be read.",
158+ )
159+ }
160+ }
161+ } catch (e: CancellationException) {
162+ throw e
163+ } catch (e: Exception) {
164+ _state.update {
165+ it.copy(error = e.message ?: "Couldn't switch servers.")
166+ }
167+ } finally {
168+ // Always clear the flag. Without finally, an exception or cancellation would
169+ // leave isSwitching = true, and the early-return guard above would then make
170+ // server-switching permanently impossible for this ViewModel instance.
171+ _state.update { it.copy(isSwitching = false) }
172+ }
173+ }
174+ }
175+
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
184+ viewModelScope.launch {
185+ _state.update { it.copy(isDeleting = true) }
186+ try {
187+ val wasActive = settingsRepository.deleteServer(serverId)
188+ if (wasActive) {
189+ // The active connection was torn down, so the Server Connection / Server Info
190+ // cards are now showing a deleted server. Clear them for the transition frame,
191+ // then navigate back to login — there is no valid session to keep using and the
192+ // in-memory api client still points at the deleted server, so any fetch from
193+ // here would hit it.
194+ _state.update {
195+ it.copy(
196+ serverUrl = "",
197+ username = "",
198+ aboutInfo = null,
199+ users = emptyList(),
200+ )
201+ }
202+ _loggedOutEvent.send(Unit)
203+ }
204+ } catch (e: CancellationException) {
205+ throw e
206+ } catch (e: Exception) {
207+ // Surface the failure instead of silently swallowing it, so the user knows the
208+ // server wasn't removed and can retry.
209+ _state.update { it.copy(error = e.message ?: "Couldn't remove server.") }
210+ } finally {
211+ // Always clear the flag, even on exception/cancellation, or the guard above would
212+ // make deletion permanently impossible for this ViewModel instance.
213+ _state.update { it.copy(isDeleting = false) }
214+ }
215+ }
216+ }
99217}
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsScreen.kt+20−15
@@ -10,15 +10,13 @@ import androidx.compose.material.icons.filled.*
1010import androidx.compose.material3.*
1111import androidx.compose.material3.pulltorefresh.PullToRefreshBox
1212import androidx.compose.runtime.*
13+import androidx.compose.runtime.saveable.rememberSaveable
1314import androidx.compose.ui.Alignment
1415import androidx.compose.ui.Modifier
15−import androidx.lifecycle.compose.LocalLifecycleOwner
1616import androidx.compose.ui.text.font.FontWeight
1717import androidx.compose.ui.unit.dp
1818import androidx.hilt.navigation.compose.hiltViewModel
19−import androidx.lifecycle.Lifecycle
2019import androidx.lifecycle.compose.collectAsStateWithLifecycle
21−import androidx.lifecycle.repeatOnLifecycle
2220import kotlinx.coroutines.flow.collectLatest
2321import kotlinx.coroutines.launch
2422
@@ -34,16 +32,21 @@ fun StreamsScreen(
3432 val state by viewModel.state.collectAsStateWithLifecycle()
3533 val scope = rememberCoroutineScope()
3634 var showLogoutConfirmation by remember { mutableStateOf(false) }
37− var searchQuery by remember { mutableStateOf("") }
35+ var searchQuery by rememberSaveable { mutableStateOf("") }
3836 val snackbarHostState = remember { SnackbarHostState() }
3937
4038 val listState = rememberLazyListState()
4139
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) }
4345 LaunchedEffect(state.isLoading) {
44− if (!state.isLoading) {
46+ if (wasLoading && !state.isLoading) {
4547 listState.scrollToItem(0)
4648 }
49+ wasLoading = state.isLoading
4750 }
4851
4952 val filteredStreams = remember(state.streams, searchQuery, state.favoriteNames) {
@@ -55,14 +58,11 @@ fun StreamsScreen(
5558 }
5659 // Partition by favorites instead of full sort for O(n) vs O(n log n)
5760 val (favs, rest) = filtered.partition { it.name in favorites }
58− favs + rest
59− }
60−
61− val lifecycleOwner = LocalLifecycleOwner.current
62− LaunchedEffect(lifecycleOwner) {
63− lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
64− viewModel.refresh()
65− }
61+ // `favorites` iterates most-recently-favorited first (DAO ORDER BY addedAt DESC
62+ // preserved through LinkedHashSet); order favs to match so recency is reflected.
63+ val favRank = favorites.withIndex().associate { (i, name) -> name to i }
64+ val sortedFavs = favs.sortedBy { favRank[it.name] ?: Int.MAX_VALUE }
65+ sortedFavs + rest
6666 }
6767
6868 LaunchedEffect(Unit) {
@@ -157,7 +157,12 @@ fun StreamsScreen(
157157 }
158158 } else {
159159 Column {
160− if (state.streams.size > 5) {
160+ // Keep the search field visible whenever a query is active, even if the
161+ // total stream count has since dropped to <=5 (e.g. after a refresh or a
162+ // server-side deletion). Otherwise the field would vanish while the stale
163+ // query keeps filtering `filteredStreams`, stranding the user with no way
164+ // to clear it.
165+ if (state.streams.size > 5 || searchQuery.isNotEmpty()) {
161166 OutlinedTextField(
162167 value = searchQuery,
163168 onValueChange = { searchQuery = it },
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsViewModel.kt+53−11
@@ -22,7 +22,9 @@ import kotlinx.coroutines.flow.asStateFlow
2222import kotlinx.coroutines.flow.receiveAsFlow
2323import kotlinx.coroutines.flow.update
2424import kotlinx.coroutines.launch
25+import kotlinx.coroutines.sync.Mutex
2526import kotlinx.coroutines.sync.Semaphore
27+import kotlinx.coroutines.sync.withLock
2628import kotlinx.coroutines.sync.withPermit
2729import javax.inject.Inject
2830
@@ -61,22 +63,40 @@ class StreamsViewModel @Inject constructor(
6163 _state.update { it.copy(favoriteNames = names.toSet()) }
6264 }
6365 }
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()
6469 }
6570
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+
6676 fun toggleFavorite(streamName: String) {
6777 viewModelScope.launch {
68− if (streamName in _state.value.favoriteNames) {
69− favoriteDao.deleteByName(streamName)
70− _snackbarEvent.send("Removed from favorites")
71− } else {
72− favoriteDao.insert(FavoriteStream(streamName = streamName))
73− _snackbarEvent.send("Added to favorites")
78+ favoriteMutex.withLock {
79+ // Decide from the committed DB state, not the async-mirrored favoriteNames.
80+ if (favoriteDao.isFavoriteNow(streamName)) {
81+ favoriteDao.deleteByName(streamName)
82+ _snackbarEvent.send("Removed from favorites")
83+ } else {
84+ favoriteDao.insert(FavoriteStream(streamName = streamName))
85+ _snackbarEvent.send("Added to favorites")
86+ }
7487 }
7588 }
7689 }
7790
91+ private var refreshJob: Job? = null
92+
7893 fun refresh() {
79− viewModelScope.launch {
94+ // Cancel any in-flight refresh so out-of-order completions can't overwrite a newer
95+ // result with a stale one (the init load and a pull-to-refresh, or two quick pulls,
96+ // can otherwise race and leave the older stream list showing). Mirrors the refreshJob
97+ // guard in LogViewerViewModel.
98+ refreshJob?.cancel()
99+ refreshJob = viewModelScope.launch {
80100 _state.update { it.copy(isLoading = true, error = null) }
81101
82102 try {
@@ -93,9 +113,14 @@ class StreamsViewModel @Inject constructor(
93113
94114 when (streamsResult) {
95115 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 }
96119 _state.update {
97120 it.copy(
98− streams = streamsResult.data,
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 },
99124 isLoading = false,
100125 )
101126 }
@@ -124,9 +149,14 @@ class StreamsViewModel @Inject constructor(
124149
125150 private val statsSemaphore = Semaphore(8)
126151 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>()
127155
128156 private fun loadStreamStats(streams: List<LogStream>) {
129157 statsJob?.cancel()
158+ retryJobs.values.forEach { it.cancel() }
159+ retryJobs.clear()
130160 statsJob = viewModelScope.launch {
131161 streams.forEach { stream ->
132162 launch {
@@ -139,12 +169,20 @@ class StreamsViewModel @Inject constructor(
139169 }
140170
141171 fun retryStats(streamName: String) {
142− viewModelScope.launch {
172+ retryJobs[streamName]?.cancel()
173+ val job = viewModelScope.launch {
143174 _state.update { it.copy(failedStats = it.failedStats - streamName) }
144175 statsSemaphore.withPermit {
145176 loadSingleStreamStats(streamName)
146177 }
147178 }
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+ }
148186 }
149187
150188 private suspend fun loadSingleStreamStats(streamName: String) {
@@ -160,7 +198,10 @@ class StreamsViewModel @Inject constructor(
160198 ?: formatBytes(stats.storage?.lifetimeSize),
161199 )
162200 _state.update {
163− it.copy(
201+ // Drop late results for streams a concurrent refresh has already pruned,
202+ // so an in-flight retry can't resurrect stats for a deleted stream.
203+ if (it.streams.none { s -> s.name == streamName }) it
204+ else it.copy(
164205 streamStats = it.streamStats + (streamName to ui),
165206 failedStats = it.failedStats - streamName,
166207 )
@@ -168,7 +209,8 @@ class StreamsViewModel @Inject constructor(
168209 }
169210 is ApiResult.Error -> {
170211 _state.update {
171− it.copy(failedStats = it.failedStats + streamName)
212+ if (it.streams.none { s -> s.name == streamName }) it
213+ else it.copy(failedStats = it.failedStats + streamName)
172214 }
173215 }
174216 }
app/src/main/res/drawable/ic_launcher_background.xmldeleted−10
@@ -1,10 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<vector xmlns:android="http://schemas.android.com/apk/res/android"
3− android:width="108dp"
4− android:height="108dp"
5− android:viewportWidth="108"
6− android:viewportHeight="108">
7− <path
8− android:fillColor="#545BEB"
9− android:pathData="M0,0h108v108h-108z" />
10−</vector>
app/src/main/res/drawable/ic_launcher_foreground.xmldeleted−15
@@ -1,15 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<vector xmlns:android="http://schemas.android.com/apk/res/android"
3− android:width="108dp"
4− android:height="108dp"
5− android:viewportWidth="108"
6− android:viewportHeight="108">
7− <!-- Background circle -->
8− <path
9− android:fillColor="#545BEB"
10− android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0" />
11− <!-- Letter P -->
12− <path
13− android:fillColor="#FFFFFF"
14− android:pathData="M40,35h12c6.6,0 12,5.4 12,12s-5.4,12 -12,12h-6v14h-6V35zM46,53h6c3.3,0 6,-2.7 6,-6s-2.7,-6 -6,-6h-6V53z" />
15−</vector>
app/src/main/res/drawable/ic_launcher_monochrome.xmldeleted−11
@@ -1,11 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<vector xmlns:android="http://schemas.android.com/apk/res/android"
3− android:width="108dp"
4− android:height="108dp"
5− android:viewportWidth="108"
6− android:viewportHeight="108">
7− <!-- Letter P -->
8− <path
9− android:fillColor="#000000"
10− android:pathData="M40,35h12c6.6,0 12,5.4 12,12s-5.4,12 -12,12h-6v14h-6V35zM46,53h6c3.3,0 6,-2.7 6,-6s-2.7,-6 -6,-6h-6V53z" />
11−</vector>
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xmldeleted−6
@@ -1,6 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3− <background android:drawable="@drawable/ic_launcher_background" />
4− <foreground android:drawable="@drawable/ic_launcher_foreground" />
5− <monochrome android:drawable="@drawable/ic_launcher_monochrome" />
6−</adaptive-icon>
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xmldeleted−6
@@ -1,6 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3− <background android:drawable="@drawable/ic_launcher_background" />
4− <foreground android:drawable="@drawable/ic_launcher_foreground" />
5− <monochrome android:drawable="@drawable/ic_launcher_monochrome" />
6−</adaptive-icon>
app/src/main/res/mipmap-hdpi/ic_launcher.webpnew file

Binary file not shown.

app/src/main/res/mipmap-mdpi/ic_launcher.webpnew file

Binary file not shown.

app/src/main/res/mipmap-xhdpi/ic_launcher.webpnew file

Binary file not shown.

app/src/main/res/mipmap-xxhdpi/ic_launcher.webpnew file

Binary file not shown.

app/src/main/res/mipmap-xxxhdpi/ic_launcher.webpnew file

Binary file not shown.

app/src/test/java/com/parseable/android/AlertsViewModelTest.kt+4
@@ -24,6 +24,10 @@ class AlertsViewModelTest {
2424 fun setup() {
2525 Dispatchers.setMain(testDispatcher)
2626 repository = mockk(relaxed = true)
27+ // The ViewModel loads once in init { refresh() }. Give that load a deterministic
28+ // empty-success default so the initial state is clean; tests that exercise refresh
29+ // override this stub and call refresh() again.
30+ coEvery { repository.listAlerts() } returns ApiResult.Success(emptyList())
2731 viewModel = AlertsViewModel(repository)
2832 }
2933
app/src/test/java/com/parseable/android/EscapeSqlTest.kt+27
@@ -1,6 +1,7 @@
11package com.parseable.android
22
33import com.parseable.android.data.escapeIdentifier
4+import com.parseable.android.data.escapeLikePattern
45import com.parseable.android.data.escapeSql
56import org.junit.Assert.assertEquals
67import org.junit.Test
@@ -52,6 +53,32 @@ class EscapeSqlTest {
5253 assertEquals("''; DROP TABLE users --", escapeSql("'; DROP TABLE users --"))
5354 }
5455
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+
5582 @Test
5683 fun `escapeIdentifier doubles double quotes`() {
5784 assertEquals("foo\"\"bar", escapeIdentifier("foo\"bar"))
app/src/test/java/com/parseable/android/FormatTimestampTest.kt+14
@@ -32,6 +32,20 @@ class FormatTimestampTest {
3232 }
3333 }
3434
35+ @Test
36+ fun `treats offset-less timestamp as UTC and converts to local`() {
37+ val original = TimeZone.getDefault()
38+ try {
39+ TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"))
40+ // No offset/zone: should be interpreted as UTC (Parseable stores UTC),
41+ // then converted to local (June = EDT = UTC-4).
42+ val result = formatTimestamp("2024-06-15T18:30:45.000")
43+ assertEquals("Jun 15 14:30:45.000", result)
44+ } finally {
45+ TimeZone.setDefault(original)
46+ }
47+ }
48+
3549 @Test
3650 fun `returns raw string for unparseable timestamp`() {
3751 val result = formatTimestamp("not-a-timestamp")
app/src/test/java/com/parseable/android/LogViewerViewModelTest.kt+57−3
@@ -164,9 +164,9 @@ class LogViewerViewModelTest {
164164 startTime = any(),
165165 endTime = any(),
166166 filterSql = match {
167− it.contains("\"message\" ILIKE '%error%'") &&
168− it.contains("\"level\" ILIKE '%error%'") &&
169− !it.contains("p_timestamp ILIKE") // Internal columns excluded
167+ it.contains("CAST(\"message\" AS VARCHAR) ILIKE '%error%'") &&
168+ it.contains("CAST(\"level\" AS VARCHAR) ILIKE '%error%'") &&
169+ !it.contains("p_timestamp") // Internal columns excluded
170170 },
171171 limit = any(),
172172 )
@@ -185,6 +185,24 @@ class LogViewerViewModelTest {
185185 assertEquals(500, state.currentLimit) // Reset
186186 }
187187
188+ @Test
189+ fun `pullRefresh is a no-op while streaming`() {
190+ viewModel.initialize("test")
191+ // Page out so currentLimit is no longer at its initial 500.
192+ viewModel.loadMore()
193+ assertEquals(1000, viewModel.state.value.currentLimit)
194+
195+ viewModel.toggleStreaming()
196+ assertTrue(viewModel.state.value.isStreaming)
197+
198+ // While live-tailing, pullRefresh must not run refresh() (which would reset the limit
199+ // to 500 and race the streaming poller). Streaming stays on and the limit is untouched.
200+ viewModel.pullRefresh()
201+
202+ assertTrue(viewModel.state.value.isStreaming)
203+ assertEquals(1000, viewModel.state.value.currentLimit)
204+ }
205+
188206 @Test
189207 fun `toggleStreaming starts and stops`() {
190208 viewModel.initialize("test")
@@ -256,6 +274,42 @@ class LogViewerViewModelTest {
256274 }
257275 }
258276
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+
259313 @Test
260314 fun `addFilter escapes column names`() {
261315 viewModel.initialize("test")
app/src/test/java/com/parseable/android/LoginViewModelTest.kt+28−4
@@ -9,6 +9,8 @@ import io.mockk.*
99import kotlinx.coroutines.Dispatchers
1010import kotlinx.coroutines.ExperimentalCoroutinesApi
1111import kotlinx.coroutines.flow.flowOf
12+import kotlinx.coroutines.flow.toList
13+import kotlinx.coroutines.launch
1214import kotlinx.coroutines.test.*
1315import org.junit.After
1416import org.junit.Assert.*
@@ -37,6 +39,17 @@ class LoginViewModelTest {
3739 Dispatchers.resetMain()
3840 }
3941
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+
4053 @Test
4154 fun `onLogin validates required fields`() {
4255 viewModel.onLogin()
@@ -65,6 +78,11 @@ class LoginViewModelTest {
6578 fun `onLogin succeeds with valid credentials`() = runTest {
6679 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
6780 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()
6886
6987 viewModel.onServerUrlChange("https://test.parseable.com")
7088 viewModel.onUsernameChange("admin")
@@ -72,7 +90,7 @@ class LoginViewModelTest {
7290 viewModel.onLogin()
7391
7492 val state = viewModel.state.value
75− assertTrue(state.loginSuccess)
93+ assertEquals(1, events.size)
7694 assertFalse(state.isLoading)
7795 coVerify { settingsRepository.saveServerConfig(any()) }
7896 }
@@ -81,13 +99,15 @@ class LoginViewModelTest {
8199 fun `onLogin fails when connection fails`() = runTest {
82100 coEvery { repository.testConnection() } returns ApiResult.Error("Connection refused", 0)
83101
102+ val events = collectSuccessEvents()
103+
84104 viewModel.onServerUrlChange("https://bad.server")
85105 viewModel.onUsernameChange("admin")
86106 viewModel.onPasswordChange("password")
87107 viewModel.onLogin()
88108
89109 val state = viewModel.state.value
90− assertFalse(state.loginSuccess)
110+ assertEquals(0, events.size)
91111 assertNotNull(state.error)
92112 assertTrue(state.error!!.contains("Connection failed"))
93113 }
@@ -97,13 +117,15 @@ class LoginViewModelTest {
97117 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
98118 coEvery { repository.verifyServer() } returns ApiResult.Error("not parseable", 404)
99119
120+ val events = collectSuccessEvents()
121+
100122 viewModel.onServerUrlChange("https://test.com")
101123 viewModel.onUsernameChange("admin")
102124 viewModel.onPasswordChange("password")
103125 viewModel.onLogin()
104126
105127 val state = viewModel.state.value
106− assertFalse(state.loginSuccess)
128+ assertEquals(0, events.size)
107129 assertNotNull(state.error)
108130 assertTrue(state.error!!.contains("doesn't appear to be Parseable"))
109131 }
@@ -113,13 +135,15 @@ class LoginViewModelTest {
113135 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
114136 coEvery { repository.verifyServer() } returns ApiResult.Error("Unauthorized", 401)
115137
138+ val events = collectSuccessEvents()
139+
116140 viewModel.onServerUrlChange("https://test.parseable.com")
117141 viewModel.onUsernameChange("admin")
118142 viewModel.onPasswordChange("wrongpass")
119143 viewModel.onLogin()
120144
121145 val state = viewModel.state.value
122− assertFalse(state.loginSuccess)
146+ assertEquals(0, events.size)
123147 assertNotNull(state.error)
124148 assertTrue(state.error!!.contains("Invalid credentials"))
125149 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 {
107107 }
108108
109109 @Test
110− fun `load sets error when users fail`() = runTest {
110+ fun `load ignores users failure when about succeeds`() = runTest {
111111 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"))
113113 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
114114
115115 val viewModel = SettingsViewModel(settingsRepository, repository)
116116
117+ // The user list (/user) is secondary and is commonly forbidden for restricted accounts,
118+ // so its failure must not surface a blocking error or hide otherwise-valid server info.
117119 val state = viewModel.state.value
118− assertNotNull(state.error)
120+ assertNull(state.error)
121+ assertEquals("1.2.0", state.aboutInfo?.version)
119122 assertTrue(state.users.isEmpty())
120123 }
121124
122125 @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 {
124127 coEvery { settingsRepository.serverConfig } returns flowOf(null)
125128 coEvery { repository.getAbout() } returns ApiResult.Error("Server error", 500)
126129 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
127130
128131 val viewModel = SettingsViewModel(settingsRepository, repository)
129132
133+ // Only the primary server-info call (/about) is treated as blocking; the secondary
134+ // /user failure stays silent.
130135 val state = viewModel.state.value
131136 assertNotNull(state.error)
132− // Both error messages should be present
133137 assertTrue(state.error!!.contains("Server error"))
134− assertTrue(state.error!!.contains("Permission denied"))
138+ assertFalse(state.error!!.contains("Permission denied"))
135139 }
136140
137141 @Test
app/src/test/java/com/parseable/android/SeverityDetectionTest.kt+13
@@ -128,6 +128,7 @@ class SeverityDetectionTest {
128128
129129 @Test
130130 fun `HTTP status code mapping`() {
131+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "100")))
131132 assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "200")))
132133 assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "301")))
133134 assertEquals(LogSeverity.WARNING, detectSeverity(logWith("status" to "404")))
@@ -145,6 +146,18 @@ class SeverityDetectionTest {
145146 assertEquals(LogSeverity.TRACE, detectSeverity(logWith("level" to "100")))
146147 }
147148
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+
148161 // --- Priority among fields ---
149162
150163 @Test
app/src/test/java/com/parseable/android/StreamsViewModelTest.kt+3
@@ -143,6 +143,9 @@ class StreamsViewModelTest {
143143 @Test
144144 fun `toggleFavorite removes when already favorited`() = runTest {
145145 every { favoriteDao.getAllNames() } returns flowOf(listOf("existing"))
146+ // toggleFavorite decides insert-vs-delete from the committed DB state (isFavoriteNow),
147+ // not the async-mirrored favoriteNames flow, so stub that as the source of truth.
148+ coEvery { favoriteDao.isFavoriteNow("existing") } returns true
146149
147150 viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
148151 viewModel.toggleFavorite("existing")