git.iko.soy/parseable-android
on 9596377 help sign in
56 files changed+524−2697
since splitsnapshots
unifiedsplit
.dockerignore−15 .github/workflows/build-release.yml−18 .github/workflows/ci.yml+25−47 .gitignore−2 Dockerfile−72 README.md−11 app/build.gradle.kts+3−25 app/src/main/AndroidManifest.xml+1−1 app/src/main/java/com/parseable/android/MainActivity.kt+1−21 app/src/main/java/com/parseable/android/data/FormatUtils.kt+2−19 app/src/main/java/com/parseable/android/data/SqlUtils.kt−16 app/src/main/java/com/parseable/android/data/TimestampUtils.kt+14−57 app/src/main/java/com/parseable/android/data/api/ParseableApiClient.kt+32−89 app/src/main/java/com/parseable/android/data/local/FavoriteStreamDao.kt+2−12 app/src/main/java/com/parseable/android/data/local/ParseableDatabase.kt+2−3 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+2−57 app/src/main/java/com/parseable/android/data/repository/ParseableRepository.kt+4−15 app/src/main/java/com/parseable/android/data/repository/SettingsRepository.kt+8−238 app/src/main/java/com/parseable/android/di/AppModule.kt−59 app/src/main/java/com/parseable/android/ui/navigation/NavGraph.kt+10−54 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+10−43 app/src/main/java/com/parseable/android/ui/screens/login/LoginScreen.kt+8−64 app/src/main/java/com/parseable/android/ui/screens/login/LoginViewModel.kt+11−88 app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.kt+71−213 app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerViewModel.kt+113−415 app/src/main/java/com/parseable/android/ui/screens/logviewer/SeverityColor.kt+8−37 app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoScreen.kt+86−100 app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoViewModel.kt+10−59 app/src/main/java/com/parseable/android/ui/screens/settings/SettingsScreen.kt+2−193 app/src/main/java/com/parseable/android/ui/screens/settings/SettingsViewModel.kt+8−126 app/src/main/java/com/parseable/android/ui/screens/streams/StreamsScreen.kt+15−20 app/src/main/java/com/parseable/android/ui/screens/streams/StreamsViewModel.kt+11−53 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+3−57 app/src/test/java/com/parseable/android/LoginViewModelTest.kt+4−28 app/src/test/java/com/parseable/android/PrivateHostTest.kt−73 app/src/test/java/com/parseable/android/SettingsViewModelTest.kt+6−10 app/src/test/java/com/parseable/android/SeverityDetectionTest.kt−13 app/src/test/java/com/parseable/android/StreamsViewModelTest.kt−3 gradle/libs.versions.toml+4
.dockerignoredeleted−15
@@ -1,15 +0,0 @@
1−# Keep the build context small, but DO keep .git — build.gradle.kts derives the
2−# app version from `git log -1`, so the repository history must be present.
3−build/
4−app/build/
5−.gradle/
6−*.iml
7−.idea/
8−.DS_Store
9−local.properties
10−captures/
11−.externalNativeBuild/
12−.cxx/
13−*.keystore
14−*.jks
15−.jj/
.github/workflows/build-release.yml−18
@@ -12,8 +12,6 @@ concurrency:
1212
1313permissions:
1414 contents: write
15− id-token: write
16− attestations: write
1715
1816jobs:
1917 build:
@@ -24,12 +22,6 @@ jobs:
2422 - name: Checkout code
2523 uses: actions/checkout@v4
2624
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−
3325 - name: Set up JDK 17
3426 uses: actions/setup-java@v4
3527 with:
@@ -46,11 +38,6 @@ jobs:
4638 run: gradle wrapper --gradle-version 8.9
4739
4840 - 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 }}
5441 run: ./gradlew assembleRelease
5542
5643 - name: Run unit tests
@@ -62,11 +49,6 @@ jobs:
6249 name: parseable-android-release
6350 path: app/build/outputs/apk/release/*.apk
6451
65− - name: Attest release APK
66− uses: actions/attest-build-provenance@v2
67− with:
68− subject-path: app/build/outputs/apk/release/*.apk
69−
7052 - name: Create Draft Release
7153 if: startsWith(github.ref, 'refs/tags/v')
7254 uses: softprops/action-gh-release@v2
.github/workflows/ci.yml+25−47
@@ -6,24 +6,19 @@ on:
66 push:
77 branches: [ master, main ]
88
9+concurrency:
10+ group: ci-${{ github.ref }}
11+ cancel-in-progress: true
12+
913jobs:
1014 build:
1115 name: Build & Lint
1216 runs-on: ubuntu-latest
13− concurrency:
14− group: ci-build-${{ github.ref }}
15− cancel-in-progress: true
1617
1718 steps:
1819 - name: Checkout code
1920 uses: actions/checkout@v4
2021
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−
2722 - name: Set up JDK 17
2823 uses: actions/setup-java@v4
2924 with:
@@ -40,11 +35,6 @@ jobs:
4035 run: gradle wrapper --gradle-version 8.9
4136
4237 - 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 }}
4838 run: ./gradlew assembleRelease
4939
5040 - name: Run unit tests
@@ -69,34 +59,18 @@ jobs:
6959 path: app/build/reports/lint-results-release.html
7060 retention-days: 14
7161
72− release:
73− name: Create Release
62+ prerelease:
63+ name: Create Prerelease
7464 needs: build
7565 if: github.event_name == 'push'
7666 runs-on: ubuntu-latest
77− concurrency:
78− group: release
79− cancel-in-progress: false
8067 permissions:
8168 contents: write
82− id-token: write
83− attestations: write
8469
8570 steps:
8671 - name: Checkout code
8772 uses: actions/checkout@v4
8873
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−
95− - name: Set version from commit timestamp
96− run: |
97− VERSION_NAME=$(TZ=UTC git log -1 --format='%cd' --date=format-local:'%Y.%m.%d.%H%M')
98− echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
99−
10074 - name: Set up JDK 17
10175 uses: actions/setup-java@v4
10276 with:
@@ -113,29 +87,33 @@ jobs:
11387 run: gradle wrapper --gradle-version 8.9
11488
11589 - 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 }}
12190 run: ./gradlew assembleRelease
12291
123− - name: Rename APK with version
92+ - name: Rename APK with commit SHA
12493 run: |
94+ SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
12595 for apk in app/build/outputs/apk/release/*.apk; do
126− mv "$apk" "app/build/outputs/apk/release/parseable-android-${VERSION_NAME}.apk"
96+ mv "$apk" "app/build/outputs/apk/release/parseable-android-${SHORT_SHA}.apk"
12797 done
12898
129− - name: Attest release APK
130− uses: actions/attest-build-provenance@v2
131− with:
132− subject-path: app/build/outputs/apk/release/*.apk
99+ - name: Delete existing latest-master tag and release
100+ env:
101+ GH_TOKEN: ${{ github.token }}
102+ run: |
103+ gh release delete latest-master --yes || true
104+ git push origin :refs/tags/latest-master || true
133105
134− - name: Create release
106+ - name: Create prerelease
135107 uses: softprops/action-gh-release@v2
136108 with:
137− tag_name: v${{ env.VERSION_NAME }}
138− name: v${{ env.VERSION_NAME }}
139− generate_release_notes: true
109+ tag_name: latest-master
110+ name: "Latest Master Build (${{ github.sha }})"
111+ prerelease: true
112+ body: |
113+ Automated prerelease from commit ${{ github.sha }}.
114+
115+ **Commit:** [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})
116+ **Branch:** `${{ github.ref_name }}`
117+ **Triggered by:** @${{ github.actor }}
140118 files: |
141119 app/build/outputs/apk/release/*.apk
.gitignore−2
@@ -10,5 +10,3 @@
1010local.properties
1111app/build/
1212gradle/wrapper/gradle-wrapper.jar
13−*.keystore
14−*.jks
Dockerfiledeleted−72
@@ -1,72 +0,0 @@
1−# syntax=docker/dockerfile:1
2−
3−# ---- Build stage -------------------------------------------------------------
4−# Builds the Parseable Android APK from source. Mirrors the GitHub Actions CI:
5−# JDK 17, Android SDK (compileSdk 35), Gradle 8.9, with the wrapper generated
6−# on the fly (gradle-wrapper.jar is gitignored). The app's build.gradle.kts
7−# derives its version from `git log`, so git + the .git directory are required.
8−#
9−# Pinned to linux/amd64: the Android toolchain (aapt2) ships only x86-64 Linux
10−# binaries. On an arm64 host (Apple Silicon) a native arm64 image can't exec
11−# them ("rosetta error: failed to open elf .../ld-linux-x86-64.so.2"); a full
12−# amd64 image gives Rosetta/QEMU a complete x86-64 userland to emulate. On an
13−# amd64 host this is native. Override with --build-arg BUILD_PLATFORM=... .
14−ARG BUILD_PLATFORM=linux/amd64
15−FROM --platform=${BUILD_PLATFORM} eclipse-temurin:17-jdk-jammy AS build
16−
17−# Versions (override at build time with --build-arg if needed)
18−ARG GRADLE_VERSION=8.9
19−ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708
20−ARG ANDROID_PLATFORM=35
21−ARG ANDROID_BUILD_TOOLS=35.0.0
22−# Gradle task to run: assembleDebug (default) or assembleRelease
23−ARG GRADLE_TASK=assembleDebug
24−
25−ENV ANDROID_SDK_ROOT=/opt/android-sdk \
26− ANDROID_HOME=/opt/android-sdk \
27− GRADLE_USER_HOME=/root/.gradle \
28− DEBIAN_FRONTEND=noninteractive
29−
30−# Base tooling: git (version stamp), unzip/curl (fetching SDK + Gradle)
31−RUN apt-get update && \
32− apt-get install -y --no-install-recommends git unzip curl ca-certificates && \
33− rm -rf /var/lib/apt/lists/*
34−
35−# Android SDK command-line tools
36−RUN mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools" && \
37− curl -fsSL "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" -o /tmp/cmdline-tools.zip && \
38− unzip -q /tmp/cmdline-tools.zip -d "${ANDROID_SDK_ROOT}/cmdline-tools" && \
39− mv "${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools" "${ANDROID_SDK_ROOT}/cmdline-tools/latest" && \
40− rm /tmp/cmdline-tools.zip
41−
42−ENV PATH="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${PATH}"
43−
44−# Accept licenses and install required SDK packages
45−RUN yes | sdkmanager --licenses >/dev/null && \
46− sdkmanager --install \
47− "platform-tools" \
48− "platforms;android-${ANDROID_PLATFORM}" \
49− "build-tools;${ANDROID_BUILD_TOOLS}" >/dev/null
50−
51−# Gradle (used to generate the wrapper, then the wrapper drives the build)
52−RUN curl -fsSL "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" -o /tmp/gradle.zip && \
53− unzip -q /tmp/gradle.zip -d /opt && \
54− rm /tmp/gradle.zip
55−ENV PATH="/opt/gradle-${GRADLE_VERSION}/bin:${PATH}"
56−
57−WORKDIR /workspace
58−
59−# Copy the full project (including .git for the version stamp; see .dockerignore)
60−COPY . .
61−
62−# Generate the Gradle wrapper, then build the APK
63−RUN gradle wrapper --gradle-version "${GRADLE_VERSION}" && \
64− ./gradlew --no-daemon "${GRADLE_TASK}"
65−
66−# ---- Export stage ------------------------------------------------------------
67−# Minimal final image holding just the built APK(s) under /apks.
68−# Extract them to ./out with:
69−# docker build --target export --output type=local,dest=./out .
70−# (A scratch image has no shell, so use --output, not docker create/cp.)
71−FROM scratch AS export
72−COPY --from=build /workspace/app/build/outputs/apk/ /apks/
README.md−11
@@ -43,17 +43,6 @@ An Android client for [Parseable](https://www.parseable.com/), the open-source l
4343| `/api/v1/alerts` | GET | List alerts |
4444| `/api/v1/user` | GET | List users |
4545
46−## Installation
47−
48−The recommended way to install Parseable Android is via [Obtainium](https://github.com/ImranR98/Obtainium), which will automatically notify you of new releases.
49−
50−1. Install [Obtainium](https://github.com/ImranR98/Obtainium) on your device.
51−2. Open Obtainium and tap **Add App**.
52−3. Enter this repository URL: `https://github.com/ilyakooo0/parseable-android`
53−4. Tap **Add** — Obtainium will fetch the latest release APK and install it.
54−
55−Updates will be detected automatically whenever a new release is published.
56−
5746## Building
5847
5948```bash
app/build.gradle.kts+3−25
@@ -1,7 +1,3 @@
1−import java.time.Instant
2−import java.time.ZoneOffset
3−import java.time.format.DateTimeFormatter
4−
51plugins {
62 alias(libs.plugins.android.application)
73 alias(libs.plugins.kotlin.android)
@@ -11,13 +7,6 @@ plugins {
117 alias(libs.plugins.ksp)
128}
139
14−val commitEpoch = providers.exec {
15− commandLine("git", "log", "-1", "--format=%ct")
16−}.standardOutput.asText.get().trim().toLong()
17−val commitTime = Instant.ofEpochSecond(commitEpoch).atZone(ZoneOffset.UTC)
18−val dateVersionName = commitTime.format(DateTimeFormatter.ofPattern("yyyy.MM.dd.HHmm"))
19−val dateVersionCode = (commitEpoch / 60).toInt()
20−
2110android {
2211 namespace = "com.parseable.android"
2312 compileSdk = 35
@@ -26,28 +15,17 @@ android {
2615 applicationId = "com.parseable.android"
2716 minSdk = 26
2817 targetSdk = 35
29− versionCode = dateVersionCode
30− versionName = dateVersionName
18+ versionCode = libs.versions.appVersionCode.get().toInt()
19+ versionName = libs.versions.appVersionName.get()
3120
3221 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
3322 }
3423
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−
4624 buildTypes {
4725 release {
4826 isMinifyEnabled = true
4927 isShrinkResources = true
50− signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
28+ signingConfig = signingConfigs.getByName("debug")
5129 proguardFiles(
5230 getDefaultProguardFile("proguard-android-optimize.txt"),
5331 "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"
13+ android:roundIcon="@mipmap/ic_launcher_round"
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+1−21
@@ -51,17 +51,6 @@ class MainActivity : ComponentActivity() {
5151 repository.configure(savedConfig)
5252 }
5353 startDestination = if (savedConfig != null) Routes.STREAMS else Routes.LOGIN
54−
55− // Best-effort startup reconciliation for state a row-collapsing migration
56− // (raw SQL) can't reach: re-link a dangling active_server_id to its
57− // surviving row, then drop password entries orphaned by the collapse.
58− // Runs after startDestination so it never blocks first paint.
59− try {
60− settingsRepository.reconcileActiveServerId()
61− settingsRepository.cleanupOrphanedServerPasswords()
62− } catch (_: Exception) {
63− // Reconciliation/cleanup are non-critical; ignore failures.
64− }
6554 }
6655
6756 val dest = startDestination
@@ -72,17 +61,8 @@ class MainActivity : ComponentActivity() {
7261 LaunchedEffect(navController) {
7362 repository.authErrors
7463 .onEach {
75− // Several parallel requests can each surface a 401 in quick
76− // succession. Skip re-navigating (and re-clearing) when we're
77− // already on the login screen, so a burst can't thrash the
78− // back stack or re-trigger the "session expired" message.
79− val onLogin = navController.currentDestination
80− ?.route?.startsWith(Routes.LOGIN) == true
81− if (onLogin) return@onEach
8264 settingsRepository.clearConfig()
83− // The login screen shows the "session expired" message
84− // itself via the sessionExpired flag; showing it here too
85− // would queue a duplicate snackbar.
65+ errorHandler.showError("Session expired. Please log in again.")
8666 navController.navigate(Routes.login(sessionExpired = true)) {
8767 popUpTo(0) { inclusive = true }
8868 }
app/src/main/java/com/parseable/android/data/FormatUtils.kt+2−19
@@ -2,10 +2,7 @@ package com.parseable.android.data
22
33import java.util.Locale
44
5−// "EB" is included so PB is never the last unit: the promotion guard below (which only fires
6−// when a higher unit exists) then handles a PB value in [1023.5, 1024) — promoting it to "1.00 EB"
7−// instead of rendering "1024 PB". The remaining unhandled top-unit case sits at ~1.2e24 bytes.
8−private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
5+private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB")
96
107/**
118 * Converts a raw byte-count string (e.g. "123456789") to a human-readable
@@ -15,28 +12,14 @@ private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
1512fun formatBytes(raw: String?): String? {
1613 if (raw == null) return null
1714 val bytes = raw.trim().toDoubleOrNull() ?: return raw
18− // toDoubleOrNull accepts "NaN"/"Infinity"; those slip past the range guards below and
19− // would format as "NaN B" / "Infinity PB". Pass them through unchanged instead.
20− if (!bytes.isFinite()) return raw
2115 if (bytes < 0) return raw
22− // Use 1023.5, not 1024, as the cutoff: "%.0f" rounds half-up, so a byte count in
23− // [1023.5, 1024) would render as "1024 B" — a value that should promote to "1.00 KB".
24− // Such inputs fall through to the unit-scaling path, where the promotion guard below
25− // (value >= 1023.5) handles them.
26− if (bytes < 1023.5) return String.format(Locale.US, "%.0f B", bytes)
16+ if (bytes < 1024) return String.format(Locale.US, "%.0f B", bytes)
2717 var value = bytes
2818 var unitIndex = 0
2919 while (value >= 1024 && unitIndex < BYTE_UNITS.size - 1) {
3020 value /= 1024
3121 unitIndex++
3222 }
33− // A value just under 1024 (e.g. 1023.999 KB) is formatted with "%.0f", which rounds it to
34− // "1024 KB" — a unit that should never display. Promote to the next unit so it renders as
35− // "1.00 MB". The "%.1f"/"%.2f" branches operate far below 1024, so only this case crosses.
36− if (value >= 1023.5 && unitIndex < BYTE_UNITS.size - 1) {
37− value /= 1024
38− unitIndex++
39− }
4023 return if (value >= 100) {
4124 String.format(Locale.US, "%.0f %s", value, BYTE_UNITS[unitIndex])
4225 } else if (value >= 10) {
app/src/main/java/com/parseable/android/data/SqlUtils.kt−16
@@ -5,19 +5,3 @@ fun escapeSql(value: String): String = value.replace("'", "''")
55
66/** Escape a SQL identifier (table/column name) for use inside double quotes. */
77fun escapeIdentifier(name: String): String = name.replace("\"", "\"\"")
8−
9−/**
10− * Escape a user-supplied substring for use as the body of a LIKE/ILIKE pattern.
11− *
12− * Besides the single-quote escaping every string literal needs, this neutralizes the
13− * LIKE wildcards `%` and `_` (and the escape character `\` itself) so that searching or
14− * filtering for a literal `%` or `_` matches that character instead of acting as a
15− * wildcard. The backslash must be escaped first so the backslashes added for `%`/`_`
16− * aren't doubled. Callers must pair the result with an `ESCAPE '\'` clause.
17− */
18−fun escapeLikePattern(value: String): String =
19− value
20− .replace("\\", "\\\\")
21− .replace("%", "\\%")
22− .replace("_", "\\_")
23− .replace("'", "''")
app/src/main/java/com/parseable/android/data/TimestampUtils.kt+14−57
@@ -1,69 +1,26 @@
11package com.parseable.android.data
22
33import java.time.Instant
4−import java.time.LocalDateTime
5−import java.time.OffsetDateTime
64import java.time.ZoneId
7−import java.time.ZoneOffset
85import java.time.ZonedDateTime
96import java.time.format.DateTimeFormatter
10−import java.util.Locale
117
12−// Pin to Locale.US so the MMM month token is always rendered as a stable English
13−// abbreviation (e.g. "Jun") regardless of the device's locale.
14−private val displayFormatter = DateTimeFormatter.ofPattern("MMM dd HH:mm:ss.SSS", Locale.US)
8+private val displayFormatter = DateTimeFormatter.ofPattern("MMM dd HH:mm:ss.SSS")
159
1610fun formatTimestamp(raw: String): String {
17− // Parseable/DataFusion sometimes renders p_timestamp with a space between the date and
18− // time ("2026-06-20 12:00:00.000") instead of the ISO 'T', and may append a zone/offset
19− // after another space ("... +00:00", "... UTC"). Normalize both so the parsers below
20− // accept them; values already in canonical ISO form are left untouched.
21− var iso = raw.trim()
22− // Convert the FIRST space (date↔time separator) to 'T', unless a 'T' already precedes it
23− // (in which case that space sits before a zone token, handled next).
24− val firstSpace = iso.indexOf(' ')
25− if (firstSpace != -1 && 'T' !in iso.substring(0, firstSpace + 1)) {
26− iso = iso.substring(0, firstSpace) + "T" + iso.substring(firstSpace + 1)
27− }
28− // Drop a space sitting between the time and a trailing offset/zone token:
29− // "...12:00:00 +00:00" → "...12:00:00+00:00", "...12:00:00 UTC" → "...12:00:00UTC".
30− iso = iso.replace(Regex("(?<=\\d)\\s+(?=[+\\-Z]|UTC|GMT)"), "")
31− // Map a textual "UTC"/"GMT" suffix to the ISO 'Z' the parsers understand (GMT is exactly
32− // UTC, so this is a safe rewrite). Other textual zones (e.g. "PST") are ambiguous, so they
33− // are left untouched and fall through to the raw-string passthrough rather than risk
34− // displaying a wrong local time.
35− if (iso.endsWith("UTC")) iso = iso.removeSuffix("UTC") + "Z"
36− else if (iso.endsWith("GMT")) iso = iso.removeSuffix("GMT") + "Z"
37−
38− // Fast path: an ISO-8601 instant in UTC (e.g. "2026-06-20T12:00:00Z").
39− try {
40− val instant = Instant.parse(iso)
41− return ZonedDateTime.ofInstant(instant, ZoneId.systemDefault()).format(displayFormatter)
42− } catch (_: Exception) {
43− // Fall through to the offset-aware parser.
44− }
45−
46− // General path: any ISO-8601 timestamp carrying an explicit offset, including
47− // "+00:00" and non-UTC offsets like "+05:30" that Instant.parse rejects.
48− try {
49− val offset = OffsetDateTime.parse(iso)
50− return offset.atZoneSameInstant(ZoneId.systemDefault()).format(displayFormatter)
11+ return try {
12+ val instant = Instant.parse(raw)
13+ val local = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
14+ local.format(displayFormatter)
5115 } catch (_: Exception) {
52− // Fall through to the naive (offset-less) parser.
16+ // Try alternate format with +00:00 suffix
17+ try {
18+ val normalized = raw.replace("+00:00", "Z")
19+ val instant = Instant.parse(normalized)
20+ val local = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
21+ local.format(displayFormatter)
22+ } catch (_: Exception) {
23+ raw
24+ }
5325 }
54−
55− // Final path: an offset-less ISO-8601 timestamp (e.g. "2026-06-20T12:00:00.000").
56− // Parseable stores p_timestamp in UTC, so interpret a naive timestamp as UTC and
57− // convert to the device's zone — otherwise these would render in UTC while
58− // offset-carrying values render in local time, an inconsistent mix.
59− try {
60− val local = LocalDateTime.parse(iso)
61− return local.atOffset(ZoneOffset.UTC)
62− .atZoneSameInstant(ZoneId.systemDefault())
63− .format(displayFormatter)
64− } catch (_: Exception) {
65− // Unparseable — show the server value verbatim rather than nothing.
66− }
67−
68− return raw
6926}
app/src/main/java/com/parseable/android/data/api/ParseableApiClient.kt+32−89
@@ -39,7 +39,7 @@ class ParseableApiClient @Inject constructor() {
3939 coerceInputValues = true
4040 }
4141
42− private val sharedPool = okhttp3.ConnectionPool(16, 5, TimeUnit.MINUTES)
42+ private val sharedPool = okhttp3.ConnectionPool(5, 5, TimeUnit.MINUTES)
4343 private val sharedDispatcher = okhttp3.Dispatcher()
4444
4545 private val secureClient: OkHttpClient = buildClient(allowInsecure = false)
@@ -77,18 +77,11 @@ class ParseableApiClient @Inject constructor() {
7777
7878 fun configure(serverConfig: ServerConfig) {
7979 val url = serverConfig.serverUrl.trimEnd('/')
80− // Leave the auth header empty for blank credentials so isConfigured() reports
81− // false instead of silently sending a bogus "Basic <user:>" header.
82− val auth = if (serverConfig.username.isBlank() && serverConfig.password.isBlank()) {
83− ""
84− } else {
85− val credentials = "${serverConfig.username}:${serverConfig.password}"
86− "Basic " + android.util.Base64.encodeToString(
87− // Explicit UTF-8 per RFC 7617 rather than the platform default charset.
88− credentials.toByteArray(Charsets.UTF_8),
89− android.util.Base64.NO_WRAP
90− )
91− }
80+ val credentials = "${serverConfig.username}:${serverConfig.password}"
81+ val auth = "Basic " + android.util.Base64.encodeToString(
82+ credentials.toByteArray(),
83+ android.util.Base64.NO_WRAP
84+ )
9285 config = ClientConfig(
9386 baseUrl = url,
9487 authHeader = auth,
@@ -103,48 +96,30 @@ class ParseableApiClient @Inject constructor() {
10396 }
10497
10598 fun shutdown() {
106− // Only evict idle connections. The dispatcher's executor is SHARED across the
107− // secure/insecure clients of this @Singleton; shutting it down is irreversible and
108− // would make every later request throw RejectedExecutionException after a
109− // clearConfig()/configure() cycle. Eviction is enough to release pooled sockets.
11099 sharedPool.evictAll()
100+ sharedDispatcher.executorService.shutdown()
111101 }
112102
113103 private fun encodePathSegment(segment: String): String =
114104 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
115105
116106 private fun buildRequest(path: String): Request.Builder {
117− // Snapshot the volatile config ONCE and derive both the URL/auth header and the
118− // TLS client (secure vs. insecure) from it. Selecting the client lazily in
119− // executeRequest would re-read `config`, so a concurrent server switch could
120− // dispatch server A's URL/auth through server B's TLS client. Pinning the client
121− // onto the request as a tag keeps the whole request internally consistent.
122107 val snapshot = config
123− val selectedClient = if (snapshot.allowInsecure) insecureClient else secureClient
124− val builder = Request.Builder()
108+ return Request.Builder()
125109 .url("${snapshot.baseUrl}$path")
126− .tag(OkHttpClient::class.java, selectedClient)
127− // Don't send a literal empty "Authorization:" header for unconfigured calls
128− // (e.g. pre-login connectivity probes) — strict servers/proxies may reject it.
129− if (snapshot.authHeader.isNotEmpty()) {
130− builder.header("Authorization", snapshot.authHeader)
131− }
132− return builder
110+ .header("Authorization", snapshot.authHeader)
133111 }
134112
135113 private suspend fun executeRequest(request: Request): ApiResult<String> =
136114 withContext(Dispatchers.IO) {
137115 try {
138− val callClient = request.tag(OkHttpClient::class.java) ?: client
139− callClient.newCall(request).execute().use { response ->
116+ client.newCall(request).execute().use { response ->
140117 val body = response.body?.string() ?: ""
141118 if (response.isSuccessful) {
142119 ApiResult.Success(body)
143120 } else {
144121 ApiResult.Error(
145− // ifBlank, not ifEmpty: a whitespace-only error body would otherwise
146− // be shown verbatim as a blank message instead of the status reason.
147− message = body.ifBlank { response.message },
122+ message = body.ifEmpty { response.message },
148123 code = response.code
149124 )
150125 }
@@ -152,7 +127,7 @@ class ParseableApiClient @Inject constructor() {
152127 } catch (e: SocketTimeoutException) {
153128 ApiResult.Error(message = "Connection timed out")
154129 } catch (e: UnknownHostException) {
155− ApiResult.Error(message = "Unable to resolve host")
130+ ApiResult.Error(message = "Unable to resolve host \"${e.message}\"")
156131 } catch (e: ConnectException) {
157132 ApiResult.Error(message = "Connection refused. Is the server running?")
158133 } catch (e: SSLException) {
@@ -165,21 +140,13 @@ class ParseableApiClient @Inject constructor() {
165140 }
166141 }
167142
168− private fun <T> parseResponse(description: String, parse: () -> T): ApiResult<T> {
143+ private fun <T> parseResponse(body: String, description: String, parse: () -> T): ApiResult<T> {
169144 return try {
170145 ApiResult.Success(parse())
171146 } catch (e: SerializationException) {
172− ApiResult.Error("Invalid response format from server", code = ApiResult.PARSE_ERROR_CODE)
147+ ApiResult.Error("Invalid response format from server")
173148 } catch (e: IllegalArgumentException) {
174− ApiResult.Error("Unexpected response format for $description", code = ApiResult.PARSE_ERROR_CODE)
175− } catch (e: Exception) {
176− // kotlinx-serialization's JsonElement accessors (jsonArray/jsonObject/
177− // jsonPrimitive) throw IllegalStateException — not IllegalArgumentException —
178− // when an element has the wrong type. Catch any remaining parse-time failure
179− // so a malformed payload surfaces as an error instead of an uncaught crash.
180− // parse() is non-suspending, so there's no CancellationException to preserve.
181− Timber.w(e, "Failed to parse response for $description")
182− ApiResult.Error("Unexpected response format for $description", code = ApiResult.PARSE_ERROR_CODE)
149+ ApiResult.Error("Unexpected response format for $description")
183150 }
184151 }
185152
@@ -189,7 +156,7 @@ class ParseableApiClient @Inject constructor() {
189156 suspend fun getAbout(): ApiResult<AboutInfo> {
190157 val request = buildRequest("/api/v1/about").get().build()
191158 return when (val result = executeRequest(request)) {
192− is ApiResult.Success -> parseResponse("about info") {
159+ is ApiResult.Success -> parseResponse(result.data, "about info") {
193160 json.decodeFromString<AboutInfo>(result.data)
194161 }
195162 is ApiResult.Error -> result
@@ -230,7 +197,7 @@ class ParseableApiClient @Inject constructor() {
230197 }
231198 ApiResult.Success(streams)
232199 } catch (e2: Exception) {
233− ApiResult.Error("Failed to parse streams: ${e2.message}", code = ApiResult.PARSE_ERROR_CODE)
200+ ApiResult.Error("Failed to parse streams: ${e2.message}")
234201 }
235202 }
236203 }
@@ -245,7 +212,7 @@ class ParseableApiClient @Inject constructor() {
245212 val encoded = encodePathSegment(stream)
246213 val request = buildRequest("/api/v1/logstream/$encoded/schema").get().build()
247214 return when (val result = executeRequest(request)) {
248− is ApiResult.Success -> parseResponse("schema") {
215+ is ApiResult.Success -> parseResponse(result.data, "schema") {
249216 json.decodeFromString<StreamSchema>(result.data)
250217 }
251218 is ApiResult.Error -> result
@@ -259,7 +226,7 @@ class ParseableApiClient @Inject constructor() {
259226 val encoded = encodePathSegment(stream)
260227 val request = buildRequest("/api/v1/logstream/$encoded/stats").get().build()
261228 return when (val result = executeRequest(request)) {
262− is ApiResult.Success -> parseResponse("stats") {
229+ is ApiResult.Success -> parseResponse(result.data, "stats") {
263230 json.decodeFromString<StreamStats>(result.data)
264231 }
265232 is ApiResult.Error -> result
@@ -273,7 +240,7 @@ class ParseableApiClient @Inject constructor() {
273240 val encoded = encodePathSegment(stream)
274241 val request = buildRequest("/api/v1/logstream/$encoded/info").get().build()
275242 return when (val result = executeRequest(request)) {
276− is ApiResult.Success -> parseResponse("stream info") {
243+ is ApiResult.Success -> parseResponse(result.data, "stream info") {
277244 val element = json.parseToJsonElement(result.data)
278245 (element as? JsonObject)
279246 ?: throw IllegalArgumentException("Expected JSON object, got ${element::class.simpleName}")
@@ -293,15 +260,8 @@ class ParseableApiClient @Inject constructor() {
293260 try {
294261 ApiResult.Success(json.decodeFromString<List<RetentionConfig>>(result.data))
295262 } catch (_: Exception) {
296− parseResponse("retention config") {
297− val single = json.decodeFromString<RetentionConfig>(result.data)
298− // An empty object ({}) decodes into an all-null config — that means
299− // "no retention configured" (zero rules), not one empty rule.
300− if (single.description == null && single.duration == null && single.action == null) {
301− emptyList()
302− } else {
303− listOf(single)
304− }
263+ parseResponse(result.data, "retention config") {
264+ listOf(json.decodeFromString<RetentionConfig>(result.data))
305265 }
306266 }
307267 }
@@ -331,7 +291,7 @@ class ParseableApiClient @Inject constructor() {
331291 .build()
332292
333293 return when (val result = executeRequest(request)) {
334− is ApiResult.Success -> parseResponse("query results") {
294+ is ApiResult.Success -> parseResponse(result.data, "query results") {
335295 val element = json.parseToJsonElement(result.data)
336296 val elements = (element as? JsonArray)
337297 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
@@ -356,14 +316,11 @@ class ParseableApiClient @Inject constructor() {
356316 suspend fun listAlerts(): ApiResult<List<Alert>> {
357317 val request = buildRequest("/api/v1/alerts").get().build()
358318 return when (val result = executeRequest(request)) {
359− is ApiResult.Success -> parseResponse("alerts") {
319+ is ApiResult.Success -> parseResponse(result.data, "alerts") {
360320 val element = json.parseToJsonElement(result.data)
361321 val alertsArray = when (element) {
362322 is JsonArray -> element
363− // Use a safe cast, not the `.jsonArray` accessor: a present-but-non-array
364− // value (e.g. {"alerts": null} or an object) makes `.jsonArray` throw, which
365− // would surface as a parse error instead of degrading to an empty list.
366− is JsonObject -> (element["alerts"] as? JsonArray) ?: JsonArray(emptyList())
323+ is JsonObject -> element["alerts"]?.jsonArray ?: JsonArray(emptyList())
367324 else -> JsonArray(emptyList())
368325 }
369326 alertsArray.mapNotNull { alertElement ->
@@ -398,7 +355,7 @@ class ParseableApiClient @Inject constructor() {
398355 .post(requestBody)
399356 .build()
400357 return when (val result = executeRequest(request)) {
401− is ApiResult.Success -> parseResponse("alert") {
358+ is ApiResult.Success -> parseResponse(result.data, "alert") {
402359 json.decodeFromString<Alert>(result.data)
403360 }
404361 is ApiResult.Error -> result
@@ -411,7 +368,7 @@ class ParseableApiClient @Inject constructor() {
411368 suspend fun listUsers(): ApiResult<List<JsonObject>> {
412369 val request = buildRequest("/api/v1/user").get().build()
413370 return when (val result = executeRequest(request)) {
414− is ApiResult.Success -> parseResponse("users") {
371+ is ApiResult.Success -> parseResponse(result.data, "users") {
415372 val element = json.parseToJsonElement(result.data)
416373 val elements = (element as? JsonArray)
417374 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
@@ -427,22 +384,8 @@ class ParseableApiClient @Inject constructor() {
427384 suspend fun listFilters(): ApiResult<List<SavedFilter>> {
428385 val request = buildRequest("/api/v1/filters").get().build()
429386 return when (val result = executeRequest(request)) {
430− is ApiResult.Success -> parseResponse("filters") {
431− val element = json.parseToJsonElement(result.data)
432− val array = (element as? JsonArray)
433− ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
434− array.mapNotNull { filterElement ->
435− try {
436− val filter = json.decodeFromJsonElement<SavedFilter>(filterElement)
437− // coerceInputValues turns a server-null identity field into "". Skip such
438− // entries rather than surface a nameless/stream-less ghost filter (and so
439− // one malformed entry doesn't fail the whole list).
440− if (filter.filterName.isBlank() || filter.streamName.isBlank()) null else filter
441− } catch (e: Exception) {
442− Timber.w(e, "Skipping malformed filter entry")
443− null
444− }
445− }
387+ is ApiResult.Success -> parseResponse(result.data, "filters") {
388+ json.decodeFromString<List<SavedFilter>>(result.data)
446389 }
447390 is ApiResult.Error -> result
448391 }
@@ -455,7 +398,7 @@ class ParseableApiClient @Inject constructor() {
455398 val encoded = encodePathSegment(filterId)
456399 val request = buildRequest("/api/v1/filters/$encoded").get().build()
457400 return when (val result = executeRequest(request)) {
458− is ApiResult.Success -> parseResponse("filter") {
401+ is ApiResult.Success -> parseResponse(result.data, "filter") {
459402 json.decodeFromString<SavedFilter>(result.data)
460403 }
461404 is ApiResult.Error -> result
@@ -472,7 +415,7 @@ class ParseableApiClient @Inject constructor() {
472415 .post(requestBody)
473416 .build()
474417 return when (val result = executeRequest(request)) {
475− is ApiResult.Success -> parseResponse("filter") {
418+ is ApiResult.Success -> parseResponse(result.data, "filter") {
476419 json.decodeFromString<SavedFilter>(result.data)
477420 }
478421 is ApiResult.Error -> result
@@ -490,7 +433,7 @@ class ParseableApiClient @Inject constructor() {
490433 .put(requestBody)
491434 .build()
492435 return when (val result = executeRequest(request)) {
493− is ApiResult.Success -> parseResponse("filter") {
436+ is ApiResult.Success -> parseResponse(result.data, "filter") {
494437 json.decodeFromString<SavedFilter>(result.data)
495438 }
496439 is ApiResult.Error -> result
app/src/main/java/com/parseable/android/data/local/FavoriteStreamDao.kt+2−12
@@ -12,14 +12,10 @@ interface FavoriteStreamDao {
1212 @Query("SELECT * FROM favorite_streams ORDER BY addedAt DESC")
1313 fun getAll(): Flow<List<FavoriteStream>>
1414
15− // Ordered most-recently-favorited first so the UI can show favorites in recency order
16− // (toSet() downstream preserves this iteration order via LinkedHashSet).
17− @Query("SELECT streamName FROM favorite_streams ORDER BY addedAt DESC")
15+ @Query("SELECT streamName FROM favorite_streams")
1816 fun getAllNames(): Flow<List<String>>
1917
20− // IGNORE (not REPLACE): re-favoriting an existing stream must keep its original
21− // addedAt so the getAll() "ORDER BY addedAt DESC" ordering stays stable.
22− @Insert(onConflict = OnConflictStrategy.IGNORE)
18+ @Insert(onConflict = OnConflictStrategy.REPLACE)
2319 suspend fun insert(favorite: FavoriteStream)
2420
2521 @Delete
@@ -30,10 +26,4 @@ interface FavoriteStreamDao {
3026
3127 @Query("SELECT EXISTS(SELECT 1 FROM favorite_streams WHERE streamName = :name)")
3228 fun isFavorite(name: String): Flow<Boolean>
33−
34− // One-shot suspend variant. toggleFavorite() must decide insert-vs-delete from the
35− // committed DB state rather than the async-mirrored UI state, so rapid double-taps
36− // can't both observe a stale value and take the same branch.
37− @Query("SELECT EXISTS(SELECT 1 FROM favorite_streams WHERE streamName = :name)")
38− suspend fun isFavoriteNow(name: String): Boolean
3929}
app/src/main/java/com/parseable/android/data/local/ParseableDatabase.kt+2−3
@@ -4,11 +4,10 @@ import androidx.room.Database
44import androidx.room.RoomDatabase
55
66@Database(
7− entities = [FavoriteStream::class, SavedServer::class],
8− version = 3,
7+ entities = [FavoriteStream::class],
8+ version = 1,
99 exportSchema = true,
1010)
1111abstract class ParseableDatabase : RoomDatabase() {
1212 abstract fun favoriteStreamDao(): FavoriteStreamDao
13− abstract fun savedServerDao(): SavedServerDao
1413}
app/src/main/java/com/parseable/android/data/local/SavedServer.ktdeleted−23
@@ -1,23 +0,0 @@
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.ktdeleted−25
@@ -1,25 +0,0 @@
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+2−57
@@ -1,43 +1,9 @@
11package com.parseable.android.data.model
22
3−import kotlinx.serialization.KSerializer
43import kotlinx.serialization.SerialName
54import kotlinx.serialization.Serializable
6−import kotlinx.serialization.descriptors.PrimitiveKind
7−import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
8−import kotlinx.serialization.descriptors.SerialDescriptor
9−import kotlinx.serialization.encoding.Decoder
10−import kotlinx.serialization.encoding.Encoder
11−import kotlinx.serialization.json.JsonDecoder
125import kotlinx.serialization.json.JsonElement
13−import kotlinx.serialization.json.JsonNull
146import kotlinx.serialization.json.JsonObject
15−import kotlinx.serialization.json.JsonPrimitive
16−
17−/**
18− * Deserializes a value that some Parseable builds return as a JSON string ("12345") and other
19− * builds return as a JSON number (12345) into a [String], so stats parsing doesn't fail outright
20− * on the numeric form. Applied to the size fields below, which are rendered via [formatBytes]
21− * (itself tolerant of both a raw byte count and a pre-formatted string).
22− */
23−object FlexibleStringSerializer : KSerializer<String> {
24− override val descriptor: SerialDescriptor =
25− PrimitiveSerialDescriptor("FlexibleString", PrimitiveKind.STRING)
26−
27− override fun serialize(encoder: Encoder, value: String) = encoder.encodeString(value)
28−
29− override fun deserialize(decoder: Decoder): String {
30− val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeString()
31− val element = jsonDecoder.decodeJsonElement()
32− // JsonNull is itself a JsonPrimitive whose .content is the literal string "null". For the
33− // nullable fields this serializer is applied to, the compiler-generated nullable wrapper
34− // already intercepts a JSON null before deserialize() runs, so this never fires today. The
35− // guard exists so that if this serializer is ever attached to a non-null field, a JSON null
36− // surfaces as "" (rendered as absent) rather than the misleading literal text "null".
37− if (element is JsonNull) return ""
38− return (element as? JsonPrimitive)?.content ?: element.toString()
39− }
40−}
417
428/**
439 * Server connection configuration stored in DataStore.
@@ -81,31 +47,25 @@ data class StreamStats(
8147@Serializable
8248data class IngestionStats(
8349 val count: Long? = null,
84− @Serializable(with = FlexibleStringSerializer::class)
8550 val size: String? = null,
8651 val format: String? = null,
8752 @SerialName("lifetime_count")
8853 val lifetimeCount: Long? = null,
8954 @SerialName("lifetime_size")
90− @Serializable(with = FlexibleStringSerializer::class)
9155 val lifetimeSize: String? = null,
9256 @SerialName("deleted_count")
9357 val deletedCount: Long? = null,
9458 @SerialName("deleted_size")
95− @Serializable(with = FlexibleStringSerializer::class)
9659 val deletedSize: String? = null,
9760)
9861
9962@Serializable
10063data class StorageStats(
101− @Serializable(with = FlexibleStringSerializer::class)
10264 val size: String? = null,
10365 val format: String? = null,
10466 @SerialName("lifetime_size")
105− @Serializable(with = FlexibleStringSerializer::class)
10667 val lifetimeSize: String? = null,
10768 @SerialName("deleted_size")
108− @Serializable(with = FlexibleStringSerializer::class)
10969 val deletedSize: String? = null,
11070)
11171
@@ -192,14 +152,11 @@ data class Alert(
192152 val datasets: List<String> = emptyList(),
193153) {
194154 /** Best-effort display name from either response format. */
195− val displayName: String
196− get() = title?.takeIf { it.isNotBlank() }
197− ?: name?.takeIf { it.isNotBlank() }
198− ?: "Unnamed Alert"
155+ val displayName: String get() = title ?: name ?: "Unnamed Alert"
199156
200157 /** Whether the alert is active, derived from either response format. */
201158 val isEnabled: Boolean get() = when {
202− state != null -> !state.equals("Disabled", ignoreCase = true)
159+ state != null -> state != "Disabled"
203160 enabled != null -> enabled
204161 else -> true
205162 }
@@ -221,8 +178,6 @@ sealed class ApiResult<out T> {
221178 val isUnauthorized: Boolean get() = code == 401
222179 val isNotFound: Boolean get() = code == 404
223180 val isServerError: Boolean get() = code in 500..599
224− // Only genuine transport failures use code 0. A successful HTTP response whose body
225− // failed to parse carries PARSE_ERROR_CODE so it isn't mislabeled as a network error.
226181 val isNetworkError: Boolean get() = code == 0
227182
228183 val userMessage: String get() = when {
@@ -240,14 +195,4 @@ sealed class ApiResult<out T> {
240195 else -> message
241196 }
242197 }
243−
244− companion object {
245− /**
246− * Code used for client-side response-parsing failures: the HTTP request succeeded
247− * but the body was malformed/unexpected. Distinct from 0 (network/transport failure)
248− * so [Error.isNetworkError] and [Error.userMessage] don't show a misleading
249− * "Network error" for what is actually a server-data problem.
250− */
251− const val PARSE_ERROR_CODE = -1
252− }
253198}
app/src/main/java/com/parseable/android/data/repository/ParseableRepository.kt+4−15
@@ -71,23 +71,12 @@ class ParseableRepository @Inject constructor(
7171 * Does NOT trigger [checkAuth] so the global auth-error handler won't fire during login.
7272 */
7373 suspend fun verifyServer(): ApiResult<AboutInfo> {
74− return when (val aboutResult = apiClient.getAbout()) {
74+ return when (val result = apiClient.getAbout()) {
7575 is ApiResult.Success -> {
76− // GET /about is not auth-gated on Parseable, so a success here only proves
77− // the server is reachable and is Parseable — NOT that the credentials are
78− // valid. Probe an auth-required endpoint (logstream listing, which the app
79− // needs anyway) so wrong credentials are rejected at login instead of being
80− // persisted and only failing on the first real request. Call apiClient
81− // directly to avoid checkAuth firing the global auth-error handler mid-login.
82− when (val authProbe = apiClient.listStreams()) {
83− is ApiResult.Success -> {
84− aboutCache = CacheEntry(aboutResult.data)
85− aboutResult
86− }
87− is ApiResult.Error -> authProbe
88− }
76+ aboutCache = CacheEntry(result.data)
77+ result
8978 }
90− is ApiResult.Error -> aboutResult
79+ is ApiResult.Error -> result
9180 }
9281 }
9382
app/src/main/java/com/parseable/android/data/repository/SettingsRepository.kt+8−238
@@ -8,8 +8,6 @@ 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
1311import com.parseable.android.data.model.ServerConfig
1412import dagger.hilt.android.qualifiers.ApplicationContext
1513import kotlinx.coroutines.Dispatchers
@@ -29,12 +27,10 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
2927@Singleton
3028class SettingsRepository @Inject constructor(
3129 @ApplicationContext private val context: Context,
32− private val savedServerDao: SavedServerDao,
3330) {
3431 private val serverUrlKey = stringPreferencesKey("server_url")
3532 private val usernameKey = stringPreferencesKey("username")
3633 private val useTlsKey = booleanPreferencesKey("use_tls")
37− private val activeServerIdKey = longPreferencesKey("active_server_id")
3834
3935 /** Guards concurrent reads/writes across DataStore and EncryptedSharedPreferences. */
4036 private val configMutex = Mutex()
@@ -66,24 +62,13 @@ class SettingsRepository @Inject constructor(
6662 )
6763 }
6864
69− // ── Active server config (current connection) ──────────────────────
70−
7165 val serverConfig: Flow<ServerConfig?> = context.dataStore.data
7266 .catch { e ->
7367 Timber.e(e, "Failed to read DataStore")
7468 emit(emptyPreferences())
7569 }
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.
70+ .map { prefs ->
8571 configMutex.withLock {
86− val prefs = context.dataStore.data.first()
8772 val url = prefs[serverUrlKey] ?: return@withLock null
8873 val user = prefs[usernameKey] ?: return@withLock null
8974 val pass = try {
@@ -102,43 +87,21 @@ class SettingsRepository @Inject constructor(
10287 }
10388 .flowOn(Dispatchers.IO)
10489
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−
11390 suspend fun getSavedPassword(): ServerConfig? = serverConfig.first()
11491
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 {
92+ suspend fun saveServerConfig(config: ServerConfig) {
93+ configMutex.withLock {
12294 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− }
13395 context.dataStore.edit { prefs ->
13496 prefs[serverUrlKey] = config.serverUrl
13597 prefs[usernameKey] = config.username
13698 prefs[useTlsKey] = config.useTls
13799 }
138− true
100+ withContext(Dispatchers.IO) {
101+ encryptedPrefs.edit().putString("password", config.password).apply()
102+ }
139103 } catch (e: Exception) {
140104 Timber.e(e, "Failed to save server config")
141− false
142105 }
143106 }
144107 }
@@ -146,206 +109,13 @@ class SettingsRepository @Inject constructor(
146109 suspend fun clearConfig() {
147110 configMutex.withLock {
148111 try {
149− context.dataStore.edit { prefs ->
150− prefs.remove(serverUrlKey)
151− prefs.remove(usernameKey)
152− prefs.remove(useTlsKey)
153− prefs.remove(activeServerIdKey)
154− }
112+ context.dataStore.edit { it.clear() }
155113 withContext(Dispatchers.IO) {
156− // commit() (not apply()) so the cleared password is durably gone before we
157− // return — a queued async clear could survive into the next session.
158− encryptedPrefs.edit().remove("password").commit()
114+ encryptedPrefs.edit().clear().apply()
159115 }
160116 } catch (e: Exception) {
161117 Timber.e(e, "Failed to clear config")
162118 }
163119 }
164120 }
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− }
351121}
app/src/main/java/com/parseable/android/di/AppModule.kt−59
@@ -2,11 +2,8 @@ package com.parseable.android.di
22
33import android.content.Context
44import androidx.room.Room
5−import androidx.room.migration.Migration
6−import androidx.sqlite.db.SupportSQLiteDatabase
75import com.parseable.android.data.local.FavoriteStreamDao
86import com.parseable.android.data.local.ParseableDatabase
9−import com.parseable.android.data.local.SavedServerDao
107import dagger.Module
118import dagger.Provides
129import dagger.hilt.InstallIn
@@ -18,61 +15,10 @@ import javax.inject.Singleton
1815@InstallIn(SingletonComponent::class)
1916object AppModule {
2017
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−
6618 @Provides
6719 @Singleton
6820 fun provideDatabase(@ApplicationContext context: Context): ParseableDatabase =
6921 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)
7622 .fallbackToDestructiveMigration()
7723 .build()
7824
@@ -80,9 +26,4 @@ object AppModule {
8026 @Singleton
8127 fun provideFavoriteStreamDao(db: ParseableDatabase): FavoriteStreamDao =
8228 db.favoriteStreamDao()
83−
84− @Provides
85− @Singleton
86− fun provideSavedServerDao(db: ParseableDatabase): SavedServerDao =
87− db.savedServerDao()
8829}
app/src/main/java/com/parseable/android/ui/navigation/NavGraph.kt+10−54
@@ -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
1716import com.parseable.android.ui.screens.settings.SettingsScreen
1817import com.parseable.android.ui.screens.streams.StreamsScreen
18+import java.net.URLDecoder
1919import java.net.URLEncoder
2020
2121object Routes {
@@ -25,21 +25,13 @@ 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"
2928
3029 fun login(sessionExpired: Boolean = false) =
3130 if (sessionExpired) "login?sessionExpired=true" else LOGIN
3231 fun logViewer(streamName: String) =
33− "log_viewer/${encodeArg(streamName)}"
32+ "log_viewer/${URLEncoder.encode(streamName, "UTF-8")}"
3433 fun streamInfo(streamName: String) =
35− "stream_info/${encodeArg(streamName)}"
36−
37− // URLEncoder maps space to "+", but Navigation decodes captured path args with Uri.decode,
38− // which only understands "%20" and leaves "+" untouched (it would survive as a literal
39− // "+"). Normalize to "%20" so the name Navigation hands back matches the original. Do NOT
40− // decode again at the call site — Navigation already does that one decode for us.
41− private fun encodeArg(value: String) =
42− URLEncoder.encode(value, "UTF-8").replace("+", "%20")
34+ "stream_info/${URLEncoder.encode(streamName, "UTF-8")}"
4335}
4436
4537@Composable
@@ -120,8 +112,9 @@ fun ParseableNavGraph(
120112 }
121113 return@composable
122114 }
123− // Navigation already URI-decodes captured path args, so use the value as-is.
124− val streamName = backStackEntry.arguments?.getString("streamName") ?: ""
115+ val streamName = URLDecoder.decode(
116+ backStackEntry.arguments?.getString("streamName") ?: "", "UTF-8"
117+ )
125118 LogViewerScreen(
126119 streamName = streamName,
127120 onBack = { navController.popBackStack() },
@@ -137,22 +130,14 @@ fun ParseableNavGraph(
137130 route = Routes.STREAM_INFO,
138131 arguments = listOf(navArgument("streamName") { type = NavType.StringType }),
139132 ) { backStackEntry ->
140− // Navigation already URI-decodes captured path args, so use the value as-is.
141− val streamName = backStackEntry.arguments?.getString("streamName") ?: ""
133+ val streamName = URLDecoder.decode(
134+ backStackEntry.arguments?.getString("streamName") ?: "", "UTF-8"
135+ )
142136 StreamInfoScreen(
143137 streamName = streamName,
144138 onBack = { navController.popBackStack() },
145139 onStreamDeleted = {
146− // popBackStack no-ops (returns false) when there's no streams entry on
147− // the back stack — e.g. when the user arrived via the deep link. In that
148− // case navigate to streams fresh so we don't strand them on the info
149− // screen for a now-deleted stream.
150− val popped = navController.popBackStack(Routes.STREAMS, inclusive = false)
151− if (!popped) {
152− navController.navigate(Routes.STREAMS) {
153− popUpTo(0) { inclusive = true }
154− }
155− }
140+ navController.popBackStack(Routes.STREAMS, inclusive = false)
156141 },
157142 )
158143 }
@@ -185,35 +170,6 @@ fun ParseableNavGraph(
185170 }
186171 SettingsScreen(
187172 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() },
217173 )
218174 }
219175 }
app/src/main/java/com/parseable/android/ui/screens/about/AboutScreen.ktdeleted−178
@@ -1,178 +0,0 @@
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+10−43
@@ -6,7 +6,6 @@ import com.parseable.android.data.model.Alert
66import com.parseable.android.data.model.ApiResult
77import com.parseable.android.data.repository.ParseableRepository
88import dagger.hilt.android.lifecycle.HiltViewModel
9−import kotlinx.coroutines.Job
109import kotlinx.coroutines.flow.MutableStateFlow
1110import kotlinx.coroutines.flow.StateFlow
1211import kotlinx.coroutines.flow.asStateFlow
@@ -29,40 +28,16 @@ class AlertsViewModel @Inject constructor(
2928 private val _state = MutableStateFlow(AlertsState())
3029 val state: StateFlow<AlertsState> = _state.asStateFlow()
3130
32− private var refreshJob: Job? = null
33−
34− init {
35− // Load once on creation; the screen no longer refreshes on every RESUME.
36− refresh()
37− }
38−
3931 fun refresh() {
40− // Cancel any in-flight load so out-of-order completions can't overwrite a newer alert
41− // list with a stale one (init load, pull-to-refresh and deleteAlert->refresh can overlap).
42− refreshJob?.cancel()
43− refreshJob = viewModelScope.launch {
32+ viewModelScope.launch {
4433 _state.update { it.copy(isLoading = true, error = null) }
45− loadAlerts()
46− }
47− }
48−
49− /**
50− * Fetches the alert list and writes a terminal state update (clearing isLoading). Callers set
51− * isLoading = true before invoking. Extracted so deleteAlert can reload within its own
52− * (never-cancelled) job rather than delegating to refresh(), whose refreshJob can be cancelled
53− * by a concurrent refresh before it clears the spinner.
54− */
55− private suspend fun loadAlerts() {
56− when (val result = repository.listAlerts()) {
57− is ApiResult.Success -> {
58− // Sort by the same field the UI shows (displayName = title ?: name), otherwise
59− // alerts that carry both fields, or a mix of per-stream and global formats,
60− // render in an order that doesn't match their visible labels.
61− val sorted = result.data.sortedBy { it.displayName.lowercase() }
62− _state.update { it.copy(alerts = sorted, isLoading = false) }
63− }
64− is ApiResult.Error -> {
65− _state.update { it.copy(isLoading = false, error = result.userMessage) }
34+ when (val result = repository.listAlerts()) {
35+ is ApiResult.Success -> {
36+ _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+ }
6641 }
6742 }
6843 }
@@ -75,19 +50,11 @@ class AlertsViewModel @Inject constructor(
7550 _state.update { it.copy(alertToDelete = null) }
7651 }
7752
78− private var deleteJob: Job? = null
79−
8053 fun deleteAlert(alertId: String) {
81− // Guard against a double-tap on the confirm button firing two concurrent DELETEs.
82− if (deleteJob?.isActive == true) return
83− deleteJob = viewModelScope.launch {
54+ viewModelScope.launch {
8455 _state.update { it.copy(alertToDelete = null, isLoading = true, error = null) }
8556 when (val result = repository.deleteAlert(alertId)) {
86− // Reload inline rather than via refresh(): refresh() runs on the cancellable
87− // refreshJob, so a pull-to-refresh landing right after could cancel it before it
88− // clears isLoading, leaving the spinner stuck. This job is never cancelled by a
89− // refresh, so its terminal state update always runs.
90− is ApiResult.Success -> loadAlerts()
57+ is ApiResult.Success -> refresh()
9158 is ApiResult.Error -> {
9259 _state.update { it.copy(isLoading = false, error = result.userMessage) }
9360 }
app/src/main/java/com/parseable/android/ui/screens/login/LoginScreen.kt+8−64
@@ -1,6 +1,5 @@
11package com.parseable.android.ui.screens.login
22
3−import androidx.compose.foundation.Image
43import androidx.compose.foundation.layout.*
54import androidx.compose.foundation.rememberScrollState
65import androidx.compose.foundation.text.KeyboardActions
@@ -16,9 +15,7 @@ import androidx.compose.ui.Alignment
1615import androidx.compose.ui.Modifier
1716import androidx.compose.ui.focus.FocusDirection
1817import androidx.compose.ui.platform.LocalFocusManager
19−import androidx.compose.ui.res.painterResource
2018import androidx.compose.ui.text.font.FontWeight
21−import com.parseable.android.R
2219import com.parseable.android.ui.LocalErrorHandler
2320import androidx.compose.ui.text.input.ImeAction
2421import androidx.compose.ui.text.input.KeyboardType
@@ -40,8 +37,8 @@ fun LoginScreen(
4037 var passwordVisible by rememberSaveable { mutableStateOf(false) }
4138 val errorHandler = LocalErrorHandler.current
4239
43− LaunchedEffect(Unit) {
44− viewModel.loginSuccessEvent.collect {
40+ LaunchedEffect(state.loginSuccess) {
41+ if (state.loginSuccess) {
4542 onLoginSuccess()
4643 }
4744 }
@@ -62,12 +59,6 @@ fun LoginScreen(
6259 horizontalAlignment = Alignment.CenterHorizontally,
6360 verticalArrangement = Arrangement.Center,
6461 ) {
65− Image(
66− painter = painterResource(id = R.mipmap.ic_launcher),
67− contentDescription = "Parseable logo",
68− modifier = Modifier.size(120.dp),
69− )
70−
7162 Text(
7263 text = "Parseable",
7364 style = MaterialTheme.typography.headlineLarge,
@@ -178,7 +169,12 @@ fun LoginScreen(
178169
179170 if (state.allowInsecure) {
180171 val isPrivateNetwork = remember(state.serverUrl) {
181− isPrivateHost(extractHost(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])\\..*"))
182178 }
183179 if (!isPrivateNetwork) {
184180 Card(
@@ -250,55 +246,3 @@ fun LoginScreen(
250246 }
251247 }
252248}
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+11−88
@@ -7,12 +7,10 @@ import com.parseable.android.data.model.ServerConfig
77import com.parseable.android.data.repository.ParseableRepository
88import com.parseable.android.data.repository.SettingsRepository
99import dagger.hilt.android.lifecycle.HiltViewModel
10−import kotlinx.coroutines.channels.Channel
1110import kotlinx.coroutines.flow.MutableStateFlow
1211import kotlinx.coroutines.flow.StateFlow
1312import kotlinx.coroutines.flow.asStateFlow
1413import kotlinx.coroutines.flow.first
15−import kotlinx.coroutines.flow.receiveAsFlow
1614import kotlinx.coroutines.flow.update
1715import kotlinx.coroutines.launch
1816import javax.inject.Inject
@@ -24,6 +22,7 @@ data class LoginState(
2422 val allowInsecure: Boolean = false,
2523 val isLoading: Boolean = false,
2624 val error: String? = null,
25+ val loginSuccess: Boolean = false,
2726 val serverUrlError: String? = null,
2827 val usernameError: String? = null,
2928 val passwordError: String? = null,
@@ -39,15 +38,6 @@ class LoginViewModel @Inject constructor(
3938 private val _state = MutableStateFlow(LoginState())
4039 val state: StateFlow<LoginState> = _state.asStateFlow()
4140
42− /**
43− * Emitted exactly once when login succeeds so the screen navigates away. A one-shot [Channel]
44− * (like the codebase's other nav events) rather than a retained StateFlow flag: a retained
45− * `loginSuccess = true` would re-fire navigation on any later recomposition that re-collects
46− * the state if this ViewModel instance outlived the first navigation.
47− */
48− private val _loginSuccessEvent = Channel<Unit>(Channel.BUFFERED)
49− val loginSuccessEvent = _loginSuccessEvent.receiveAsFlow()
50−
5141 init {
5242 viewModelScope.launch {
5343 val config = settingsRepository.serverConfig.first()
@@ -65,39 +55,9 @@ class LoginViewModel @Inject constructor(
6555 }
6656
6757 fun loginWithSavedCredentials() {
68− // Guard against a second tap (or a concurrent onLogin()) launching a duplicate
69− // login. Set isLoading synchronously so the guard is effective before the
70− // coroutine starts.
71− if (_state.value.isLoading) return
72− _state.update { it.copy(isLoading = true, error = null) }
7358 viewModelScope.launch {
74− val config = settingsRepository.getSavedPassword()
75− if (config == null) {
76− // URL/username are present but the saved password can't be read (e.g. the
77− // Keystore entry was lost). Surface this instead of silently stopping the
78− // spinner, and hide the now-useless "use saved credentials" button.
79− _state.update {
80− it.copy(
81− isLoading = false,
82− hasSavedCredentials = false,
83− error = "Saved credentials could not be read. Please log in again.",
84− )
85− }
86− return@launch
87− }
88− // Restore the saved URL/username so the saved password is paired with the
89− // server/user it was saved for, even if the user edited those fields.
90− _state.update {
91− it.copy(
92− serverUrl = config.serverUrl,
93− username = config.username,
94− allowInsecure = !config.useTls,
95− serverUrlError = null,
96− usernameError = null,
97− passwordError = null,
98− )
99− }
100− performLogin(config.serverUrl, config.username, config.password, !config.useTls)
59+ val config = settingsRepository.getSavedPassword() ?: return@launch
60+ performLogin(config.password)
10161 }
10262 }
10363
@@ -120,15 +80,6 @@ class LoginViewModel @Inject constructor(
12080 fun onLogin() {
12181 val current = _state.value
12282
123− // With saved credentials the password field is intentionally left empty (the user is
124− // expected to tap "Use saved credentials"). Tapping the primary Connect button in that
125− // state should do the obvious thing — log in with the saved password — rather than
126− // reject with "Password is required".
127− if (current.password.isBlank() && current.hasSavedCredentials) {
128− loginWithSavedCredentials()
129− return
130− }
131−
13283 // Validate
13384 var hasError = false
13485 var urlError: String? = null
@@ -159,34 +110,19 @@ class LoginViewModel @Inject constructor(
159110 return
160111 }
161112
162− // Guard against a duplicate in-flight login and set isLoading synchronously so a
163− // fast second tap can't launch a concurrent performLogin that races on _state.
164− if (current.isLoading) return
165− _state.update { it.copy(isLoading = true, error = null) }
166− // Pass every field from the same snapshot so the validated password can't be
167− // paired with a URL/username the user edited after tapping.
168113 viewModelScope.launch {
169− performLogin(current.serverUrl, current.username, current.password, current.allowInsecure)
114+ performLogin(current.password)
170115 }
171116 }
172117
173− private suspend fun performLogin(
174− serverUrl: String,
175− username: String,
176− password: String,
177− allowInsecure: Boolean,
178− ) {
118+ private suspend fun performLogin(password: String) {
119+ val current = _state.value
179120 _state.update { it.copy(isLoading = true, error = null) }
180121
181− // Strip trailing slashes so the persisted URL matches the canonical form the API
182− // client uses (ParseableApiClient.configure also does trimEnd('/')). Without this,
183− // "http://host:8000" and "http://host:8000/" persist as distinct strings, defeating
184− // saveServer's (serverUrl, username) dedup and the UNIQUE index — the same server
185− // ends up as two saved-server rows.
186− var url = serverUrl.trim().trimEnd('/')
122+ var url = current.serverUrl.trim()
187123 val urlLower = url.lowercase()
188124 if (!urlLower.startsWith("http://") && !urlLower.startsWith("https://")) {
189− url = if (allowInsecure) "http://$url" else "https://$url"
125+ url = if (current.allowInsecure) "http://$url" else "https://$url"
190126 }
191127
192128 val isValidUrl = try {
@@ -207,7 +143,7 @@ class LoginViewModel @Inject constructor(
207143
208144 val config = ServerConfig(
209145 serverUrl = url,
210− username = username.trim(),
146+ username = current.username.trim(),
211147 password = password,
212148 useTls = url.lowercase().startsWith("https"),
213149 )
@@ -220,21 +156,8 @@ class LoginViewModel @Inject constructor(
220156 // Uses verifyServer() to avoid triggering the global auth-error handler.
221157 when (val aboutResult = repository.verifyServer()) {
222158 is ApiResult.Success -> {
223− // Only report success if the credentials were durably persisted.
224− // Otherwise the app would navigate in and clear the typed password,
225− // yet bounce back to login on next launch with nothing to restore.
226− if (settingsRepository.saveServerConfig(config)) {
227− settingsRepository.saveServer(config)
228− _state.update { it.copy(isLoading = false, password = "") }
229− _loginSuccessEvent.send(Unit)
230− } else {
231− _state.update {
232− it.copy(
233− isLoading = false,
234− error = "Connected, but your credentials couldn't be saved securely on this device. Please try again.",
235− )
236− }
237− }
159+ settingsRepository.saveServerConfig(config)
160+ _state.update { it.copy(isLoading = false, loginSuccess = true, password = "") }
238161 }
239162 is ApiResult.Error -> {
240163 val errorMsg = if (aboutResult.isUnauthorized) {
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.kt+71−213
@@ -101,10 +101,6 @@ fun LogViewerScreen(
101101 actions = {
102102 StreamingToggleButton(
103103 isStreaming = state.isStreaming,
104− // Live-tail re-derives its own default SELECT * query in the poller, so
105− // it can't honor a custom SQL projection/filter. Disable the toggle while
106− // a custom query is active to avoid prepending mismatched rows.
107− enabled = state.customSql.isBlank(),
108104 onClick = viewModel::toggleStreaming,
109105 )
110106 IconButton(onClick = { onStreamInfo(streamName) }) {
@@ -171,9 +167,7 @@ fun LogViewerScreen(
171167 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
172168 keyboardActions = KeyboardActions(onSearch = {
173169 keyboardController?.hide()
174− // Runs the search now and cancels the pending debounce (which stops live-tail
175− // and refreshes) so we don't fire a second, redundant refresh ~300ms later.
176− viewModel.submitSearch()
170+ viewModel.refresh()
177171 }),
178172 modifier = Modifier
179173 .fillMaxWidth()
@@ -287,41 +281,9 @@ fun LogViewerScreen(
287281 // Log entries
288282 PullToRefreshBox(
289283 isRefreshing = state.isLoading,
290− onRefresh = viewModel::pullRefresh,
284+ onRefresh = viewModel::refresh,
291285 modifier = Modifier.fillMaxSize(),
292286 ) {
293− // Hoisted above the error/loading/empty/list branches so the scroll position,
294− // the clipboard handle, and the viewing-top / load-more effects survive
295− // transitions through those states. If they lived inside the list `else` branch
296− // they would be disposed and recreated on every empty/loading/error transition,
297− // resetting scroll to the top and re-arming loadMore mid-load.
298− val listState = rememberLazyListState()
299− val clipboard = remember {
300− context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
301− }
302−
303− // Tell the ViewModel when the list is at the top so it can clear the live-tail
304− // "+N new" badge — new rows prepend at the top and are seen immediately there.
305− val atTop by remember {
306− derivedStateOf {
307− listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
308− }
309− }
310− LaunchedEffect(atTop) { viewModel.setViewingTop(atTop) }
311−
312− // Auto-load more when scrolling near the bottom.
313− val shouldLoadMore by remember {
314− derivedStateOf {
315− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
316− lastVisible >= listState.layoutInfo.totalItemsCount - 5
317− }
318− }
319− LaunchedEffect(shouldLoadMore, state.hasMore, state.isLoading) {
320− if (shouldLoadMore && state.hasMore && !state.isLoading) {
321− viewModel.loadMore()
322− }
323− }
324−
325287 if (state.error != null && state.logs.isEmpty()) {
326288 Box(
327289 modifier = Modifier.fillMaxSize(),
@@ -388,6 +350,29 @@ fun LogViewerScreen(
388350 }
389351 }
390352 } else {
353+ val listState = rememberLazyListState()
354+ val clipboard = remember {
355+ context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
356+ }
357+
358+ // Auto-load more when scrolling near the bottom
359+ if (state.hasMore && !state.isLoading) {
360+ val shouldLoadMore by remember {
361+ derivedStateOf {
362+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
363+ lastVisible >= listState.layoutInfo.totalItemsCount - 5
364+ }
365+ }
366+ LaunchedEffect(shouldLoadMore) {
367+ if (shouldLoadMore) viewModel.loadMore()
368+ }
369+ }
370+
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+
391376 LazyColumn(
392377 state = listState,
393378 contentPadding = PaddingValues(8.dp),
@@ -395,34 +380,14 @@ fun LogViewerScreen(
395380 ) {
396381 itemsIndexed(
397382 state.logs,
398− key = { index, _ -> state.logKeys.getOrElse(index) { "log_$index" } },
383+ key = { index, _ -> logKeys.getOrElse(index) { "log_$index" } },
399384 ) { index, logEntry ->
400− val key = state.logKeys.getOrElse(index) { "log_$index" }
385+ val key = logKeys.getOrElse(index) { "log_$index" }
401386 LogEntryCard(
402387 logEntry = logEntry,
403− stableKey = key,
404388 isExpanded = expandedLogKey == key,
405389 onClick = {
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− }
390+ expandedLogKey = if (expandedLogKey == key) null else key
426391 },
427392 clipboardManager = clipboard,
428393 onCopied = {
@@ -433,12 +398,6 @@ fun LogViewerScreen(
433398 )
434399 }
435400 },
436− onFilterInclude = { column, value ->
437− viewModel.addFilter(column, "=", value)
438− },
439− onFilterExclude = { column, value ->
440− viewModel.addFilter(column, "!=", value)
441− },
442401 )
443402 }
444403
@@ -491,7 +450,6 @@ fun LogViewerScreen(
491450 if (showFilterSheet) {
492451 FilterBottomSheet(
493452 columns = state.columns,
494− logs = state.logs,
495453 onDismiss = { showFilterSheet = false },
496454 onApplyFilter = { column, operator, value ->
497455 viewModel.addFilter(column, operator, value)
@@ -534,18 +492,10 @@ fun LogViewerScreen(
534492 if (!dir.mkdirs() && !dir.isDirectory) {
535493 throw java.io.IOException("Failed to create shared_logs directory")
536494 }
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− }
495+ // Clean up previous exports
496+ dir.listFiles()?.forEach { old -> old.delete() }
547497 val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
548− val file = java.io.File(dir, "logs_${safeFileName}_${System.currentTimeMillis()}.json")
498+ val file = java.io.File(dir, "logs_$safeFileName.json")
549499 file.bufferedWriter().use { writer ->
550500 // Use compact JSON for large exports to reduce memory/file size
551501 val encoder = if (logs.size > 1000) Json else prettyJson
@@ -607,18 +557,17 @@ fun LogViewerScreen(
607557 val start = dateRangePickerState.selectedStartDateMillis
608558 val end = dateRangePickerState.selectedEndDateMillis
609559 if (start != null && end != null) {
610− // DateRangePicker returns midnight UTC for the selected date, so the
611− // calendar date must be read back in UTC — reading it in the local zone
612− // would roll west-of-UTC users to the previous day. Once we have the
613− // tapped date, build the boundaries to cover the full day locally.
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.
614563 val localZone = java.time.ZoneId.systemDefault()
615564 val endLocalDate = java.time.Instant.ofEpochMilli(end)
616− .atZone(java.time.ZoneOffset.UTC).toLocalDate()
565+ .atZone(localZone).toLocalDate()
617566 val endOfDayUtc = endLocalDate.plusDays(1)
618567 .atStartOfDay(localZone)
619568 .toInstant().toEpochMilli() - 1
620569 val startLocalDate = java.time.Instant.ofEpochMilli(start)
621− .atZone(java.time.ZoneOffset.UTC).toLocalDate()
570+ .atZone(localZone).toLocalDate()
622571 val startUtc = startLocalDate
623572 .atStartOfDay(localZone)
624573 .toInstant().toEpochMilli()
@@ -701,10 +650,9 @@ private fun TimeRangeBar(
701650@Composable
702651private fun StreamingToggleButton(
703652 isStreaming: Boolean,
704− enabled: Boolean,
705653 onClick: () -> Unit,
706654) {
707− IconButton(onClick = onClick, enabled = enabled || isStreaming) {
655+ IconButton(onClick = onClick) {
708656 if (isStreaming) {
709657 val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
710658 val alpha by infiniteTransition.animateFloat(
@@ -735,12 +683,9 @@ private fun StreamingToggleButton(
735683@Composable
736684fun LogEntryCard(
737685 logEntry: JsonObject,
738− stableKey: String,
739686 isExpanded: Boolean,
740687 onClick: () -> Unit,
741688 onCopied: () -> Unit = {},
742− onFilterInclude: (column: String, value: String) -> Unit = { _, _ -> },
743− onFilterExclude: (column: String, value: String) -> Unit = { _, _ -> },
744689 clipboardManager: android.content.ClipboardManager,
745690) {
746691 val severity = remember(logEntry) { detectSeverity(logEntry) }
@@ -754,9 +699,7 @@ fun LogEntryCard(
754699 ) {
755700 Column(modifier = Modifier.padding(12.dp)) {
756701 // Timestamp row
757− // Key the derived values on the content-stable log key (not the JsonObject
758− // instance), so live-tail re-emissions of unchanged rows don't re-parse them.
759− val rawTimestamp = remember(stableKey) {
702+ val rawTimestamp = remember(logEntry) {
760703 try {
761704 logEntry["p_timestamp"]?.jsonPrimitive?.content
762705 ?: logEntry["datetime"]?.jsonPrimitive?.content
@@ -781,7 +724,7 @@ fun LogEntryCard(
781724
782725 if (!isExpanded) {
783726 // Compact view: show first meaningful fields
784− val preview = remember(stableKey) {
727+ val preview = remember(logEntry) {
785728 logEntry.entries
786729 .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
787730 .take(3)
@@ -823,55 +766,25 @@ fun LogEntryCard(
823766 }
824767 HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
825768 logEntry.entries.forEach { (key, value) ->
826− val displayValue = formatJsonValue(value)
827769 Row(
828770 modifier = Modifier
829771 .fillMaxWidth()
830772 .padding(vertical = 2.dp),
831− verticalAlignment = Alignment.CenterVertically,
832773 ) {
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− }
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+ )
875788 }
876789 }
877790 }
@@ -879,6 +792,24 @@ fun LogEntryCard(
879792 }
880793}
881794
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+
882813private fun formatJsonValue(element: JsonElement): String {
883814 return when (element) {
884815 is JsonPrimitive -> element.content
@@ -891,22 +822,10 @@ private fun formatJsonValue(element: JsonElement): String {
891822@Composable
892823fun FilterBottomSheet(
893824 columns: List<String>,
894− logs: List<JsonObject> = emptyList(),
895825 onDismiss: () -> Unit,
896826 onApplyFilter: (column: String, operator: String, value: String) -> Unit,
897827) {
898828 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− }
910829 var selectedOperator by remember { mutableStateOf("=") }
911830 var filterValue by remember { mutableStateOf("") }
912831 var columnDropdownExpanded by remember { mutableStateOf(false) }
@@ -914,28 +833,6 @@ fun FilterBottomSheet(
914833
915834 val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
916835
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−
939836 ModalBottomSheet(onDismissRequest = onDismiss) {
940837 Column(
941838 modifier = Modifier.padding(16.dp),
@@ -971,7 +868,6 @@ fun FilterBottomSheet(
971868 text = { Text(column) },
972869 onClick = {
973870 selectedColumn = column
974− filterValue = ""
975871 columnDropdownExpanded = false
976872 },
977873 )
@@ -1040,44 +936,6 @@ fun FilterBottomSheet(
1040936 }
1041937 }),
1042938 )
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− }
1081939 }
1082940
1083941 val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerViewModel.kt+113−415
@@ -3,7 +3,6 @@ package com.parseable.android.ui.screens.logviewer
33import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
55import com.parseable.android.data.escapeIdentifier
6−import com.parseable.android.data.escapeLikePattern
76import com.parseable.android.data.escapeSql
87import com.parseable.android.data.model.*
98import com.parseable.android.data.repository.ParseableRepository
@@ -16,13 +15,8 @@ import kotlinx.coroutines.flow.asStateFlow
1615import kotlinx.coroutines.flow.update
1716import kotlinx.coroutines.isActive
1817import kotlinx.coroutines.launch
19−import kotlinx.coroutines.sync.Mutex
20−import kotlinx.coroutines.sync.withLock
2118import kotlinx.serialization.json.JsonObject
2219import kotlinx.serialization.json.jsonPrimitive
23−import java.time.Instant
24−import java.time.LocalDateTime
25−import java.time.OffsetDateTime
2620import java.time.ZoneOffset
2721import java.time.ZonedDateTime
2822import java.time.format.DateTimeFormatter
@@ -40,21 +34,10 @@ enum class TimeRange(val label: String, val minutes: Long) {
4034 CUSTOM("Custom", 0),
4135}
4236
43−/** The original (column, operator, value) a filter chip was built from. */
44−data class FilterCondition(
45− val column: String,
46− val operator: String,
47− val value: String,
48−)
49−
5037data class FilterState(
5138 val searchQuery: String = "",
5239 val activeFilters: List<String> = emptyList(),
5340 val filterClauses: List<String> = emptyList(),
54− // Structured form of each chip, index-aligned with activeFilters/filterClauses.
55− // Kept so saved filters reconstruct from real values instead of re-parsing the
56− // human-readable display string (which is ambiguous when a value contains an operator).
57− val filterConditions: List<FilterCondition> = emptyList(),
5841 val customSql: String = "",
5942 val isSearching: Boolean = false,
6043)
@@ -76,9 +59,7 @@ data class SavedFiltersState(
7659data class LogViewerState(
7760 val streamName: String = "",
7861 val logs: List<JsonObject> = emptyList(),
79− val logKeys: List<String> = emptyList(),
8062 val columns: List<String> = emptyList(),
81− val searchableColumns: List<String> = emptyList(),
8263 val isLoading: Boolean = false,
8364 val error: String? = null,
8465 val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
@@ -89,10 +70,6 @@ data class LogViewerState(
8970 val hasMore: Boolean = false,
9071 val customStartTime: Long? = null,
9172 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,
9673) {
9774 // Convenience accessors for backward compatibility with Screen
9875 val searchQuery: String get() = filters.searchQuery
@@ -120,53 +97,17 @@ class LogViewerViewModel @Inject constructor(
12097 @Volatile private var streamingGeneration: Int = 0
12198 @Volatile private var lastSeenTimestamp: String? = null
12299 @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>()
135100 private var searchJob: Job? = null
136101 private var schemaJob: Job? = null
137− private var refreshJob: Job? = null
138102
139103 companion object {
140104 private const val STREAMING_BASE_INTERVAL_MS = 3000L
141105 private const val STREAMING_MAX_INTERVAL_MS = 30000L
142106 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
146107 private const val MAX_LOAD_LIMIT = 5000
147108 private const val LOAD_MORE_INCREMENT = 500
148109 private const val MAX_STREAMING_ERRORS = 5
149110 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− }
170111 }
171112
172113 fun initialize(streamName: String) {
@@ -182,9 +123,8 @@ class LogViewerViewModel @Inject constructor(
182123 schemaJob = viewModelScope.launch {
183124 when (val result = repository.getStreamSchema(streamName)) {
184125 is ApiResult.Success -> {
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) }
126+ val columns = result.data.fields.map { it.name }
127+ _state.update { it.copy(columns = columns) }
188128 }
189129 is ApiResult.Error -> { /* Schema loading failure is non-fatal */ }
190130 }
@@ -218,11 +158,7 @@ class LogViewerViewModel @Inject constructor(
218158 }
219159
220160 fun onSearchQueryChange(query: String) {
221− // A new query invalidates the live-tail baseline and the paged-up limit, matching
222− // the time-range / custom-SQL handlers.
223− stopStreaming()
224− // Searching returns to the filter-builder query path, so drop any active custom SQL.
225− _state.update { it.copy(currentLimit = 500, filters = it.filters.copy(searchQuery = query, customSql = "", isSearching = query.isNotBlank())) }
161+ _state.update { it.copy(filters = it.filters.copy(searchQuery = query, isSearching = query.isNotBlank())) }
226162 searchJob?.cancel()
227163 searchJob = viewModelScope.launch {
228164 delay(300)
@@ -231,55 +167,28 @@ class LogViewerViewModel @Inject constructor(
231167 }
232168 }
233169
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−
245170 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) {
251171 if (operator !in ALLOWED_OPERATORS) return
252172 val safeColumn = escapeIdentifier(column)
173+ val safeValue = escapeSql(value)
253174 val clause = when (operator) {
254175 "IS NULL", "IS NOT NULL" -> "\"$safeColumn\" $operator"
255− // %/_ in the user's value must match literally, so escape the LIKE wildcards
256− // and declare the escape character. Exact comparisons keep %/_ verbatim.
257− "LIKE", "ILIKE" -> "\"$safeColumn\" $operator '%${escapeLikePattern(value)}%' ESCAPE '\\'"
258− else -> "\"$safeColumn\" $operator '${escapeSql(value)}'"
259− }
260− // IS NULL / IS NOT NULL ignore the value in both the clause and the display, so store
261− // an empty value in the structured condition too (keeps saved filters clean).
262− val conditionValue = when (operator) {
263− "IS NULL", "IS NOT NULL" -> ""
264− else -> value
176+ "LIKE", "ILIKE" -> "\"$safeColumn\" $operator '%$safeValue%'"
177+ else -> "\"$safeColumn\" $operator '$safeValue'"
265178 }
266179 val display = when (operator) {
267180 "IS NULL", "IS NOT NULL" -> "$column $operator"
268181 else -> "$column $operator $value"
269182 }
270− // A new filter invalidates the live-tail baseline and the paged-up limit.
271− stopStreaming()
272183 _state.update {
273184 it.copy(
274− currentLimit = 500,
275185 filters = it.filters.copy(
276186 filterClauses = it.filters.filterClauses + clause,
277187 activeFilters = it.filters.activeFilters + display,
278− filterConditions = it.filters.filterConditions + FilterCondition(column, operator, conditionValue),
279− customSql = "",
280188 ),
281189 )
282190 }
191+ refresh()
283192 }
284193
285194 fun removeFilter(display: String) {
@@ -289,93 +198,81 @@ class LogViewerViewModel @Inject constructor(
289198 if (index >= 0 && index < it.filters.filterClauses.size) {
290199 removed = true
291200 it.copy(
292− currentLimit = 500,
293201 filters = it.filters.copy(
294202 activeFilters = it.filters.activeFilters.filterIndexed { i, _ -> i != index },
295203 filterClauses = it.filters.filterClauses.filterIndexed { i, _ -> i != index },
296− filterConditions = it.filters.filterConditions.filterIndexed { i, _ -> i != index },
297− customSql = "",
298204 ),
299205 )
300206 } else {
301207 it
302208 }
303209 }
304− if (removed) {
305− // A changed filter set invalidates the live-tail baseline.
306− stopStreaming()
307− refresh()
308− }
210+ if (removed) refresh()
309211 }
310212
311213 fun clearFilters() {
312− stopStreaming()
313214 _state.update {
314− it.copy(currentLimit = 500, filters = it.filters.copy(activeFilters = emptyList(), filterClauses = emptyList(), filterConditions = emptyList(), customSql = "", searchQuery = "", isSearching = false))
215+ it.copy(filters = it.filters.copy(activeFilters = emptyList(), filterClauses = emptyList()))
315216 }
316217 refresh()
317218 }
318219
319220 fun executeCustomSql(sql: String) {
320− // Custom SQL changes the query basis, so cancel any pending search debounce that would
321− // otherwise fire a redundant refresh() ~300ms later. (Unlike the filter/time-range
322− // handlers, this path doesn't route through stopStreaming().)
323− searchJob?.cancel()
324− // Drop a trailing statement terminator before we append anything — otherwise
325− // "SELECT * FROM x;" would become "SELECT * FROM x; LIMIT 5000", a syntax error.
326− val trimmed = sql.trim().trimEnd(';').trim()
221+ val trimmed = sql.trim()
327222 if (!trimmed.uppercase().startsWith("SELECT")) {
328223 _state.update { it.copy(error = "Only SELECT queries are allowed") }
329224 return
330225 }
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) {
226+ // Enforce a LIMIT to prevent OOM from unbounded queries
227+ val safeSql = if (!trimmed.uppercase().contains("LIMIT")) {
339228 "$trimmed LIMIT $MAX_LOAD_LIMIT"
340229 } else {
341230 trimmed
342231 }
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.
346232 _state.update { it.copy(filters = it.filters.copy(customSql = safeSql), currentLimit = 500) }
347− refresh()
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+ }
348254 }
349255
350− private fun buildSearchClause(searchableColumns: List<String>, searchQuery: String): String? {
256+ private fun buildSearchClause(columns: List<String>, searchQuery: String): String? {
351257 if (searchQuery.isBlank()) return null
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 '\\'"
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%'"
356264 }
357265 }
358266
359267 fun refresh() {
360268 if (_state.value.streamName.isEmpty()) return
361269
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− }
270+ viewModelScope.launch {
373271 _state.update {
374272 it.copy(
375273 isLoading = true,
376274 error = null,
377275 streaming = it.streaming.copy(streamingError = null),
378− pageAnchorTime = anchor,
379276 )
380277 }
381278
@@ -383,24 +280,10 @@ class LogViewerViewModel @Inject constructor(
383280 val current = _state.value
384281 val (startTime, endTime) = getTimeRange()
385282
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−
400283 // Build WHERE clause from filters + search
401284 val clauses = current.filters.filterClauses.toMutableList()
402285 if (current.filters.searchQuery.isNotBlank()) {
403− val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
286+ val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
404287 if (searchClause != null) {
405288 clauses.add("($searchClause)")
406289 } else {
@@ -413,26 +296,20 @@ class LogViewerViewModel @Inject constructor(
413296
414297 val filterSql = clauses.joinToString(" AND ")
415298
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
420299 when (val result = repository.queryLogs(
421300 stream = current.streamName,
422301 startTime = startTime,
423302 endTime = endTime,
424303 filterSql = filterSql,
425− limit = requestLimit,
304+ limit = current.currentLimit,
426305 )) {
427306 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− }
434307 _state.update {
435− it.copy(logs = logs, logKeys = computeLogKeys(logs), isLoading = false, hasMore = hasMore)
308+ it.copy(
309+ logs = result.data,
310+ isLoading = false,
311+ hasMore = result.data.size >= it.currentLimit,
312+ )
436313 }
437314 }
438315 is ApiResult.Error -> {
@@ -444,29 +321,8 @@ class LogViewerViewModel @Inject constructor(
444321 }
445322 }
446323
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−
465324 fun loadMore() {
466325 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
470326 if (current.currentLimit >= MAX_LOAD_LIMIT) {
471327 _state.update { it.copy(hasMore = false) }
472328 return
@@ -489,9 +345,7 @@ class LogViewerViewModel @Inject constructor(
489345 )
490346 return Pair(start.format(dateFormatter), end.format(dateFormatter))
491347 }
492− val now = current.pageAnchorTime
493− ?.let { ZonedDateTime.ofInstant(java.time.Instant.ofEpochMilli(it), ZoneOffset.UTC) }
494− ?: ZonedDateTime.now(ZoneOffset.UTC)
348+ val now = ZonedDateTime.now(ZoneOffset.UTC)
495349 val start = now.minusMinutes(current.selectedTimeRange.minutes)
496350 return Pair(
497351 start.format(dateFormatter),
@@ -499,17 +353,6 @@ class LogViewerViewModel @Inject constructor(
499353 )
500354 }
501355
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−
513356 fun toggleStreaming() {
514357 if (_state.value.streaming.isStreaming) {
515358 stopStreaming()
@@ -519,10 +362,6 @@ class LogViewerViewModel @Inject constructor(
519362 }
520363
521364 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
526365 stopStreaming()
527366 consecutiveStreamingErrors = 0
528367 val generation = ++streamingGeneration
@@ -530,12 +369,6 @@ class LogViewerViewModel @Inject constructor(
530369 it.copy(streaming = StreamingState(isStreaming = true, streamingNewCount = 0))
531370 }
532371
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−
539372 // Set the baseline timestamp to "now" so we only poll for new logs
540373 lastSeenTimestamp = ZonedDateTime.now(ZoneOffset.UTC).format(dateFormatter)
541374
@@ -558,13 +391,6 @@ class LogViewerViewModel @Inject constructor(
558391 }
559392
560393 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()
568394 streamingGeneration++
569395 streamingJob?.cancel()
570396 streamingJob = null
@@ -579,49 +405,6 @@ class LogViewerViewModel @Inject constructor(
579405 }
580406 }
581407
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−
625408 private suspend fun pollNewLogs() {
626409 val current = _state.value
627410 if (current.streamName.isEmpty()) return
@@ -632,118 +415,46 @@ class LogViewerViewModel @Inject constructor(
632415 // Build WHERE clause from active filters + search
633416 val clauses = current.filters.filterClauses.toMutableList()
634417 if (current.filters.searchQuery.isNotBlank()) {
635− val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
418+ val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
636419 if (searchClause != null) {
637− // Parenthesize: the search clause is OR-joined and must not break the
638− // precedence of the AND-joined filter clauses around it.
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
420+ clauses.add(searchClause)
653421 }
654422 }
655423
656424 val whereClause = if (clauses.isNotEmpty()) " WHERE ${clauses.joinToString(" AND ")}" else ""
657425 val safeName = escapeIdentifier(current.streamName)
658− // Fetch up to the in-memory display cap. With a small limit (e.g. 200), a burst of
659− // more than that many logs in one interval would advance lastSeenTimestamp past the
660− // un-fetched rows and drop them permanently. Capping at STREAMING_MAX_LOGS means we
661− // never skip rows we'd actually keep (anything older is evicted by the cap anyway).
662− val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT $STREAMING_MAX_LOGS"
426+ val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT 200"
663427
664428 when (val result = repository.queryLogsRaw(sql, startTime, endTime)) {
665429 is ApiResult.Success -> {
666430 val newLogs = result.data
667431 if (newLogs.isNotEmpty()) {
668− // Advance the last-seen boundary. Rows are ordered newest-first, so
669− // normally we jump to the newest. But a full page means the LIMIT may
670− // have capped off matching rows OLDER than the newest in this batch
671− // (possible once filters/search narrow a high-volume stream) — advancing
672− // to the newest would skip them forever. In that case advance only to the
673− // oldest row we did fetch, so the next poll re-queries forward and closes
674− // the gap (re-fetched rows are deduped below).
675− val fullPage = newLogs.size >= STREAMING_MAX_LOGS
676− val boundaryLog = if (fullPage) newLogs.lastOrNull() else newLogs.firstOrNull()
677− val boundaryTimestamp = try {
678− boundaryLog?.get("p_timestamp")?.jsonPrimitive?.content
432+ // Update the last seen timestamp to the most recent log
433+ val newestTimestamp = try {
434+ newLogs.firstOrNull()
435+ ?.get("p_timestamp")?.jsonPrimitive?.content
679436 } catch (_: Exception) {
680437 null
681438 }
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
439+ if (newestTimestamp != null) {
440+ lastSeenTimestamp = newestTimestamp
708441 }
709442
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− )
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
746450 }
451+ state.copy(
452+ logs = capped,
453+ streaming = state.streaming.copy(
454+ streamingNewCount = state.streaming.streamingNewCount + newLogs.size,
455+ streamingError = null,
456+ ),
457+ )
747458 }
748459 }
749460 consecutiveStreamingErrors = 0
@@ -770,37 +481,19 @@ class LogViewerViewModel @Inject constructor(
770481
771482 // --- Saved Filters (synced with Parseable server) ---
772483
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−
779484 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
784485 viewModelScope.launch {
785− savedFiltersMutex.withLock {
786− _state.update { it.copy(savedFilters = it.savedFilters.copy(isLoading = true, error = null)) }
787− when (val result = repository.listFilters()) {
788− is ApiResult.Success -> {
789− if (_state.value.streamName != stream) {
790− _state.update { it.copy(savedFilters = it.savedFilters.copy(isLoading = false)) }
791− return@withLock
792− }
793− val streamFilters = result.data
794− .filter { it.streamName == stream }
795− .sortedBy { it.filterName.lowercase() }
796− _state.update {
797− it.copy(savedFilters = it.savedFilters.copy(filters = streamFilters, isLoading = false))
798− }
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))
799492 }
800− is ApiResult.Error -> {
801− _state.update {
802− it.copy(savedFilters = it.savedFilters.copy(isLoading = false, error = result.userMessage))
803− }
493+ }
494+ is ApiResult.Error -> {
495+ _state.update {
496+ it.copy(savedFilters = it.savedFilters.copy(isLoading = false, error = result.userMessage))
804497 }
805498 }
806499 }
@@ -812,7 +505,6 @@ class LogViewerViewModel @Inject constructor(
812505 if (current.streamName.isEmpty()) return
813506
814507 viewModelScope.launch {
815− savedFiltersMutex.withLock {
816508 _state.update { it.copy(savedFilters = it.savedFilters.copy(isSaving = true, error = null)) }
817509
818510 val query = if (current.filters.customSql.isNotBlank()) {
@@ -821,13 +513,15 @@ class LogViewerViewModel @Inject constructor(
821513 filterQuery = current.filters.customSql,
822514 )
823515 } else {
824− val ruleGroups = current.filters.filterConditions
825− .mapIndexed { index, condition ->
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)
826520 FilterRule(
827521 id = "rule_$index",
828− field = condition.column,
829− value = condition.value,
830− operator = condition.operator,
522+ field = parts.first,
523+ value = parts.third,
524+ operator = parts.second,
831525 )
832526 }
833527 val builder = if (ruleGroups.isNotEmpty()) {
@@ -874,7 +568,6 @@ class LogViewerViewModel @Inject constructor(
874568 }
875569 }
876570 }
877− }
878571 }
879572 }
880573
@@ -884,15 +577,9 @@ class LogViewerViewModel @Inject constructor(
884577 when (filter.query.filterType) {
885578 "sql" -> {
886579 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.
893580 _state.update {
894581 it.copy(
895− filters = FilterState(),
582+ filters = FilterState(customSql = sql),
896583 currentLimit = 500,
897584 )
898585 }
@@ -928,7 +615,7 @@ class LogViewerViewModel @Inject constructor(
928615 for (group in builder.rules) {
929616 for (rule in group.rules) {
930617 if (rule.field.isNotBlank()) {
931− addFilterInternal(rule.field, rule.operator, rule.value)
618+ addFilter(rule.field, rule.operator, rule.value)
932619 }
933620 }
934621 }
@@ -936,21 +623,32 @@ class LogViewerViewModel @Inject constructor(
936623
937624 fun deleteSavedFilter(filterId: String) {
938625 viewModelScope.launch {
939− savedFiltersMutex.withLock {
940− when (repository.deleteFilter(filterId)) {
941− is ApiResult.Success -> {
942− _state.update {
943− it.copy(
944− savedFilters = it.savedFilters.copy(
945− filters = it.savedFilters.filters.filter { f -> f.filterId != filterId },
946− ),
947− )
948− }
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+ )
949634 }
950− is ApiResult.Error -> { /* Silently ignore — will show on next reload */ }
951635 }
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)
952649 }
953650 }
651+ return Triple(display, "=", "")
954652 }
955653
956654 override fun onCleared() {
app/src/main/java/com/parseable/android/ui/screens/logviewer/SeverityColor.kt+8−37
@@ -65,7 +65,8 @@ private fun parseSeverityValue(raw: String, fieldName: String): LogSeverity {
6565 trimmed.toIntOrNull()?.let { num ->
6666 return when {
6767 // Syslog severity (0 = emergency .. 7 = debug)
68− fieldName.equals("syslog_severity", ignoreCase = true) -> when (num) {
68+ fieldName.equals("syslog_severity", ignoreCase = true) ||
69+ fieldName.equals("priority", ignoreCase = true) -> when (num) {
6970 0, 1, 2 -> LogSeverity.FATAL // emergency, alert, critical
7071 3 -> LogSeverity.ERROR
7172 4 -> LogSeverity.WARNING
@@ -74,50 +75,20 @@ private fun parseSeverityValue(raw: String, fieldName: String): LogSeverity {
7475 7 -> LogSeverity.DEBUG
7576 else -> LogSeverity.UNKNOWN
7677 }
77− // Syslog "priority" (PRI) is the encoded value facility*8 + severity (0..191), so the
78− // severity is the low 3 bits. For a bare 0..7 this is identity; a real PRI like 134
79− // decodes to severity 6 (info) instead of falling through to UNKNOWN.
80− fieldName.equals("priority", ignoreCase = true) -> when {
81− num < 0 || num > 191 -> LogSeverity.UNKNOWN
82− else -> when (num % 8) {
83− 0, 1, 2 -> LogSeverity.FATAL // emergency, alert, critical
84− 3 -> LogSeverity.ERROR
85− 4 -> LogSeverity.WARNING
86− 5, 6 -> LogSeverity.INFO // notice, informational
87− else -> LogSeverity.DEBUG // 7
88− }
89− }
90− // HTTP status codes. Bound every branch to the valid HTTP range (100..599):
91− // `status` is a very generic field name, so an unbounded `>= 500`/`>= 400`
92− // would mis-color non-HTTP values (e.g. status: 50000) as ERROR/WARNING.
93− fieldName.equals("status", ignoreCase = true) -> when (num) {
94− in 500..599 -> LogSeverity.ERROR
95− in 400..499 -> LogSeverity.WARNING
96− // 1xx (informational) through 3xx are non-error HTTP statuses; 1xx previously
97− // fell through to UNKNOWN.
98− in 100..399 -> LogSeverity.INFO
78+ // HTTP status codes
79+ fieldName.equals("status", ignoreCase = true) -> when {
80+ num >= 500 -> LogSeverity.ERROR
81+ num >= 400 -> LogSeverity.WARNING
82+ num in 200..399 -> LogSeverity.INFO
9983 else -> LogSeverity.UNKNOWN
10084 }
101− // Generic numeric (higher = more severe). Two scales coexist under a bare numeric
102− // "level"/"severity" field, so handle both:
103− // • Large-magnitude java.util.logging (FINEST=300 … SEVERE=1000).
104− // • Small-integer structured JSON loggers (e.g. Bunyan: trace=10 … fatal=60).
105− // Without the small-integer arm, Bunyan-style levels (10..60) would all fall into the
106− // `num > 0 -> TRACE` catch-all and every such log would render as TRACE. The >= 100
107− // boundary keeps every existing java.util.logging mapping unchanged (100..399 stayed
108− // TRACE before and still does).
85+ // Generic numeric (Java util logging style: higher = more severe)
10986 else -> when {
11087 num >= 1000 -> LogSeverity.FATAL
11188 num >= 900 -> LogSeverity.ERROR
11289 num >= 800 -> LogSeverity.WARNING
11390 num >= 700 -> LogSeverity.INFO
11491 num >= 400 -> LogSeverity.DEBUG
115− num >= 100 -> LogSeverity.TRACE
116− num >= 60 -> LogSeverity.FATAL
117− num >= 50 -> LogSeverity.ERROR
118− num >= 40 -> LogSeverity.WARNING
119− num >= 30 -> LogSeverity.INFO
120− num >= 20 -> LogSeverity.DEBUG
12192 num > 0 -> LogSeverity.TRACE
12293 else -> LogSeverity.UNKNOWN
12394 }
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoScreen.kt+86−100
@@ -7,7 +7,6 @@ import androidx.compose.material.icons.Icons
77import androidx.compose.material.icons.automirrored.filled.ArrowBack
88import androidx.compose.material.icons.filled.*
99import androidx.compose.material3.*
10−import androidx.compose.material3.pulltorefresh.PullToRefreshBox
1110import androidx.compose.runtime.*
1211import androidx.compose.ui.Alignment
1312import androidx.compose.ui.Modifier
@@ -15,7 +14,10 @@ import androidx.compose.ui.text.font.FontFamily
1514import androidx.compose.ui.text.font.FontWeight
1615import androidx.compose.ui.unit.dp
1716import androidx.hilt.navigation.compose.hiltViewModel
17+import androidx.lifecycle.Lifecycle
18+import androidx.lifecycle.compose.LocalLifecycleOwner
1819import androidx.lifecycle.compose.collectAsStateWithLifecycle
20+import androidx.lifecycle.repeatOnLifecycle
1921import com.parseable.android.data.formatBytes
2022import kotlinx.serialization.json.Json
2123import kotlinx.serialization.json.JsonObject
@@ -33,10 +35,11 @@ fun StreamInfoScreen(
3335 val state by viewModel.state.collectAsStateWithLifecycle()
3436 var showDeleteConfirmation by remember { mutableStateOf(false) }
3537
36− // Load once per stream. Reloading on every RESUME would refetch all four endpoints on
37− // each return and could re-query a stream that was just deleted (spurious error card).
38− LaunchedEffect(streamName) {
39− viewModel.load(streamName)
38+ val lifecycleOwner = LocalLifecycleOwner.current
39+ LaunchedEffect(streamName, lifecycleOwner) {
40+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
41+ viewModel.load(streamName)
42+ }
4043 }
4144
4245 LaunchedEffect(state.deleteSuccess) {
@@ -70,114 +73,97 @@ fun StreamInfoScreen(
7073 )
7174 },
7275 ) { padding ->
73− // Has any section loaded yet? Drives whether a load shows the centered first-load
74− // spinner or keeps existing content visible (so pull-to-refresh stays usable on retry).
75− val hasContent = state.stats != null || state.schema.isNotEmpty() ||
76− state.rawInfo != null || state.retention.isNotEmpty() || state.schemaFailed
77− PullToRefreshBox(
78− // Only show the pull indicator when refreshing over existing content; the very
79− // first load uses the centered spinner below, so don't show both at once.
80− isRefreshing = state.isLoading && hasContent,
81− onRefresh = viewModel::refresh,
82− modifier = Modifier
83− .fillMaxSize()
84− .padding(padding),
85− ) {
86− if (state.isLoading && !hasContent) {
87− Box(
88− modifier = Modifier.fillMaxSize(),
89− contentAlignment = Alignment.Center,
90− ) {
91− CircularProgressIndicator()
92− }
93− } else {
94− Column(
95− modifier = Modifier
96− .fillMaxSize()
97− .verticalScroll(rememberScrollState())
98− .padding(16.dp),
99− verticalArrangement = Arrangement.spacedBy(16.dp),
100− ) {
101− // Stats section
102− state.stats?.let { stats ->
103− InfoSection(title = "Statistics") {
104− // Show the current-window count when available. Fall back to "N/A"
105− // only when there's no lifetime count either — otherwise the lifetime
106− // figure is shown in its own row below, so don't duplicate it here.
107− when {
108− stats.ingestion?.count != null -> InfoRow("Event Count", stats.ingestion.count.toString())
109− stats.ingestion?.lifetimeCount == null -> InfoRow("Event Count", "N/A")
110− }
111− InfoRow("Ingestion Size", formatBytes(stats.ingestion?.size) ?: formatBytes(stats.ingestion?.lifetimeSize) ?: "N/A")
112− InfoRow("Storage Size", formatBytes(stats.storage?.size) ?: formatBytes(stats.storage?.lifetimeSize) ?: "N/A")
113− if (stats.ingestion?.lifetimeCount != null) {
114− InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
115− }
116− if (stats.ingestion?.deletedCount != null) {
117− InfoRow("Deleted Events", stats.ingestion.deletedCount.toString())
118− }
76+ if (state.isLoading) {
77+ Box(
78+ modifier = Modifier
79+ .fillMaxSize()
80+ .padding(padding),
81+ contentAlignment = Alignment.Center,
82+ ) {
83+ CircularProgressIndicator()
84+ }
85+ } else {
86+ Column(
87+ modifier = Modifier
88+ .fillMaxSize()
89+ .padding(padding)
90+ .verticalScroll(rememberScrollState())
91+ .padding(16.dp),
92+ verticalArrangement = Arrangement.spacedBy(16.dp),
93+ ) {
94+ // Stats section
95+ state.stats?.let { stats ->
96+ InfoSection(title = "Statistics") {
97+ InfoRow("Event Count", stats.ingestion?.count?.toString() ?: stats.ingestion?.lifetimeCount?.toString() ?: "N/A")
98+ InfoRow("Ingestion Size", formatBytes(stats.ingestion?.size) ?: formatBytes(stats.ingestion?.lifetimeSize) ?: "N/A")
99+ InfoRow("Storage Size", formatBytes(stats.storage?.size) ?: formatBytes(stats.storage?.lifetimeSize) ?: "N/A")
100+ if (stats.ingestion?.lifetimeCount != null) {
101+ InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
102+ }
103+ if (stats.ingestion?.deletedCount != null) {
104+ InfoRow("Deleted Events", stats.ingestion.deletedCount.toString())
119105 }
120106 }
107+ }
121108
122− // Schema section
123− if (state.schema.isNotEmpty()) {
124− InfoSection(title = "Schema (${state.schema.size} fields)") {
125− state.schema.forEach { field ->
126− InfoRow(
127− field.name,
128− field.dataType?.toString()?.trim('"') ?: "Unknown",
129− )
130− }
131− }
132− } else if (state.schemaFailed) {
133− InfoSection(title = "Schema") {
134− Text(
135− text = "Schema unavailable",
136− style = MaterialTheme.typography.bodyMedium,
137− color = MaterialTheme.colorScheme.onSurfaceVariant,
109+ // Schema section
110+ if (state.schema.isNotEmpty()) {
111+ InfoSection(title = "Schema (${state.schema.size} fields)") {
112+ state.schema.forEach { field ->
113+ InfoRow(
114+ field.name,
115+ field.dataType?.toString()?.trim('"') ?: "Unknown",
138116 )
139117 }
140118 }
119+ } else if (state.schemaFailed) {
120+ InfoSection(title = "Schema") {
121+ Text(
122+ text = "Schema unavailable",
123+ style = MaterialTheme.typography.bodyMedium,
124+ color = MaterialTheme.colorScheme.onSurfaceVariant,
125+ )
126+ }
127+ }
141128
142− // Retention section
143− if (state.retention.isNotEmpty()) {
144− InfoSection(title = "Retention") {
145− state.retention.forEach { r ->
146− InfoRow("Duration", r.duration ?: "N/A")
147− InfoRow("Action", r.action ?: "N/A")
148− if (r.description != null) {
149− InfoRow("Description", r.description)
150− }
129+ // Retention section
130+ if (state.retention.isNotEmpty()) {
131+ InfoSection(title = "Retention") {
132+ state.retention.forEach { r ->
133+ InfoRow("Duration", r.duration ?: "N/A")
134+ InfoRow("Action", r.action ?: "N/A")
135+ if (r.description != null) {
136+ InfoRow("Description", r.description)
151137 }
152138 }
153139 }
140+ }
154141
155− // Raw info section
156− state.rawInfo?.let { rawInfo ->
157− val prettyInfo = remember(rawInfo) {
158− prettyJson.encodeToString(JsonObject.serializer(), rawInfo)
159− }
160− InfoSection(title = "Stream Info") {
161− Text(
162− text = prettyInfo,
163− style = MaterialTheme.typography.bodySmall,
164− fontFamily = FontFamily.Monospace,
165− )
166− }
142+ // Raw info section
143+ state.rawInfo?.let { rawInfo ->
144+ val prettyInfo = remember(rawInfo) {
145+ prettyJson.encodeToString(JsonObject.serializer(), rawInfo)
167146 }
147+ InfoSection(title = "Stream Info") {
148+ Text(
149+ text = prettyInfo,
150+ style = MaterialTheme.typography.bodySmall,
151+ fontFamily = FontFamily.Monospace,
152+ )
153+ }
154+ }
168155
169− state.error?.let { errorText ->
170− Card(
171− colors = CardDefaults.cardColors(
172− containerColor = MaterialTheme.colorScheme.errorContainer,
173− ),
174− ) {
175− Text(
176− text = errorText,
177− modifier = Modifier.padding(16.dp),
178− color = MaterialTheme.colorScheme.onErrorContainer,
179− )
180− }
156+ state.error?.let { errorText ->
157+ Card(
158+ colors = CardDefaults.cardColors(
159+ containerColor = MaterialTheme.colorScheme.errorContainer,
160+ ),
161+ ) {
162+ Text(
163+ text = errorText,
164+ modifier = Modifier.padding(16.dp),
165+ color = MaterialTheme.colorScheme.onErrorContainer,
166+ )
181167 }
182168 }
183169 }
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoViewModel.kt+10−59
@@ -8,7 +8,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
88import kotlinx.coroutines.CancellationException
99import kotlinx.coroutines.Job
1010import kotlinx.coroutines.async
11−import kotlinx.coroutines.coroutineScope
1211import kotlinx.coroutines.flow.MutableStateFlow
1312import kotlinx.coroutines.flow.StateFlow
1413import kotlinx.coroutines.flow.asStateFlow
@@ -39,61 +38,21 @@ class StreamInfoViewModel @Inject constructor(
3938 val state: StateFlow<StreamInfoState> = _state.asStateFlow()
4039 private var loadJob: Job? = null
4140
42− /** Holds the four parallel load results so coroutineScope can return them with their types intact. */
43− private data class LoadResults(
44− val stats: ApiResult<StreamStats>,
45− val schema: ApiResult<StreamSchema>,
46− val retention: ApiResult<List<RetentionConfig>>,
47− val info: ApiResult<JsonObject>,
48− )
49−
5041 fun load(streamName: String) {
51− // Already showing (or loading) this stream — don't cancel and refetch all four
52− // endpoints on recomposition / config change. Matches LogViewerViewModel.initialize's
53− // guard; the screen loads once per stream (see StreamInfoScreen's LaunchedEffect).
54− if (_state.value.streamName == streamName) return
55− fetch(streamName)
56− }
57−
58− /**
59− * Re-fetch the currently-shown stream, bypassing load()'s identity guard. Wired to
60− * pull-to-refresh so the user can retry after a transient endpoint failure (e.g. a failed
61− * schema fetch or a delete that errored) without leaving and re-entering the screen.
62− */
63− fun refresh() {
64− val name = _state.value.streamName
65− if (name.isEmpty()) return
66− fetch(name)
67− }
68−
69− private fun fetch(streamName: String) {
7042 loadJob?.cancel()
7143 _state.update { it.copy(streamName = streamName, isLoading = true, error = null) }
7244
7345 loadJob = viewModelScope.launch {
7446 try {
75− // Wrap the parallel fetches in coroutineScope so the four async children
76− // form their own structured-concurrency scope (matching StreamsViewModel /
77− // SettingsViewModel). The repository returns ApiResult.Error rather than
78− // throwing, but if any call did throw unexpectedly this keeps the failure
79− // contained to this load instead of it propagating oddly through the launch.
80− val results = coroutineScope {
81− val statsDeferred = async { repository.getStreamStats(streamName) }
82− val schemaDeferred = async { repository.getStreamSchema(streamName) }
83− val retentionDeferred = async { repository.getStreamRetention(streamName) }
84− val infoDeferred = async { repository.getStreamInfo(streamName) }
47+ val statsDeferred = async { repository.getStreamStats(streamName) }
48+ val schemaDeferred = async { repository.getStreamSchema(streamName) }
49+ val retentionDeferred = async { repository.getStreamRetention(streamName) }
50+ val infoDeferred = async { repository.getStreamInfo(streamName) }
8551
86− LoadResults(
87− stats = statsDeferred.await(),
88− schema = schemaDeferred.await(),
89− retention = retentionDeferred.await(),
90− info = infoDeferred.await(),
91− )
92− }
93− val statsResult = results.stats
94− val schemaResult = results.schema
95− val retentionResult = results.retention
96− val infoResult = results.info
52+ val statsResult = statsDeferred.await()
53+ val schemaResult = schemaDeferred.await()
54+ val retentionResult = retentionDeferred.await()
55+ val infoResult = infoDeferred.await()
9756
9857 _state.update {
9958 it.copy(
@@ -127,17 +86,9 @@ class StreamInfoViewModel @Inject constructor(
12786
12887 fun deleteStream() {
12988 val name = _state.value.streamName
130− // Guard against a no-op or a second tap firing a duplicate DELETE while one is in flight.
131− if (name.isEmpty() || _state.value.isDeleting) return
132− // Cancel any in-flight load so its terminal update can't re-populate stats/schema for the
133− // stream we're about to delete after the DELETE completes.
134− loadJob?.cancel()
89+ if (name.isEmpty()) return
13590 viewModelScope.launch {
136− // Clear isLoading here too: cancelling loadJob above kills any in-flight fetch before
137− // its terminal update can flip isLoading back to false, so without this a load that was
138− // running when delete started would leave the pull-to-refresh spinner stuck on the
139− // error path (delete only manages isDeleting).
140− _state.update { it.copy(isDeleting = true, isLoading = false, error = null) }
91+ _state.update { it.copy(isDeleting = true, error = null) }
14192 when (val result = repository.deleteStream(name)) {
14293 is ApiResult.Success -> {
14394 _state.update { it.copy(isDeleting = false, deleteSuccess = true) }
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsScreen.kt+2−193
@@ -12,12 +12,10 @@ 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
1615import androidx.compose.ui.unit.dp
1716import androidx.hilt.navigation.compose.hiltViewModel
1817import androidx.lifecycle.compose.collectAsStateWithLifecycle
1918import com.parseable.android.BuildConfig
20−import com.parseable.android.data.local.SavedServer
2119import kotlinx.serialization.json.contentOrNull
2220import kotlinx.serialization.json.jsonPrimitive
2321
@@ -25,25 +23,10 @@ import kotlinx.serialization.json.jsonPrimitive
2523@Composable
2624fun SettingsScreen(
2725 onBack: () -> Unit,
28− onAboutClick: () -> Unit,
29− onServerSwitched: () -> Unit,
30− onLoggedOut: () -> Unit,
3126 viewModel: SettingsViewModel = hiltViewModel(),
3227) {
3328 val state by viewModel.state.collectAsStateWithLifecycle()
3429
35− LaunchedEffect(Unit) {
36− viewModel.switchEvent.collect {
37− onServerSwitched()
38− }
39− }
40−
41− LaunchedEffect(Unit) {
42− viewModel.loggedOutEvent.collect {
43− onLoggedOut()
44− }
45− }
46−
4730 Scaffold(
4831 topBar = {
4932 TopAppBar(
@@ -96,21 +79,8 @@ fun SettingsScreen(
9679 }
9780 }
9881
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) {
82+ // Loading indicator for server data
83+ if (state.isLoading) {
11484 Box(
11585 modifier = Modifier
11686 .fillMaxWidth()
@@ -248,167 +218,6 @@ fun SettingsScreen(
248218 SettingsRow("Platform", "Android")
249219 }
250220 }
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− )
412221 }
413222 }
414223 }
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsViewModel.kt+8−126
@@ -2,22 +2,18 @@ package com.parseable.android.ui.screens.settings
22
33import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
5−import com.parseable.android.data.local.SavedServer
65import com.parseable.android.data.model.AboutInfo
76import com.parseable.android.data.model.ApiResult
87import com.parseable.android.data.repository.ParseableRepository
98import com.parseable.android.data.repository.SettingsRepository
109import dagger.hilt.android.lifecycle.HiltViewModel
1110import kotlinx.coroutines.CancellationException
12−import kotlinx.coroutines.Job
1311import kotlinx.coroutines.async
14−import kotlinx.coroutines.channels.Channel
1512import kotlinx.coroutines.coroutineScope
1613import kotlinx.coroutines.flow.MutableStateFlow
1714import kotlinx.coroutines.flow.StateFlow
1815import kotlinx.coroutines.flow.asStateFlow
1916import kotlinx.coroutines.flow.first
20−import kotlinx.coroutines.flow.receiveAsFlow
2117import kotlinx.coroutines.flow.update
2218import kotlinx.coroutines.launch
2319import kotlinx.serialization.json.jsonPrimitive
@@ -31,10 +27,6 @@ data class SettingsState(
3127 val users: List<String> = emptyList(),
3228 val isLoading: Boolean = false,
3329 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,
3830)
3931
4032@HiltViewModel
@@ -46,44 +38,12 @@ class SettingsViewModel @Inject constructor(
4638 private val _state = MutableStateFlow(SettingsState())
4739 val state: StateFlow<SettingsState> = _state.asStateFlow()
4840
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−
6941 init {
7042 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− }
8143 }
8244
8345 fun load() {
84− // Cancel any in-flight load so a slower completion can't overwrite a newer one.
85− loadJob?.cancel()
86− loadJob = viewModelScope.launch {
46+ viewModelScope.launch {
8747 _state.update { it.copy(isLoading = true, error = null) }
8848
8949 val config = settingsRepository.serverConfig.first()
@@ -106,24 +66,24 @@ class SettingsViewModel @Inject constructor(
10666
10767 val userNames = (usersResult as? ApiResult.Success)?.data?.mapNotNull { obj ->
10868 try {
109− obj["username"]?.jsonPrimitive?.content
110− ?: obj["id"]?.jsonPrimitive?.content
69+ obj["id"]?.jsonPrimitive?.content
70+ ?: obj["username"]?.jsonPrimitive?.content
11171 } catch (_: IllegalStateException) {
11272 null
11373 }
11474 } ?: emptyList()
11575
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
76+ val errors = listOfNotNull(
77+ (aboutResult as? ApiResult.Error)?.userMessage,
78+ (usersResult as? ApiResult.Error)?.userMessage,
79+ ).distinct().joinToString("\n").ifEmpty { null }
12080
12181 _state.update {
12282 it.copy(
12383 aboutInfo = (aboutResult as? ApiResult.Success)?.data,
12484 users = userNames,
12585 isLoading = false,
126− error = error,
86+ error = errors,
12787 )
12888 }
12989 } catch (e: CancellationException) {
@@ -136,82 +96,4 @@ class SettingsViewModel @Inject constructor(
13696 }
13797 }
13898 }
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− }
21799}
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsScreen.kt+15−20
@@ -10,13 +10,15 @@ import androidx.compose.material.icons.filled.*
1010import androidx.compose.material3.*
1111import androidx.compose.material3.pulltorefresh.PullToRefreshBox
1212import androidx.compose.runtime.*
13−import androidx.compose.runtime.saveable.rememberSaveable
1413import androidx.compose.ui.Alignment
1514import androidx.compose.ui.Modifier
15+import androidx.lifecycle.compose.LocalLifecycleOwner
1616import androidx.compose.ui.text.font.FontWeight
1717import androidx.compose.ui.unit.dp
1818import androidx.hilt.navigation.compose.hiltViewModel
19+import androidx.lifecycle.Lifecycle
1920import androidx.lifecycle.compose.collectAsStateWithLifecycle
21+import androidx.lifecycle.repeatOnLifecycle
2022import kotlinx.coroutines.flow.collectLatest
2123import kotlinx.coroutines.launch
2224
@@ -32,21 +34,16 @@ fun StreamsScreen(
3234 val state by viewModel.state.collectAsStateWithLifecycle()
3335 val scope = rememberCoroutineScope()
3436 var showLogoutConfirmation by remember { mutableStateOf(false) }
35− var searchQuery by rememberSaveable { mutableStateOf("") }
37+ var searchQuery by remember { mutableStateOf("") }
3638 val snackbarHostState = remember { SnackbarHostState() }
3739
3840 val listState = rememberLazyListState()
3941
40− // Scroll to top only when a refresh actually completes (isLoading transitions true -> false
41− // while composed). Keying solely on isLoading also fired on return-to-screen and on the
42− // initial composition, yanking the list to the top and discarding the user's restored
43− // scroll position. Tracking the previous value limits the jump to genuine refresh events.
44− var wasLoading by remember { mutableStateOf(false) }
42+ // Scroll to top when a refresh completes (isLoading transitions to false)
4543 LaunchedEffect(state.isLoading) {
46− if (wasLoading && !state.isLoading) {
44+ if (!state.isLoading) {
4745 listState.scrollToItem(0)
4846 }
49− wasLoading = state.isLoading
5047 }
5148
5249 val filteredStreams = remember(state.streams, searchQuery, state.favoriteNames) {
@@ -58,11 +55,14 @@ fun StreamsScreen(
5855 }
5956 // Partition by favorites instead of full sort for O(n) vs O(n log n)
6057 val (favs, rest) = filtered.partition { it.name in favorites }
61− // `favorites` iterates most-recently-favorited first (DAO ORDER BY addedAt DESC
62− // preserved through LinkedHashSet); order favs to match so recency is reflected.
63− val favRank = favorites.withIndex().associate { (i, name) -> name to i }
64− val sortedFavs = favs.sortedBy { favRank[it.name] ?: Int.MAX_VALUE }
65− sortedFavs + rest
58+ favs + rest
59+ }
60+
61+ val lifecycleOwner = LocalLifecycleOwner.current
62+ LaunchedEffect(lifecycleOwner) {
63+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
64+ viewModel.refresh()
65+ }
6666 }
6767
6868 LaunchedEffect(Unit) {
@@ -157,12 +157,7 @@ fun StreamsScreen(
157157 }
158158 } else {
159159 Column {
160− // Keep the search field visible whenever a query is active, even if the
161− // total stream count has since dropped to <=5 (e.g. after a refresh or a
162− // server-side deletion). Otherwise the field would vanish while the stale
163− // query keeps filtering `filteredStreams`, stranding the user with no way
164− // to clear it.
165− if (state.streams.size > 5 || searchQuery.isNotEmpty()) {
160+ if (state.streams.size > 5) {
166161 OutlinedTextField(
167162 value = searchQuery,
168163 onValueChange = { searchQuery = it },
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsViewModel.kt+11−53
@@ -22,9 +22,7 @@ import kotlinx.coroutines.flow.asStateFlow
2222import kotlinx.coroutines.flow.receiveAsFlow
2323import kotlinx.coroutines.flow.update
2424import kotlinx.coroutines.launch
25−import kotlinx.coroutines.sync.Mutex
2625import kotlinx.coroutines.sync.Semaphore
27−import kotlinx.coroutines.sync.withLock
2826import kotlinx.coroutines.sync.withPermit
2927import javax.inject.Inject
3028
@@ -63,40 +61,22 @@ class StreamsViewModel @Inject constructor(
6361 _state.update { it.copy(favoriteNames = names.toSet()) }
6462 }
6563 }
66− // Load once when the ViewModel is created. The screen no longer refreshes on every
67− // RESUME, so returning via back-navigation reuses the already-loaded data.
68− refresh()
6964 }
7065
71− // Serializes toggles so two rapid taps can't both read the same pre-update state and
72− // take the same branch (which, with idempotent DAO ops, would leave the favorite in the
73− // wrong final state — e.g. tap-twice nets to "favorited" instead of cancelling out).
74− private val favoriteMutex = Mutex()
75−
7666 fun toggleFavorite(streamName: String) {
7767 viewModelScope.launch {
78− favoriteMutex.withLock {
79− // Decide from the committed DB state, not the async-mirrored favoriteNames.
80− if (favoriteDao.isFavoriteNow(streamName)) {
81− favoriteDao.deleteByName(streamName)
82− _snackbarEvent.send("Removed from favorites")
83− } else {
84− favoriteDao.insert(FavoriteStream(streamName = streamName))
85− _snackbarEvent.send("Added to favorites")
86− }
68+ if (streamName in _state.value.favoriteNames) {
69+ favoriteDao.deleteByName(streamName)
70+ _snackbarEvent.send("Removed from favorites")
71+ } else {
72+ favoriteDao.insert(FavoriteStream(streamName = streamName))
73+ _snackbarEvent.send("Added to favorites")
8774 }
8875 }
8976 }
9077
91− private var refreshJob: Job? = null
92−
9378 fun refresh() {
94− // Cancel any in-flight refresh so out-of-order completions can't overwrite a newer
95− // result with a stale one (the init load and a pull-to-refresh, or two quick pulls,
96− // can otherwise race and leave the older stream list showing). Mirrors the refreshJob
97− // guard in LogViewerViewModel.
98− refreshJob?.cancel()
99− refreshJob = viewModelScope.launch {
79+ viewModelScope.launch {
10080 _state.update { it.copy(isLoading = true, error = null) }
10181
10282 try {
@@ -113,14 +93,9 @@ class StreamsViewModel @Inject constructor(
11393
11494 when (streamsResult) {
11595 is ApiResult.Success -> {
116− // Prune cached stats/failures for streams that no longer exist so
117− // deleted or recreated streams don't show stale numbers.
118− val names = streamsResult.data.mapTo(HashSet()) { it.name }
11996 _state.update {
12097 it.copy(
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 },
98+ streams = streamsResult.data,
12499 isLoading = false,
125100 )
126101 }
@@ -149,14 +124,9 @@ class StreamsViewModel @Inject constructor(
149124
150125 private val statsSemaphore = Semaphore(8)
151126 private var statsJob: Job? = null
152− // Per-stream retry jobs, tracked so a new bulk refresh can cancel any in-flight
153− // retry — otherwise a late retry result could overwrite freshly-loaded stats.
154− private val retryJobs = mutableMapOf<String, Job>()
155127
156128 private fun loadStreamStats(streams: List<LogStream>) {
157129 statsJob?.cancel()
158− retryJobs.values.forEach { it.cancel() }
159− retryJobs.clear()
160130 statsJob = viewModelScope.launch {
161131 streams.forEach { stream ->
162132 launch {
@@ -169,20 +139,12 @@ class StreamsViewModel @Inject constructor(
169139 }
170140
171141 fun retryStats(streamName: String) {
172− retryJobs[streamName]?.cancel()
173− val job = viewModelScope.launch {
142+ viewModelScope.launch {
174143 _state.update { it.copy(failedStats = it.failedStats - streamName) }
175144 statsSemaphore.withPermit {
176145 loadSingleStreamStats(streamName)
177146 }
178147 }
179− retryJobs[streamName] = job
180− // Drop the entry once the retry finishes so the map doesn't accumulate completed
181− // jobs for the ViewModel's lifetime. Guard with an identity check so we never remove
182− // a newer retry that has since replaced this one for the same stream.
183− job.invokeOnCompletion {
184− if (retryJobs[streamName] === job) retryJobs.remove(streamName)
185− }
186148 }
187149
188150 private suspend fun loadSingleStreamStats(streamName: String) {
@@ -198,10 +160,7 @@ class StreamsViewModel @Inject constructor(
198160 ?: formatBytes(stats.storage?.lifetimeSize),
199161 )
200162 _state.update {
201− // Drop late results for streams a concurrent refresh has already pruned,
202− // so an in-flight retry can't resurrect stats for a deleted stream.
203− if (it.streams.none { s -> s.name == streamName }) it
204− else it.copy(
163+ it.copy(
205164 streamStats = it.streamStats + (streamName to ui),
206165 failedStats = it.failedStats - streamName,
207166 )
@@ -209,8 +168,7 @@ class StreamsViewModel @Inject constructor(
209168 }
210169 is ApiResult.Error -> {
211170 _state.update {
212− if (it.streams.none { s -> s.name == streamName }) it
213− else it.copy(failedStats = it.failedStats + streamName)
171+ it.copy(failedStats = it.failedStats + streamName)
214172 }
215173 }
216174 }
app/src/main/res/drawable/ic_launcher_background.xmlnew file+10
@@ -0,0 +1,10 @@
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.xmlnew file+15
@@ -0,0 +1,15 @@
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.xmlnew file+11
@@ -0,0 +1,11 @@
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.xmlnew file+6
@@ -0,0 +1,6 @@
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.xmlnew file+6
@@ -0,0 +1,6 @@
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.webpdeleted

Binary file not shown.

app/src/main/res/mipmap-mdpi/ic_launcher.webpdeleted

Binary file not shown.

app/src/main/res/mipmap-xhdpi/ic_launcher.webpdeleted

Binary file not shown.

app/src/main/res/mipmap-xxhdpi/ic_launcher.webpdeleted

Binary file not shown.

app/src/main/res/mipmap-xxxhdpi/ic_launcher.webpdeleted

Binary file not shown.

app/src/test/java/com/parseable/android/AlertsViewModelTest.kt−4
@@ -24,10 +24,6 @@ class AlertsViewModelTest {
2424 fun setup() {
2525 Dispatchers.setMain(testDispatcher)
2626 repository = mockk(relaxed = true)
27− // The ViewModel loads once in init { refresh() }. Give that load a deterministic
28− // empty-success default so the initial state is clean; tests that exercise refresh
29− // override this stub and call refresh() again.
30− coEvery { repository.listAlerts() } returns ApiResult.Success(emptyList())
3127 viewModel = AlertsViewModel(repository)
3228 }
3329
app/src/test/java/com/parseable/android/EscapeSqlTest.kt−27
@@ -1,7 +1,6 @@
11package com.parseable.android
22
33import com.parseable.android.data.escapeIdentifier
4−import com.parseable.android.data.escapeLikePattern
54import com.parseable.android.data.escapeSql
65import org.junit.Assert.assertEquals
76import org.junit.Test
@@ -53,32 +52,6 @@ class EscapeSqlTest {
5352 assertEquals("''; DROP TABLE users --", escapeSql("'; DROP TABLE users --"))
5453 }
5554
56− @Test
57− fun `escapeLikePattern escapes percent wildcard`() {
58− assertEquals("100\\%", escapeLikePattern("100%"))
59− }
60−
61− @Test
62− fun `escapeLikePattern escapes underscore wildcard`() {
63− assertEquals("a\\_b", escapeLikePattern("a_b"))
64− }
65−
66− @Test
67− fun `escapeLikePattern escapes backslash before wildcards`() {
68− // Input backslash becomes a doubled backslash; %/_ each gain one escape.
69− assertEquals("\\\\\\%\\_", escapeLikePattern("\\%_"))
70− }
71−
72− @Test
73− fun `escapeLikePattern also escapes single quotes`() {
74− assertEquals("it''s 50\\%", escapeLikePattern("it's 50%"))
75− }
76−
77− @Test
78− fun `escapeLikePattern leaves plain text untouched`() {
79− assertEquals("hello world", escapeLikePattern("hello world"))
80− }
81−
8255 @Test
8356 fun `escapeIdentifier doubles double quotes`() {
8457 assertEquals("foo\"\"bar", escapeIdentifier("foo\"bar"))
app/src/test/java/com/parseable/android/FormatTimestampTest.kt−14
@@ -32,20 +32,6 @@ class FormatTimestampTest {
3232 }
3333 }
3434
35− @Test
36− fun `treats offset-less timestamp as UTC and converts to local`() {
37− val original = TimeZone.getDefault()
38− try {
39− TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"))
40− // No offset/zone: should be interpreted as UTC (Parseable stores UTC),
41− // then converted to local (June = EDT = UTC-4).
42− val result = formatTimestamp("2024-06-15T18:30:45.000")
43− assertEquals("Jun 15 14:30:45.000", result)
44− } finally {
45− TimeZone.setDefault(original)
46− }
47− }
48−
4935 @Test
5036 fun `returns raw string for unparseable timestamp`() {
5137 val result = formatTimestamp("not-a-timestamp")
app/src/test/java/com/parseable/android/LogViewerViewModelTest.kt+3−57
@@ -164,9 +164,9 @@ class LogViewerViewModelTest {
164164 startTime = any(),
165165 endTime = any(),
166166 filterSql = match {
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
167+ it.contains("\"message\" ILIKE '%error%'") &&
168+ it.contains("\"level\" ILIKE '%error%'") &&
169+ !it.contains("p_timestamp ILIKE") // Internal columns excluded
170170 },
171171 limit = any(),
172172 )
@@ -185,24 +185,6 @@ class LogViewerViewModelTest {
185185 assertEquals(500, state.currentLimit) // Reset
186186 }
187187
188− @Test
189− fun `pullRefresh is a no-op while streaming`() {
190− viewModel.initialize("test")
191− // Page out so currentLimit is no longer at its initial 500.
192− viewModel.loadMore()
193− assertEquals(1000, viewModel.state.value.currentLimit)
194−
195− viewModel.toggleStreaming()
196− assertTrue(viewModel.state.value.isStreaming)
197−
198− // While live-tailing, pullRefresh must not run refresh() (which would reset the limit
199− // to 500 and race the streaming poller). Streaming stays on and the limit is untouched.
200− viewModel.pullRefresh()
201−
202− assertTrue(viewModel.state.value.isStreaming)
203− assertEquals(1000, viewModel.state.value.currentLimit)
204− }
205−
206188 @Test
207189 fun `toggleStreaming starts and stops`() {
208190 viewModel.initialize("test")
@@ -274,42 +256,6 @@ class LogViewerViewModelTest {
274256 }
275257 }
276258
277− @Test
278− fun `executeCustomSql strips trailing semicolon before appending LIMIT`() {
279− coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
280−
281− viewModel.initialize("test")
282− // Without stripping the ';', the appended LIMIT would land after the terminator
283− // ("...test_stream; LIMIT 5000") and produce invalid SQL.
284− viewModel.executeCustomSql("SELECT * FROM test_stream;")
285−
286− coVerify {
287− repository.queryLogsRaw(
288− match { it.trim() == "SELECT * FROM test_stream LIMIT 5000" },
289− any(),
290− any(),
291− )
292− }
293− }
294−
295− @Test
296− fun `executeCustomSql adds outer LIMIT when only a subquery has one`() {
297− coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
298−
299− viewModel.initialize("test")
300− // The inner LIMIT doesn't bound the outer result set, so the safety LIMIT must still
301− // be appended.
302− viewModel.executeCustomSql("SELECT * FROM (SELECT * FROM test_stream LIMIT 10) t")
303−
304− coVerify {
305− repository.queryLogsRaw(
306− match { it.contains("LIMIT 10") && it.trimEnd().endsWith("LIMIT 5000") },
307− any(),
308− any(),
309− )
310− }
311− }
312−
313259 @Test
314260 fun `addFilter escapes column names`() {
315261 viewModel.initialize("test")
app/src/test/java/com/parseable/android/LoginViewModelTest.kt+4−28
@@ -9,8 +9,6 @@ import io.mockk.*
99import kotlinx.coroutines.Dispatchers
1010import kotlinx.coroutines.ExperimentalCoroutinesApi
1111import kotlinx.coroutines.flow.flowOf
12−import kotlinx.coroutines.flow.toList
13−import kotlinx.coroutines.launch
1412import kotlinx.coroutines.test.*
1513import org.junit.After
1614import org.junit.Assert.*
@@ -39,17 +37,6 @@ class LoginViewModelTest {
3937 Dispatchers.resetMain()
4038 }
4139
42− /**
43− * Start collecting the one-shot login-success events into a list (on the eager
44− * UnconfinedTestDispatcher) before the action under test, so the success emission is observed.
45− * The collector lives in backgroundScope, which runTest auto-cancels.
46− */
47− private fun TestScope.collectSuccessEvents(): MutableList<Unit> {
48− val events = mutableListOf<Unit>()
49− backgroundScope.launch(testDispatcher) { viewModel.loginSuccessEvent.toList(events) }
50− return events
51− }
52−
5340 @Test
5441 fun `onLogin validates required fields`() {
5542 viewModel.onLogin()
@@ -78,11 +65,6 @@ class LoginViewModelTest {
7865 fun `onLogin succeeds with valid credentials`() = runTest {
7966 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
8067 coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo(version = "1.0"))
81− // Reach the success branch deterministically: the login-success event only fires when the
82− // credentials were durably saved.
83− coEvery { settingsRepository.saveServerConfig(any()) } returns true
84−
85− val events = collectSuccessEvents()
8668
8769 viewModel.onServerUrlChange("https://test.parseable.com")
8870 viewModel.onUsernameChange("admin")
@@ -90,7 +72,7 @@ class LoginViewModelTest {
9072 viewModel.onLogin()
9173
9274 val state = viewModel.state.value
93− assertEquals(1, events.size)
75+ assertTrue(state.loginSuccess)
9476 assertFalse(state.isLoading)
9577 coVerify { settingsRepository.saveServerConfig(any()) }
9678 }
@@ -99,15 +81,13 @@ class LoginViewModelTest {
9981 fun `onLogin fails when connection fails`() = runTest {
10082 coEvery { repository.testConnection() } returns ApiResult.Error("Connection refused", 0)
10183
102− val events = collectSuccessEvents()
103−
10484 viewModel.onServerUrlChange("https://bad.server")
10585 viewModel.onUsernameChange("admin")
10686 viewModel.onPasswordChange("password")
10787 viewModel.onLogin()
10888
10989 val state = viewModel.state.value
110− assertEquals(0, events.size)
90+ assertFalse(state.loginSuccess)
11191 assertNotNull(state.error)
11292 assertTrue(state.error!!.contains("Connection failed"))
11393 }
@@ -117,15 +97,13 @@ class LoginViewModelTest {
11797 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
11898 coEvery { repository.verifyServer() } returns ApiResult.Error("not parseable", 404)
11999
120− val events = collectSuccessEvents()
121−
122100 viewModel.onServerUrlChange("https://test.com")
123101 viewModel.onUsernameChange("admin")
124102 viewModel.onPasswordChange("password")
125103 viewModel.onLogin()
126104
127105 val state = viewModel.state.value
128− assertEquals(0, events.size)
106+ assertFalse(state.loginSuccess)
129107 assertNotNull(state.error)
130108 assertTrue(state.error!!.contains("doesn't appear to be Parseable"))
131109 }
@@ -135,15 +113,13 @@ class LoginViewModelTest {
135113 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
136114 coEvery { repository.verifyServer() } returns ApiResult.Error("Unauthorized", 401)
137115
138− val events = collectSuccessEvents()
139−
140116 viewModel.onServerUrlChange("https://test.parseable.com")
141117 viewModel.onUsernameChange("admin")
142118 viewModel.onPasswordChange("wrongpass")
143119 viewModel.onLogin()
144120
145121 val state = viewModel.state.value
146− assertEquals(0, events.size)
122+ assertFalse(state.loginSuccess)
147123 assertNotNull(state.error)
148124 assertTrue(state.error!!.contains("Invalid credentials"))
149125 assertFalse(state.error!!.contains("doesn't appear to be Parseable"))
app/src/test/java/com/parseable/android/PrivateHostTest.ktdeleted−73
@@ -1,73 +0,0 @@
1−package com.parseable.android
2−
3−import com.parseable.android.ui.screens.login.extractHost
4−import com.parseable.android.ui.screens.login.isPrivateHost
5−import org.junit.Assert.assertEquals
6−import org.junit.Assert.assertFalse
7−import org.junit.Assert.assertTrue
8−import org.junit.Test
9−
10−class PrivateHostTest {
11−
12− @Test
13− fun `extractHost strips scheme port and path`() {
14− assertEquals("192.168.1.5", extractHost("http://192.168.1.5:8000/path"))
15− assertEquals("example.com", extractHost("https://example.com"))
16− assertEquals("example.com", extractHost("example.com:9000"))
17− }
18−
19− @Test
20− fun `extractHost handles bracketed ipv6`() {
21− assertEquals("::1", extractHost("http://[::1]:8000"))
22− assertEquals("fe80::1", extractHost("[fe80::1]"))
23− }
24−
25− @Test
26− fun `extractHost leaves bare ipv6 intact`() {
27− assertEquals("fe80::1", extractHost("fe80::1"))
28− }
29−
30− @Test
31− fun `private ipv4 ranges are detected`() {
32− assertTrue(isPrivateHost("10.0.0.1"))
33− assertTrue(isPrivateHost("127.0.0.1"))
34− assertTrue(isPrivateHost("192.168.1.10"))
35− assertTrue(isPrivateHost("172.16.0.1"))
36− assertTrue(isPrivateHost("172.31.255.255"))
37− }
38−
39− @Test
40− fun `public ipv4 in the 172 gap is not private`() {
41− assertFalse(isPrivateHost("172.15.0.1"))
42− assertFalse(isPrivateHost("172.32.0.1"))
43− assertFalse(isPrivateHost("8.8.8.8"))
44− }
45−
46− @Test
47− fun `public hostnames sharing a private prefix are not private`() {
48− // The old prefix-string check wrongly classified these as private, hiding the
49− // plaintext-HTTP warning.
50− assertFalse(isPrivateHost("10.example.com"))
51− assertFalse(isPrivateHost("192.168.evil.com"))
52− }
53−
54− @Test
55− fun `localhost is private`() {
56− assertTrue(isPrivateHost("localhost"))
57− assertTrue(isPrivateHost("api.localhost"))
58− assertTrue(isPrivateHost(""))
59− }
60−
61− @Test
62− fun `ipv6 loopback and local ranges are private`() {
63− assertTrue(isPrivateHost("::1"))
64− assertTrue(isPrivateHost("fe80::1"))
65− assertTrue(isPrivateHost("fd00::1"))
66− assertTrue(isPrivateHost("fc00::1"))
67− }
68−
69− @Test
70− fun `public ipv6 is not private`() {
71− assertFalse(isPrivateHost("2001:4860:4860::8888"))
72− }
73−}
app/src/test/java/com/parseable/android/SettingsViewModelTest.kt+6−10
@@ -107,35 +107,31 @@ class SettingsViewModelTest {
107107 }
108108
109109 @Test
110− fun `load ignores users failure when about succeeds`() = runTest {
110+ fun `load sets error when users fail`() = runTest {
111111 coEvery { settingsRepository.serverConfig } returns flowOf(null)
112− coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo(version = "1.2.0"))
112+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
113113 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
114114
115115 val viewModel = SettingsViewModel(settingsRepository, repository)
116116
117− // The user list (/user) is secondary and is commonly forbidden for restricted accounts,
118− // so its failure must not surface a blocking error or hide otherwise-valid server info.
119117 val state = viewModel.state.value
120− assertNull(state.error)
121− assertEquals("1.2.0", state.aboutInfo?.version)
118+ assertNotNull(state.error)
122119 assertTrue(state.users.isEmpty())
123120 }
124121
125122 @Test
126− fun `load surfaces only the about error, not the users error`() = runTest {
123+ fun `load consolidates errors from both about and users`() = runTest {
127124 coEvery { settingsRepository.serverConfig } returns flowOf(null)
128125 coEvery { repository.getAbout() } returns ApiResult.Error("Server error", 500)
129126 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
130127
131128 val viewModel = SettingsViewModel(settingsRepository, repository)
132129
133− // Only the primary server-info call (/about) is treated as blocking; the secondary
134− // /user failure stays silent.
135130 val state = viewModel.state.value
136131 assertNotNull(state.error)
132+ // Both error messages should be present
137133 assertTrue(state.error!!.contains("Server error"))
138− assertFalse(state.error!!.contains("Permission denied"))
134+ assertTrue(state.error!!.contains("Permission denied"))
139135 }
140136
141137 @Test
app/src/test/java/com/parseable/android/SeverityDetectionTest.kt−13
@@ -128,7 +128,6 @@ class SeverityDetectionTest {
128128
129129 @Test
130130 fun `HTTP status code mapping`() {
131− assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "100")))
132131 assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "200")))
133132 assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "301")))
134133 assertEquals(LogSeverity.WARNING, detectSeverity(logWith("status" to "404")))
@@ -146,18 +145,6 @@ class SeverityDetectionTest {
146145 assertEquals(LogSeverity.TRACE, detectSeverity(logWith("level" to "100")))
147146 }
148147
149− @Test
150− fun `small-integer (Bunyan-style) numeric level mapping`() {
151− // Structured JSON loggers like Bunyan emit a numeric level field (trace=10 .. fatal=60,
152− // higher = more severe). These previously all collapsed into TRACE.
153− assertEquals(LogSeverity.FATAL, detectSeverity(logWith("level" to "60")))
154− assertEquals(LogSeverity.ERROR, detectSeverity(logWith("level" to "50")))
155− assertEquals(LogSeverity.WARNING, detectSeverity(logWith("level" to "40")))
156− assertEquals(LogSeverity.INFO, detectSeverity(logWith("level" to "30")))
157− assertEquals(LogSeverity.DEBUG, detectSeverity(logWith("level" to "20")))
158− assertEquals(LogSeverity.TRACE, detectSeverity(logWith("level" to "10")))
159− }
160−
161148 // --- Priority among fields ---
162149
163150 @Test
app/src/test/java/com/parseable/android/StreamsViewModelTest.kt−3
@@ -143,9 +143,6 @@ class StreamsViewModelTest {
143143 @Test
144144 fun `toggleFavorite removes when already favorited`() = runTest {
145145 every { favoriteDao.getAllNames() } returns flowOf(listOf("existing"))
146− // toggleFavorite decides insert-vs-delete from the committed DB state (isFavoriteNow),
147− // not the async-mirrored favoriteNames flow, so stub that as the source of truth.
148− coEvery { favoriteDao.isFavoriteNow("existing") } returns true
149146
150147 viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
151148 viewModel.toggleFavorite("existing")
gradle/libs.versions.toml+4
@@ -1,4 +1,8 @@
11[versions]
2+# App versioning
3+appVersionCode = "1"
4+appVersionName = "1.0.0"
5+
26agp = "8.7.3"
37kotlin = "2.1.0"
48ksp = "2.1.0-1.0.29"