git.iko.soy/parseable-android
on master help sign in
.dockerignorenew file+15
since splitsnapshots
unifiedsplit
@@ -0,0 +1,15 @@
1+# Keep the build context small, but DO keep .git — build.gradle.kts derives the
2+# app version from `git log -1`, so the repository history must be present.
3+build/
4+app/build/
5+.gradle/
6+*.iml
7+.idea/
8+.DS_Store
9+local.properties
10+captures/
11+.externalNativeBuild/
12+.cxx/
13+*.keystore
14+*.jks
15+.jj/
.github/workflows/build-release.yml+18
@@ -12,6 +12,8 @@ concurrency:
12
12
13permissions:
13permissions:
14 contents: write
14 contents: write
15+ id-token: write
16+ attestations: write
15
17
16jobs:
18jobs:
17 build:
19 build:
@@ -22,6 +24,12 @@ jobs:
22 - name: Checkout code
24 - name: Checkout code
23 uses: actions/checkout@v4
25 uses: actions/checkout@v4
24
26
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+
25 - name: Set up JDK 17
33 - name: Set up JDK 17
26 uses: actions/setup-java@v4
34 uses: actions/setup-java@v4
27 with:
35 with:
@@ -38,6 +46,11 @@ jobs:
38 run: gradle wrapper --gradle-version 8.9
46 run: gradle wrapper --gradle-version 8.9
39
47
40 - name: Build release APK
48 - 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 }}
41 run: ./gradlew assembleRelease
54 run: ./gradlew assembleRelease
42
55
43 - name: Run unit tests
56 - name: Run unit tests
@@ -49,6 +62,11 @@ jobs:
49 name: parseable-android-release
62 name: parseable-android-release
50 path: app/build/outputs/apk/release/*.apk
63 path: app/build/outputs/apk/release/*.apk
51
64
65+ - name: Attest release APK
66+ uses: actions/attest-build-provenance@v2
67+ with:
68+ subject-path: app/build/outputs/apk/release/*.apk
69+
52 - name: Create Draft Release
70 - name: Create Draft Release
53 if: startsWith(github.ref, 'refs/tags/v')
71 if: startsWith(github.ref, 'refs/tags/v')
54 uses: softprops/action-gh-release@v2
72 uses: softprops/action-gh-release@v2
.github/workflows/ci.yml+47−25
@@ -6,19 +6,24 @@ on:
6 push:
6 push:
7 branches: [ master, main ]
7 branches: [ master, main ]
8
8
9−concurrency:
10− group: ci-${{ github.ref }}
11− cancel-in-progress: true
12−
13jobs:
9jobs:
14 build:
10 build:
15 name: Build & Lint
11 name: Build & Lint
16 runs-on: ubuntu-latest
12 runs-on: ubuntu-latest
13+ concurrency:
14+ group: ci-build-${{ github.ref }}
15+ cancel-in-progress: true
17
16
18 steps:
17 steps:
19 - name: Checkout code
18 - name: Checkout code
20 uses: actions/checkout@v4
19 uses: actions/checkout@v4
21
20
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+
22 - name: Set up JDK 17
27 - name: Set up JDK 17
23 uses: actions/setup-java@v4
28 uses: actions/setup-java@v4
24 with:
29 with:
@@ -35,6 +40,11 @@ jobs:
35 run: gradle wrapper --gradle-version 8.9
40 run: gradle wrapper --gradle-version 8.9
36
41
37 - name: Build release APK
42 - 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 }}
38 run: ./gradlew assembleRelease
48 run: ./gradlew assembleRelease
39
49
40 - name: Run unit tests
50 - name: Run unit tests
@@ -59,18 +69,34 @@ jobs:
59 path: app/build/reports/lint-results-release.html
69 path: app/build/reports/lint-results-release.html
60 retention-days: 14
70 retention-days: 14
61
71
62− prerelease:
72+ release:
63− name: Create Prerelease
73+ name: Create Release
64 needs: build
74 needs: build
65 if: github.event_name == 'push'
75 if: github.event_name == 'push'
66 runs-on: ubuntu-latest
76 runs-on: ubuntu-latest
77+ concurrency:
78+ group: release
79+ cancel-in-progress: false
67 permissions:
80 permissions:
68 contents: write
81 contents: write
82+ id-token: write
83+ attestations: write
69
84
70 steps:
85 steps:
71 - name: Checkout code
86 - name: Checkout code
72 uses: actions/checkout@v4
87 uses: actions/checkout@v4
73
88
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+
74 - name: Set up JDK 17
100 - name: Set up JDK 17
75 uses: actions/setup-java@v4
101 uses: actions/setup-java@v4
76 with:
102 with:
@@ -87,33 +113,29 @@ jobs:
87 run: gradle wrapper --gradle-version 8.9
113 run: gradle wrapper --gradle-version 8.9
88
114
89 - name: Build release APK
115 - 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 }}
90 run: ./gradlew assembleRelease
121 run: ./gradlew assembleRelease
91
122
92− - name: Rename APK with commit SHA
123+ - name: Rename APK with version
93 run: |
124 run: |
94− SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
95 for apk in app/build/outputs/apk/release/*.apk; do
125 for apk in app/build/outputs/apk/release/*.apk; do
96− mv "$apk" "app/build/outputs/apk/release/parseable-android-${SHORT_SHA}.apk"
126+ mv "$apk" "app/build/outputs/apk/release/parseable-android-${VERSION_NAME}.apk"
97 done
127 done
98
128
99− - name: Delete existing latest-master tag and release
129+ - name: Attest release APK
100− env:
130+ uses: actions/attest-build-provenance@v2
101− GH_TOKEN: ${{ github.token }}
131+ with:
102− run: |
132+ subject-path: app/build/outputs/apk/release/*.apk
103− gh release delete latest-master --yes || true
104− git push origin :refs/tags/latest-master || true
105
133
106− - name: Create prerelease
134+ - name: Create release
107 uses: softprops/action-gh-release@v2
135 uses: softprops/action-gh-release@v2
108 with:
136 with:
109− tag_name: latest-master
137+ tag_name: v${{ env.VERSION_NAME }}
110− name: "Latest Master Build (${{ github.sha }})"
138+ name: v${{ env.VERSION_NAME }}
111− prerelease: true
139+ generate_release_notes: 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 }}
118 files: |
140 files: |
119 app/build/outputs/apk/release/*.apk
141 app/build/outputs/apk/release/*.apk
.gitignore+2
@@ -10,3 +10,5 @@
10local.properties
10local.properties
11app/build/
11app/build/
12gradle/wrapper/gradle-wrapper.jar
12gradle/wrapper/gradle-wrapper.jar
13+*.keystore
14+*.jks
Dockerfilenew file+72
@@ -0,0 +1,72 @@
1+# syntax=docker/dockerfile:1
2+
3+# ---- Build stage -------------------------------------------------------------
4+# Builds the Parseable Android APK from source. Mirrors the GitHub Actions CI:
5+# JDK 17, Android SDK (compileSdk 35), Gradle 8.9, with the wrapper generated
6+# on the fly (gradle-wrapper.jar is gitignored). The app's build.gradle.kts
7+# derives its version from `git log`, so git + the .git directory are required.
8+#
9+# Pinned to linux/amd64: the Android toolchain (aapt2) ships only x86-64 Linux
10+# binaries. On an arm64 host (Apple Silicon) a native arm64 image can't exec
11+# them ("rosetta error: failed to open elf .../ld-linux-x86-64.so.2"); a full
12+# amd64 image gives Rosetta/QEMU a complete x86-64 userland to emulate. On an
13+# amd64 host this is native. Override with --build-arg BUILD_PLATFORM=... .
14+ARG BUILD_PLATFORM=linux/amd64
15+FROM --platform=${BUILD_PLATFORM} eclipse-temurin:17-jdk-jammy AS build
16+
17+# Versions (override at build time with --build-arg if needed)
18+ARG GRADLE_VERSION=8.9
19+ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708
20+ARG ANDROID_PLATFORM=35
21+ARG ANDROID_BUILD_TOOLS=35.0.0
22+# Gradle task to run: assembleDebug (default) or assembleRelease
23+ARG GRADLE_TASK=assembleDebug
24+
25+ENV ANDROID_SDK_ROOT=/opt/android-sdk \
26+ ANDROID_HOME=/opt/android-sdk \
27+ GRADLE_USER_HOME=/root/.gradle \
28+ DEBIAN_FRONTEND=noninteractive
29+
30+# Base tooling: git (version stamp), unzip/curl (fetching SDK + Gradle)
31+RUN apt-get update && \
32+ apt-get install -y --no-install-recommends git unzip curl ca-certificates && \
33+ rm -rf /var/lib/apt/lists/*
34+
35+# Android SDK command-line tools
36+RUN mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools" && \
37+ curl -fsSL "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" -o /tmp/cmdline-tools.zip && \
38+ unzip -q /tmp/cmdline-tools.zip -d "${ANDROID_SDK_ROOT}/cmdline-tools" && \
39+ mv "${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools" "${ANDROID_SDK_ROOT}/cmdline-tools/latest" && \
40+ rm /tmp/cmdline-tools.zip
41+
42+ENV PATH="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${PATH}"
43+
44+# Accept licenses and install required SDK packages
45+RUN yes | sdkmanager --licenses >/dev/null && \
46+ sdkmanager --install \
47+ "platform-tools" \
48+ "platforms;android-${ANDROID_PLATFORM}" \
49+ "build-tools;${ANDROID_BUILD_TOOLS}" >/dev/null
50+
51+# Gradle (used to generate the wrapper, then the wrapper drives the build)
52+RUN curl -fsSL "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" -o /tmp/gradle.zip && \
53+ unzip -q /tmp/gradle.zip -d /opt && \
54+ rm /tmp/gradle.zip
55+ENV PATH="/opt/gradle-${GRADLE_VERSION}/bin:${PATH}"
56+
57+WORKDIR /workspace
58+
59+# Copy the full project (including .git for the version stamp; see .dockerignore)
60+COPY . .
61+
62+# Generate the Gradle wrapper, then build the APK
63+RUN gradle wrapper --gradle-version "${GRADLE_VERSION}" && \
64+ ./gradlew --no-daemon "${GRADLE_TASK}"
65+
66+# ---- Export stage ------------------------------------------------------------
67+# Minimal final image holding just the built APK(s) under /apks.
68+# Extract them to ./out with:
69+# docker build --target export --output type=local,dest=./out .
70+# (A scratch image has no shell, so use --output, not docker create/cp.)
71+FROM scratch AS export
72+COPY --from=build /workspace/app/build/outputs/apk/ /apks/
README.md+11
@@ -43,6 +43,17 @@ An Android client for [Parseable](https://www.parseable.com/), the open-source l
43| `/api/v1/alerts` | GET | List alerts |
43| `/api/v1/alerts` | GET | List alerts |
44| `/api/v1/user` | GET | List users |
44| `/api/v1/user` | GET | List users |
45
45
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+
46## Building
57## Building
47
58
48```bash
59```bash
app/build.gradle.kts+25−3
@@ -1,3 +1,7 @@
1+import java.time.Instant
2+import java.time.ZoneOffset
3+import java.time.format.DateTimeFormatter
4+
1plugins {
5plugins {
2 alias(libs.plugins.android.application)
6 alias(libs.plugins.android.application)
3 alias(libs.plugins.kotlin.android)
7 alias(libs.plugins.kotlin.android)
@@ -7,6 +11,13 @@ plugins {
7 alias(libs.plugins.ksp)
11 alias(libs.plugins.ksp)
8}
12}
9
13
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+
10android {
21android {
11 namespace = "com.parseable.android"
22 namespace = "com.parseable.android"
12 compileSdk = 35
23 compileSdk = 35
@@ -15,17 +26,28 @@ android {
15 applicationId = "com.parseable.android"
26 applicationId = "com.parseable.android"
16 minSdk = 26
27 minSdk = 26
17 targetSdk = 35
28 targetSdk = 35
18− versionCode = libs.versions.appVersionCode.get().toInt()
29+ versionCode = dateVersionCode
19− versionName = libs.versions.appVersionName.get()
30+ versionName = dateVersionName
20
31
21 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
32 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
22 }
33 }
23
34
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+
24 buildTypes {
46 buildTypes {
25 release {
47 release {
26 isMinifyEnabled = true
48 isMinifyEnabled = true
27 isShrinkResources = true
49 isShrinkResources = true
28− signingConfig = signingConfigs.getByName("debug")
50+ signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
29 proguardFiles(
51 proguardFiles(
30 getDefaultProguardFile("proguard-android-optimize.txt"),
52 getDefaultProguardFile("proguard-android-optimize.txt"),
31 "proguard-rules.pro"
53 "proguard-rules.pro"
app/src/main/AndroidManifest.xml+1−1
@@ -10,7 +10,7 @@
10 android:allowBackup="false"
10 android:allowBackup="false"
11 android:icon="@mipmap/ic_launcher"
11 android:icon="@mipmap/ic_launcher"
12 android:label="@string/app_name"
12 android:label="@string/app_name"
13− android:roundIcon="@mipmap/ic_launcher_round"
13+ android:roundIcon="@mipmap/ic_launcher"
14 android:supportsRtl="true"
14 android:supportsRtl="true"
15 android:theme="@style/Theme.ParseableAndroid"
15 android:theme="@style/Theme.ParseableAndroid"
16 android:networkSecurityConfig="@xml/network_security_config"
16 android:networkSecurityConfig="@xml/network_security_config"
app/src/main/java/com/parseable/android/MainActivity.kt+21−1
@@ -51,6 +51,17 @@ class MainActivity : ComponentActivity() {
51 repository.configure(savedConfig)
51 repository.configure(savedConfig)
52 }
52 }
53 startDestination = if (savedConfig != null) Routes.STREAMS else Routes.LOGIN
53 startDestination = if (savedConfig != null) Routes.STREAMS else Routes.LOGIN
54+
55+ // Best-effort startup reconciliation for state a row-collapsing migration
56+ // (raw SQL) can't reach: re-link a dangling active_server_id to its
57+ // surviving row, then drop password entries orphaned by the collapse.
58+ // Runs after startDestination so it never blocks first paint.
59+ try {
60+ settingsRepository.reconcileActiveServerId()
61+ settingsRepository.cleanupOrphanedServerPasswords()
62+ } catch (_: Exception) {
63+ // Reconciliation/cleanup are non-critical; ignore failures.
64+ }
54 }
65 }
55
66
56 val dest = startDestination
67 val dest = startDestination
@@ -61,8 +72,17 @@ class MainActivity : ComponentActivity() {
61 LaunchedEffect(navController) {
72 LaunchedEffect(navController) {
62 repository.authErrors
73 repository.authErrors
63 .onEach {
74 .onEach {
75+ // Several parallel requests can each surface a 401 in quick
76+ // succession. Skip re-navigating (and re-clearing) when we're
77+ // already on the login screen, so a burst can't thrash the
78+ // back stack or re-trigger the "session expired" message.
79+ val onLogin = navController.currentDestination
80+ ?.route?.startsWith(Routes.LOGIN) == true
81+ if (onLogin) return@onEach
64 settingsRepository.clearConfig()
82 settingsRepository.clearConfig()
65− errorHandler.showError("Session expired. Please log in again.")
83+ // The login screen shows the "session expired" message
84+ // itself via the sessionExpired flag; showing it here too
85+ // would queue a duplicate snackbar.
66 navController.navigate(Routes.login(sessionExpired = true)) {
86 navController.navigate(Routes.login(sessionExpired = true)) {
67 popUpTo(0) { inclusive = true }
87 popUpTo(0) { inclusive = true }
68 }
88 }
app/src/main/java/com/parseable/android/data/FormatUtils.kt+19−2
@@ -2,7 +2,10 @@ package com.parseable.android.data
2
2
3import java.util.Locale
3import java.util.Locale
4
4
5+// "EB" is included so PB is never the last unit: the promotion guard below (which only fires
6+// when a higher unit exists) then handles a PB value in [1023.5, 1024) — promoting it to "1.00 EB"
7+// instead of rendering "1024 PB". The remaining unhandled top-unit case sits at ~1.2e24 bytes.
5−private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB")
8+private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
6
9
7/**
10/**
8 * Converts a raw byte-count string (e.g. "123456789") to a human-readable
11 * Converts a raw byte-count string (e.g. "123456789") to a human-readable
@@ -12,14 +15,28 @@ private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB")
12fun formatBytes(raw: String?): String? {
15fun formatBytes(raw: String?): String? {
13 if (raw == null) return null
16 if (raw == null) return null
14 val bytes = raw.trim().toDoubleOrNull() ?: return raw
17 val bytes = raw.trim().toDoubleOrNull() ?: return raw
18+ // toDoubleOrNull accepts "NaN"/"Infinity"; those slip past the range guards below and
19+ // would format as "NaN B" / "Infinity PB". Pass them through unchanged instead.
20+ if (!bytes.isFinite()) return raw
15 if (bytes < 0) return raw
21 if (bytes < 0) return raw
22+ // Use 1023.5, not 1024, as the cutoff: "%.0f" rounds half-up, so a byte count in
23+ // [1023.5, 1024) would render as "1024 B" — a value that should promote to "1.00 KB".
24+ // Such inputs fall through to the unit-scaling path, where the promotion guard below
25+ // (value >= 1023.5) handles them.
16− if (bytes < 1024) return String.format(Locale.US, "%.0f B", bytes)
26+ if (bytes < 1023.5) return String.format(Locale.US, "%.0f B", bytes)
17 var value = bytes
27 var value = bytes
18 var unitIndex = 0
28 var unitIndex = 0
19 while (value >= 1024 && unitIndex < BYTE_UNITS.size - 1) {
29 while (value >= 1024 && unitIndex < BYTE_UNITS.size - 1) {
20 value /= 1024
30 value /= 1024
21 unitIndex++
31 unitIndex++
22 }
32 }
33+ // A value just under 1024 (e.g. 1023.999 KB) is formatted with "%.0f", which rounds it to
34+ // "1024 KB" — a unit that should never display. Promote to the next unit so it renders as
35+ // "1.00 MB". The "%.1f"/"%.2f" branches operate far below 1024, so only this case crosses.
36+ if (value >= 1023.5 && unitIndex < BYTE_UNITS.size - 1) {
37+ value /= 1024
38+ unitIndex++
39+ }
23 return if (value >= 100) {
40 return if (value >= 100) {
24 String.format(Locale.US, "%.0f %s", value, BYTE_UNITS[unitIndex])
41 String.format(Locale.US, "%.0f %s", value, BYTE_UNITS[unitIndex])
25 } else if (value >= 10) {
42 } else if (value >= 10) {
app/src/main/java/com/parseable/android/data/SqlUtils.kt+16
@@ -5,3 +5,19 @@ fun escapeSql(value: String): String = value.replace("'", "''")
5
5
6/** Escape a SQL identifier (table/column name) for use inside double quotes. */
6/** Escape a SQL identifier (table/column name) for use inside double quotes. */
7fun escapeIdentifier(name: String): String = name.replace("\"", "\"\"")
7fun escapeIdentifier(name: String): String = name.replace("\"", "\"\"")
8+
9+/**
10+ * Escape a user-supplied substring for use as the body of a LIKE/ILIKE pattern.
11+ *
12+ * Besides the single-quote escaping every string literal needs, this neutralizes the
13+ * LIKE wildcards `%` and `_` (and the escape character `\` itself) so that searching or
14+ * filtering for a literal `%` or `_` matches that character instead of acting as a
15+ * wildcard. The backslash must be escaped first so the backslashes added for `%`/`_`
16+ * aren't doubled. Callers must pair the result with an `ESCAPE '\'` clause.
17+ */
18+fun escapeLikePattern(value: String): String =
19+ value
20+ .replace("\\", "\\\\")
21+ .replace("%", "\\%")
22+ .replace("_", "\\_")
23+ .replace("'", "''")
app/src/main/java/com/parseable/android/data/TimestampUtils.kt+57−14
@@ -1,26 +1,69 @@
1package com.parseable.android.data
1package com.parseable.android.data
2
2
3import java.time.Instant
3import java.time.Instant
4+import java.time.LocalDateTime
5+import java.time.OffsetDateTime
4import java.time.ZoneId
6import java.time.ZoneId
7+import java.time.ZoneOffset
5import java.time.ZonedDateTime
8import java.time.ZonedDateTime
6import java.time.format.DateTimeFormatter
9import java.time.format.DateTimeFormatter
10+import java.util.Locale
7
11
12+// Pin to Locale.US so the MMM month token is always rendered as a stable English
13+// abbreviation (e.g. "Jun") regardless of the device's locale.
8−private val displayFormatter = DateTimeFormatter.ofPattern("MMM dd HH:mm:ss.SSS")
14+private val displayFormatter = DateTimeFormatter.ofPattern("MMM dd HH:mm:ss.SSS", Locale.US)
9
15
10fun formatTimestamp(raw: String): String {
16fun formatTimestamp(raw: String): String {
11− return try {
17+ // Parseable/DataFusion sometimes renders p_timestamp with a space between the date and
12− val instant = Instant.parse(raw)
18+ // time ("2026-06-20 12:00:00.000") instead of the ISO 'T', and may append a zone/offset
19+ // after another space ("... +00:00", "... UTC"). Normalize both so the parsers below
20+ // accept them; values already in canonical ISO form are left untouched.
21+ var iso = raw.trim()
22+ // Convert the FIRST space (date↔time separator) to 'T', unless a 'T' already precedes it
23+ // (in which case that space sits before a zone token, handled next).
24+ val firstSpace = iso.indexOf(' ')
25+ if (firstSpace != -1 && 'T' !in iso.substring(0, firstSpace + 1)) {
26+ iso = iso.substring(0, firstSpace) + "T" + iso.substring(firstSpace + 1)
27+ }
28+ // Drop a space sitting between the time and a trailing offset/zone token:
29+ // "...12:00:00 +00:00" → "...12:00:00+00:00", "...12:00:00 UTC" → "...12:00:00UTC".
30+ iso = iso.replace(Regex("(?<=\\d)\\s+(?=[+\\-Z]|UTC|GMT)"), "")
31+ // Map a textual "UTC"/"GMT" suffix to the ISO 'Z' the parsers understand (GMT is exactly
32+ // UTC, so this is a safe rewrite). Other textual zones (e.g. "PST") are ambiguous, so they
33+ // are left untouched and fall through to the raw-string passthrough rather than risk
34+ // displaying a wrong local time.
35+ if (iso.endsWith("UTC")) iso = iso.removeSuffix("UTC") + "Z"
36+ else if (iso.endsWith("GMT")) iso = iso.removeSuffix("GMT") + "Z"
37+
38+ // Fast path: an ISO-8601 instant in UTC (e.g. "2026-06-20T12:00:00Z").
39+ try {
40+ val instant = Instant.parse(iso)
13− val local = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
41+ return ZonedDateTime.ofInstant(instant, ZoneId.systemDefault()).format(displayFormatter)
42+ } catch (_: Exception) {
43+ // Fall through to the offset-aware parser.
44+ }
45+
46+ // General path: any ISO-8601 timestamp carrying an explicit offset, including
47+ // "+00:00" and non-UTC offsets like "+05:30" that Instant.parse rejects.
48+ try {
49+ val offset = OffsetDateTime.parse(iso)
14− local.format(displayFormatter)
50+ return offset.atZoneSameInstant(ZoneId.systemDefault()).format(displayFormatter)
15 } catch (_: Exception) {
51 } catch (_: Exception) {
16− // Try alternate format with +00:00 suffix
52+ // Fall through to the naive (offset-less) parser.
17− try {
18− val normalized = raw.replace("+00:00", "Z")
19− val instant = Instant.parse(normalized)
20− val local = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
21− local.format(displayFormatter)
22− } catch (_: Exception) {
23− raw
24− }
25 }
53 }
54+
55+ // Final path: an offset-less ISO-8601 timestamp (e.g. "2026-06-20T12:00:00.000").
56+ // Parseable stores p_timestamp in UTC, so interpret a naive timestamp as UTC and
57+ // convert to the device's zone — otherwise these would render in UTC while
58+ // offset-carrying values render in local time, an inconsistent mix.
59+ try {
60+ val local = LocalDateTime.parse(iso)
61+ return local.atOffset(ZoneOffset.UTC)
62+ .atZoneSameInstant(ZoneId.systemDefault())
63+ .format(displayFormatter)
64+ } catch (_: Exception) {
65+ // Unparseable — show the server value verbatim rather than nothing.
66+ }
67+
68+ return raw
26}
69}
app/src/main/java/com/parseable/android/data/api/ParseableApiClient.kt+155−27
@@ -39,7 +39,7 @@ class ParseableApiClient @Inject constructor() {
39 coerceInputValues = true
39 coerceInputValues = true
40 }
40 }
41
41
42− private val sharedPool = okhttp3.ConnectionPool(5, 5, TimeUnit.MINUTES)
42+ private val sharedPool = okhttp3.ConnectionPool(16, 5, TimeUnit.MINUTES)
43 private val sharedDispatcher = okhttp3.Dispatcher()
43 private val sharedDispatcher = okhttp3.Dispatcher()
44
44
45 private val secureClient: OkHttpClient = buildClient(allowInsecure = false)
45 private val secureClient: OkHttpClient = buildClient(allowInsecure = false)
@@ -77,11 +77,18 @@ class ParseableApiClient @Inject constructor() {
77
77
78 fun configure(serverConfig: ServerConfig) {
78 fun configure(serverConfig: ServerConfig) {
79 val url = serverConfig.serverUrl.trimEnd('/')
79 val url = serverConfig.serverUrl.trimEnd('/')
80+ // Leave the auth header empty for blank credentials so isConfigured() reports
81+ // false instead of silently sending a bogus "Basic <user:>" header.
82+ val auth = if (serverConfig.username.isBlank() && serverConfig.password.isBlank()) {
83+ ""
84+ } else {
80− val credentials = "${serverConfig.username}:${serverConfig.password}"
85+ val credentials = "${serverConfig.username}:${serverConfig.password}"
81− val auth = "Basic " + android.util.Base64.encodeToString(
86+ "Basic " + android.util.Base64.encodeToString(
87+ // Explicit UTF-8 per RFC 7617 rather than the platform default charset.
82− credentials.toByteArray(),
88+ credentials.toByteArray(Charsets.UTF_8),
83− android.util.Base64.NO_WRAP
89+ android.util.Base64.NO_WRAP
84− )
90+ )
91+ }
85 config = ClientConfig(
92 config = ClientConfig(
86 baseUrl = url,
93 baseUrl = url,
87 authHeader = auth,
94 authHeader = auth,
@@ -96,30 +103,48 @@ class ParseableApiClient @Inject constructor() {
96 }
103 }
97
104
98 fun shutdown() {
105 fun shutdown() {
106+ // Only evict idle connections. The dispatcher's executor is SHARED across the
107+ // secure/insecure clients of this @Singleton; shutting it down is irreversible and
108+ // would make every later request throw RejectedExecutionException after a
109+ // clearConfig()/configure() cycle. Eviction is enough to release pooled sockets.
99 sharedPool.evictAll()
110 sharedPool.evictAll()
100− sharedDispatcher.executorService.shutdown()
101 }
111 }
102
112
103 private fun encodePathSegment(segment: String): String =
113 private fun encodePathSegment(segment: String): String =
104 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
114 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
105
115
106 private fun buildRequest(path: String): Request.Builder {
116 private fun buildRequest(path: String): Request.Builder {
117+ // Snapshot the volatile config ONCE and derive both the URL/auth header and the
118+ // TLS client (secure vs. insecure) from it. Selecting the client lazily in
119+ // executeRequest would re-read `config`, so a concurrent server switch could
120+ // dispatch server A's URL/auth through server B's TLS client. Pinning the client
121+ // onto the request as a tag keeps the whole request internally consistent.
107 val snapshot = config
122 val snapshot = config
123+ val selectedClient = if (snapshot.allowInsecure) insecureClient else secureClient
108− return Request.Builder()
124+ val builder = Request.Builder()
109 .url("${snapshot.baseUrl}$path")
125 .url("${snapshot.baseUrl}$path")
126+ .tag(OkHttpClient::class.java, selectedClient)
127+ // Don't send a literal empty "Authorization:" header for unconfigured calls
128+ // (e.g. pre-login connectivity probes) — strict servers/proxies may reject it.
129+ if (snapshot.authHeader.isNotEmpty()) {
110− .header("Authorization", snapshot.authHeader)
130+ builder.header("Authorization", snapshot.authHeader)
131+ }
132+ return builder
111 }
133 }
112
134
113 private suspend fun executeRequest(request: Request): ApiResult<String> =
135 private suspend fun executeRequest(request: Request): ApiResult<String> =
114 withContext(Dispatchers.IO) {
136 withContext(Dispatchers.IO) {
115 try {
137 try {
138+ val callClient = request.tag(OkHttpClient::class.java) ?: client
116− client.newCall(request).execute().use { response ->
139+ callClient.newCall(request).execute().use { response ->
117 val body = response.body?.string() ?: ""
140 val body = response.body?.string() ?: ""
118 if (response.isSuccessful) {
141 if (response.isSuccessful) {
119 ApiResult.Success(body)
142 ApiResult.Success(body)
120 } else {
143 } else {
121 ApiResult.Error(
144 ApiResult.Error(
145+ // ifBlank, not ifEmpty: a whitespace-only error body would otherwise
146+ // be shown verbatim as a blank message instead of the status reason.
122− message = body.ifEmpty { response.message },
147+ message = body.ifBlank { response.message },
123 code = response.code
148 code = response.code
124 )
149 )
125 }
150 }
@@ -127,7 +152,7 @@ class ParseableApiClient @Inject constructor() {
127 } catch (e: SocketTimeoutException) {
152 } catch (e: SocketTimeoutException) {
128 ApiResult.Error(message = "Connection timed out")
153 ApiResult.Error(message = "Connection timed out")
129 } catch (e: UnknownHostException) {
154 } catch (e: UnknownHostException) {
130− ApiResult.Error(message = "Unable to resolve host \"${e.message}\"")
155+ ApiResult.Error(message = "Unable to resolve host")
131 } catch (e: ConnectException) {
156 } catch (e: ConnectException) {
132 ApiResult.Error(message = "Connection refused. Is the server running?")
157 ApiResult.Error(message = "Connection refused. Is the server running?")
133 } catch (e: SSLException) {
158 } catch (e: SSLException) {
@@ -140,13 +165,21 @@ class ParseableApiClient @Inject constructor() {
140 }
165 }
141 }
166 }
142
167
143− private fun <T> parseResponse(body: String, description: String, parse: () -> T): ApiResult<T> {
168+ private fun <T> parseResponse(description: String, parse: () -> T): ApiResult<T> {
144 return try {
169 return try {
145 ApiResult.Success(parse())
170 ApiResult.Success(parse())
146 } catch (e: SerializationException) {
171 } catch (e: SerializationException) {
147− ApiResult.Error("Invalid response format from server")
172+ ApiResult.Error("Invalid response format from server", code = ApiResult.PARSE_ERROR_CODE)
148 } catch (e: IllegalArgumentException) {
173 } catch (e: IllegalArgumentException) {
149− ApiResult.Error("Unexpected response format for $description")
174+ ApiResult.Error("Unexpected response format for $description", code = ApiResult.PARSE_ERROR_CODE)
175+ } catch (e: Exception) {
176+ // kotlinx-serialization's JsonElement accessors (jsonArray/jsonObject/
177+ // jsonPrimitive) throw IllegalStateException — not IllegalArgumentException —
178+ // when an element has the wrong type. Catch any remaining parse-time failure
179+ // so a malformed payload surfaces as an error instead of an uncaught crash.
180+ // parse() is non-suspending, so there's no CancellationException to preserve.
181+ Timber.w(e, "Failed to parse response for $description")
182+ ApiResult.Error("Unexpected response format for $description", code = ApiResult.PARSE_ERROR_CODE)
150 }
183 }
151 }
184 }
152
185
@@ -156,7 +189,7 @@ class ParseableApiClient @Inject constructor() {
156 suspend fun getAbout(): ApiResult<AboutInfo> {
189 suspend fun getAbout(): ApiResult<AboutInfo> {
157 val request = buildRequest("/api/v1/about").get().build()
190 val request = buildRequest("/api/v1/about").get().build()
158 return when (val result = executeRequest(request)) {
191 return when (val result = executeRequest(request)) {
159− is ApiResult.Success -> parseResponse(result.data, "about info") {
192+ is ApiResult.Success -> parseResponse("about info") {
160 json.decodeFromString<AboutInfo>(result.data)
193 json.decodeFromString<AboutInfo>(result.data)
161 }
194 }
162 is ApiResult.Error -> result
195 is ApiResult.Error -> result
@@ -197,7 +230,7 @@ class ParseableApiClient @Inject constructor() {
197 }
230 }
198 ApiResult.Success(streams)
231 ApiResult.Success(streams)
199 } catch (e2: Exception) {
232 } catch (e2: Exception) {
200− ApiResult.Error("Failed to parse streams: ${e2.message}")
233+ ApiResult.Error("Failed to parse streams: ${e2.message}", code = ApiResult.PARSE_ERROR_CODE)
201 }
234 }
202 }
235 }
203 }
236 }
@@ -212,7 +245,7 @@ class ParseableApiClient @Inject constructor() {
212 val encoded = encodePathSegment(stream)
245 val encoded = encodePathSegment(stream)
213 val request = buildRequest("/api/v1/logstream/$encoded/schema").get().build()
246 val request = buildRequest("/api/v1/logstream/$encoded/schema").get().build()
214 return when (val result = executeRequest(request)) {
247 return when (val result = executeRequest(request)) {
215− is ApiResult.Success -> parseResponse(result.data, "schema") {
248+ is ApiResult.Success -> parseResponse("schema") {
216 json.decodeFromString<StreamSchema>(result.data)
249 json.decodeFromString<StreamSchema>(result.data)
217 }
250 }
218 is ApiResult.Error -> result
251 is ApiResult.Error -> result
@@ -226,7 +259,7 @@ class ParseableApiClient @Inject constructor() {
226 val encoded = encodePathSegment(stream)
259 val encoded = encodePathSegment(stream)
227 val request = buildRequest("/api/v1/logstream/$encoded/stats").get().build()
260 val request = buildRequest("/api/v1/logstream/$encoded/stats").get().build()
228 return when (val result = executeRequest(request)) {
261 return when (val result = executeRequest(request)) {
229− is ApiResult.Success -> parseResponse(result.data, "stats") {
262+ is ApiResult.Success -> parseResponse("stats") {
230 json.decodeFromString<StreamStats>(result.data)
263 json.decodeFromString<StreamStats>(result.data)
231 }
264 }
232 is ApiResult.Error -> result
265 is ApiResult.Error -> result
@@ -240,7 +273,7 @@ class ParseableApiClient @Inject constructor() {
240 val encoded = encodePathSegment(stream)
273 val encoded = encodePathSegment(stream)
241 val request = buildRequest("/api/v1/logstream/$encoded/info").get().build()
274 val request = buildRequest("/api/v1/logstream/$encoded/info").get().build()
242 return when (val result = executeRequest(request)) {
275 return when (val result = executeRequest(request)) {
243− is ApiResult.Success -> parseResponse(result.data, "stream info") {
276+ is ApiResult.Success -> parseResponse("stream info") {
244 val element = json.parseToJsonElement(result.data)
277 val element = json.parseToJsonElement(result.data)
245 (element as? JsonObject)
278 (element as? JsonObject)
246 ?: throw IllegalArgumentException("Expected JSON object, got ${element::class.simpleName}")
279 ?: throw IllegalArgumentException("Expected JSON object, got ${element::class.simpleName}")
@@ -260,8 +293,15 @@ class ParseableApiClient @Inject constructor() {
260 try {
293 try {
261 ApiResult.Success(json.decodeFromString<List<RetentionConfig>>(result.data))
294 ApiResult.Success(json.decodeFromString<List<RetentionConfig>>(result.data))
262 } catch (_: Exception) {
295 } catch (_: Exception) {
263− parseResponse(result.data, "retention config") {
296+ parseResponse("retention config") {
264− listOf(json.decodeFromString<RetentionConfig>(result.data))
297+ val single = json.decodeFromString<RetentionConfig>(result.data)
298+ // An empty object ({}) decodes into an all-null config — that means
299+ // "no retention configured" (zero rules), not one empty rule.
300+ if (single.description == null && single.duration == null && single.action == null) {
301+ emptyList()
302+ } else {
303+ listOf(single)
304+ }
265 }
305 }
266 }
306 }
267 }
307 }
@@ -291,7 +331,7 @@ class ParseableApiClient @Inject constructor() {
291 .build()
331 .build()
292
332
293 return when (val result = executeRequest(request)) {
333 return when (val result = executeRequest(request)) {
294− is ApiResult.Success -> parseResponse(result.data, "query results") {
334+ is ApiResult.Success -> parseResponse("query results") {
295 val element = json.parseToJsonElement(result.data)
335 val element = json.parseToJsonElement(result.data)
296 val elements = (element as? JsonArray)
336 val elements = (element as? JsonArray)
297 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
337 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
@@ -316,11 +356,14 @@ class ParseableApiClient @Inject constructor() {
316 suspend fun listAlerts(): ApiResult<List<Alert>> {
356 suspend fun listAlerts(): ApiResult<List<Alert>> {
317 val request = buildRequest("/api/v1/alerts").get().build()
357 val request = buildRequest("/api/v1/alerts").get().build()
318 return when (val result = executeRequest(request)) {
358 return when (val result = executeRequest(request)) {
319− is ApiResult.Success -> parseResponse(result.data, "alerts") {
359+ is ApiResult.Success -> parseResponse("alerts") {
320 val element = json.parseToJsonElement(result.data)
360 val element = json.parseToJsonElement(result.data)
321 val alertsArray = when (element) {
361 val alertsArray = when (element) {
322 is JsonArray -> element
362 is JsonArray -> element
363+ // Use a safe cast, not the `.jsonArray` accessor: a present-but-non-array
364+ // value (e.g. {"alerts": null} or an object) makes `.jsonArray` throw, which
365+ // would surface as a parse error instead of degrading to an empty list.
323− is JsonObject -> element["alerts"]?.jsonArray ?: JsonArray(emptyList())
366+ is JsonObject -> (element["alerts"] as? JsonArray) ?: JsonArray(emptyList())
324 else -> JsonArray(emptyList())
367 else -> JsonArray(emptyList())
325 }
368 }
326 alertsArray.mapNotNull { alertElement ->
369 alertsArray.mapNotNull { alertElement ->
@@ -355,7 +398,7 @@ class ParseableApiClient @Inject constructor() {
355 .post(requestBody)
398 .post(requestBody)
356 .build()
399 .build()
357 return when (val result = executeRequest(request)) {
400 return when (val result = executeRequest(request)) {
358− is ApiResult.Success -> parseResponse(result.data, "alert") {
401+ is ApiResult.Success -> parseResponse("alert") {
359 json.decodeFromString<Alert>(result.data)
402 json.decodeFromString<Alert>(result.data)
360 }
403 }
361 is ApiResult.Error -> result
404 is ApiResult.Error -> result
@@ -368,7 +411,7 @@ class ParseableApiClient @Inject constructor() {
368 suspend fun listUsers(): ApiResult<List<JsonObject>> {
411 suspend fun listUsers(): ApiResult<List<JsonObject>> {
369 val request = buildRequest("/api/v1/user").get().build()
412 val request = buildRequest("/api/v1/user").get().build()
370 return when (val result = executeRequest(request)) {
413 return when (val result = executeRequest(request)) {
371− is ApiResult.Success -> parseResponse(result.data, "users") {
414+ is ApiResult.Success -> parseResponse("users") {
372 val element = json.parseToJsonElement(result.data)
415 val element = json.parseToJsonElement(result.data)
373 val elements = (element as? JsonArray)
416 val elements = (element as? JsonArray)
374 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
417 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
@@ -377,4 +420,89 @@ class ParseableApiClient @Inject constructor() {
377 is ApiResult.Error -> result
420 is ApiResult.Error -> result
378 }
421 }
379 }
422 }
423+
424+ /**
425+ * GET /api/v1/filters - list saved filters for the authenticated user
426+ */
427+ suspend fun listFilters(): ApiResult<List<SavedFilter>> {
428+ val request = buildRequest("/api/v1/filters").get().build()
429+ 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+ }
446+ }
447+ is ApiResult.Error -> result
448+ }
449+ }
450+
451+ /**
452+ * GET /api/v1/filters/{filterId} - get a specific saved filter
453+ */
454+ suspend fun getFilter(filterId: String): ApiResult<SavedFilter> {
455+ val encoded = encodePathSegment(filterId)
456+ val request = buildRequest("/api/v1/filters/$encoded").get().build()
457+ return when (val result = executeRequest(request)) {
458+ is ApiResult.Success -> parseResponse("filter") {
459+ json.decodeFromString<SavedFilter>(result.data)
460+ }
461+ is ApiResult.Error -> result
462+ }
463+ }
464+
465+ /**
466+ * POST /api/v1/filters - create a new saved filter
467+ */
468+ suspend fun createFilter(filter: SavedFilter): ApiResult<SavedFilter> {
469+ val requestBody = json.encodeToString(SavedFilter.serializer(), filter)
470+ .toRequestBody("application/json".toMediaType())
471+ val request = buildRequest("/api/v1/filters")
472+ .post(requestBody)
473+ .build()
474+ return when (val result = executeRequest(request)) {
475+ is ApiResult.Success -> parseResponse("filter") {
476+ json.decodeFromString<SavedFilter>(result.data)
477+ }
478+ is ApiResult.Error -> result
479+ }
480+ }
481+
482+ /**
483+ * PUT /api/v1/filters/{filterId} - update an existing saved filter
484+ */
485+ suspend fun updateFilter(filterId: String, filter: SavedFilter): ApiResult<SavedFilter> {
486+ val encoded = encodePathSegment(filterId)
487+ val requestBody = json.encodeToString(SavedFilter.serializer(), filter)
488+ .toRequestBody("application/json".toMediaType())
489+ val request = buildRequest("/api/v1/filters/$encoded")
490+ .put(requestBody)
491+ .build()
492+ return when (val result = executeRequest(request)) {
493+ is ApiResult.Success -> parseResponse("filter") {
494+ json.decodeFromString<SavedFilter>(result.data)
495+ }
496+ is ApiResult.Error -> result
497+ }
498+ }
499+
500+ /**
501+ * DELETE /api/v1/filters/{filterId} - delete a saved filter
502+ */
503+ suspend fun deleteFilter(filterId: String): ApiResult<String> {
504+ val encoded = encodePathSegment(filterId)
505+ val request = buildRequest("/api/v1/filters/$encoded").delete().build()
506+ return executeRequest(request)
507+ }
380}
508}
app/src/main/java/com/parseable/android/data/local/FavoriteStreamDao.kt+12−2
@@ -12,10 +12,14 @@ interface FavoriteStreamDao {
12 @Query("SELECT * FROM favorite_streams ORDER BY addedAt DESC")
12 @Query("SELECT * FROM favorite_streams ORDER BY addedAt DESC")
13 fun getAll(): Flow<List<FavoriteStream>>
13 fun getAll(): Flow<List<FavoriteStream>>
14
14
15+ // Ordered most-recently-favorited first so the UI can show favorites in recency order
16+ // (toSet() downstream preserves this iteration order via LinkedHashSet).
15− @Query("SELECT streamName FROM favorite_streams")
17+ @Query("SELECT streamName FROM favorite_streams ORDER BY addedAt DESC")
16 fun getAllNames(): Flow<List<String>>
18 fun getAllNames(): Flow<List<String>>
17
19
20+ // IGNORE (not REPLACE): re-favoriting an existing stream must keep its original
21+ // addedAt so the getAll() "ORDER BY addedAt DESC" ordering stays stable.
18− @Insert(onConflict = OnConflictStrategy.REPLACE)
22+ @Insert(onConflict = OnConflictStrategy.IGNORE)
19 suspend fun insert(favorite: FavoriteStream)
23 suspend fun insert(favorite: FavoriteStream)
20
24
21 @Delete
25 @Delete
@@ -26,4 +30,10 @@ interface FavoriteStreamDao {
26
30
27 @Query("SELECT EXISTS(SELECT 1 FROM favorite_streams WHERE streamName = :name)")
31 @Query("SELECT EXISTS(SELECT 1 FROM favorite_streams WHERE streamName = :name)")
28 fun isFavorite(name: String): Flow<Boolean>
32 fun isFavorite(name: String): Flow<Boolean>
33+
34+ // One-shot suspend variant. toggleFavorite() must decide insert-vs-delete from the
35+ // committed DB state rather than the async-mirrored UI state, so rapid double-taps
36+ // can't both observe a stale value and take the same branch.
37+ @Query("SELECT EXISTS(SELECT 1 FROM favorite_streams WHERE streamName = :name)")
38+ suspend fun isFavoriteNow(name: String): Boolean
29}
39}
app/src/main/java/com/parseable/android/data/local/ParseableDatabase.kt+3−2
@@ -4,10 +4,11 @@ import androidx.room.Database
4import androidx.room.RoomDatabase
4import androidx.room.RoomDatabase
5
5
6@Database(
6@Database(
7− entities = [FavoriteStream::class],
7+ entities = [FavoriteStream::class, SavedServer::class],
8− version = 1,
8+ version = 3,
9 exportSchema = true,
9 exportSchema = true,
10)
10)
11abstract class ParseableDatabase : RoomDatabase() {
11abstract class ParseableDatabase : RoomDatabase() {
12 abstract fun favoriteStreamDao(): FavoriteStreamDao
12 abstract fun favoriteStreamDao(): FavoriteStreamDao
13+ abstract fun savedServerDao(): SavedServerDao
13}
14}
app/src/main/java/com/parseable/android/data/local/SavedServer.ktnew file+23
@@ -0,0 +1,23 @@
1+package com.parseable.android.data.local
2+
3+import androidx.room.Entity
4+import androidx.room.Index
5+import androidx.room.PrimaryKey
6+
7+@Entity(
8+ tableName = "saved_servers",
9+ // One logical server per (url, username). The dedupe in SettingsRepository.saveServer
10+ // relies on this invariant; enforcing it in the schema means REPLACE actually upserts on
11+ // the natural key instead of silently inserting a duplicate row if the read-back misses.
12+ indices = [Index(value = ["serverUrl", "username"], unique = true)],
13+)
14+data class SavedServer(
15+ @PrimaryKey(autoGenerate = true)
16+ val id: Long = 0,
17+ val serverUrl: String,
18+ val username: String,
19+ val useTls: Boolean = true,
20+ /** Key into EncryptedSharedPreferences for this server's password. */
21+ val passwordKey: String,
22+ val addedAt: Long = System.currentTimeMillis(),
23+)
app/src/main/java/com/parseable/android/data/local/SavedServerDao.ktnew file+25
@@ -0,0 +1,25 @@
1+package com.parseable.android.data.local
2+
3+import androidx.room.Dao
4+import androidx.room.Insert
5+import androidx.room.OnConflictStrategy
6+import androidx.room.Query
7+import kotlinx.coroutines.flow.Flow
8+
9+@Dao
10+interface SavedServerDao {
11+ @Query("SELECT * FROM saved_servers ORDER BY addedAt DESC")
12+ fun getAll(): Flow<List<SavedServer>>
13+
14+ @Query("SELECT * FROM saved_servers WHERE id = :id")
15+ suspend fun getById(id: Long): SavedServer?
16+
17+ @Query("SELECT * FROM saved_servers WHERE serverUrl = :url AND username = :username LIMIT 1")
18+ suspend fun findByUrlAndUsername(url: String, username: String): SavedServer?
19+
20+ @Insert(onConflict = OnConflictStrategy.REPLACE)
21+ suspend fun insert(server: SavedServer): Long
22+
23+ @Query("DELETE FROM saved_servers WHERE id = :id")
24+ suspend fun deleteById(id: Long)
25+}
app/src/main/java/com/parseable/android/data/model/Models.kt+57−2
@@ -1,9 +1,43 @@
1package com.parseable.android.data.model
1package com.parseable.android.data.model
2
2
3+import kotlinx.serialization.KSerializer
3import kotlinx.serialization.SerialName
4import kotlinx.serialization.SerialName
4import kotlinx.serialization.Serializable
5import kotlinx.serialization.Serializable
6+import kotlinx.serialization.descriptors.PrimitiveKind
7+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
8+import kotlinx.serialization.descriptors.SerialDescriptor
9+import kotlinx.serialization.encoding.Decoder
10+import kotlinx.serialization.encoding.Encoder
11+import kotlinx.serialization.json.JsonDecoder
5import kotlinx.serialization.json.JsonElement
12import kotlinx.serialization.json.JsonElement
13+import kotlinx.serialization.json.JsonNull
6import kotlinx.serialization.json.JsonObject
14import kotlinx.serialization.json.JsonObject
15+import kotlinx.serialization.json.JsonPrimitive
16+
17+/**
18+ * Deserializes a value that some Parseable builds return as a JSON string ("12345") and other
19+ * builds return as a JSON number (12345) into a [String], so stats parsing doesn't fail outright
20+ * on the numeric form. Applied to the size fields below, which are rendered via [formatBytes]
21+ * (itself tolerant of both a raw byte count and a pre-formatted string).
22+ */
23+object FlexibleStringSerializer : KSerializer<String> {
24+ override val descriptor: SerialDescriptor =
25+ PrimitiveSerialDescriptor("FlexibleString", PrimitiveKind.STRING)
26+
27+ override fun serialize(encoder: Encoder, value: String) = encoder.encodeString(value)
28+
29+ override fun deserialize(decoder: Decoder): String {
30+ val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeString()
31+ val element = jsonDecoder.decodeJsonElement()
32+ // JsonNull is itself a JsonPrimitive whose .content is the literal string "null". For the
33+ // nullable fields this serializer is applied to, the compiler-generated nullable wrapper
34+ // already intercepts a JSON null before deserialize() runs, so this never fires today. The
35+ // guard exists so that if this serializer is ever attached to a non-null field, a JSON null
36+ // surfaces as "" (rendered as absent) rather than the misleading literal text "null".
37+ if (element is JsonNull) return ""
38+ return (element as? JsonPrimitive)?.content ?: element.toString()
39+ }
40+}
7
41
8/**
42/**
9 * Server connection configuration stored in DataStore.
43 * Server connection configuration stored in DataStore.
@@ -47,25 +81,31 @@ data class StreamStats(
47@Serializable
81@Serializable
48data class IngestionStats(
82data class IngestionStats(
49 val count: Long? = null,
83 val count: Long? = null,
84+ @Serializable(with = FlexibleStringSerializer::class)
50 val size: String? = null,
85 val size: String? = null,
51 val format: String? = null,
86 val format: String? = null,
52 @SerialName("lifetime_count")
87 @SerialName("lifetime_count")
53 val lifetimeCount: Long? = null,
88 val lifetimeCount: Long? = null,
54 @SerialName("lifetime_size")
89 @SerialName("lifetime_size")
90+ @Serializable(with = FlexibleStringSerializer::class)
55 val lifetimeSize: String? = null,
91 val lifetimeSize: String? = null,
56 @SerialName("deleted_count")
92 @SerialName("deleted_count")
57 val deletedCount: Long? = null,
93 val deletedCount: Long? = null,
58 @SerialName("deleted_size")
94 @SerialName("deleted_size")
95+ @Serializable(with = FlexibleStringSerializer::class)
59 val deletedSize: String? = null,
96 val deletedSize: String? = null,
60)
97)
61
98
62@Serializable
99@Serializable
63data class StorageStats(
100data class StorageStats(
101+ @Serializable(with = FlexibleStringSerializer::class)
64 val size: String? = null,
102 val size: String? = null,
65 val format: String? = null,
103 val format: String? = null,
66 @SerialName("lifetime_size")
104 @SerialName("lifetime_size")
105+ @Serializable(with = FlexibleStringSerializer::class)
67 val lifetimeSize: String? = null,
106 val lifetimeSize: String? = null,
68 @SerialName("deleted_size")
107 @SerialName("deleted_size")
108+ @Serializable(with = FlexibleStringSerializer::class)
69 val deletedSize: String? = null,
109 val deletedSize: String? = null,
70)
110)
71
111
@@ -152,11 +192,14 @@ data class Alert(
152 val datasets: List<String> = emptyList(),
192 val datasets: List<String> = emptyList(),
153) {
193) {
154 /** Best-effort display name from either response format. */
194 /** Best-effort display name from either response format. */
155− val displayName: String get() = title ?: name ?: "Unnamed Alert"
195+ val displayName: String
196+ get() = title?.takeIf { it.isNotBlank() }
197+ ?: name?.takeIf { it.isNotBlank() }
198+ ?: "Unnamed Alert"
156
199
157 /** Whether the alert is active, derived from either response format. */
200 /** Whether the alert is active, derived from either response format. */
158 val isEnabled: Boolean get() = when {
201 val isEnabled: Boolean get() = when {
159− state != null -> state != "Disabled"
202+ state != null -> !state.equals("Disabled", ignoreCase = true)
160 enabled != null -> enabled
203 enabled != null -> enabled
161 else -> true
204 else -> true
162 }
205 }
@@ -178,6 +221,8 @@ sealed class ApiResult<out T> {
178 val isUnauthorized: Boolean get() = code == 401
221 val isUnauthorized: Boolean get() = code == 401
179 val isNotFound: Boolean get() = code == 404
222 val isNotFound: Boolean get() = code == 404
180 val isServerError: Boolean get() = code in 500..599
223 val isServerError: Boolean get() = code in 500..599
224+ // Only genuine transport failures use code 0. A successful HTTP response whose body
225+ // failed to parse carries PARSE_ERROR_CODE so it isn't mislabeled as a network error.
181 val isNetworkError: Boolean get() = code == 0
226 val isNetworkError: Boolean get() = code == 0
182
227
183 val userMessage: String get() = when {
228 val userMessage: String get() = when {
@@ -195,4 +240,14 @@ sealed class ApiResult<out T> {
195 else -> message
240 else -> message
196 }
241 }
197 }
242 }
243+
244+ companion object {
245+ /**
246+ * Code used for client-side response-parsing failures: the HTTP request succeeded
247+ * but the body was malformed/unexpected. Distinct from 0 (network/transport failure)
248+ * so [Error.isNetworkError] and [Error.userMessage] don't show a misleading
249+ * "Network error" for what is actually a server-data problem.
250+ */
251+ const val PARSE_ERROR_CODE = -1
252+ }
198}
253}
app/src/main/java/com/parseable/android/data/model/SavedFilter.ktnew file+61
@@ -0,0 +1,61 @@
1+package com.parseable.android.data.model
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+
6+/**
7+ * Saved filter synced with Parseable server via /api/v1/filters.
8+ */
9+@Serializable
10+data class SavedFilter(
11+ @SerialName("filter_id")
12+ val filterId: String? = null,
13+ @SerialName("filter_name")
14+ val filterName: String = "",
15+ @SerialName("stream_name")
16+ val streamName: String = "",
17+ @SerialName("user_id")
18+ val userId: String? = null,
19+ val version: String? = null,
20+ val query: SavedFilterQuery = SavedFilterQuery(),
21+ @SerialName("time_filter")
22+ val timeFilter: SavedFilterTimeFilter? = null,
23+)
24+
25+@Serializable
26+data class SavedFilterQuery(
27+ @SerialName("filter_type")
28+ val filterType: String = "filter",
29+ @SerialName("filter_query")
30+ val filterQuery: String? = null,
31+ @SerialName("filter_builder")
32+ val filterBuilder: FilterBuilder? = null,
33+)
34+
35+@Serializable
36+data class FilterBuilder(
37+ val id: String = "root",
38+ val combinator: String = "and",
39+ val rules: List<FilterRuleGroup> = emptyList(),
40+)
41+
42+@Serializable
43+data class FilterRuleGroup(
44+ val id: String = "",
45+ val combinator: String = "and",
46+ val rules: List<FilterRule> = emptyList(),
47+)
48+
49+@Serializable
50+data class FilterRule(
51+ val id: String = "",
52+ val field: String = "",
53+ val value: String = "",
54+ val operator: String = "=",
55+)
56+
57+@Serializable
58+data class SavedFilterTimeFilter(
59+ val from: String? = null,
60+ val to: String? = null,
61+)
app/src/main/java/com/parseable/android/data/repository/ParseableRepository.kt+29−4
@@ -71,12 +71,23 @@ class ParseableRepository @Inject constructor(
71 * Does NOT trigger [checkAuth] so the global auth-error handler won't fire during login.
71 * Does NOT trigger [checkAuth] so the global auth-error handler won't fire during login.
72 */
72 */
73 suspend fun verifyServer(): ApiResult<AboutInfo> {
73 suspend fun verifyServer(): ApiResult<AboutInfo> {
74− return when (val result = apiClient.getAbout()) {
74+ return when (val aboutResult = apiClient.getAbout()) {
75 is ApiResult.Success -> {
75 is ApiResult.Success -> {
76+ // GET /about is not auth-gated on Parseable, so a success here only proves
77+ // the server is reachable and is Parseable — NOT that the credentials are
78+ // valid. Probe an auth-required endpoint (logstream listing, which the app
79+ // needs anyway) so wrong credentials are rejected at login instead of being
80+ // persisted and only failing on the first real request. Call apiClient
81+ // directly to avoid checkAuth firing the global auth-error handler mid-login.
82+ when (val authProbe = apiClient.listStreams()) {
83+ is ApiResult.Success -> {
76− aboutCache = CacheEntry(result.data)
84+ aboutCache = CacheEntry(aboutResult.data)
77− result
85+ aboutResult
86+ }
87+ is ApiResult.Error -> authProbe
88+ }
78 }
89 }
79− is ApiResult.Error -> result
90+ is ApiResult.Error -> aboutResult
80 }
91 }
81 }
92 }
82
93
@@ -162,4 +173,18 @@ class ParseableRepository @Inject constructor(
162 checkAuth(apiClient.createAlert(alert))
173 checkAuth(apiClient.createAlert(alert))
163
174
164 suspend fun listUsers(): ApiResult<List<JsonObject>> = checkAuth(apiClient.listUsers())
175 suspend fun listUsers(): ApiResult<List<JsonObject>> = checkAuth(apiClient.listUsers())
176+
177+ suspend fun listFilters(): ApiResult<List<SavedFilter>> = checkAuth(apiClient.listFilters())
178+
179+ suspend fun getFilter(filterId: String): ApiResult<SavedFilter> =
180+ checkAuth(apiClient.getFilter(filterId))
181+
182+ suspend fun createFilter(filter: SavedFilter): ApiResult<SavedFilter> =
183+ checkAuth(apiClient.createFilter(filter))
184+
185+ suspend fun updateFilter(filterId: String, filter: SavedFilter): ApiResult<SavedFilter> =
186+ checkAuth(apiClient.updateFilter(filterId, filter))
187+
188+ suspend fun deleteFilter(filterId: String): ApiResult<String> =
189+ checkAuth(apiClient.deleteFilter(filterId))
165}
190}
app/src/main/java/com/parseable/android/data/repository/SettingsRepository.kt+238−8
@@ -8,6 +8,8 @@ import androidx.datastore.preferences.core.*
8import androidx.datastore.preferences.preferencesDataStore
8import androidx.datastore.preferences.preferencesDataStore
9import androidx.security.crypto.EncryptedSharedPreferences
9import androidx.security.crypto.EncryptedSharedPreferences
10import androidx.security.crypto.MasterKey
10import androidx.security.crypto.MasterKey
11+import com.parseable.android.data.local.SavedServer
12+import com.parseable.android.data.local.SavedServerDao
11import com.parseable.android.data.model.ServerConfig
13import com.parseable.android.data.model.ServerConfig
12import dagger.hilt.android.qualifiers.ApplicationContext
14import dagger.hilt.android.qualifiers.ApplicationContext
13import kotlinx.coroutines.Dispatchers
15import kotlinx.coroutines.Dispatchers
@@ -27,10 +29,12 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
27@Singleton
29@Singleton
28class SettingsRepository @Inject constructor(
30class SettingsRepository @Inject constructor(
29 @ApplicationContext private val context: Context,
31 @ApplicationContext private val context: Context,
32+ private val savedServerDao: SavedServerDao,
30) {
33) {
31 private val serverUrlKey = stringPreferencesKey("server_url")
34 private val serverUrlKey = stringPreferencesKey("server_url")
32 private val usernameKey = stringPreferencesKey("username")
35 private val usernameKey = stringPreferencesKey("username")
33 private val useTlsKey = booleanPreferencesKey("use_tls")
36 private val useTlsKey = booleanPreferencesKey("use_tls")
37+ private val activeServerIdKey = longPreferencesKey("active_server_id")
34
38
35 /** Guards concurrent reads/writes across DataStore and EncryptedSharedPreferences. */
39 /** Guards concurrent reads/writes across DataStore and EncryptedSharedPreferences. */
36 private val configMutex = Mutex()
40 private val configMutex = Mutex()
@@ -62,13 +66,24 @@ class SettingsRepository @Inject constructor(
62 )
66 )
63 }
67 }
64
68
69+ // ── Active server config (current connection) ──────────────────────
70+
65 val serverConfig: Flow<ServerConfig?> = context.dataStore.data
71 val serverConfig: Flow<ServerConfig?> = context.dataStore.data
66 .catch { e ->
72 .catch { e ->
67 Timber.e(e, "Failed to read DataStore")
73 Timber.e(e, "Failed to read DataStore")
68 emit(emptyPreferences())
74 emit(emptyPreferences())
69 }
75 }
70− .map { prefs ->
76+ .map {
77+ // Read url/username/tls AND the encrypted password together under configMutex,
78+ // re-reading a fresh DataStore snapshot inside the lock. Writers (switch/save/clear)
79+ // hold this mutex across their whole multi-store edit, so acquiring it here guarantees
80+ // the url/username returned always belong to the same server as the password. A
81+ // lock-free read could otherwise pair one server's username with another server's
82+ // password if a concurrent switch's writes landed between the DataStore read and the
83+ // encrypted-password read. All callers consume this via .first(), so serializing the
84+ // read behind the (brief) write critical sections costs at most one extra snapshot read.
71 configMutex.withLock {
85 configMutex.withLock {
86+ val prefs = context.dataStore.data.first()
72 val url = prefs[serverUrlKey] ?: return@withLock null
87 val url = prefs[serverUrlKey] ?: return@withLock null
73 val user = prefs[usernameKey] ?: return@withLock null
88 val user = prefs[usernameKey] ?: return@withLock null
74 val pass = try {
89 val pass = try {
@@ -87,21 +102,43 @@ class SettingsRepository @Inject constructor(
87 }
102 }
88 .flowOn(Dispatchers.IO)
103 .flowOn(Dispatchers.IO)
89
104
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+
90 suspend fun getSavedPassword(): ServerConfig? = serverConfig.first()
113 suspend fun getSavedPassword(): ServerConfig? = serverConfig.first()
91
114
115+ /**
116+ * Persist the server config. Returns true only if the credentials were durably saved.
117+ * Callers (e.g. login) must not report success when this returns false — otherwise the
118+ * session would appear logged in but vanish on the next launch.
119+ */
92− suspend fun saveServerConfig(config: ServerConfig) {
120+ suspend fun saveServerConfig(config: ServerConfig): Boolean {
93− configMutex.withLock {
121+ return configMutex.withLock {
94 try {
122 try {
123+ // Persist the password (encrypted) first and confirm it committed. If the
124+ // Keystore-backed write fails we abort before touching DataStore, so we never
125+ // leave a new URL/username paired with a stale password.
126+ val passwordSaved = withContext(Dispatchers.IO) {
127+ encryptedPrefs.edit().putString("password", config.password).commit()
128+ }
129+ if (!passwordSaved) {
130+ Timber.e("Failed to persist credentials securely; aborting config save")
131+ return@withLock false
132+ }
95 context.dataStore.edit { prefs ->
133 context.dataStore.edit { prefs ->
96 prefs[serverUrlKey] = config.serverUrl
134 prefs[serverUrlKey] = config.serverUrl
97 prefs[usernameKey] = config.username
135 prefs[usernameKey] = config.username
98 prefs[useTlsKey] = config.useTls
136 prefs[useTlsKey] = config.useTls
99 }
137 }
100− withContext(Dispatchers.IO) {
138+ true
101− encryptedPrefs.edit().putString("password", config.password).apply()
102− }
103 } catch (e: Exception) {
139 } catch (e: Exception) {
104 Timber.e(e, "Failed to save server config")
140 Timber.e(e, "Failed to save server config")
141+ false
105 }
142 }
106 }
143 }
107 }
144 }
@@ -109,13 +146,206 @@ class SettingsRepository @Inject constructor(
109 suspend fun clearConfig() {
146 suspend fun clearConfig() {
110 configMutex.withLock {
147 configMutex.withLock {
111 try {
148 try {
112− context.dataStore.edit { it.clear() }
149+ context.dataStore.edit { prefs ->
150+ prefs.remove(serverUrlKey)
151+ prefs.remove(usernameKey)
152+ prefs.remove(useTlsKey)
153+ prefs.remove(activeServerIdKey)
154+ }
113 withContext(Dispatchers.IO) {
155 withContext(Dispatchers.IO) {
156+ // commit() (not apply()) so the cleared password is durably gone before we
157+ // return — a queued async clear could survive into the next session.
114− encryptedPrefs.edit().clear().apply()
158+ encryptedPrefs.edit().remove("password").commit()
115 }
159 }
116 } catch (e: Exception) {
160 } catch (e: Exception) {
117 Timber.e(e, "Failed to clear config")
161 Timber.e(e, "Failed to clear config")
118 }
162 }
119 }
163 }
120 }
164 }
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+ }
121}
351}
app/src/main/java/com/parseable/android/di/AppModule.kt+59
@@ -2,8 +2,11 @@ package com.parseable.android.di
2
2
3import android.content.Context
3import android.content.Context
4import androidx.room.Room
4import androidx.room.Room
5+import androidx.room.migration.Migration
6+import androidx.sqlite.db.SupportSQLiteDatabase
5import com.parseable.android.data.local.FavoriteStreamDao
7import com.parseable.android.data.local.FavoriteStreamDao
6import com.parseable.android.data.local.ParseableDatabase
8import com.parseable.android.data.local.ParseableDatabase
9+import com.parseable.android.data.local.SavedServerDao
7import dagger.Module
10import dagger.Module
8import dagger.Provides
11import dagger.Provides
9import dagger.hilt.InstallIn
12import dagger.hilt.InstallIn
@@ -15,10 +18,61 @@ import javax.inject.Singleton
15@InstallIn(SingletonComponent::class)
18@InstallIn(SingletonComponent::class)
16object AppModule {
19object AppModule {
17
20
21+ /**
22+ * v1 → v2 added the `saved_servers` table (multi-server management). This is NOT a
23+ * rebuildable cache like favorites: it holds the user's saved server list and the keys
24+ * into EncryptedSharedPreferences for each server's password. Losing it strands users'
25+ * credentials, so the upgrade must be a real additive migration, not a destructive wipe.
26+ *
27+ * The CREATE TABLE statement must match the schema Room generates for the SavedServer
28+ * entity exactly (column names, affinities, NOT NULL, the AUTOINCREMENT primary key) or
29+ * Room's post-migration schema validation will throw on first open.
30+ */
31+ private val MIGRATION_1_2 = object : Migration(1, 2) {
32+ override fun migrate(db: SupportSQLiteDatabase) {
33+ db.execSQL(
34+ "CREATE TABLE IF NOT EXISTS `saved_servers` (" +
35+ "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
36+ "`serverUrl` TEXT NOT NULL, " +
37+ "`username` TEXT NOT NULL, " +
38+ "`useTls` INTEGER NOT NULL, " +
39+ "`passwordKey` TEXT NOT NULL, " +
40+ "`addedAt` INTEGER NOT NULL)"
41+ )
42+ }
43+ }
44+
45+ /**
46+ * v2 → v3 adds a UNIQUE index on (serverUrl, username) so the one-row-per-server invariant
47+ * is enforced by the schema rather than only by app logic. Any pre-existing duplicate rows
48+ * (possible because v2 had no constraint) must be collapsed first or the index creation
49+ * fails — keep the lowest id for each (url, username). The index name and SQL must match
50+ * what Room generates for the entity's @Index or post-migration validation will throw.
51+ */
52+ private val MIGRATION_2_3 = object : Migration(2, 3) {
53+ override fun migrate(db: SupportSQLiteDatabase) {
54+ db.execSQL(
55+ "DELETE FROM `saved_servers` WHERE `id` NOT IN " +
56+ "(SELECT MIN(`id`) FROM `saved_servers` GROUP BY `serverUrl`, `username`)"
57+ )
58+ db.execSQL(
59+ "CREATE UNIQUE INDEX IF NOT EXISTS " +
60+ "`index_saved_servers_serverUrl_username` " +
61+ "ON `saved_servers` (`serverUrl`, `username`)"
62+ )
63+ }
64+ }
65+
18 @Provides
66 @Provides
19 @Singleton
67 @Singleton
20 fun provideDatabase(@ApplicationContext context: Context): ParseableDatabase =
68 fun provideDatabase(@ApplicationContext context: Context): ParseableDatabase =
21 Room.databaseBuilder(context, ParseableDatabase::class.java, "parseable.db")
69 Room.databaseBuilder(context, ParseableDatabase::class.java, "parseable.db")
70+ // Register real migrations for any schema bump that must preserve user data.
71+ // IMPORTANT: every future version bump needs a Migration added here — the
72+ // destructive fallback below is only a last resort for unmigrated paths and will
73+ // silently drop ALL tables (including saved_servers) if it ever runs on upgrade.
74+ // Exported schemas (see ParseableDatabase, exportSchema = true) provide the diffs.
75+ .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
22 .fallbackToDestructiveMigration()
76 .fallbackToDestructiveMigration()
23 .build()
77 .build()
24
78
@@ -26,4 +80,9 @@ object AppModule {
26 @Singleton
80 @Singleton
27 fun provideFavoriteStreamDao(db: ParseableDatabase): FavoriteStreamDao =
81 fun provideFavoriteStreamDao(db: ParseableDatabase): FavoriteStreamDao =
28 db.favoriteStreamDao()
82 db.favoriteStreamDao()
83+
84+ @Provides
85+ @Singleton
86+ fun provideSavedServerDao(db: ParseableDatabase): SavedServerDao =
87+ db.savedServerDao()
29}
88}
app/src/main/java/com/parseable/android/ui/navigation/NavGraph.kt+54−10
@@ -13,9 +13,9 @@ import com.parseable.android.ui.screens.alerts.AlertsScreen
13import com.parseable.android.ui.screens.login.LoginScreen
13import com.parseable.android.ui.screens.login.LoginScreen
14import com.parseable.android.ui.screens.logviewer.LogViewerScreen
14import com.parseable.android.ui.screens.logviewer.LogViewerScreen
15import com.parseable.android.ui.screens.logviewer.StreamInfoScreen
15import com.parseable.android.ui.screens.logviewer.StreamInfoScreen
16+import com.parseable.android.ui.screens.about.AboutScreen
16import com.parseable.android.ui.screens.settings.SettingsScreen
17import com.parseable.android.ui.screens.settings.SettingsScreen
17import com.parseable.android.ui.screens.streams.StreamsScreen
18import com.parseable.android.ui.screens.streams.StreamsScreen
18−import java.net.URLDecoder
19import java.net.URLEncoder
19import java.net.URLEncoder
20
20
21object Routes {
21object Routes {
@@ -25,13 +25,21 @@ object Routes {
25 const val STREAM_INFO = "stream_info/{streamName}"
25 const val STREAM_INFO = "stream_info/{streamName}"
26 const val ALERTS = "alerts"
26 const val ALERTS = "alerts"
27 const val SETTINGS = "settings"
27 const val SETTINGS = "settings"
28+ const val ABOUT = "about"
28
29
29 fun login(sessionExpired: Boolean = false) =
30 fun login(sessionExpired: Boolean = false) =
30 if (sessionExpired) "login?sessionExpired=true" else LOGIN
31 if (sessionExpired) "login?sessionExpired=true" else LOGIN
31 fun logViewer(streamName: String) =
32 fun logViewer(streamName: String) =
32− "log_viewer/${URLEncoder.encode(streamName, "UTF-8")}"
33+ "log_viewer/${encodeArg(streamName)}"
33 fun streamInfo(streamName: String) =
34 fun streamInfo(streamName: String) =
35+ "stream_info/${encodeArg(streamName)}"
36+
37+ // URLEncoder maps space to "+", but Navigation decodes captured path args with Uri.decode,
38+ // which only understands "%20" and leaves "+" untouched (it would survive as a literal
39+ // "+"). Normalize to "%20" so the name Navigation hands back matches the original. Do NOT
40+ // decode again at the call site — Navigation already does that one decode for us.
41+ private fun encodeArg(value: String) =
34− "stream_info/${URLEncoder.encode(streamName, "UTF-8")}"
42+ URLEncoder.encode(value, "UTF-8").replace("+", "%20")
35}
43}
36
44
37@Composable
45@Composable
@@ -112,9 +120,8 @@ fun ParseableNavGraph(
112 }
120 }
113 return@composable
121 return@composable
114 }
122 }
115− val streamName = URLDecoder.decode(
123+ // Navigation already URI-decodes captured path args, so use the value as-is.
116− backStackEntry.arguments?.getString("streamName") ?: "", "UTF-8"
124+ val streamName = backStackEntry.arguments?.getString("streamName") ?: ""
117− )
118 LogViewerScreen(
125 LogViewerScreen(
119 streamName = streamName,
126 streamName = streamName,
120 onBack = { navController.popBackStack() },
127 onBack = { navController.popBackStack() },
@@ -130,14 +137,22 @@ fun ParseableNavGraph(
130 route = Routes.STREAM_INFO,
137 route = Routes.STREAM_INFO,
131 arguments = listOf(navArgument("streamName") { type = NavType.StringType }),
138 arguments = listOf(navArgument("streamName") { type = NavType.StringType }),
132 ) { backStackEntry ->
139 ) { backStackEntry ->
133− val streamName = URLDecoder.decode(
140+ // Navigation already URI-decodes captured path args, so use the value as-is.
134− backStackEntry.arguments?.getString("streamName") ?: "", "UTF-8"
141+ val streamName = backStackEntry.arguments?.getString("streamName") ?: ""
135− )
136 StreamInfoScreen(
142 StreamInfoScreen(
137 streamName = streamName,
143 streamName = streamName,
138 onBack = { navController.popBackStack() },
144 onBack = { navController.popBackStack() },
139 onStreamDeleted = {
145 onStreamDeleted = {
146+ // popBackStack no-ops (returns false) when there's no streams entry on
147+ // the back stack — e.g. when the user arrived via the deep link. In that
148+ // case navigate to streams fresh so we don't strand them on the info
149+ // screen for a now-deleted stream.
140− navController.popBackStack(Routes.STREAMS, inclusive = false)
150+ val popped = navController.popBackStack(Routes.STREAMS, inclusive = false)
151+ if (!popped) {
152+ navController.navigate(Routes.STREAMS) {
153+ popUpTo(0) { inclusive = true }
154+ }
155+ }
141 },
156 },
142 )
157 )
143 }
158 }
@@ -170,6 +185,35 @@ fun ParseableNavGraph(
170 }
185 }
171 SettingsScreen(
186 SettingsScreen(
172 onBack = { navController.popBackStack() },
187 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() },
173 )
217 )
174 }
218 }
175 }
219 }
app/src/main/java/com/parseable/android/ui/screens/about/AboutScreen.ktnew file+178
@@ -0,0 +1,178 @@
1+package com.parseable.android.ui.screens.about
2+
3+import android.content.Intent
4+import android.net.Uri
5+import androidx.compose.foundation.Image
6+import androidx.compose.foundation.layout.*
7+import androidx.compose.foundation.rememberScrollState
8+import androidx.compose.foundation.verticalScroll
9+import androidx.compose.material.icons.Icons
10+import androidx.compose.material.icons.automirrored.filled.ArrowBack
11+import androidx.compose.material.icons.filled.*
12+import androidx.compose.material3.*
13+import androidx.compose.runtime.Composable
14+import androidx.compose.ui.Alignment
15+import androidx.compose.ui.Modifier
16+import androidx.compose.ui.platform.LocalContext
17+import androidx.compose.ui.res.painterResource
18+import androidx.compose.ui.text.font.FontWeight
19+import androidx.compose.ui.unit.dp
20+import com.parseable.android.BuildConfig
21+import com.parseable.android.R
22+
23+private const val GITHUB_REPO_URL = "https://github.com/parseablehq/parseable-android"
24+private const val PARSEABLE_WEBSITE_URL = "https://www.parseable.com"
25+
26+@OptIn(ExperimentalMaterial3Api::class)
27+@Composable
28+fun AboutScreen(
29+ onBack: () -> Unit,
30+) {
31+ val context = LocalContext.current
32+
33+ Scaffold(
34+ topBar = {
35+ TopAppBar(
36+ title = { Text("About") },
37+ navigationIcon = {
38+ IconButton(onClick = onBack) {
39+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
40+ }
41+ },
42+ )
43+ },
44+ ) { padding ->
45+ Column(
46+ modifier = Modifier
47+ .fillMaxSize()
48+ .padding(padding)
49+ .verticalScroll(rememberScrollState())
50+ .padding(16.dp),
51+ verticalArrangement = Arrangement.spacedBy(16.dp),
52+ ) {
53+ // App header
54+ Card(
55+ modifier = Modifier.fillMaxWidth(),
56+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
57+ ) {
58+ Column(
59+ modifier = Modifier
60+ .fillMaxWidth()
61+ .padding(24.dp),
62+ horizontalAlignment = Alignment.CenterHorizontally,
63+ ) {
64+ Image(
65+ painter = painterResource(id = R.mipmap.ic_launcher),
66+ contentDescription = "Parseable logo",
67+ modifier = Modifier.size(80.dp),
68+ )
69+ Spacer(modifier = Modifier.height(12.dp))
70+ Text(
71+ text = "Parseable",
72+ style = MaterialTheme.typography.headlineSmall,
73+ fontWeight = FontWeight.Bold,
74+ )
75+ Text(
76+ text = "Android Client",
77+ style = MaterialTheme.typography.titleMedium,
78+ color = MaterialTheme.colorScheme.onSurfaceVariant,
79+ )
80+ Spacer(modifier = Modifier.height(8.dp))
81+ Text(
82+ text = "Version ${BuildConfig.VERSION_NAME}",
83+ style = MaterialTheme.typography.bodyMedium,
84+ color = MaterialTheme.colorScheme.onSurfaceVariant,
85+ )
86+ }
87+ }
88+
89+ // Description
90+ Card(
91+ modifier = Modifier.fillMaxWidth(),
92+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
93+ ) {
94+ Column(modifier = Modifier.padding(16.dp)) {
95+ Row(verticalAlignment = Alignment.CenterVertically) {
96+ Icon(
97+ Icons.Filled.Info,
98+ contentDescription = null,
99+ tint = MaterialTheme.colorScheme.primary,
100+ )
101+ Spacer(modifier = Modifier.width(8.dp))
102+ Text(
103+ text = "About",
104+ style = MaterialTheme.typography.titleMedium,
105+ fontWeight = FontWeight.Bold,
106+ )
107+ }
108+ Spacer(modifier = Modifier.height(12.dp))
109+ Text(
110+ text = "Parseable is an open-source log analytics platform. " +
111+ "This app connects to a Parseable server instance and lets you " +
112+ "browse, search, filter, and live-tail log data.",
113+ style = MaterialTheme.typography.bodyMedium,
114+ )
115+ }
116+ }
117+
118+ // Links
119+ Card(
120+ modifier = Modifier.fillMaxWidth(),
121+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
122+ ) {
123+ Column(modifier = Modifier.padding(16.dp)) {
124+ Row(verticalAlignment = Alignment.CenterVertically) {
125+ Icon(
126+ Icons.Filled.Link,
127+ contentDescription = null,
128+ tint = MaterialTheme.colorScheme.primary,
129+ )
130+ Spacer(modifier = Modifier.width(8.dp))
131+ Text(
132+ text = "Links",
133+ style = MaterialTheme.typography.titleMedium,
134+ fontWeight = FontWeight.Bold,
135+ )
136+ }
137+ Spacer(modifier = Modifier.height(12.dp))
138+
139+ OutlinedButton(
140+ onClick = {
141+ context.startActivity(
142+ Intent(Intent.ACTION_VIEW, Uri.parse(GITHUB_REPO_URL))
143+ )
144+ },
145+ modifier = Modifier.fillMaxWidth(),
146+ ) {
147+ Icon(
148+ Icons.Filled.Code,
149+ contentDescription = null,
150+ modifier = Modifier.size(18.dp),
151+ )
152+ Spacer(modifier = Modifier.width(8.dp))
153+ Text("GitHub Repository")
154+ }
155+
156+ Spacer(modifier = Modifier.height(8.dp))
157+
158+ OutlinedButton(
159+ onClick = {
160+ context.startActivity(
161+ Intent(Intent.ACTION_VIEW, Uri.parse(PARSEABLE_WEBSITE_URL))
162+ )
163+ },
164+ modifier = Modifier.fillMaxWidth(),
165+ ) {
166+ Icon(
167+ Icons.Filled.Language,
168+ contentDescription = null,
169+ modifier = Modifier.size(18.dp),
170+ )
171+ Spacer(modifier = Modifier.width(8.dp))
172+ Text("Parseable Website")
173+ }
174+ }
175+ }
176+ }
177+ }
178+}
app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsScreen.kt

Binary file not shown.

app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsViewModel.kt+43−10
@@ -6,6 +6,7 @@ import com.parseable.android.data.model.Alert
6import com.parseable.android.data.model.ApiResult
6import com.parseable.android.data.model.ApiResult
7import com.parseable.android.data.repository.ParseableRepository
7import com.parseable.android.data.repository.ParseableRepository
8import dagger.hilt.android.lifecycle.HiltViewModel
8import dagger.hilt.android.lifecycle.HiltViewModel
9+import kotlinx.coroutines.Job
9import kotlinx.coroutines.flow.MutableStateFlow
10import kotlinx.coroutines.flow.MutableStateFlow
10import kotlinx.coroutines.flow.StateFlow
11import kotlinx.coroutines.flow.StateFlow
11import kotlinx.coroutines.flow.asStateFlow
12import kotlinx.coroutines.flow.asStateFlow
@@ -28,16 +29,40 @@ class AlertsViewModel @Inject constructor(
28 private val _state = MutableStateFlow(AlertsState())
29 private val _state = MutableStateFlow(AlertsState())
29 val state: StateFlow<AlertsState> = _state.asStateFlow()
30 val state: StateFlow<AlertsState> = _state.asStateFlow()
30
31
32+ private var refreshJob: Job? = null
33+
34+ init {
35+ // Load once on creation; the screen no longer refreshes on every RESUME.
36+ refresh()
37+ }
38+
31 fun refresh() {
39 fun refresh() {
40+ // Cancel any in-flight load so out-of-order completions can't overwrite a newer alert
41+ // list with a stale one (init load, pull-to-refresh and deleteAlert->refresh can overlap).
42+ refreshJob?.cancel()
32− viewModelScope.launch {
43+ refreshJob = viewModelScope.launch {
33 _state.update { it.copy(isLoading = true, error = null) }
44 _state.update { it.copy(isLoading = true, error = null) }
45+ loadAlerts()
46+ }
47+ }
48+
49+ /**
50+ * Fetches the alert list and writes a terminal state update (clearing isLoading). Callers set
51+ * isLoading = true before invoking. Extracted so deleteAlert can reload within its own
52+ * (never-cancelled) job rather than delegating to refresh(), whose refreshJob can be cancelled
53+ * by a concurrent refresh before it clears the spinner.
54+ */
55+ private suspend fun loadAlerts() {
34− when (val result = repository.listAlerts()) {
56+ when (val result = repository.listAlerts()) {
35− is ApiResult.Success -> {
57+ is ApiResult.Success -> {
36− _state.update { it.copy(alerts = result.data, isLoading = false) }
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) }
37− }
63+ }
38− is ApiResult.Error -> {
64+ is ApiResult.Error -> {
39− _state.update { it.copy(isLoading = false, error = result.userMessage) }
65+ _state.update { it.copy(isLoading = false, error = result.userMessage) }
40− }
41 }
66 }
42 }
67 }
43 }
68 }
@@ -50,11 +75,19 @@ class AlertsViewModel @Inject constructor(
50 _state.update { it.copy(alertToDelete = null) }
75 _state.update { it.copy(alertToDelete = null) }
51 }
76 }
52
77
78+ private var deleteJob: Job? = null
79+
53 fun deleteAlert(alertId: String) {
80 fun deleteAlert(alertId: String) {
81+ // Guard against a double-tap on the confirm button firing two concurrent DELETEs.
82+ if (deleteJob?.isActive == true) return
54− viewModelScope.launch {
83+ deleteJob = viewModelScope.launch {
55 _state.update { it.copy(alertToDelete = null, isLoading = true, error = null) }
84 _state.update { it.copy(alertToDelete = null, isLoading = true, error = null) }
56 when (val result = repository.deleteAlert(alertId)) {
85 when (val result = repository.deleteAlert(alertId)) {
86+ // Reload inline rather than via refresh(): refresh() runs on the cancellable
87+ // refreshJob, so a pull-to-refresh landing right after could cancel it before it
88+ // clears isLoading, leaving the spinner stuck. This job is never cancelled by a
89+ // refresh, so its terminal state update always runs.
57− is ApiResult.Success -> refresh()
90+ is ApiResult.Success -> loadAlerts()
58 is ApiResult.Error -> {
91 is ApiResult.Error -> {
59 _state.update { it.copy(isLoading = false, error = result.userMessage) }
92 _state.update { it.copy(isLoading = false, error = result.userMessage) }
60 }
93 }
app/src/main/java/com/parseable/android/ui/screens/login/LoginScreen.kt+64−8
@@ -1,5 +1,6 @@
1package com.parseable.android.ui.screens.login
1package com.parseable.android.ui.screens.login
2
2
3+import androidx.compose.foundation.Image
3import androidx.compose.foundation.layout.*
4import androidx.compose.foundation.layout.*
4import androidx.compose.foundation.rememberScrollState
5import androidx.compose.foundation.rememberScrollState
5import androidx.compose.foundation.text.KeyboardActions
6import androidx.compose.foundation.text.KeyboardActions
@@ -15,7 +16,9 @@ import androidx.compose.ui.Alignment
15import androidx.compose.ui.Modifier
16import androidx.compose.ui.Modifier
16import androidx.compose.ui.focus.FocusDirection
17import androidx.compose.ui.focus.FocusDirection
17import androidx.compose.ui.platform.LocalFocusManager
18import androidx.compose.ui.platform.LocalFocusManager
19+import androidx.compose.ui.res.painterResource
18import androidx.compose.ui.text.font.FontWeight
20import androidx.compose.ui.text.font.FontWeight
21+import com.parseable.android.R
19import com.parseable.android.ui.LocalErrorHandler
22import com.parseable.android.ui.LocalErrorHandler
20import androidx.compose.ui.text.input.ImeAction
23import androidx.compose.ui.text.input.ImeAction
21import androidx.compose.ui.text.input.KeyboardType
24import androidx.compose.ui.text.input.KeyboardType
@@ -37,8 +40,8 @@ fun LoginScreen(
37 var passwordVisible by rememberSaveable { mutableStateOf(false) }
40 var passwordVisible by rememberSaveable { mutableStateOf(false) }
38 val errorHandler = LocalErrorHandler.current
41 val errorHandler = LocalErrorHandler.current
39
42
40− LaunchedEffect(state.loginSuccess) {
43+ LaunchedEffect(Unit) {
41− if (state.loginSuccess) {
44+ viewModel.loginSuccessEvent.collect {
42 onLoginSuccess()
45 onLoginSuccess()
43 }
46 }
44 }
47 }
@@ -59,6 +62,12 @@ fun LoginScreen(
59 horizontalAlignment = Alignment.CenterHorizontally,
62 horizontalAlignment = Alignment.CenterHorizontally,
60 verticalArrangement = Arrangement.Center,
63 verticalArrangement = Arrangement.Center,
61 ) {
64 ) {
65+ Image(
66+ painter = painterResource(id = R.mipmap.ic_launcher),
67+ contentDescription = "Parseable logo",
68+ modifier = Modifier.size(120.dp),
69+ )
70+
62 Text(
71 Text(
63 text = "Parseable",
72 text = "Parseable",
64 style = MaterialTheme.typography.headlineLarge,
73 style = MaterialTheme.typography.headlineLarge,
@@ -169,12 +178,7 @@ fun LoginScreen(
169
178
170 if (state.allowInsecure) {
179 if (state.allowInsecure) {
171 val isPrivateNetwork = remember(state.serverUrl) {
180 val isPrivateNetwork = remember(state.serverUrl) {
172− val host = state.serverUrl
181+ isPrivateHost(extractHost(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])\\..*"))
178 }
182 }
179 if (!isPrivateNetwork) {
183 if (!isPrivateNetwork) {
180 Card(
184 Card(
@@ -246,3 +250,55 @@ fun LoginScreen(
246 }
250 }
247 }
251 }
248}
252}
253+
254+/**
255+ * Extract the bare host from a possibly-schemeless, possibly-ported URL, handling
256+ * IPv6 literals (`[::1]:8000`) and bare IPv6 addresses correctly.
257+ */
258+internal fun extractHost(url: String): String {
259+ // Drop the scheme if present, then the path.
260+ var hostPort = url.substringAfter("://").substringBefore("/")
261+ return if (hostPort.startsWith("[")) {
262+ // Bracketed IPv6 literal, e.g. "[::1]:8000" -> "::1"
263+ hostPort.substringAfter("[").substringBefore("]")
264+ } else {
265+ // Strip ":port" only when there's a single colon (host:port). A bare IPv6
266+ // address has multiple colons and no brackets, so leave it intact.
267+ if (hostPort.count { it == ':' } == 1) hostPort.substringBefore(":") else hostPort
268+ }
269+}
270+
271+/**
272+ * Whether [host] is a loopback/private-range address. Used to decide whether the
273+ * plaintext-HTTP warning is needed. Matches actual IPv4 octet ranges (not string
274+ * prefixes, so "10.example.com" is correctly treated as public) and IPv6
275+ * loopback/link-local/unique-local addresses.
276+ */
277+internal fun isPrivateHost(host: String): Boolean {
278+ if (host.isBlank()) return true
279+ val h = host.lowercase()
280+ if (h == "localhost" || h.endsWith(".localhost")) return true
281+
282+ // IPv6 ranges (only meaningful for actual IPv6 literals, which contain ':').
283+ if (h.contains(":")) {
284+ if (h == "::1") return true // loopback
285+ if (h.startsWith("fe80:")) return true // link-local fe80::/10
286+ if (h.startsWith("fc") || h.startsWith("fd")) return true // unique-local fc00::/7
287+ return false
288+ }
289+
290+ // IPv4: require four numeric octets in 0..255, then match private ranges.
291+ val octets = h.split(".")
292+ if (octets.size == 4 && octets.all { val n = it.toIntOrNull(); n != null && n in 0..255 }) {
293+ val a = octets[0].toInt()
294+ val b = octets[1].toInt()
295+ return when {
296+ a == 10 -> true // 10.0.0.0/8
297+ a == 127 -> true // loopback 127.0.0.0/8
298+ a == 192 && b == 168 -> true // 192.168.0.0/16
299+ a == 172 && b in 16..31 -> true // 172.16.0.0/12
300+ else -> false
301+ }
302+ }
303+ return false
304+}
app/src/main/java/com/parseable/android/ui/screens/login/LoginViewModel.kt+88−11
@@ -7,10 +7,12 @@ import com.parseable.android.data.model.ServerConfig
7import com.parseable.android.data.repository.ParseableRepository
7import com.parseable.android.data.repository.ParseableRepository
8import com.parseable.android.data.repository.SettingsRepository
8import com.parseable.android.data.repository.SettingsRepository
9import dagger.hilt.android.lifecycle.HiltViewModel
9import dagger.hilt.android.lifecycle.HiltViewModel
10+import kotlinx.coroutines.channels.Channel
10import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.StateFlow
12import kotlinx.coroutines.flow.StateFlow
12import kotlinx.coroutines.flow.asStateFlow
13import kotlinx.coroutines.flow.asStateFlow
13import kotlinx.coroutines.flow.first
14import kotlinx.coroutines.flow.first
15+import kotlinx.coroutines.flow.receiveAsFlow
14import kotlinx.coroutines.flow.update
16import kotlinx.coroutines.flow.update
15import kotlinx.coroutines.launch
17import kotlinx.coroutines.launch
16import javax.inject.Inject
18import javax.inject.Inject
@@ -22,7 +24,6 @@ data class LoginState(
22 val allowInsecure: Boolean = false,
24 val allowInsecure: Boolean = false,
23 val isLoading: Boolean = false,
25 val isLoading: Boolean = false,
24 val error: String? = null,
26 val error: String? = null,
25− val loginSuccess: Boolean = false,
26 val serverUrlError: String? = null,
27 val serverUrlError: String? = null,
27 val usernameError: String? = null,
28 val usernameError: String? = null,
28 val passwordError: String? = null,
29 val passwordError: String? = null,
@@ -38,6 +39,15 @@ class LoginViewModel @Inject constructor(
38 private val _state = MutableStateFlow(LoginState())
39 private val _state = MutableStateFlow(LoginState())
39 val state: StateFlow<LoginState> = _state.asStateFlow()
40 val state: StateFlow<LoginState> = _state.asStateFlow()
40
41
42+ /**
43+ * Emitted exactly once when login succeeds so the screen navigates away. A one-shot [Channel]
44+ * (like the codebase's other nav events) rather than a retained StateFlow flag: a retained
45+ * `loginSuccess = true` would re-fire navigation on any later recomposition that re-collects
46+ * the state if this ViewModel instance outlived the first navigation.
47+ */
48+ private val _loginSuccessEvent = Channel<Unit>(Channel.BUFFERED)
49+ val loginSuccessEvent = _loginSuccessEvent.receiveAsFlow()
50+
41 init {
51 init {
42 viewModelScope.launch {
52 viewModelScope.launch {
43 val config = settingsRepository.serverConfig.first()
53 val config = settingsRepository.serverConfig.first()
@@ -55,9 +65,39 @@ class LoginViewModel @Inject constructor(
55 }
65 }
56
66
57 fun loginWithSavedCredentials() {
67 fun loginWithSavedCredentials() {
68+ // Guard against a second tap (or a concurrent onLogin()) launching a duplicate
69+ // login. Set isLoading synchronously so the guard is effective before the
70+ // coroutine starts.
71+ if (_state.value.isLoading) return
72+ _state.update { it.copy(isLoading = true, error = null) }
58 viewModelScope.launch {
73 viewModelScope.launch {
59− val config = settingsRepository.getSavedPassword() ?: return@launch
74+ val config = settingsRepository.getSavedPassword()
75+ if (config == null) {
76+ // URL/username are present but the saved password can't be read (e.g. the
77+ // Keystore entry was lost). Surface this instead of silently stopping the
78+ // spinner, and hide the now-useless "use saved credentials" button.
79+ _state.update {
80+ it.copy(
81+ isLoading = false,
82+ hasSavedCredentials = false,
83+ error = "Saved credentials could not be read. Please log in again.",
84+ )
85+ }
86+ return@launch
87+ }
88+ // Restore the saved URL/username so the saved password is paired with the
89+ // server/user it was saved for, even if the user edited those fields.
90+ _state.update {
91+ it.copy(
92+ serverUrl = config.serverUrl,
93+ username = config.username,
94+ allowInsecure = !config.useTls,
95+ serverUrlError = null,
96+ usernameError = null,
97+ passwordError = null,
98+ )
99+ }
60− performLogin(config.password)
100+ performLogin(config.serverUrl, config.username, config.password, !config.useTls)
61 }
101 }
62 }
102 }
63
103
@@ -80,6 +120,15 @@ class LoginViewModel @Inject constructor(
80 fun onLogin() {
120 fun onLogin() {
81 val current = _state.value
121 val current = _state.value
82
122
123+ // With saved credentials the password field is intentionally left empty (the user is
124+ // expected to tap "Use saved credentials"). Tapping the primary Connect button in that
125+ // state should do the obvious thing — log in with the saved password — rather than
126+ // reject with "Password is required".
127+ if (current.password.isBlank() && current.hasSavedCredentials) {
128+ loginWithSavedCredentials()
129+ return
130+ }
131+
83 // Validate
132 // Validate
84 var hasError = false
133 var hasError = false
85 var urlError: String? = null
134 var urlError: String? = null
@@ -110,19 +159,34 @@ class LoginViewModel @Inject constructor(
110 return
159 return
111 }
160 }
112
161
162+ // Guard against a duplicate in-flight login and set isLoading synchronously so a
163+ // fast second tap can't launch a concurrent performLogin that races on _state.
164+ if (current.isLoading) return
165+ _state.update { it.copy(isLoading = true, error = null) }
166+ // Pass every field from the same snapshot so the validated password can't be
167+ // paired with a URL/username the user edited after tapping.
113 viewModelScope.launch {
168 viewModelScope.launch {
114− performLogin(current.password)
169+ performLogin(current.serverUrl, current.username, current.password, current.allowInsecure)
115 }
170 }
116 }
171 }
117
172
118− private suspend fun performLogin(password: String) {
173+ private suspend fun performLogin(
119− val current = _state.value
174+ serverUrl: String,
175+ username: String,
176+ password: String,
177+ allowInsecure: Boolean,
178+ ) {
120 _state.update { it.copy(isLoading = true, error = null) }
179 _state.update { it.copy(isLoading = true, error = null) }
121
180
181+ // Strip trailing slashes so the persisted URL matches the canonical form the API
182+ // client uses (ParseableApiClient.configure also does trimEnd('/')). Without this,
183+ // "http://host:8000" and "http://host:8000/" persist as distinct strings, defeating
184+ // saveServer's (serverUrl, username) dedup and the UNIQUE index — the same server
185+ // ends up as two saved-server rows.
122− var url = current.serverUrl.trim()
186+ var url = serverUrl.trim().trimEnd('/')
123 val urlLower = url.lowercase()
187 val urlLower = url.lowercase()
124 if (!urlLower.startsWith("http://") && !urlLower.startsWith("https://")) {
188 if (!urlLower.startsWith("http://") && !urlLower.startsWith("https://")) {
125− url = if (current.allowInsecure) "http://$url" else "https://$url"
189+ url = if (allowInsecure) "http://$url" else "https://$url"
126 }
190 }
127
191
128 val isValidUrl = try {
192 val isValidUrl = try {
@@ -143,7 +207,7 @@ class LoginViewModel @Inject constructor(
143
207
144 val config = ServerConfig(
208 val config = ServerConfig(
145 serverUrl = url,
209 serverUrl = url,
146− username = current.username.trim(),
210+ username = username.trim(),
147 password = password,
211 password = password,
148 useTls = url.lowercase().startsWith("https"),
212 useTls = url.lowercase().startsWith("https"),
149 )
213 )
@@ -156,8 +220,21 @@ class LoginViewModel @Inject constructor(
156 // Uses verifyServer() to avoid triggering the global auth-error handler.
220 // Uses verifyServer() to avoid triggering the global auth-error handler.
157 when (val aboutResult = repository.verifyServer()) {
221 when (val aboutResult = repository.verifyServer()) {
158 is ApiResult.Success -> {
222 is ApiResult.Success -> {
223+ // Only report success if the credentials were durably persisted.
224+ // Otherwise the app would navigate in and clear the typed password,
225+ // yet bounce back to login on next launch with nothing to restore.
159− settingsRepository.saveServerConfig(config)
226+ if (settingsRepository.saveServerConfig(config)) {
227+ settingsRepository.saveServer(config)
160− _state.update { it.copy(isLoading = false, loginSuccess = true, password = "") }
228+ _state.update { it.copy(isLoading = false, password = "") }
229+ _loginSuccessEvent.send(Unit)
230+ } else {
231+ _state.update {
232+ it.copy(
233+ isLoading = false,
234+ error = "Connected, but your credentials couldn't be saved securely on this device. Please try again.",
235+ )
236+ }
237+ }
161 }
238 }
162 is ApiResult.Error -> {
239 is ApiResult.Error -> {
163 val errorMsg = if (aboutResult.isUnauthorized) {
240 val errorMsg = if (aboutResult.isUnauthorized) {
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.kt+494−72
@@ -56,6 +56,8 @@ fun LogViewerScreen(
56 val keyboardController = LocalSoftwareKeyboardController.current
56 val keyboardController = LocalSoftwareKeyboardController.current
57 var showFilterSheet by remember { mutableStateOf(false) }
57 var showFilterSheet by remember { mutableStateOf(false) }
58 var showSqlSheet by remember { mutableStateOf(false) }
58 var showSqlSheet by remember { mutableStateOf(false) }
59+ var showSavedFiltersSheet by remember { mutableStateOf(false) }
60+ var showSaveFilterDialog by remember { mutableStateOf(false) }
59 var expandedLogKey by remember { mutableStateOf<String?>(null) }
61 var expandedLogKey by remember { mutableStateOf<String?>(null) }
60 var showDateRangePicker by remember { mutableStateOf(false) }
62 var showDateRangePicker by remember { mutableStateOf(false) }
61 var showShareWarning by remember { mutableStateOf(false) }
63 var showShareWarning by remember { mutableStateOf(false) }
@@ -99,6 +101,10 @@ fun LogViewerScreen(
99 actions = {
101 actions = {
100 StreamingToggleButton(
102 StreamingToggleButton(
101 isStreaming = state.isStreaming,
103 isStreaming = state.isStreaming,
104+ // Live-tail re-derives its own default SELECT * query in the poller, so
105+ // it can't honor a custom SQL projection/filter. Disable the toggle while
106+ // a custom query is active to avoid prepending mismatched rows.
107+ enabled = state.customSql.isBlank(),
102 onClick = viewModel::toggleStreaming,
108 onClick = viewModel::toggleStreaming,
103 )
109 )
104 IconButton(onClick = { onStreamInfo(streamName) }) {
110 IconButton(onClick = { onStreamInfo(streamName) }) {
@@ -110,6 +116,12 @@ fun LogViewerScreen(
110 IconButton(onClick = { showSqlSheet = true }) {
116 IconButton(onClick = { showSqlSheet = true }) {
111 Icon(Icons.Filled.Code, contentDescription = "SQL Query")
117 Icon(Icons.Filled.Code, contentDescription = "SQL Query")
112 }
118 }
119+ IconButton(onClick = {
120+ viewModel.loadSavedFilters()
121+ showSavedFiltersSheet = true
122+ }) {
123+ Icon(Icons.Filled.BookmarkBorder, contentDescription = "Saved Filters")
124+ }
113 IconButton(
125 IconButton(
114 onClick = { showShareWarning = true },
126 onClick = { showShareWarning = true },
115 enabled = state.logs.isNotEmpty(),
127 enabled = state.logs.isNotEmpty(),
@@ -159,7 +171,9 @@ fun LogViewerScreen(
159 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
171 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
160 keyboardActions = KeyboardActions(onSearch = {
172 keyboardActions = KeyboardActions(onSearch = {
161 keyboardController?.hide()
173 keyboardController?.hide()
174+ // Runs the search now and cancels the pending debounce (which stops live-tail
175+ // and refreshes) so we don't fire a second, redundant refresh ~300ms later.
162− viewModel.refresh()
176+ viewModel.submitSearch()
163 }),
177 }),
164 modifier = Modifier
178 modifier = Modifier
165 .fillMaxWidth()
179 .fillMaxWidth()
@@ -273,9 +287,41 @@ fun LogViewerScreen(
273 // Log entries
287 // Log entries
274 PullToRefreshBox(
288 PullToRefreshBox(
275 isRefreshing = state.isLoading,
289 isRefreshing = state.isLoading,
276− onRefresh = viewModel::refresh,
290+ onRefresh = viewModel::pullRefresh,
277 modifier = Modifier.fillMaxSize(),
291 modifier = Modifier.fillMaxSize(),
278 ) {
292 ) {
293+ // Hoisted above the error/loading/empty/list branches so the scroll position,
294+ // the clipboard handle, and the viewing-top / load-more effects survive
295+ // transitions through those states. If they lived inside the list `else` branch
296+ // they would be disposed and recreated on every empty/loading/error transition,
297+ // resetting scroll to the top and re-arming loadMore mid-load.
298+ val listState = rememberLazyListState()
299+ val clipboard = remember {
300+ context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
301+ }
302+
303+ // Tell the ViewModel when the list is at the top so it can clear the live-tail
304+ // "+N new" badge — new rows prepend at the top and are seen immediately there.
305+ val atTop by remember {
306+ derivedStateOf {
307+ listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
308+ }
309+ }
310+ LaunchedEffect(atTop) { viewModel.setViewingTop(atTop) }
311+
312+ // Auto-load more when scrolling near the bottom.
313+ val shouldLoadMore by remember {
314+ derivedStateOf {
315+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
316+ lastVisible >= listState.layoutInfo.totalItemsCount - 5
317+ }
318+ }
319+ LaunchedEffect(shouldLoadMore, state.hasMore, state.isLoading) {
320+ if (shouldLoadMore && state.hasMore && !state.isLoading) {
321+ viewModel.loadMore()
322+ }
323+ }
324+
279 if (state.error != null && state.logs.isEmpty()) {
325 if (state.error != null && state.logs.isEmpty()) {
280 Box(
326 Box(
281 modifier = Modifier.fillMaxSize(),
327 modifier = Modifier.fillMaxSize(),
@@ -342,29 +388,6 @@ fun LogViewerScreen(
342 }
388 }
343 }
389 }
344 } else {
390 } else {
345− val listState = rememberLazyListState()
346− val clipboard = remember {
347− context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
348− }
349−
350− // Auto-load more when scrolling near the bottom
351− if (state.hasMore && !state.isLoading) {
352− val shouldLoadMore by remember {
353− derivedStateOf {
354− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
355− lastVisible >= listState.layoutInfo.totalItemsCount - 5
356− }
357− }
358− LaunchedEffect(shouldLoadMore) {
359− if (shouldLoadMore) viewModel.loadMore()
360− }
361− }
362−
363− // Pre-compute stable keys so each log is assigned exactly one key
364− val logKeys = remember(state.logs) {
365− stableLogKeys(state.logs)
366− }
367−
368 LazyColumn(
391 LazyColumn(
369 state = listState,
392 state = listState,
370 contentPadding = PaddingValues(8.dp),
393 contentPadding = PaddingValues(8.dp),
@@ -372,14 +395,34 @@ fun LogViewerScreen(
372 ) {
395 ) {
373 itemsIndexed(
396 itemsIndexed(
374 state.logs,
397 state.logs,
375− key = { index, _ -> logKeys.getOrElse(index) { "log_$index" } },
398+ key = { index, _ -> state.logKeys.getOrElse(index) { "log_$index" } },
376 ) { index, logEntry ->
399 ) { index, logEntry ->
377− val key = logKeys.getOrElse(index) { "log_$index" }
400+ val key = state.logKeys.getOrElse(index) { "log_$index" }
378 LogEntryCard(
401 LogEntryCard(
379 logEntry = logEntry,
402 logEntry = logEntry,
403+ stableKey = key,
380 isExpanded = expandedLogKey == key,
404 isExpanded = expandedLogKey == key,
381 onClick = {
405 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
382− expandedLogKey = if (expandedLogKey == key) null else key
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+ }
383 },
426 },
384 clipboardManager = clipboard,
427 clipboardManager = clipboard,
385 onCopied = {
428 onCopied = {
@@ -390,6 +433,12 @@ fun LogViewerScreen(
390 )
433 )
391 }
434 }
392 },
435 },
436+ onFilterInclude = { column, value ->
437+ viewModel.addFilter(column, "=", value)
438+ },
439+ onFilterExclude = { column, value ->
440+ viewModel.addFilter(column, "!=", value)
441+ },
393 )
442 )
394 }
443 }
395
444
@@ -442,6 +491,7 @@ fun LogViewerScreen(
442 if (showFilterSheet) {
491 if (showFilterSheet) {
443 FilterBottomSheet(
492 FilterBottomSheet(
444 columns = state.columns,
493 columns = state.columns,
494+ logs = state.logs,
445 onDismiss = { showFilterSheet = false },
495 onDismiss = { showFilterSheet = false },
446 onApplyFilter = { column, operator, value ->
496 onApplyFilter = { column, operator, value ->
447 viewModel.addFilter(column, operator, value)
497 viewModel.addFilter(column, operator, value)
@@ -484,10 +534,18 @@ fun LogViewerScreen(
484 if (!dir.mkdirs() && !dir.isDirectory) {
534 if (!dir.mkdirs() && !dir.isDirectory) {
485 throw java.io.IOException("Failed to create shared_logs directory")
535 throw java.io.IOException("Failed to create shared_logs directory")
486 }
536 }
487− // Clean up previous exports
537+ // Clean up STALE exports only. A share chooser opened moments
538+ // ago may still be reading a recent file via its FileProvider
539+ // URI; deleting every file (as before) could pull one out from
540+ // under an in-flight share. Keep anything written in the last
541+ // hour and give each export a unique name so concurrent shares
542+ // never collide.
543+ val cutoff = System.currentTimeMillis() - 60 * 60 * 1000L
488− dir.listFiles()?.forEach { old -> old.delete() }
544+ dir.listFiles()?.forEach { old ->
545+ if (old.lastModified() < cutoff) old.delete()
546+ }
489 val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
547 val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
490− val file = java.io.File(dir, "logs_$safeFileName.json")
548+ val file = java.io.File(dir, "logs_${safeFileName}_${System.currentTimeMillis()}.json")
491 file.bufferedWriter().use { writer ->
549 file.bufferedWriter().use { writer ->
492 // Use compact JSON for large exports to reduce memory/file size
550 // Use compact JSON for large exports to reduce memory/file size
493 val encoder = if (logs.size > 1000) Json else prettyJson
551 val encoder = if (logs.size > 1000) Json else prettyJson
@@ -549,17 +607,18 @@ fun LogViewerScreen(
549 val start = dateRangePickerState.selectedStartDateMillis
607 val start = dateRangePickerState.selectedStartDateMillis
550 val end = dateRangePickerState.selectedEndDateMillis
608 val end = dateRangePickerState.selectedEndDateMillis
551 if (start != null && end != null) {
609 if (start != null && end != null) {
552− // DateRangePicker returns midnight UTC for the selected date.
610+ // DateRangePicker returns midnight UTC for the selected date, so the
553− // Adjust to cover the full end day in the user's local timezone:
611+ // calendar date must be read back in UTC — reading it in the local zone
554− // Convert to local date, get end-of-day, convert back to UTC millis.
612+ // would roll west-of-UTC users to the previous day. Once we have the
613+ // tapped date, build the boundaries to cover the full day locally.
555 val localZone = java.time.ZoneId.systemDefault()
614 val localZone = java.time.ZoneId.systemDefault()
556 val endLocalDate = java.time.Instant.ofEpochMilli(end)
615 val endLocalDate = java.time.Instant.ofEpochMilli(end)
557− .atZone(localZone).toLocalDate()
616+ .atZone(java.time.ZoneOffset.UTC).toLocalDate()
558 val endOfDayUtc = endLocalDate.plusDays(1)
617 val endOfDayUtc = endLocalDate.plusDays(1)
559 .atStartOfDay(localZone)
618 .atStartOfDay(localZone)
560 .toInstant().toEpochMilli() - 1
619 .toInstant().toEpochMilli() - 1
561 val startLocalDate = java.time.Instant.ofEpochMilli(start)
620 val startLocalDate = java.time.Instant.ofEpochMilli(start)
562− .atZone(localZone).toLocalDate()
621+ .atZone(java.time.ZoneOffset.UTC).toLocalDate()
563 val startUtc = startLocalDate
622 val startUtc = startLocalDate
564 .atStartOfDay(localZone)
623 .atStartOfDay(localZone)
565 .toInstant().toEpochMilli()
624 .toInstant().toEpochMilli()
@@ -585,6 +644,37 @@ fun LogViewerScreen(
585 )
644 )
586 }
645 }
587 }
646 }
647+
648+ // Saved filters bottom sheet
649+ if (showSavedFiltersSheet) {
650+ SavedFiltersBottomSheet(
651+ savedFiltersState = state.savedFilters,
652+ hasActiveFilters = state.activeFilters.isNotEmpty() ||
653+ state.searchQuery.isNotBlank() ||
654+ state.customSql.isNotBlank(),
655+ onDismiss = { showSavedFiltersSheet = false },
656+ onSaveCurrentFilter = { showSaveFilterDialog = true },
657+ onApplyFilter = { filter ->
658+ viewModel.applySavedFilter(filter)
659+ showSavedFiltersSheet = false
660+ },
661+ onDeleteFilter = { filterId -> viewModel.deleteSavedFilter(filterId) },
662+ onRetry = { viewModel.loadSavedFilters() },
663+ )
664+ }
665+
666+ // Save filter name dialog
667+ if (showSaveFilterDialog) {
668+ SaveFilterDialog(
669+ isSaving = state.savedFilters.isSaving,
670+ onDismiss = { showSaveFilterDialog = false },
671+ onSave = { name ->
672+ viewModel.saveCurrentFilter(name)
673+ showSaveFilterDialog = false
674+ showSavedFiltersSheet = false
675+ },
676+ )
677+ }
588}
678}
589
679
590@Composable
680@Composable
@@ -611,9 +701,10 @@ private fun TimeRangeBar(
611@Composable
701@Composable
612private fun StreamingToggleButton(
702private fun StreamingToggleButton(
613 isStreaming: Boolean,
703 isStreaming: Boolean,
704+ enabled: Boolean,
614 onClick: () -> Unit,
705 onClick: () -> Unit,
615) {
706) {
616− IconButton(onClick = onClick) {
707+ IconButton(onClick = onClick, enabled = enabled || isStreaming) {
617 if (isStreaming) {
708 if (isStreaming) {
618 val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
709 val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
619 val alpha by infiniteTransition.animateFloat(
710 val alpha by infiniteTransition.animateFloat(
@@ -644,12 +735,17 @@ private fun StreamingToggleButton(
644@Composable
735@Composable
645fun LogEntryCard(
736fun LogEntryCard(
646 logEntry: JsonObject,
737 logEntry: JsonObject,
738+ stableKey: String,
647 isExpanded: Boolean,
739 isExpanded: Boolean,
648 onClick: () -> Unit,
740 onClick: () -> Unit,
649 onCopied: () -> Unit = {},
741 onCopied: () -> Unit = {},
742+ onFilterInclude: (column: String, value: String) -> Unit = { _, _ -> },
743+ onFilterExclude: (column: String, value: String) -> Unit = { _, _ -> },
650 clipboardManager: android.content.ClipboardManager,
744 clipboardManager: android.content.ClipboardManager,
651) {
745) {
746+ val severity = remember(logEntry) { detectSeverity(logEntry) }
747+ val severityTint = severityBackgroundColor(severity)
652− val containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
748+ val containerColor = severityTint ?: MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
653 val cardColors = CardDefaults.cardColors(containerColor = containerColor)
749 val cardColors = CardDefaults.cardColors(containerColor = containerColor)
654 Card(
750 Card(
655 onClick = onClick,
751 onClick = onClick,
@@ -658,7 +754,9 @@ fun LogEntryCard(
658 ) {
754 ) {
659 Column(modifier = Modifier.padding(12.dp)) {
755 Column(modifier = Modifier.padding(12.dp)) {
660 // Timestamp row
756 // Timestamp row
757+ // Key the derived values on the content-stable log key (not the JsonObject
758+ // instance), so live-tail re-emissions of unchanged rows don't re-parse them.
661− val rawTimestamp = remember(logEntry) {
759+ val rawTimestamp = remember(stableKey) {
662 try {
760 try {
663 logEntry["p_timestamp"]?.jsonPrimitive?.content
761 logEntry["p_timestamp"]?.jsonPrimitive?.content
664 ?: logEntry["datetime"]?.jsonPrimitive?.content
762 ?: logEntry["datetime"]?.jsonPrimitive?.content
@@ -683,7 +781,7 @@ fun LogEntryCard(
683
781
684 if (!isExpanded) {
782 if (!isExpanded) {
685 // Compact view: show first meaningful fields
783 // Compact view: show first meaningful fields
686− val preview = remember(logEntry) {
784+ val preview = remember(stableKey) {
687 logEntry.entries
785 logEntry.entries
688 .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
786 .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
689 .take(3)
787 .take(3)
@@ -725,25 +823,55 @@ fun LogEntryCard(
725 }
823 }
726 HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
824 HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
727 logEntry.entries.forEach { (key, value) ->
825 logEntry.entries.forEach { (key, value) ->
826+ val displayValue = formatJsonValue(value)
728 Row(
827 Row(
729 modifier = Modifier
828 modifier = Modifier
730 .fillMaxWidth()
829 .fillMaxWidth()
731 .padding(vertical = 2.dp),
830 .padding(vertical = 2.dp),
831+ verticalAlignment = Alignment.CenterVertically,
732 ) {
832 ) {
833+ Column(modifier = Modifier.weight(1f)) {
834+ Row {
733− Text(
835+ Text(
734− text = "$key: ",
836+ text = "$key: ",
735− style = MaterialTheme.typography.bodySmall,
837+ style = MaterialTheme.typography.bodySmall,
736− fontWeight = FontWeight.Bold,
838+ fontWeight = FontWeight.Bold,
737− fontFamily = FontFamily.Monospace,
839+ fontFamily = FontFamily.Monospace,
738− color = MaterialTheme.colorScheme.primary,
840+ color = MaterialTheme.colorScheme.primary,
739− fontSize = 12.sp,
841+ fontSize = 12.sp,
740− )
842+ )
741− Text(
843+ Text(
742− text = formatJsonValue(value),
844+ text = displayValue,
743− style = MaterialTheme.typography.bodySmall,
845+ style = MaterialTheme.typography.bodySmall,
744− fontFamily = FontFamily.Monospace,
846+ fontFamily = FontFamily.Monospace,
745− fontSize = 12.sp,
847+ fontSize = 12.sp,
746− )
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+ }
747 }
875 }
748 }
876 }
749 }
877 }
@@ -751,24 +879,6 @@ fun LogEntryCard(
751 }
879 }
752}
880}
753
881
754−/**
755− * Produces deterministic keys for a list of log entries so that expanded state survives
756− * list mutations (e.g., new logs prepended during streaming). Keys are content-based
757− * using p_timestamp + p_metadata + p_tags; duplicate content gets a disambiguation suffix.
758− */
759−private fun stableLogKeys(logs: List<JsonObject>): List<String> {
760− val seen = mutableMapOf<String, Int>()
761− return logs.map { log ->
762− val ts = log["p_timestamp"]?.toString()
763− val meta = log["p_metadata"]?.toString()
764− val tag = log["p_tags"]?.toString()
765− val base = if (ts != null) "$ts|${meta.orEmpty()}|${tag.orEmpty()}" else log.hashCode().toString()
766− val count = seen.getOrDefault(base, 0)
767− seen[base] = count + 1
768− if (count == 0) base else "$base|$count"
769− }
770−}
771−
772private fun formatJsonValue(element: JsonElement): String {
882private fun formatJsonValue(element: JsonElement): String {
773 return when (element) {
883 return when (element) {
774 is JsonPrimitive -> element.content
884 is JsonPrimitive -> element.content
@@ -781,10 +891,22 @@ private fun formatJsonValue(element: JsonElement): String {
781@Composable
891@Composable
782fun FilterBottomSheet(
892fun FilterBottomSheet(
783 columns: List<String>,
893 columns: List<String>,
894+ logs: List<JsonObject> = emptyList(),
784 onDismiss: () -> Unit,
895 onDismiss: () -> Unit,
785 onApplyFilter: (column: String, operator: String, value: String) -> Unit,
896 onApplyFilter: (column: String, operator: String, value: String) -> Unit,
786) {
897) {
787 var selectedColumn by remember { mutableStateOf(columns.firstOrNull() ?: "") }
898 var selectedColumn by remember { mutableStateOf(columns.firstOrNull() ?: "") }
899+ // The filter icon is always enabled, so the sheet can open before the schema (and thus
900+ // `columns`) has loaded. In that case `selectedColumn` initializes to "" and — because the
901+ // remember above isn't keyed on `columns` — would stay blank even after columns arrive,
902+ // leaving the Column field empty and the Apply button permanently disabled. Backfill the
903+ // default once columns are available, but only while the selection is still blank so a
904+ // user's manual pick isn't clobbered by a late schema refresh.
905+ LaunchedEffect(columns) {
906+ if (selectedColumn.isBlank()) {
907+ selectedColumn = columns.firstOrNull() ?: ""
908+ }
909+ }
788 var selectedOperator by remember { mutableStateOf("=") }
910 var selectedOperator by remember { mutableStateOf("=") }
789 var filterValue by remember { mutableStateOf("") }
911 var filterValue by remember { mutableStateOf("") }
790 var columnDropdownExpanded by remember { mutableStateOf(false) }
912 var columnDropdownExpanded by remember { mutableStateOf(false) }
@@ -792,6 +914,28 @@ fun FilterBottomSheet(
792
914
793 val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
915 val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
794
916
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+
795 ModalBottomSheet(onDismissRequest = onDismiss) {
939 ModalBottomSheet(onDismissRequest = onDismiss) {
796 Column(
940 Column(
797 modifier = Modifier.padding(16.dp),
941 modifier = Modifier.padding(16.dp),
@@ -827,6 +971,7 @@ fun FilterBottomSheet(
827 text = { Text(column) },
971 text = { Text(column) },
828 onClick = {
972 onClick = {
829 selectedColumn = column
973 selectedColumn = column
974+ filterValue = ""
830 columnDropdownExpanded = false
975 columnDropdownExpanded = false
831 },
976 },
832 )
977 )
@@ -895,6 +1040,44 @@ fun FilterBottomSheet(
895 }
1040 }
896 }),
1041 }),
897 )
1042 )
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+ }
898 }
1081 }
899
1082
900 val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
1083 val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
@@ -999,3 +1182,242 @@ fun SqlQueryBottomSheet(
999 }
1182 }
1000 }
1183 }
1001}
1184}
1185+
1186+@OptIn(ExperimentalMaterial3Api::class)
1187+@Composable
1188+fun SavedFiltersBottomSheet(
1189+ savedFiltersState: SavedFiltersState,
1190+ hasActiveFilters: Boolean,
1191+ onDismiss: () -> Unit,
1192+ onSaveCurrentFilter: () -> Unit,
1193+ onApplyFilter: (com.parseable.android.data.model.SavedFilter) -> Unit,
1194+ onDeleteFilter: (String) -> Unit,
1195+ onRetry: () -> Unit,
1196+) {
1197+ var filterToDelete by remember { mutableStateOf<String?>(null) }
1198+
1199+ ModalBottomSheet(onDismissRequest = onDismiss) {
1200+ Column(
1201+ modifier = Modifier.padding(16.dp),
1202+ verticalArrangement = Arrangement.spacedBy(12.dp),
1203+ ) {
1204+ Row(
1205+ modifier = Modifier.fillMaxWidth(),
1206+ horizontalArrangement = Arrangement.SpaceBetween,
1207+ verticalAlignment = Alignment.CenterVertically,
1208+ ) {
1209+ Text(
1210+ text = "Saved Filters",
1211+ style = MaterialTheme.typography.titleLarge,
1212+ fontWeight = FontWeight.Bold,
1213+ )
1214+ if (hasActiveFilters) {
1215+ FilledTonalButton(onClick = onSaveCurrentFilter) {
1216+ Icon(
1217+ Icons.Filled.Add,
1218+ contentDescription = null,
1219+ modifier = Modifier.size(18.dp),
1220+ )
1221+ Spacer(modifier = Modifier.width(4.dp))
1222+ Text("Save Current")
1223+ }
1224+ }
1225+ }
1226+
1227+ when {
1228+ savedFiltersState.isLoading -> {
1229+ Box(
1230+ modifier = Modifier
1231+ .fillMaxWidth()
1232+ .padding(32.dp),
1233+ contentAlignment = Alignment.Center,
1234+ ) {
1235+ CircularProgressIndicator()
1236+ }
1237+ }
1238+ savedFiltersState.error != null -> {
1239+ Column(
1240+ modifier = Modifier
1241+ .fillMaxWidth()
1242+ .padding(16.dp),
1243+ horizontalAlignment = Alignment.CenterHorizontally,
1244+ ) {
1245+ Text(
1246+ text = savedFiltersState.error,
1247+ style = MaterialTheme.typography.bodyMedium,
1248+ color = MaterialTheme.colorScheme.error,
1249+ )
1250+ Spacer(modifier = Modifier.height(8.dp))
1251+ OutlinedButton(onClick = onRetry) {
1252+ Text("Retry")
1253+ }
1254+ }
1255+ }
1256+ savedFiltersState.filters.isEmpty() -> {
1257+ Box(
1258+ modifier = Modifier
1259+ .fillMaxWidth()
1260+ .padding(32.dp),
1261+ contentAlignment = Alignment.Center,
1262+ ) {
1263+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
1264+ Icon(
1265+ Icons.Filled.BookmarkBorder,
1266+ contentDescription = null,
1267+ modifier = Modifier.size(40.dp),
1268+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1269+ )
1270+ Spacer(modifier = Modifier.height(8.dp))
1271+ Text(
1272+ text = "No saved filters for this stream",
1273+ style = MaterialTheme.typography.bodyMedium,
1274+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1275+ )
1276+ if (hasActiveFilters) {
1277+ Spacer(modifier = Modifier.height(4.dp))
1278+ Text(
1279+ text = "Use \"Save Current\" to save your active filters",
1280+ style = MaterialTheme.typography.bodySmall,
1281+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1282+ )
1283+ }
1284+ }
1285+ }
1286+ }
1287+ else -> {
1288+ savedFiltersState.filters.forEach { filter ->
1289+ Card(
1290+ onClick = { onApplyFilter(filter) },
1291+ modifier = Modifier.fillMaxWidth(),
1292+ ) {
1293+ Row(
1294+ modifier = Modifier
1295+ .fillMaxWidth()
1296+ .padding(12.dp),
1297+ verticalAlignment = Alignment.CenterVertically,
1298+ ) {
1299+ Icon(
1300+ Icons.Filled.Bookmark,
1301+ contentDescription = null,
1302+ tint = MaterialTheme.colorScheme.primary,
1303+ modifier = Modifier.size(20.dp),
1304+ )
1305+ Spacer(modifier = Modifier.width(12.dp))
1306+ Column(modifier = Modifier.weight(1f)) {
1307+ Text(
1308+ text = filter.filterName,
1309+ style = MaterialTheme.typography.titleSmall,
1310+ fontWeight = FontWeight.Medium,
1311+ )
1312+ val description = when (filter.query.filterType) {
1313+ "sql" -> filter.query.filterQuery?.take(60) ?: "SQL query"
1314+ "search" -> "Search: ${filter.query.filterQuery ?: ""}"
1315+ else -> {
1316+ val ruleCount = filter.query.filterBuilder?.rules
1317+ ?.sumOf { it.rules.size } ?: 0
1318+ if (ruleCount > 0) "$ruleCount filter rule(s)" else "No rules"
1319+ }
1320+ }
1321+ Text(
1322+ text = description,
1323+ style = MaterialTheme.typography.bodySmall,
1324+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1325+ maxLines = 1,
1326+ overflow = TextOverflow.Ellipsis,
1327+ )
1328+ }
1329+ IconButton(onClick = {
1330+ filterToDelete = filter.filterId
1331+ }) {
1332+ Icon(
1333+ Icons.Filled.Delete,
1334+ contentDescription = "Delete filter",
1335+ tint = MaterialTheme.colorScheme.error,
1336+ )
1337+ }
1338+ }
1339+ }
1340+ }
1341+ }
1342+ }
1343+
1344+ Spacer(modifier = Modifier.height(32.dp))
1345+ }
1346+ }
1347+
1348+ // Delete confirmation dialog
1349+ if (filterToDelete != null) {
1350+ AlertDialog(
1351+ onDismissRequest = { filterToDelete = null },
1352+ title = { Text("Delete Filter") },
1353+ text = { Text("Are you sure you want to delete this saved filter? This will remove it from the server.") },
1354+ confirmButton = {
1355+ TextButton(onClick = {
1356+ filterToDelete?.let { onDeleteFilter(it) }
1357+ filterToDelete = null
1358+ }) {
1359+ Text("Delete", color = MaterialTheme.colorScheme.error)
1360+ }
1361+ },
1362+ dismissButton = {
1363+ TextButton(onClick = { filterToDelete = null }) {
1364+ Text("Cancel")
1365+ }
1366+ },
1367+ )
1368+ }
1369+}
1370+
1371+@Composable
1372+fun SaveFilterDialog(
1373+ isSaving: Boolean,
1374+ onDismiss: () -> Unit,
1375+ onSave: (String) -> Unit,
1376+) {
1377+ var filterName by remember { mutableStateOf("") }
1378+
1379+ AlertDialog(
1380+ onDismissRequest = onDismiss,
1381+ title = { Text("Save Filter") },
1382+ text = {
1383+ Column {
1384+ Text(
1385+ text = "Save your current filters to the Parseable server. You can load them later on any device.",
1386+ style = MaterialTheme.typography.bodySmall,
1387+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1388+ )
1389+ Spacer(modifier = Modifier.height(12.dp))
1390+ OutlinedTextField(
1391+ value = filterName,
1392+ onValueChange = { filterName = it },
1393+ label = { Text("Filter name") },
1394+ singleLine = true,
1395+ modifier = Modifier.fillMaxWidth(),
1396+ enabled = !isSaving,
1397+ )
1398+ }
1399+ },
1400+ confirmButton = {
1401+ if (isSaving) {
1402+ CircularProgressIndicator(
1403+ modifier = Modifier
1404+ .size(24.dp)
1405+ .padding(end = 8.dp),
1406+ strokeWidth = 2.dp,
1407+ )
1408+ } else {
1409+ TextButton(
1410+ onClick = { onSave(filterName.trim()) },
1411+ enabled = filterName.isNotBlank(),
1412+ ) {
1413+ Text("Save")
1414+ }
1415+ }
1416+ },
1417+ dismissButton = {
1418+ TextButton(onClick = onDismiss, enabled = !isSaving) {
1419+ Text("Cancel")
1420+ }
1421+ },
1422+ )
1423+}
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerViewModel.kt+555−73
@@ -3,8 +3,9 @@ package com.parseable.android.ui.screens.logviewer
3import androidx.lifecycle.ViewModel
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5import com.parseable.android.data.escapeIdentifier
5import com.parseable.android.data.escapeIdentifier
6+import com.parseable.android.data.escapeLikePattern
6import com.parseable.android.data.escapeSql
7import com.parseable.android.data.escapeSql
7−import com.parseable.android.data.model.ApiResult
8+import com.parseable.android.data.model.*
8import com.parseable.android.data.repository.ParseableRepository
9import com.parseable.android.data.repository.ParseableRepository
9import dagger.hilt.android.lifecycle.HiltViewModel
10import dagger.hilt.android.lifecycle.HiltViewModel
10import kotlinx.coroutines.Job
11import kotlinx.coroutines.Job
@@ -15,8 +16,13 @@ import kotlinx.coroutines.flow.asStateFlow
15import kotlinx.coroutines.flow.update
16import kotlinx.coroutines.flow.update
16import kotlinx.coroutines.isActive
17import kotlinx.coroutines.isActive
17import kotlinx.coroutines.launch
18import kotlinx.coroutines.launch
19+import kotlinx.coroutines.sync.Mutex
20+import kotlinx.coroutines.sync.withLock
18import kotlinx.serialization.json.JsonObject
21import kotlinx.serialization.json.JsonObject
19import kotlinx.serialization.json.jsonPrimitive
22import kotlinx.serialization.json.jsonPrimitive
23+import java.time.Instant
24+import java.time.LocalDateTime
25+import java.time.OffsetDateTime
20import java.time.ZoneOffset
26import java.time.ZoneOffset
21import java.time.ZonedDateTime
27import java.time.ZonedDateTime
22import java.time.format.DateTimeFormatter
28import java.time.format.DateTimeFormatter
@@ -34,10 +40,21 @@ enum class TimeRange(val label: String, val minutes: Long) {
34 CUSTOM("Custom", 0),
40 CUSTOM("Custom", 0),
35}
41}
36
42
43+/** The original (column, operator, value) a filter chip was built from. */
44+data class FilterCondition(
45+ val column: String,
46+ val operator: String,
47+ val value: String,
48+)
49+
37data class FilterState(
50data class FilterState(
38 val searchQuery: String = "",
51 val searchQuery: String = "",
39 val activeFilters: List<String> = emptyList(),
52 val activeFilters: List<String> = emptyList(),
40 val filterClauses: List<String> = emptyList(),
53 val filterClauses: List<String> = emptyList(),
54+ // Structured form of each chip, index-aligned with activeFilters/filterClauses.
55+ // Kept so saved filters reconstruct from real values instead of re-parsing the
56+ // human-readable display string (which is ambiguous when a value contains an operator).
57+ val filterConditions: List<FilterCondition> = emptyList(),
41 val customSql: String = "",
58 val customSql: String = "",
42 val isSearching: Boolean = false,
59 val isSearching: Boolean = false,
43)
60)
@@ -49,19 +66,33 @@ data class StreamingState(
49 val currentIntervalMs: Long = 3000L,
66 val currentIntervalMs: Long = 3000L,
50)
67)
51
68
69+data class SavedFiltersState(
70+ val filters: List<SavedFilter> = emptyList(),
71+ val isLoading: Boolean = false,
72+ val isSaving: Boolean = false,
73+ val error: String? = null,
74+)
75+
52data class LogViewerState(
76data class LogViewerState(
53 val streamName: String = "",
77 val streamName: String = "",
54 val logs: List<JsonObject> = emptyList(),
78 val logs: List<JsonObject> = emptyList(),
79+ val logKeys: List<String> = emptyList(),
55 val columns: List<String> = emptyList(),
80 val columns: List<String> = emptyList(),
81+ val searchableColumns: List<String> = emptyList(),
56 val isLoading: Boolean = false,
82 val isLoading: Boolean = false,
57 val error: String? = null,
83 val error: String? = null,
58 val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
84 val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
59 val filters: FilterState = FilterState(),
85 val filters: FilterState = FilterState(),
60 val streaming: StreamingState = StreamingState(),
86 val streaming: StreamingState = StreamingState(),
87+ val savedFilters: SavedFiltersState = SavedFiltersState(),
61 val currentLimit: Int = 500,
88 val currentLimit: Int = 500,
62 val hasMore: Boolean = false,
89 val hasMore: Boolean = false,
63 val customStartTime: Long? = null,
90 val customStartTime: Long? = null,
64 val customEndTime: Long? = null,
91 val customEndTime: Long? = null,
92+ // Upper time bound (epoch millis) fixed at the first page of a paging session, so loadMore()
93+ // doesn't slide a relative ("last N minutes") window forward as it re-queries. Null until the
94+ // first refresh of a session; re-anchored whenever currentLimit resets to the initial page.
95+ val pageAnchorTime: Long? = null,
65) {
96) {
66 // Convenience accessors for backward compatibility with Screen
97 // Convenience accessors for backward compatibility with Screen
67 val searchQuery: String get() = filters.searchQuery
98 val searchQuery: String get() = filters.searchQuery
@@ -89,17 +120,53 @@ class LogViewerViewModel @Inject constructor(
89 @Volatile private var streamingGeneration: Int = 0
120 @Volatile private var streamingGeneration: Int = 0
90 @Volatile private var lastSeenTimestamp: String? = null
121 @Volatile private var lastSeenTimestamp: String? = null
91 @Volatile private var consecutiveStreamingErrors: Int = 0
122 @Volatile private var consecutiveStreamingErrors: Int = 0
123+ // True while the log list is scrolled to the top. New rows prepend at the top, so when the
124+ // user is already there they see them live and the "+N new" badge must stay at 0. The badge
125+ // only accumulates while the user is scrolled away, and resets when they return to the top.
126+ @Volatile private var viewingTop: Boolean = true
127+
128+ // Identities of rows already surfaced during the current streaming session. Used to dedupe
129+ // re-fetched boundary rows. Kept INDEPENDENT of the displayed `logs` list — which is capped
130+ // at STREAMING_MAX_LOGS — so that under sustained high-volume streaming, boundary rows
131+ // evicted from the view are still recognized as duplicates and not prepended twice.
132+ // Insertion-ordered so the oldest keys are trimmed first. Touched only from the single
133+ // streaming coroutine and from startStreaming (which first cancels that coroutine).
134+ private val seenStreamingKeys = LinkedHashSet<String>()
92 private var searchJob: Job? = null
135 private var searchJob: Job? = null
93 private var schemaJob: Job? = null
136 private var schemaJob: Job? = null
137+ private var refreshJob: Job? = null
94
138
95 companion object {
139 companion object {
96 private const val STREAMING_BASE_INTERVAL_MS = 3000L
140 private const val STREAMING_BASE_INTERVAL_MS = 3000L
97 private const val STREAMING_MAX_INTERVAL_MS = 30000L
141 private const val STREAMING_MAX_INTERVAL_MS = 30000L
98 private const val STREAMING_MAX_LOGS = 1000
142 private const val STREAMING_MAX_LOGS = 1000
143+ // Remember more identities than the display cap so a row evicted from the view is still
144+ // deduped on a later re-fetch. 2x the display cap covers the worst realistic overlap.
145+ private const val SEEN_STREAMING_KEYS_MAX = STREAMING_MAX_LOGS * 2
99 private const val MAX_LOAD_LIMIT = 5000
146 private const val MAX_LOAD_LIMIT = 5000
100 private const val LOAD_MORE_INCREMENT = 500
147 private const val LOAD_MORE_INCREMENT = 500
101 private const val MAX_STREAMING_ERRORS = 5
148 private const val MAX_STREAMING_ERRORS = 5
102 private val ALLOWED_OPERATORS = setOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
149 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+ }
103 }
170 }
104
171
105 fun initialize(streamName: String) {
172 fun initialize(streamName: String) {
@@ -115,8 +182,9 @@ class LogViewerViewModel @Inject constructor(
115 schemaJob = viewModelScope.launch {
182 schemaJob = viewModelScope.launch {
116 when (val result = repository.getStreamSchema(streamName)) {
183 when (val result = repository.getStreamSchema(streamName)) {
117 is ApiResult.Success -> {
184 is ApiResult.Success -> {
118− val columns = result.data.fields.map { it.name }
185+ val columns = result.data.fields.map { it.name }.sorted()
186+ val searchable = columns.filter { !it.startsWith("p_") }
119− _state.update { it.copy(columns = columns) }
187+ _state.update { it.copy(columns = columns, searchableColumns = searchable) }
120 }
188 }
121 is ApiResult.Error -> { /* Schema loading failure is non-fatal */ }
189 is ApiResult.Error -> { /* Schema loading failure is non-fatal */ }
122 }
190 }
@@ -150,7 +218,11 @@ class LogViewerViewModel @Inject constructor(
150 }
218 }
151
219
152 fun onSearchQueryChange(query: String) {
220 fun onSearchQueryChange(query: String) {
221+ // A new query invalidates the live-tail baseline and the paged-up limit, matching
222+ // the time-range / custom-SQL handlers.
223+ stopStreaming()
224+ // Searching returns to the filter-builder query path, so drop any active custom SQL.
153− _state.update { it.copy(filters = it.filters.copy(searchQuery = query, isSearching = query.isNotBlank())) }
225+ _state.update { it.copy(currentLimit = 500, filters = it.filters.copy(searchQuery = query, customSql = "", isSearching = query.isNotBlank())) }
154 searchJob?.cancel()
226 searchJob?.cancel()
155 searchJob = viewModelScope.launch {
227 searchJob = viewModelScope.launch {
156 delay(300)
228 delay(300)
@@ -159,28 +231,55 @@ class LogViewerViewModel @Inject constructor(
159 }
231 }
160 }
232 }
161
233
234+ /**
235+ * Run the current search immediately (e.g. the IME "Search" action). Cancels the pending
236+ * debounce so it can't fire a second, redundant refresh ~300ms later.
237+ */
238+ fun submitSearch() {
239+ searchJob?.cancel()
240+ _state.update { it.copy(filters = it.filters.copy(isSearching = false)) }
241+ stopStreaming()
242+ refresh()
243+ }
244+
162 fun addFilter(column: String, operator: String, value: String) {
245 fun addFilter(column: String, operator: String, value: String) {
246+ addFilterInternal(column, operator, value)
247+ refresh()
248+ }
249+
250+ private fun addFilterInternal(column: String, operator: String, value: String) {
163 if (operator !in ALLOWED_OPERATORS) return
251 if (operator !in ALLOWED_OPERATORS) return
164 val safeColumn = escapeIdentifier(column)
252 val safeColumn = escapeIdentifier(column)
165− val safeValue = escapeSql(value)
166 val clause = when (operator) {
253 val clause = when (operator) {
167 "IS NULL", "IS NOT NULL" -> "\"$safeColumn\" $operator"
254 "IS NULL", "IS NOT NULL" -> "\"$safeColumn\" $operator"
255+ // %/_ in the user's value must match literally, so escape the LIKE wildcards
256+ // and declare the escape character. Exact comparisons keep %/_ verbatim.
168− "LIKE", "ILIKE" -> "\"$safeColumn\" $operator '%$safeValue%'"
257+ "LIKE", "ILIKE" -> "\"$safeColumn\" $operator '%${escapeLikePattern(value)}%' ESCAPE '\\'"
169− else -> "\"$safeColumn\" $operator '$safeValue'"
258+ else -> "\"$safeColumn\" $operator '${escapeSql(value)}'"
259+ }
260+ // IS NULL / IS NOT NULL ignore the value in both the clause and the display, so store
261+ // an empty value in the structured condition too (keeps saved filters clean).
262+ val conditionValue = when (operator) {
263+ "IS NULL", "IS NOT NULL" -> ""
264+ else -> value
170 }
265 }
171 val display = when (operator) {
266 val display = when (operator) {
172 "IS NULL", "IS NOT NULL" -> "$column $operator"
267 "IS NULL", "IS NOT NULL" -> "$column $operator"
173 else -> "$column $operator $value"
268 else -> "$column $operator $value"
174 }
269 }
270+ // A new filter invalidates the live-tail baseline and the paged-up limit.
271+ stopStreaming()
175 _state.update {
272 _state.update {
176 it.copy(
273 it.copy(
274+ currentLimit = 500,
177 filters = it.filters.copy(
275 filters = it.filters.copy(
178 filterClauses = it.filters.filterClauses + clause,
276 filterClauses = it.filters.filterClauses + clause,
179 activeFilters = it.filters.activeFilters + display,
277 activeFilters = it.filters.activeFilters + display,
278+ filterConditions = it.filters.filterConditions + FilterCondition(column, operator, conditionValue),
279+ customSql = "",
180 ),
280 ),
181 )
281 )
182 }
282 }
183− refresh()
184 }
283 }
185
284
186 fun removeFilter(display: String) {
285 fun removeFilter(display: String) {
@@ -190,81 +289,93 @@ class LogViewerViewModel @Inject constructor(
190 if (index >= 0 && index < it.filters.filterClauses.size) {
289 if (index >= 0 && index < it.filters.filterClauses.size) {
191 removed = true
290 removed = true
192 it.copy(
291 it.copy(
292+ currentLimit = 500,
193 filters = it.filters.copy(
293 filters = it.filters.copy(
194 activeFilters = it.filters.activeFilters.filterIndexed { i, _ -> i != index },
294 activeFilters = it.filters.activeFilters.filterIndexed { i, _ -> i != index },
195 filterClauses = it.filters.filterClauses.filterIndexed { i, _ -> i != index },
295 filterClauses = it.filters.filterClauses.filterIndexed { i, _ -> i != index },
296+ filterConditions = it.filters.filterConditions.filterIndexed { i, _ -> i != index },
297+ customSql = "",
196 ),
298 ),
197 )
299 )
198 } else {
300 } else {
199 it
301 it
200 }
302 }
201 }
303 }
202− if (removed) refresh()
304+ if (removed) {
305+ // A changed filter set invalidates the live-tail baseline.
306+ stopStreaming()
307+ refresh()
308+ }
203 }
309 }
204
310
205 fun clearFilters() {
311 fun clearFilters() {
312+ stopStreaming()
206 _state.update {
313 _state.update {
207− it.copy(filters = it.filters.copy(activeFilters = emptyList(), filterClauses = emptyList()))
314+ it.copy(currentLimit = 500, filters = it.filters.copy(activeFilters = emptyList(), filterClauses = emptyList(), filterConditions = emptyList(), customSql = "", searchQuery = "", isSearching = false))
208 }
315 }
209 refresh()
316 refresh()
210 }
317 }
211
318
212 fun executeCustomSql(sql: String) {
319 fun executeCustomSql(sql: String) {
320+ // Custom SQL changes the query basis, so cancel any pending search debounce that would
321+ // otherwise fire a redundant refresh() ~300ms later. (Unlike the filter/time-range
322+ // handlers, this path doesn't route through stopStreaming().)
323+ searchJob?.cancel()
324+ // Drop a trailing statement terminator before we append anything — otherwise
325+ // "SELECT * FROM x;" would become "SELECT * FROM x; LIMIT 5000", a syntax error.
213− val trimmed = sql.trim()
326+ val trimmed = sql.trim().trimEnd(';').trim()
214 if (!trimmed.uppercase().startsWith("SELECT")) {
327 if (!trimmed.uppercase().startsWith("SELECT")) {
215 _state.update { it.copy(error = "Only SELECT queries are allowed") }
328 _state.update { it.copy(error = "Only SELECT queries are allowed") }
216 return
329 return
217 }
330 }
218− // Enforce a LIMIT to prevent OOM from unbounded queries
331+ // Enforce a LIMIT to prevent OOM from unbounded queries. Only honor a *top-level*
219− val safeSql = if (!trimmed.uppercase().contains("LIMIT")) {
332+ // `LIMIT <n>` (optionally with OFFSET) anchored at the end of the statement — a
333+ // LIMIT inside a subquery doesn't bound the outer result set, so matching it
334+ // anywhere would let an unbounded outer query slip past the safety limit. Anchoring
335+ // at the end also means a string literal like 'LIMIT EXCEEDED' can't disable it.
336+ val hasTopLevelLimit =
337+ Regex("(?i)\\bLIMIT\\s+\\d+(\\s+OFFSET\\s+\\d+)?\\s*$").containsMatchIn(trimmed)
338+ val safeSql = if (!hasTopLevelLimit) {
220 "$trimmed LIMIT $MAX_LOAD_LIMIT"
339 "$trimmed LIMIT $MAX_LOAD_LIMIT"
221 } else {
340 } else {
222 trimmed
341 trimmed
223 }
342 }
343+ // Store the custom SQL and let refresh() run it. Keeping it in state means
344+ // pull-to-refresh / time-range changes re-run the custom query instead of
345+ // silently replacing it with the default filter query.
224 _state.update { it.copy(filters = it.filters.copy(customSql = safeSql), currentLimit = 500) }
346 _state.update { it.copy(filters = it.filters.copy(customSql = safeSql), currentLimit = 500) }
225− viewModelScope.launch {
347+ refresh()
226− _state.update { it.copy(isLoading = true, error = null) }
227−
228− val (startTime, endTime) = getTimeRange()
229− when (val result = repository.queryLogsRaw(safeSql, startTime, endTime)) {
230− is ApiResult.Success -> {
231− _state.update {
232− it.copy(
233− logs = result.data,
234− isLoading = false,
235− hasMore = result.data.size >= it.currentLimit,
236− )
237− }
238− }
239− is ApiResult.Error -> {
240− _state.update {
241− it.copy(isLoading = false, error = result.userMessage)
242− }
243− }
244− }
245− }
246 }
348 }
247
349
248− private fun buildSearchClause(columns: List<String>, searchQuery: String): String? {
350+ private fun buildSearchClause(searchableColumns: List<String>, searchQuery: String): String? {
249 if (searchQuery.isBlank()) return null
351 if (searchQuery.isBlank()) return null
250− val safeSearch = escapeSql(searchQuery)
251− // Search across ALL non-internal columns (not just 5)
252− val searchFields = columns.filter { !it.startsWith("p_") }
253− if (searchFields.isEmpty()) return null
352+ if (searchableColumns.isEmpty()) return null
353+ val safeSearch = escapeLikePattern(searchQuery)
254− return searchFields.joinToString(" OR ") {
354+ return searchableColumns.joinToString(" OR ") {
255− "\"${escapeIdentifier(it)}\" ILIKE '%$safeSearch%'"
355+ "CAST(\"${escapeIdentifier(it)}\" AS VARCHAR) ILIKE '%$safeSearch%' ESCAPE '\\'"
256 }
356 }
257 }
357 }
258
358
259 fun refresh() {
359 fun refresh() {
260 if (_state.value.streamName.isEmpty()) return
360 if (_state.value.streamName.isEmpty()) return
261
361
362+ // Cancel any in-flight query so out-of-order completions can't overwrite
363+ // fresh results with stale ones (filter/search/time-range/loadMore all call refresh()).
364+ refreshJob?.cancel()
262− viewModelScope.launch {
365+ refreshJob = viewModelScope.launch {
366+ // Anchor the upper time bound on the first page of a paging session (currentLimit at
367+ // its initial value) and reuse it for subsequent loadMore() pages. Otherwise a relative
368+ // range would recompute "now" on every page in getTimeRange(), sliding the window and
369+ // duplicating/dropping rows on an actively-ingesting stream.
370+ val anchor = _state.value.let { s ->
371+ if (s.currentLimit <= 500) System.currentTimeMillis() else s.pageAnchorTime
372+ }
263 _state.update {
373 _state.update {
264 it.copy(
374 it.copy(
265 isLoading = true,
375 isLoading = true,
266 error = null,
376 error = null,
267 streaming = it.streaming.copy(streamingError = null),
377 streaming = it.streaming.copy(streamingError = null),
378+ pageAnchorTime = anchor,
268 )
379 )
269 }
380 }
270
381
@@ -272,10 +383,24 @@ class LogViewerViewModel @Inject constructor(
272 val current = _state.value
383 val current = _state.value
273 val (startTime, endTime) = getTimeRange()
384 val (startTime, endTime) = getTimeRange()
274
385
386+ // A custom SQL query owns the whole statement (its own LIMIT, columns, ordering),
387+ // so re-run it verbatim. It is not paginated via loadMore(), hence hasMore = false.
388+ if (current.filters.customSql.isNotBlank()) {
389+ when (val result = repository.queryLogsRaw(current.filters.customSql, startTime, endTime)) {
390+ is ApiResult.Success -> {
391+ _state.update { it.copy(logs = result.data, logKeys = computeLogKeys(result.data), isLoading = false, hasMore = false) }
392+ }
393+ is ApiResult.Error -> {
394+ _state.update { it.copy(isLoading = false, error = result.userMessage) }
395+ }
396+ }
397+ return@launch
398+ }
399+
275 // Build WHERE clause from filters + search
400 // Build WHERE clause from filters + search
276 val clauses = current.filters.filterClauses.toMutableList()
401 val clauses = current.filters.filterClauses.toMutableList()
277 if (current.filters.searchQuery.isNotBlank()) {
402 if (current.filters.searchQuery.isNotBlank()) {
278− val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
403+ val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
279 if (searchClause != null) {
404 if (searchClause != null) {
280 clauses.add("($searchClause)")
405 clauses.add("($searchClause)")
281 } else {
406 } else {
@@ -288,20 +413,26 @@ class LogViewerViewModel @Inject constructor(
288
413
289 val filterSql = clauses.joinToString(" AND ")
414 val filterSql = clauses.joinToString(" AND ")
290
415
416+ // Over-fetch by one row so we can distinguish "exactly currentLimit rows exist"
417+ // (no more pages) from "the page is full and more may exist" without firing a
418+ // wasted extra query the next time the user scrolls to the bottom.
419+ val requestLimit = current.currentLimit + 1
291 when (val result = repository.queryLogs(
420 when (val result = repository.queryLogs(
292 stream = current.streamName,
421 stream = current.streamName,
293 startTime = startTime,
422 startTime = startTime,
294 endTime = endTime,
423 endTime = endTime,
295 filterSql = filterSql,
424 filterSql = filterSql,
296− limit = current.currentLimit,
425+ limit = requestLimit,
297 )) {
426 )) {
298 is ApiResult.Success -> {
427 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+ }
299 _state.update {
434 _state.update {
300− it.copy(
435+ it.copy(logs = logs, logKeys = computeLogKeys(logs), isLoading = false, hasMore = hasMore)
301− logs = result.data,
302− isLoading = false,
303− hasMore = result.data.size >= it.currentLimit,
304− )
305 }
436 }
306 }
437 }
307 is ApiResult.Error -> {
438 is ApiResult.Error -> {
@@ -313,8 +444,29 @@ class LogViewerViewModel @Inject constructor(
313 }
444 }
314 }
445 }
315
446
447+ /**
448+ * Pull-to-refresh: restart the paging session from the first page. Resetting currentLimit
449+ * makes refresh() re-anchor pageAnchorTime to "now", so a relative time range advances and
450+ * newly-ingested rows appear. Calling refresh() alone keeps the frozen anchor once the user
451+ * has paged past the first page, making pull-to-refresh look like it does nothing.
452+ */
453+ fun pullRefresh() {
454+ if (_state.value.streamName.isEmpty()) return
455+ // While live-tailing, the poller continuously prepends fresh rows; a concurrent refresh()
456+ // would replace the list with a full page at the same time, racing the poller on logs/
457+ // logKeys/streamingError and producing duplicated or dropped rows plus a stale "+N" badge.
458+ // The displayed logs are already current under streaming, so the pull is a no-op here —
459+ // mirror loadMore()'s guard rather than the stopStreaming() path the other mutators take.
460+ if (_state.value.streaming.isStreaming) return
461+ _state.update { it.copy(currentLimit = 500) }
462+ refresh()
463+ }
464+
316 fun loadMore() {
465 fun loadMore() {
317 val current = _state.value
466 val current = _state.value
467+ // While live-tailing, logs are prepended in memory; a refresh() here would replace
468+ // them with a fresh page and drop the streamed rows. Pagination resumes once stopped.
469+ if (current.streaming.isStreaming) return
318 if (current.currentLimit >= MAX_LOAD_LIMIT) {
470 if (current.currentLimit >= MAX_LOAD_LIMIT) {
319 _state.update { it.copy(hasMore = false) }
471 _state.update { it.copy(hasMore = false) }
320 return
472 return
@@ -337,7 +489,9 @@ class LogViewerViewModel @Inject constructor(
337 )
489 )
338 return Pair(start.format(dateFormatter), end.format(dateFormatter))
490 return Pair(start.format(dateFormatter), end.format(dateFormatter))
339 }
491 }
492+ val now = current.pageAnchorTime
493+ ?.let { ZonedDateTime.ofInstant(java.time.Instant.ofEpochMilli(it), ZoneOffset.UTC) }
340− val now = ZonedDateTime.now(ZoneOffset.UTC)
494+ ?: ZonedDateTime.now(ZoneOffset.UTC)
341 val start = now.minusMinutes(current.selectedTimeRange.minutes)
495 val start = now.minusMinutes(current.selectedTimeRange.minutes)
342 return Pair(
496 return Pair(
343 start.format(dateFormatter),
497 start.format(dateFormatter),
@@ -345,6 +499,17 @@ class LogViewerViewModel @Inject constructor(
345 )
499 )
346 }
500 }
347
501
502+ /**
503+ * Report whether the log list is scrolled to the top. Clears the "+N new" streaming badge
504+ * when the user returns to the top, since prepended rows there are seen immediately.
505+ */
506+ fun setViewingTop(atTop: Boolean) {
507+ viewingTop = atTop
508+ if (atTop && _state.value.streaming.streamingNewCount != 0) {
509+ _state.update { it.copy(streaming = it.streaming.copy(streamingNewCount = 0)) }
510+ }
511+ }
512+
348 fun toggleStreaming() {
513 fun toggleStreaming() {
349 if (_state.value.streaming.isStreaming) {
514 if (_state.value.streaming.isStreaming) {
350 stopStreaming()
515 stopStreaming()
@@ -354,6 +519,10 @@ class LogViewerViewModel @Inject constructor(
354 }
519 }
355
520
356 private fun startStreaming() {
521 private fun startStreaming() {
522+ // The poller builds its own default SELECT * query and cannot honor a custom SQL
523+ // projection/filter, so streaming custom-query results would prepend mismatched rows.
524+ // The UI disables the toggle in this state; guard here too in case it's reached anyway.
525+ if (_state.value.filters.customSql.isNotBlank()) return
357 stopStreaming()
526 stopStreaming()
358 consecutiveStreamingErrors = 0
527 consecutiveStreamingErrors = 0
359 val generation = ++streamingGeneration
528 val generation = ++streamingGeneration
@@ -361,6 +530,12 @@ class LogViewerViewModel @Inject constructor(
361 it.copy(streaming = StreamingState(isStreaming = true, streamingNewCount = 0))
530 it.copy(streaming = StreamingState(isStreaming = true, streamingNewCount = 0))
362 }
531 }
363
532
533+ // Seed the dedup set with the rows already on screen so an initial row sitting exactly
534+ // on the streaming baseline timestamp can't be re-fetched and duplicated.
535+ seenStreamingKeys.clear()
536+ _state.value.logs.forEach { seenStreamingKeys.add(logIdentity(it)) }
537+ trimSeenStreamingKeys()
538+
364 // Set the baseline timestamp to "now" so we only poll for new logs
539 // Set the baseline timestamp to "now" so we only poll for new logs
365 lastSeenTimestamp = ZonedDateTime.now(ZoneOffset.UTC).format(dateFormatter)
540 lastSeenTimestamp = ZonedDateTime.now(ZoneOffset.UTC).format(dateFormatter)
366
541
@@ -383,6 +558,13 @@ class LogViewerViewModel @Inject constructor(
383 }
558 }
384
559
385 fun stopStreaming() {
560 fun stopStreaming() {
561+ // Cancel any pending search debounce too. Every query-basis change (time range, filters,
562+ // saved filter, clear, new search) routes through stopStreaming(); without this, a
563+ // searchJob scheduled moments before such a change would still fire refresh() ~300ms
564+ // later and redundantly re-query over the just-applied state. onSearchQueryChange()
565+ // re-assigns searchJob immediately after calling stopStreaming(), so its own debounce
566+ // survives.
567+ searchJob?.cancel()
386 streamingGeneration++
568 streamingGeneration++
387 streamingJob?.cancel()
569 streamingJob?.cancel()
388 streamingJob = null
570 streamingJob = null
@@ -397,6 +579,49 @@ class LogViewerViewModel @Inject constructor(
397 }
579 }
398 }
580 }
399
581
582+ /**
583+ * Content-based identity for a log row, used to dedupe re-fetched boundary logs.
584+ * Uses the full row: two rows are duplicates only when every field matches, so
585+ * distinct logs that happen to share a timestamp/metadata/tags are not dropped.
586+ */
587+ private fun logIdentity(log: JsonObject): String = log.toString()
588+
589+ /**
590+ * Parse a server-rendered p_timestamp (which may be ISO with 'Z', offset-carrying,
591+ * space-separated, or offset-less UTC) and reformat it to the canonical query format
592+ * (`dateFormatter`) so it's a valid inclusive startTime for the next poll. Returns null
593+ * if the value can't be parsed, so the caller can keep the previous boundary.
594+ */
595+ private fun normalizeBoundaryTimestamp(raw: String): String? {
596+ val isoLike = if (' ' in raw && 'T' !in raw) raw.replaceFirst(' ', 'T') else raw
597+ val instant = try {
598+ Instant.parse(isoLike)
599+ } catch (_: Exception) {
600+ try {
601+ OffsetDateTime.parse(isoLike).toInstant()
602+ } catch (_: Exception) {
603+ try {
604+ LocalDateTime.parse(isoLike).toInstant(ZoneOffset.UTC)
605+ } catch (_: Exception) {
606+ return null
607+ }
608+ }
609+ }
610+ return instant.atOffset(ZoneOffset.UTC).format(dateFormatter)
611+ }
612+
613+ /** Bound the dedup set, evicting the oldest identities first (insertion order). */
614+ private fun trimSeenStreamingKeys() {
615+ if (seenStreamingKeys.size <= SEEN_STREAMING_KEYS_MAX) return
616+ val iterator = seenStreamingKeys.iterator()
617+ var toRemove = seenStreamingKeys.size - SEEN_STREAMING_KEYS_MAX
618+ while (toRemove > 0 && iterator.hasNext()) {
619+ iterator.next()
620+ iterator.remove()
621+ toRemove--
622+ }
623+ }
624+
400 private suspend fun pollNewLogs() {
625 private suspend fun pollNewLogs() {
401 val current = _state.value
626 val current = _state.value
402 if (current.streamName.isEmpty()) return
627 if (current.streamName.isEmpty()) return
@@ -407,46 +632,118 @@ class LogViewerViewModel @Inject constructor(
407 // Build WHERE clause from active filters + search
632 // Build WHERE clause from active filters + search
408 val clauses = current.filters.filterClauses.toMutableList()
633 val clauses = current.filters.filterClauses.toMutableList()
409 if (current.filters.searchQuery.isNotBlank()) {
634 if (current.filters.searchQuery.isNotBlank()) {
410− val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
635+ val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
411 if (searchClause != null) {
636 if (searchClause != null) {
637+ // Parenthesize: the search clause is OR-joined and must not break the
638+ // precedence of the AND-joined filter clauses around it.
412− clauses.add(searchClause)
639+ clauses.add("($searchClause)")
640+ } else {
641+ // Schema not loaded yet, so we can't build the search filter. Skip this
642+ // poll rather than querying without it — otherwise we'd prepend rows that
643+ // don't match the active search into a filtered view. Mirrors refresh(),
644+ // which errors out instead of running an unfiltered query.
645+ _state.update {
646+ it.copy(
647+ streaming = it.streaming.copy(
648+ streamingError = "Search unavailable: stream schema not loaded",
649+ ),
650+ )
651+ }
652+ return
413 }
653 }
414 }
654 }
415
655
416 val whereClause = if (clauses.isNotEmpty()) " WHERE ${clauses.joinToString(" AND ")}" else ""
656 val whereClause = if (clauses.isNotEmpty()) " WHERE ${clauses.joinToString(" AND ")}" else ""
417 val safeName = escapeIdentifier(current.streamName)
657 val safeName = escapeIdentifier(current.streamName)
658+ // Fetch up to the in-memory display cap. With a small limit (e.g. 200), a burst of
659+ // more than that many logs in one interval would advance lastSeenTimestamp past the
660+ // un-fetched rows and drop them permanently. Capping at STREAMING_MAX_LOGS means we
661+ // never skip rows we'd actually keep (anything older is evicted by the cap anyway).
418− val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT 200"
662+ val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT $STREAMING_MAX_LOGS"
419
663
420 when (val result = repository.queryLogsRaw(sql, startTime, endTime)) {
664 when (val result = repository.queryLogsRaw(sql, startTime, endTime)) {
421 is ApiResult.Success -> {
665 is ApiResult.Success -> {
422 val newLogs = result.data
666 val newLogs = result.data
423 if (newLogs.isNotEmpty()) {
667 if (newLogs.isNotEmpty()) {
424− // Update the last seen timestamp to the most recent log
668+ // Advance the last-seen boundary. Rows are ordered newest-first, so
425− val newestTimestamp = try {
669+ // normally we jump to the newest. But a full page means the LIMIT may
426− newLogs.firstOrNull()
670+ // have capped off matching rows OLDER than the newest in this batch
671+ // (possible once filters/search narrow a high-volume stream) — advancing
672+ // to the newest would skip them forever. In that case advance only to the
673+ // oldest row we did fetch, so the next poll re-queries forward and closes
674+ // the gap (re-fetched rows are deduped below).
675+ val fullPage = newLogs.size >= STREAMING_MAX_LOGS
676+ val boundaryLog = if (fullPage) newLogs.lastOrNull() else newLogs.firstOrNull()
677+ val boundaryTimestamp = try {
427− ?.get("p_timestamp")?.jsonPrimitive?.content
678+ boundaryLog?.get("p_timestamp")?.jsonPrimitive?.content
428 } catch (_: Exception) {
679 } catch (_: Exception) {
429 null
680 null
430 }
681 }
682+ // Re-canonicalize the server's p_timestamp before reusing it as the next
683+ // query's startTime. The raw value can be space-separated or offset-less,
684+ // which the query API may reject or misparse — that would stall live-tail
685+ // after the first batch. If it can't be parsed, leave lastSeenTimestamp as-is
686+ // (the inclusive startTime simply re-fetches the window; rows are deduped).
687+ var normalized = boundaryTimestamp?.let { normalizeBoundaryTimestamp(it) }
688+ // On a saturated (full) page the boundary is the OLDEST fetched row so the
689+ // next poll re-queries forward and closes the LIMIT gap. But if that oldest
690+ // row carries the SAME timestamp we're already at — e.g. a burst of more than
691+ // STREAMING_MAX_LOGS rows sharing one millisecond, or the parse failing — the
692+ // boundary wouldn't move and we'd re-query the identical window forever. Fall
693+ // back to the NEWEST row's timestamp to guarantee forward progress; the
694+ // skipped backlog would be evicted by the display cap anyway.
695+ if (fullPage && (normalized == null || normalized == lastSeenTimestamp)) {
431− if (newestTimestamp != null) {
696+ val newestTimestamp = try {
697+ newLogs.firstOrNull()?.get("p_timestamp")?.jsonPrimitive?.content
698+ } catch (_: Exception) {
699+ null
700+ }
701+ val newestNormalized = newestTimestamp?.let { normalizeBoundaryTimestamp(it) }
702+ if (newestNormalized != null && newestNormalized != lastSeenTimestamp) {
703+ normalized = newestNormalized
704+ }
705+ }
706+ if (normalized != null) {
432− lastSeenTimestamp = newestTimestamp
707+ lastSeenTimestamp = normalized
433 }
708 }
434
709
710+ // The query's startTime is inclusive, so rows sitting on the previous
711+ // boundary timestamp get re-fetched. Dedup against everything seen this
712+ // session (independent of the display cap) to avoid duplicate rows and an
713+ // inflated count. Computed BEFORE the state update — and the seen-set
714+ // mutated here, not inside the update lambda, which may re-run.
715+ val freshLogs = newLogs.filterNot { logIdentity(it) in seenStreamingKeys }
716+ newLogs.forEach { seenStreamingKeys.add(logIdentity(it)) }
717+ trimSeenStreamingKeys()
718+
719+ if (freshLogs.isEmpty()) {
720+ _state.update { it.copy(streaming = it.streaming.copy(streamingError = null)) }
721+ } else {
435− _state.update { state ->
722+ _state.update { state ->
436− // Prepend new logs, cap total
723+ // Prepend new logs, cap total — avoid full intermediate list allocation
437− val combined = newLogs + state.logs
724+ val remaining = (STREAMING_MAX_LOGS - freshLogs.size).coerceAtLeast(0)
438− val capped = if (combined.size > STREAMING_MAX_LOGS) {
725+ val capped = ArrayList<JsonObject>(freshLogs.size + remaining).apply {
439− combined.take(STREAMING_MAX_LOGS)
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
440− } else {
740+ } else {
441− combined
741+ state.streaming.streamingNewCount + freshLogs.size
742+ },
743+ streamingError = null,
744+ ),
745+ )
442 }
746 }
443− state.copy(
444− logs = capped,
445− streaming = state.streaming.copy(
446− streamingNewCount = state.streaming.streamingNewCount + newLogs.size,
447− streamingError = null,
448− ),
449− )
450 }
747 }
451 }
748 }
452 consecutiveStreamingErrors = 0
749 consecutiveStreamingErrors = 0
@@ -471,6 +768,191 @@ class LogViewerViewModel @Inject constructor(
471 }
768 }
472 }
769 }
473
770
771+ // --- Saved Filters (synced with Parseable server) ---
772+
773+ // Serializes loadSavedFilters / saveCurrentFilter / deleteSavedFilter so they never
774+ // interleave. Without this, a wholesale-overwrite load completing after a concurrent
775+ // save (append) or delete (remove) would silently drop the local add/remove — a
776+ // read-modify-write race across coroutines.
777+ private val savedFiltersMutex = Mutex()
778+
779+ fun loadSavedFilters() {
780+ // Capture the stream this load is for; if the user navigates to a different stream
781+ // before it completes, bind the result to the right stream instead of clobbering the
782+ // new stream's filter list with another stream's results.
783+ val stream = _state.value.streamName
784+ 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+ }
799+ }
800+ is ApiResult.Error -> {
801+ _state.update {
802+ it.copy(savedFilters = it.savedFilters.copy(isLoading = false, error = result.userMessage))
803+ }
804+ }
805+ }
806+ }
807+ }
808+ }
809+
810+ fun saveCurrentFilter(name: String) {
811+ val current = _state.value
812+ if (current.streamName.isEmpty()) return
813+
814+ viewModelScope.launch {
815+ savedFiltersMutex.withLock {
816+ _state.update { it.copy(savedFilters = it.savedFilters.copy(isSaving = true, error = null)) }
817+
818+ val query = if (current.filters.customSql.isNotBlank()) {
819+ SavedFilterQuery(
820+ filterType = "sql",
821+ filterQuery = current.filters.customSql,
822+ )
823+ } else {
824+ val ruleGroups = current.filters.filterConditions
825+ .mapIndexed { index, condition ->
826+ FilterRule(
827+ id = "rule_$index",
828+ field = condition.column,
829+ value = condition.value,
830+ operator = condition.operator,
831+ )
832+ }
833+ val builder = if (ruleGroups.isNotEmpty()) {
834+ FilterBuilder(
835+ id = "root",
836+ combinator = "and",
837+ rules = listOf(
838+ FilterRuleGroup(
839+ id = "group_0",
840+ combinator = "and",
841+ rules = ruleGroups,
842+ ),
843+ ),
844+ )
845+ } else null
846+
847+ SavedFilterQuery(
848+ filterType = if (current.filters.searchQuery.isNotBlank()) "search" else "filter",
849+ filterQuery = current.filters.searchQuery.ifBlank { null },
850+ filterBuilder = builder,
851+ )
852+ }
853+
854+ val filter = SavedFilter(
855+ filterName = name,
856+ streamName = current.streamName,
857+ query = query,
858+ )
859+
860+ when (val result = repository.createFilter(filter)) {
861+ is ApiResult.Success -> {
862+ _state.update {
863+ it.copy(
864+ savedFilters = it.savedFilters.copy(
865+ isSaving = false,
866+ filters = it.savedFilters.filters + result.data,
867+ ),
868+ )
869+ }
870+ }
871+ is ApiResult.Error -> {
872+ _state.update {
873+ it.copy(savedFilters = it.savedFilters.copy(isSaving = false, error = result.userMessage))
874+ }
875+ }
876+ }
877+ }
878+ }
879+ }
880+
881+ fun applySavedFilter(filter: SavedFilter) {
882+ stopStreaming()
883+
884+ when (filter.query.filterType) {
885+ "sql" -> {
886+ val sql = filter.query.filterQuery ?: return
887+ // Reset to a clean filter state first, then let executeCustomSql validate
888+ // the SQL and own the customSql state. Pre-seeding filters.customSql with
889+ // the raw, unvalidated query meant that if executeCustomSql rejected it
890+ // (e.g. a legacy non-SELECT saved filter, which returns early without
891+ // touching state) we were left wedged in custom-SQL mode with broken SQL
892+ // that every later refresh() would keep re-running.
893+ _state.update {
894+ it.copy(
895+ filters = FilterState(),
896+ currentLimit = 500,
897+ )
898+ }
899+ executeCustomSql(sql)
900+ }
901+ "search" -> {
902+ val searchQuery = filter.query.filterQuery ?: ""
903+ _state.update {
904+ it.copy(
905+ filters = FilterState(searchQuery = searchQuery),
906+ currentLimit = 500,
907+ )
908+ }
909+ // Also apply builder rules if present
910+ applyBuilderRules(filter.query.filterBuilder)
911+ refresh()
912+ }
913+ else -> {
914+ _state.update {
915+ it.copy(
916+ filters = FilterState(),
917+ currentLimit = 500,
918+ )
919+ }
920+ applyBuilderRules(filter.query.filterBuilder)
921+ refresh()
922+ }
923+ }
924+ }
925+
926+ private fun applyBuilderRules(builder: FilterBuilder?) {
927+ if (builder == null) return
928+ for (group in builder.rules) {
929+ for (rule in group.rules) {
930+ if (rule.field.isNotBlank()) {
931+ addFilterInternal(rule.field, rule.operator, rule.value)
932+ }
933+ }
934+ }
935+ }
936+
937+ fun deleteSavedFilter(filterId: String) {
938+ 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+ }
949+ }
950+ is ApiResult.Error -> { /* Silently ignore — will show on next reload */ }
951+ }
952+ }
953+ }
954+ }
955+
474 override fun onCleared() {
956 override fun onCleared() {
475 super.onCleared()
957 super.onCleared()
476 schemaJob?.cancel()
958 schemaJob?.cancel()
app/src/main/java/com/parseable/android/ui/screens/logviewer/SeverityColor.ktnew file+169
@@ -0,0 +1,169 @@
1+package com.parseable.android.ui.screens.logviewer
2+
3+import androidx.compose.foundation.isSystemInDarkTheme
4+import androidx.compose.runtime.Composable
5+import androidx.compose.ui.graphics.Color
6+import kotlinx.serialization.json.JsonObject
7+import kotlinx.serialization.json.JsonPrimitive
8+
9+/**
10+ * Severity levels for log entries, ordered from most to least severe.
11+ */
12+enum class LogSeverity {
13+ FATAL,
14+ ERROR,
15+ WARNING,
16+ INFO,
17+ DEBUG,
18+ TRACE,
19+ UNKNOWN,
20+}
21+
22+/**
23+ * Field names commonly used for severity/level across logging frameworks.
24+ * Checked in order of priority (case-insensitive).
25+ */
26+private val SEVERITY_FIELD_NAMES = listOf(
27+ "level",
28+ "severity",
29+ "loglevel",
30+ "log_level",
31+ "log.level",
32+ "priority",
33+ "verbosity",
34+ "p_level",
35+ "status",
36+ "syslog_severity",
37+ "log_severity",
38+ "msg_severity",
39+ "event_severity",
40+)
41+
42+/**
43+ * Attempts to detect the severity level from a log entry by checking common field names.
44+ * Returns [LogSeverity.UNKNOWN] if no recognizable severity field or value is found.
45+ */
46+fun detectSeverity(logEntry: JsonObject): LogSeverity {
47+ for (fieldName in SEVERITY_FIELD_NAMES) {
48+ val entry = logEntry.entries.firstOrNull { it.key.equals(fieldName, ignoreCase = true) }
49+ if (entry != null) {
50+ val value = entry.value
51+ if (value is JsonPrimitive) {
52+ val parsed = parseSeverityValue(value.content, entry.key)
53+ if (parsed != LogSeverity.UNKNOWN) return parsed
54+ }
55+ }
56+ }
57+ return LogSeverity.UNKNOWN
58+}
59+
60+private fun parseSeverityValue(raw: String, fieldName: String): LogSeverity {
61+ val trimmed = raw.trim()
62+ if (trimmed.isEmpty()) return LogSeverity.UNKNOWN
63+
64+ // Try numeric parsing first
65+ trimmed.toIntOrNull()?.let { num ->
66+ return when {
67+ // Syslog severity (0 = emergency .. 7 = debug)
68+ fieldName.equals("syslog_severity", ignoreCase = true) -> when (num) {
69+ 0, 1, 2 -> LogSeverity.FATAL // emergency, alert, critical
70+ 3 -> LogSeverity.ERROR
71+ 4 -> LogSeverity.WARNING
72+ 5 -> LogSeverity.INFO // notice
73+ 6 -> LogSeverity.INFO // informational
74+ 7 -> LogSeverity.DEBUG
75+ else -> LogSeverity.UNKNOWN
76+ }
77+ // Syslog "priority" (PRI) is the encoded value facility*8 + severity (0..191), so the
78+ // severity is the low 3 bits. For a bare 0..7 this is identity; a real PRI like 134
79+ // decodes to severity 6 (info) instead of falling through to UNKNOWN.
80+ fieldName.equals("priority", ignoreCase = true) -> when {
81+ num < 0 || num > 191 -> LogSeverity.UNKNOWN
82+ else -> when (num % 8) {
83+ 0, 1, 2 -> LogSeverity.FATAL // emergency, alert, critical
84+ 3 -> LogSeverity.ERROR
85+ 4 -> LogSeverity.WARNING
86+ 5, 6 -> LogSeverity.INFO // notice, informational
87+ else -> LogSeverity.DEBUG // 7
88+ }
89+ }
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
99+ else -> LogSeverity.UNKNOWN
100+ }
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).
109+ else -> when {
110+ num >= 1000 -> LogSeverity.FATAL
111+ num >= 900 -> LogSeverity.ERROR
112+ num >= 800 -> LogSeverity.WARNING
113+ num >= 700 -> LogSeverity.INFO
114+ num >= 400 -> LogSeverity.DEBUG
115+ num >= 100 -> LogSeverity.TRACE
116+ num >= 60 -> LogSeverity.FATAL
117+ num >= 50 -> LogSeverity.ERROR
118+ num >= 40 -> LogSeverity.WARNING
119+ num >= 30 -> LogSeverity.INFO
120+ num >= 20 -> LogSeverity.DEBUG
121+ num > 0 -> LogSeverity.TRACE
122+ else -> LogSeverity.UNKNOWN
123+ }
124+ }
125+ }
126+
127+ // String-based matching (case-insensitive)
128+ return when (trimmed.uppercase()) {
129+ "FATAL", "F", "CRIT", "CRITICAL", "EMERG", "EMERGENCY",
130+ "ALERT", "A", "PANIC",
131+ -> LogSeverity.FATAL
132+
133+ "ERROR", "ERR", "E", "SEVERE", "FAILURE", "FAIL",
134+ -> LogSeverity.ERROR
135+
136+ "WARN", "WARNING", "W",
137+ -> LogSeverity.WARNING
138+
139+ "INFO", "I", "INFORMATION", "INFORMATIONAL", "NOTICE", "N",
140+ "OK", "SUCCESS",
141+ -> LogSeverity.INFO
142+
143+ "DEBUG", "D", "FINE", "CONFIG", "FINER",
144+ -> LogSeverity.DEBUG
145+
146+ "TRACE", "T", "V", "VERBOSE", "FINEST", "ALL",
147+ -> LogSeverity.TRACE
148+
149+ else -> LogSeverity.UNKNOWN
150+ }
151+}
152+
153+/**
154+ * Returns a subtle background tint color for the given severity level,
155+ * or `null` to use the default card color.
156+ */
157+@Composable
158+fun severityBackgroundColor(severity: LogSeverity): Color? {
159+ val isDark = isSystemInDarkTheme()
160+ return when (severity) {
161+ LogSeverity.FATAL -> if (isDark) Color(0xFF3A1820) else Color(0xFFFCE4EC)
162+ LogSeverity.ERROR -> if (isDark) Color(0xFF2D1A1E) else Color(0xFFFDECEC)
163+ LogSeverity.WARNING -> if (isDark) Color(0xFF2D2715) else Color(0xFFFFF8E1)
164+ LogSeverity.INFO -> if (isDark) Color(0xFF152535) else Color(0xFFE8F4FD)
165+ LogSeverity.DEBUG -> null
166+ LogSeverity.TRACE -> if (isDark) Color(0xFF1E1F25) else Color(0xFFF5F5F8)
167+ LogSeverity.UNKNOWN -> null
168+ }
169+}
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoScreen.kt+100−86
@@ -7,6 +7,7 @@ import androidx.compose.material.icons.Icons
7import androidx.compose.material.icons.automirrored.filled.ArrowBack
7import androidx.compose.material.icons.automirrored.filled.ArrowBack
8import androidx.compose.material.icons.filled.*
8import androidx.compose.material.icons.filled.*
9import androidx.compose.material3.*
9import androidx.compose.material3.*
10+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
10import androidx.compose.runtime.*
11import androidx.compose.runtime.*
11import androidx.compose.ui.Alignment
12import androidx.compose.ui.Alignment
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.Modifier
@@ -14,10 +15,7 @@ import androidx.compose.ui.text.font.FontFamily
14import androidx.compose.ui.text.font.FontWeight
15import androidx.compose.ui.text.font.FontWeight
15import androidx.compose.ui.unit.dp
16import androidx.compose.ui.unit.dp
16import androidx.hilt.navigation.compose.hiltViewModel
17import androidx.hilt.navigation.compose.hiltViewModel
17−import androidx.lifecycle.Lifecycle
18−import androidx.lifecycle.compose.LocalLifecycleOwner
19import androidx.lifecycle.compose.collectAsStateWithLifecycle
18import androidx.lifecycle.compose.collectAsStateWithLifecycle
20−import androidx.lifecycle.repeatOnLifecycle
21import com.parseable.android.data.formatBytes
19import com.parseable.android.data.formatBytes
22import kotlinx.serialization.json.Json
20import kotlinx.serialization.json.Json
23import kotlinx.serialization.json.JsonObject
21import kotlinx.serialization.json.JsonObject
@@ -35,11 +33,10 @@ fun StreamInfoScreen(
35 val state by viewModel.state.collectAsStateWithLifecycle()
33 val state by viewModel.state.collectAsStateWithLifecycle()
36 var showDeleteConfirmation by remember { mutableStateOf(false) }
34 var showDeleteConfirmation by remember { mutableStateOf(false) }
37
35
38− val lifecycleOwner = LocalLifecycleOwner.current
36+ // Load once per stream. Reloading on every RESUME would refetch all four endpoints on
37+ // each return and could re-query a stream that was just deleted (spurious error card).
39− LaunchedEffect(streamName, lifecycleOwner) {
38+ LaunchedEffect(streamName) {
40− lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
41− viewModel.load(streamName)
39+ viewModel.load(streamName)
42− }
43 }
40 }
44
41
45 LaunchedEffect(state.deleteSuccess) {
42 LaunchedEffect(state.deleteSuccess) {
@@ -73,97 +70,114 @@ fun StreamInfoScreen(
73 )
70 )
74 },
71 },
75 ) { padding ->
72 ) { padding ->
73+ // Has any section loaded yet? Drives whether a load shows the centered first-load
74+ // spinner or keeps existing content visible (so pull-to-refresh stays usable on retry).
75+ val hasContent = state.stats != null || state.schema.isNotEmpty() ||
76+ state.rawInfo != null || state.retention.isNotEmpty() || state.schemaFailed
77+ PullToRefreshBox(
78+ // Only show the pull indicator when refreshing over existing content; the very
79+ // first load uses the centered spinner below, so don't show both at once.
80+ isRefreshing = state.isLoading && hasContent,
81+ onRefresh = viewModel::refresh,
82+ modifier = Modifier
83+ .fillMaxSize()
84+ .padding(padding),
85+ ) {
76− if (state.isLoading) {
86+ if (state.isLoading && !hasContent) {
77− Box(
87+ Box(
78− modifier = Modifier
88+ modifier = Modifier.fillMaxSize(),
79− .fillMaxSize()
80− .padding(padding),
81− contentAlignment = Alignment.Center,
89+ contentAlignment = Alignment.Center,
82− ) {
90+ ) {
83− CircularProgressIndicator()
91+ CircularProgressIndicator()
84− }
92+ }
85− } else {
93+ } else {
86− Column(
94+ Column(
87− modifier = Modifier
95+ modifier = Modifier
88− .fillMaxSize()
96+ .fillMaxSize()
89− .padding(padding)
90− .verticalScroll(rememberScrollState())
97+ .verticalScroll(rememberScrollState())
91− .padding(16.dp),
98+ .padding(16.dp),
92− verticalArrangement = Arrangement.spacedBy(16.dp),
99+ verticalArrangement = Arrangement.spacedBy(16.dp),
93− ) {
100+ ) {
94− // Stats section
101+ // Stats section
95− state.stats?.let { stats ->
102+ state.stats?.let { stats ->
96− InfoSection(title = "Statistics") {
103+ InfoSection(title = "Statistics") {
104+ // Show the current-window count when available. Fall back to "N/A"
105+ // only when there's no lifetime count either — otherwise the lifetime
106+ // figure is shown in its own row below, so don't duplicate it here.
107+ when {
97− InfoRow("Event Count", stats.ingestion?.count?.toString() ?: stats.ingestion?.lifetimeCount?.toString() ?: "N/A")
108+ stats.ingestion?.count != null -> InfoRow("Event Count", stats.ingestion.count.toString())
109+ stats.ingestion?.lifetimeCount == null -> InfoRow("Event Count", "N/A")
110+ }
98− InfoRow("Ingestion Size", formatBytes(stats.ingestion?.size) ?: formatBytes(stats.ingestion?.lifetimeSize) ?: "N/A")
111+ InfoRow("Ingestion Size", formatBytes(stats.ingestion?.size) ?: formatBytes(stats.ingestion?.lifetimeSize) ?: "N/A")
99− InfoRow("Storage Size", formatBytes(stats.storage?.size) ?: formatBytes(stats.storage?.lifetimeSize) ?: "N/A")
112+ InfoRow("Storage Size", formatBytes(stats.storage?.size) ?: formatBytes(stats.storage?.lifetimeSize) ?: "N/A")
100− if (stats.ingestion?.lifetimeCount != null) {
113+ if (stats.ingestion?.lifetimeCount != null) {
101− InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
114+ InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
102− }
115+ }
103− if (stats.ingestion?.deletedCount != null) {
116+ if (stats.ingestion?.deletedCount != null) {
104− InfoRow("Deleted Events", stats.ingestion.deletedCount.toString())
117+ InfoRow("Deleted Events", stats.ingestion.deletedCount.toString())
118+ }
105 }
119 }
106 }
120 }
107− }
108
121
109− // Schema section
122+ // Schema section
110− if (state.schema.isNotEmpty()) {
123+ if (state.schema.isNotEmpty()) {
111− InfoSection(title = "Schema (${state.schema.size} fields)") {
124+ InfoSection(title = "Schema (${state.schema.size} fields)") {
112− state.schema.forEach { field ->
125+ state.schema.forEach { field ->
113− InfoRow(
126+ InfoRow(
114− field.name,
127+ field.name,
115− field.dataType?.toString()?.trim('"') ?: "Unknown",
128+ field.dataType?.toString()?.trim('"') ?: "Unknown",
129+ )
130+ }
131+ }
132+ } else if (state.schemaFailed) {
133+ InfoSection(title = "Schema") {
134+ Text(
135+ text = "Schema unavailable",
136+ style = MaterialTheme.typography.bodyMedium,
137+ color = MaterialTheme.colorScheme.onSurfaceVariant,
116 )
138 )
117 }
139 }
118 }
140 }
119− } else if (state.schemaFailed) {
120− InfoSection(title = "Schema") {
121− Text(
122− text = "Schema unavailable",
123− style = MaterialTheme.typography.bodyMedium,
124− color = MaterialTheme.colorScheme.onSurfaceVariant,
125− )
126− }
127− }
128
141
129− // Retention section
142+ // Retention section
130− if (state.retention.isNotEmpty()) {
143+ if (state.retention.isNotEmpty()) {
131− InfoSection(title = "Retention") {
144+ InfoSection(title = "Retention") {
132− state.retention.forEach { r ->
145+ state.retention.forEach { r ->
133− InfoRow("Duration", r.duration ?: "N/A")
146+ InfoRow("Duration", r.duration ?: "N/A")
134− InfoRow("Action", r.action ?: "N/A")
147+ InfoRow("Action", r.action ?: "N/A")
135− if (r.description != null) {
148+ if (r.description != null) {
136− InfoRow("Description", r.description)
149+ InfoRow("Description", r.description)
150+ }
137 }
151 }
138 }
152 }
139 }
153 }
140− }
141
154
142− // Raw info section
155+ // Raw info section
143− state.rawInfo?.let { rawInfo ->
156+ state.rawInfo?.let { rawInfo ->
144− val prettyInfo = remember(rawInfo) {
157+ val prettyInfo = remember(rawInfo) {
145− prettyJson.encodeToString(JsonObject.serializer(), rawInfo)
158+ prettyJson.encodeToString(JsonObject.serializer(), rawInfo)
146− }
159+ }
147− InfoSection(title = "Stream Info") {
160+ InfoSection(title = "Stream Info") {
148− Text(
161+ Text(
149− text = prettyInfo,
162+ text = prettyInfo,
150− style = MaterialTheme.typography.bodySmall,
163+ style = MaterialTheme.typography.bodySmall,
151− fontFamily = FontFamily.Monospace,
164+ fontFamily = FontFamily.Monospace,
152− )
165+ )
166+ }
153 }
167 }
154− }
155
168
156− state.error?.let { errorText ->
169+ state.error?.let { errorText ->
157− Card(
170+ Card(
158− colors = CardDefaults.cardColors(
171+ colors = CardDefaults.cardColors(
159− containerColor = MaterialTheme.colorScheme.errorContainer,
172+ containerColor = MaterialTheme.colorScheme.errorContainer,
160− ),
173+ ),
161− ) {
174+ ) {
162− Text(
175+ Text(
163− text = errorText,
176+ text = errorText,
164− modifier = Modifier.padding(16.dp),
177+ modifier = Modifier.padding(16.dp),
165− color = MaterialTheme.colorScheme.onErrorContainer,
178+ color = MaterialTheme.colorScheme.onErrorContainer,
166− )
179+ )
180+ }
167 }
181 }
168 }
182 }
169 }
183 }
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoViewModel.kt+59−10
@@ -8,6 +8,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
8import kotlinx.coroutines.CancellationException
8import kotlinx.coroutines.CancellationException
9import kotlinx.coroutines.Job
9import kotlinx.coroutines.Job
10import kotlinx.coroutines.async
10import kotlinx.coroutines.async
11+import kotlinx.coroutines.coroutineScope
11import kotlinx.coroutines.flow.MutableStateFlow
12import kotlinx.coroutines.flow.MutableStateFlow
12import kotlinx.coroutines.flow.StateFlow
13import kotlinx.coroutines.flow.StateFlow
13import kotlinx.coroutines.flow.asStateFlow
14import kotlinx.coroutines.flow.asStateFlow
@@ -38,21 +39,61 @@ class StreamInfoViewModel @Inject constructor(
38 val state: StateFlow<StreamInfoState> = _state.asStateFlow()
39 val state: StateFlow<StreamInfoState> = _state.asStateFlow()
39 private var loadJob: Job? = null
40 private var loadJob: Job? = null
40
41
42+ /** Holds the four parallel load results so coroutineScope can return them with their types intact. */
43+ private data class LoadResults(
44+ val stats: ApiResult<StreamStats>,
45+ val schema: ApiResult<StreamSchema>,
46+ val retention: ApiResult<List<RetentionConfig>>,
47+ val info: ApiResult<JsonObject>,
48+ )
49+
41 fun load(streamName: String) {
50 fun load(streamName: String) {
51+ // Already showing (or loading) this stream — don't cancel and refetch all four
52+ // endpoints on recomposition / config change. Matches LogViewerViewModel.initialize's
53+ // guard; the screen loads once per stream (see StreamInfoScreen's LaunchedEffect).
54+ if (_state.value.streamName == streamName) return
55+ fetch(streamName)
56+ }
57+
58+ /**
59+ * Re-fetch the currently-shown stream, bypassing load()'s identity guard. Wired to
60+ * pull-to-refresh so the user can retry after a transient endpoint failure (e.g. a failed
61+ * schema fetch or a delete that errored) without leaving and re-entering the screen.
62+ */
63+ fun refresh() {
64+ val name = _state.value.streamName
65+ if (name.isEmpty()) return
66+ fetch(name)
67+ }
68+
69+ private fun fetch(streamName: String) {
42 loadJob?.cancel()
70 loadJob?.cancel()
43 _state.update { it.copy(streamName = streamName, isLoading = true, error = null) }
71 _state.update { it.copy(streamName = streamName, isLoading = true, error = null) }
44
72
45 loadJob = viewModelScope.launch {
73 loadJob = viewModelScope.launch {
46 try {
74 try {
75+ // Wrap the parallel fetches in coroutineScope so the four async children
76+ // form their own structured-concurrency scope (matching StreamsViewModel /
77+ // SettingsViewModel). The repository returns ApiResult.Error rather than
78+ // throwing, but if any call did throw unexpectedly this keeps the failure
79+ // contained to this load instead of it propagating oddly through the launch.
80+ val results = coroutineScope {
47− val statsDeferred = async { repository.getStreamStats(streamName) }
81+ val statsDeferred = async { repository.getStreamStats(streamName) }
48− val schemaDeferred = async { repository.getStreamSchema(streamName) }
82+ val schemaDeferred = async { repository.getStreamSchema(streamName) }
49− val retentionDeferred = async { repository.getStreamRetention(streamName) }
83+ val retentionDeferred = async { repository.getStreamRetention(streamName) }
50− val infoDeferred = async { repository.getStreamInfo(streamName) }
84+ val infoDeferred = async { repository.getStreamInfo(streamName) }
51
85
86+ LoadResults(
87+ stats = statsDeferred.await(),
88+ schema = schemaDeferred.await(),
89+ retention = retentionDeferred.await(),
90+ info = infoDeferred.await(),
91+ )
92+ }
52− val statsResult = statsDeferred.await()
93+ val statsResult = results.stats
53− val schemaResult = schemaDeferred.await()
94+ val schemaResult = results.schema
54− val retentionResult = retentionDeferred.await()
95+ val retentionResult = results.retention
55− val infoResult = infoDeferred.await()
96+ val infoResult = results.info
56
97
57 _state.update {
98 _state.update {
58 it.copy(
99 it.copy(
@@ -86,9 +127,17 @@ class StreamInfoViewModel @Inject constructor(
86
127
87 fun deleteStream() {
128 fun deleteStream() {
88 val name = _state.value.streamName
129 val name = _state.value.streamName
130+ // Guard against a no-op or a second tap firing a duplicate DELETE while one is in flight.
89− if (name.isEmpty()) return
131+ if (name.isEmpty() || _state.value.isDeleting) return
132+ // Cancel any in-flight load so its terminal update can't re-populate stats/schema for the
133+ // stream we're about to delete after the DELETE completes.
134+ loadJob?.cancel()
90 viewModelScope.launch {
135 viewModelScope.launch {
136+ // Clear isLoading here too: cancelling loadJob above kills any in-flight fetch before
137+ // its terminal update can flip isLoading back to false, so without this a load that was
138+ // running when delete started would leave the pull-to-refresh spinner stuck on the
139+ // error path (delete only manages isDeleting).
91− _state.update { it.copy(isDeleting = true, error = null) }
140+ _state.update { it.copy(isDeleting = true, isLoading = false, error = null) }
92 when (val result = repository.deleteStream(name)) {
141 when (val result = repository.deleteStream(name)) {
93 is ApiResult.Success -> {
142 is ApiResult.Success -> {
94 _state.update { it.copy(isDeleting = false, deleteSuccess = true) }
143 _state.update { it.copy(isDeleting = false, deleteSuccess = true) }
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsScreen.kt+193−2
@@ -12,10 +12,12 @@ import androidx.compose.runtime.*
12import androidx.compose.ui.Alignment
12import androidx.compose.ui.Alignment
13import androidx.compose.ui.Modifier
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.text.font.FontWeight
14import androidx.compose.ui.text.font.FontWeight
15+import androidx.compose.ui.text.style.TextOverflow
15import androidx.compose.ui.unit.dp
16import androidx.compose.ui.unit.dp
16import androidx.hilt.navigation.compose.hiltViewModel
17import androidx.hilt.navigation.compose.hiltViewModel
17import androidx.lifecycle.compose.collectAsStateWithLifecycle
18import androidx.lifecycle.compose.collectAsStateWithLifecycle
18import com.parseable.android.BuildConfig
19import com.parseable.android.BuildConfig
20+import com.parseable.android.data.local.SavedServer
19import kotlinx.serialization.json.contentOrNull
21import kotlinx.serialization.json.contentOrNull
20import kotlinx.serialization.json.jsonPrimitive
22import kotlinx.serialization.json.jsonPrimitive
21
23
@@ -23,10 +25,25 @@ import kotlinx.serialization.json.jsonPrimitive
23@Composable
25@Composable
24fun SettingsScreen(
26fun SettingsScreen(
25 onBack: () -> Unit,
27 onBack: () -> Unit,
28+ onAboutClick: () -> Unit,
29+ onServerSwitched: () -> Unit,
30+ onLoggedOut: () -> Unit,
26 viewModel: SettingsViewModel = hiltViewModel(),
31 viewModel: SettingsViewModel = hiltViewModel(),
27) {
32) {
28 val state by viewModel.state.collectAsStateWithLifecycle()
33 val state by viewModel.state.collectAsStateWithLifecycle()
29
34
35+ LaunchedEffect(Unit) {
36+ viewModel.switchEvent.collect {
37+ onServerSwitched()
38+ }
39+ }
40+
41+ LaunchedEffect(Unit) {
42+ viewModel.loggedOutEvent.collect {
43+ onLoggedOut()
44+ }
45+ }
46+
30 Scaffold(
47 Scaffold(
31 topBar = {
48 topBar = {
32 TopAppBar(
49 TopAppBar(
@@ -79,8 +96,21 @@ fun SettingsScreen(
79 }
96 }
80 }
97 }
81
98
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+
82− // Loading indicator for server data
110+ // Loading indicator for server data — only on the initial load. During a
111+ // pull-to-refresh the PullToRefreshBox already shows its own spinner and
112+ // aboutInfo is still populated, so gating on isLoading alone double-spins.
83− if (state.isLoading) {
113+ if (state.isLoading && state.aboutInfo == null) {
84 Box(
114 Box(
85 modifier = Modifier
115 modifier = Modifier
86 .fillMaxWidth()
116 .fillMaxWidth()
@@ -218,6 +248,167 @@ fun SettingsScreen(
218 SettingsRow("Platform", "Android")
248 SettingsRow("Platform", "Android")
219 }
249 }
220 }
250 }
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+ )
221 }
412 }
222 }
413 }
223 }
414 }
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsViewModel.kt+126−8
@@ -2,18 +2,22 @@ package com.parseable.android.ui.screens.settings
2
2
3import androidx.lifecycle.ViewModel
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5+import com.parseable.android.data.local.SavedServer
5import com.parseable.android.data.model.AboutInfo
6import com.parseable.android.data.model.AboutInfo
6import com.parseable.android.data.model.ApiResult
7import com.parseable.android.data.model.ApiResult
7import com.parseable.android.data.repository.ParseableRepository
8import com.parseable.android.data.repository.ParseableRepository
8import com.parseable.android.data.repository.SettingsRepository
9import com.parseable.android.data.repository.SettingsRepository
9import dagger.hilt.android.lifecycle.HiltViewModel
10import dagger.hilt.android.lifecycle.HiltViewModel
10import kotlinx.coroutines.CancellationException
11import kotlinx.coroutines.CancellationException
12+import kotlinx.coroutines.Job
11import kotlinx.coroutines.async
13import kotlinx.coroutines.async
14+import kotlinx.coroutines.channels.Channel
12import kotlinx.coroutines.coroutineScope
15import kotlinx.coroutines.coroutineScope
13import kotlinx.coroutines.flow.MutableStateFlow
16import kotlinx.coroutines.flow.MutableStateFlow
14import kotlinx.coroutines.flow.StateFlow
17import kotlinx.coroutines.flow.StateFlow
15import kotlinx.coroutines.flow.asStateFlow
18import kotlinx.coroutines.flow.asStateFlow
16import kotlinx.coroutines.flow.first
19import kotlinx.coroutines.flow.first
20+import kotlinx.coroutines.flow.receiveAsFlow
17import kotlinx.coroutines.flow.update
21import kotlinx.coroutines.flow.update
18import kotlinx.coroutines.launch
22import kotlinx.coroutines.launch
19import kotlinx.serialization.json.jsonPrimitive
23import kotlinx.serialization.json.jsonPrimitive
@@ -27,6 +31,10 @@ data class SettingsState(
27 val users: List<String> = emptyList(),
31 val users: List<String> = emptyList(),
28 val isLoading: Boolean = false,
32 val isLoading: Boolean = false,
29 val error: String? = null,
33 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,
30)
38)
31
39
32@HiltViewModel
40@HiltViewModel
@@ -38,12 +46,44 @@ class SettingsViewModel @Inject constructor(
38 private val _state = MutableStateFlow(SettingsState())
46 private val _state = MutableStateFlow(SettingsState())
39 val state: StateFlow<SettingsState> = _state.asStateFlow()
47 val state: StateFlow<SettingsState> = _state.asStateFlow()
40
48
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+
41 init {
69 init {
42 load()
70 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+ }
43 }
81 }
44
82
45 fun load() {
83 fun load() {
84+ // Cancel any in-flight load so a slower completion can't overwrite a newer one.
85+ loadJob?.cancel()
46− viewModelScope.launch {
86+ loadJob = viewModelScope.launch {
47 _state.update { it.copy(isLoading = true, error = null) }
87 _state.update { it.copy(isLoading = true, error = null) }
48
88
49 val config = settingsRepository.serverConfig.first()
89 val config = settingsRepository.serverConfig.first()
@@ -66,24 +106,24 @@ class SettingsViewModel @Inject constructor(
66
106
67 val userNames = (usersResult as? ApiResult.Success)?.data?.mapNotNull { obj ->
107 val userNames = (usersResult as? ApiResult.Success)?.data?.mapNotNull { obj ->
68 try {
108 try {
69− obj["id"]?.jsonPrimitive?.content
109+ obj["username"]?.jsonPrimitive?.content
70− ?: obj["username"]?.jsonPrimitive?.content
110+ ?: obj["id"]?.jsonPrimitive?.content
71 } catch (_: IllegalStateException) {
111 } catch (_: IllegalStateException) {
72 null
112 null
73 }
113 }
74 } ?: emptyList()
114 } ?: emptyList()
75
115
76− val errors = listOfNotNull(
116+ // Only treat a failure of the primary server-info call (/about) as a blocking
117+ // error. The user list (/user) is secondary and is commonly forbidden for
118+ // restricted accounts — failing it shouldn't hide otherwise-valid server info.
77− (aboutResult as? ApiResult.Error)?.userMessage,
119+ val error = (aboutResult as? ApiResult.Error)?.userMessage
78− (usersResult as? ApiResult.Error)?.userMessage,
79− ).distinct().joinToString("\n").ifEmpty { null }
80
120
81 _state.update {
121 _state.update {
82 it.copy(
122 it.copy(
83 aboutInfo = (aboutResult as? ApiResult.Success)?.data,
123 aboutInfo = (aboutResult as? ApiResult.Success)?.data,
84 users = userNames,
124 users = userNames,
85 isLoading = false,
125 isLoading = false,
86− error = errors,
126+ error = error,
87 )
127 )
88 }
128 }
89 } catch (e: CancellationException) {
129 } catch (e: CancellationException) {
@@ -96,4 +136,82 @@ class SettingsViewModel @Inject constructor(
96 }
136 }
97 }
137 }
98 }
138 }
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+ }
99}
217}
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsScreen.kt+29−13
@@ -3,21 +3,20 @@ package com.parseable.android.ui.screens.streams
3import androidx.compose.foundation.layout.*
3import androidx.compose.foundation.layout.*
4import androidx.compose.foundation.lazy.LazyColumn
4import androidx.compose.foundation.lazy.LazyColumn
5import androidx.compose.foundation.lazy.items
5import androidx.compose.foundation.lazy.items
6+import androidx.compose.foundation.lazy.rememberLazyListState
6import androidx.compose.material.icons.Icons
7import androidx.compose.material.icons.Icons
7import androidx.compose.material.icons.automirrored.filled.Logout
8import androidx.compose.material.icons.automirrored.filled.Logout
8import androidx.compose.material.icons.filled.*
9import androidx.compose.material.icons.filled.*
9import androidx.compose.material3.*
10import androidx.compose.material3.*
10import androidx.compose.material3.pulltorefresh.PullToRefreshBox
11import androidx.compose.material3.pulltorefresh.PullToRefreshBox
11import androidx.compose.runtime.*
12import androidx.compose.runtime.*
13+import androidx.compose.runtime.saveable.rememberSaveable
12import androidx.compose.ui.Alignment
14import androidx.compose.ui.Alignment
13import androidx.compose.ui.Modifier
15import androidx.compose.ui.Modifier
14−import androidx.lifecycle.compose.LocalLifecycleOwner
15import androidx.compose.ui.text.font.FontWeight
16import androidx.compose.ui.text.font.FontWeight
16import androidx.compose.ui.unit.dp
17import androidx.compose.ui.unit.dp
17import androidx.hilt.navigation.compose.hiltViewModel
18import androidx.hilt.navigation.compose.hiltViewModel
18−import androidx.lifecycle.Lifecycle
19import androidx.lifecycle.compose.collectAsStateWithLifecycle
19import androidx.lifecycle.compose.collectAsStateWithLifecycle
20−import androidx.lifecycle.repeatOnLifecycle
21import kotlinx.coroutines.flow.collectLatest
20import kotlinx.coroutines.flow.collectLatest
22import kotlinx.coroutines.launch
21import kotlinx.coroutines.launch
23
22
@@ -33,9 +32,23 @@ fun StreamsScreen(
33 val state by viewModel.state.collectAsStateWithLifecycle()
32 val state by viewModel.state.collectAsStateWithLifecycle()
34 val scope = rememberCoroutineScope()
33 val scope = rememberCoroutineScope()
35 var showLogoutConfirmation by remember { mutableStateOf(false) }
34 var showLogoutConfirmation by remember { mutableStateOf(false) }
36− var searchQuery by remember { mutableStateOf("") }
35+ var searchQuery by rememberSaveable { mutableStateOf("") }
37 val snackbarHostState = remember { SnackbarHostState() }
36 val snackbarHostState = remember { SnackbarHostState() }
38
37
38+ val listState = rememberLazyListState()
39+
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) }
45+ LaunchedEffect(state.isLoading) {
46+ if (wasLoading && !state.isLoading) {
47+ listState.scrollToItem(0)
48+ }
49+ wasLoading = state.isLoading
50+ }
51+
39 val filteredStreams = remember(state.streams, searchQuery, state.favoriteNames) {
52 val filteredStreams = remember(state.streams, searchQuery, state.favoriteNames) {
40 val favorites = state.favoriteNames
53 val favorites = state.favoriteNames
41 val filtered = if (searchQuery.isBlank()) {
54 val filtered = if (searchQuery.isBlank()) {
@@ -45,14 +58,11 @@ fun StreamsScreen(
45 }
58 }
46 // Partition by favorites instead of full sort for O(n) vs O(n log n)
59 // Partition by favorites instead of full sort for O(n) vs O(n log n)
47 val (favs, rest) = filtered.partition { it.name in favorites }
60 val (favs, rest) = filtered.partition { it.name in favorites }
48− favs + rest
61+ // `favorites` iterates most-recently-favorited first (DAO ORDER BY addedAt DESC
49− }
62+ // preserved through LinkedHashSet); order favs to match so recency is reflected.
50−
63+ val favRank = favorites.withIndex().associate { (i, name) -> name to i }
51− val lifecycleOwner = LocalLifecycleOwner.current
64+ val sortedFavs = favs.sortedBy { favRank[it.name] ?: Int.MAX_VALUE }
52− LaunchedEffect(lifecycleOwner) {
65+ sortedFavs + rest
53− lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
54− viewModel.refresh()
55− }
56 }
66 }
57
67
58 LaunchedEffect(Unit) {
68 LaunchedEffect(Unit) {
@@ -147,7 +157,12 @@ fun StreamsScreen(
147 }
157 }
148 } else {
158 } else {
149 Column {
159 Column {
160+ // Keep the search field visible whenever a query is active, even if the
161+ // total stream count has since dropped to <=5 (e.g. after a refresh or a
162+ // server-side deletion). Otherwise the field would vanish while the stale
163+ // query keeps filtering `filteredStreams`, stranding the user with no way
164+ // to clear it.
150− if (state.streams.size > 5) {
165+ if (state.streams.size > 5 || searchQuery.isNotEmpty()) {
151 OutlinedTextField(
166 OutlinedTextField(
152 value = searchQuery,
167 value = searchQuery,
153 onValueChange = { searchQuery = it },
168 onValueChange = { searchQuery = it },
@@ -169,6 +184,7 @@ fun StreamsScreen(
169 )
184 )
170 }
185 }
171 LazyColumn(
186 LazyColumn(
187+ state = listState,
172 contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
188 contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
173 verticalArrangement = Arrangement.spacedBy(8.dp),
189 verticalArrangement = Arrangement.spacedBy(8.dp),
174 modifier = Modifier.weight(1f),
190 modifier = Modifier.weight(1f),
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsViewModel.kt+53−11
@@ -22,7 +22,9 @@ import kotlinx.coroutines.flow.asStateFlow
22import kotlinx.coroutines.flow.receiveAsFlow
22import kotlinx.coroutines.flow.receiveAsFlow
23import kotlinx.coroutines.flow.update
23import kotlinx.coroutines.flow.update
24import kotlinx.coroutines.launch
24import kotlinx.coroutines.launch
25+import kotlinx.coroutines.sync.Mutex
25import kotlinx.coroutines.sync.Semaphore
26import kotlinx.coroutines.sync.Semaphore
27+import kotlinx.coroutines.sync.withLock
26import kotlinx.coroutines.sync.withPermit
28import kotlinx.coroutines.sync.withPermit
27import javax.inject.Inject
29import javax.inject.Inject
28
30
@@ -61,22 +63,40 @@ class StreamsViewModel @Inject constructor(
61 _state.update { it.copy(favoriteNames = names.toSet()) }
63 _state.update { it.copy(favoriteNames = names.toSet()) }
62 }
64 }
63 }
65 }
66+ // Load once when the ViewModel is created. The screen no longer refreshes on every
67+ // RESUME, so returning via back-navigation reuses the already-loaded data.
68+ refresh()
64 }
69 }
65
70
71+ // Serializes toggles so two rapid taps can't both read the same pre-update state and
72+ // take the same branch (which, with idempotent DAO ops, would leave the favorite in the
73+ // wrong final state — e.g. tap-twice nets to "favorited" instead of cancelling out).
74+ private val favoriteMutex = Mutex()
75+
66 fun toggleFavorite(streamName: String) {
76 fun toggleFavorite(streamName: String) {
67 viewModelScope.launch {
77 viewModelScope.launch {
68− if (streamName in _state.value.favoriteNames) {
78+ favoriteMutex.withLock {
79+ // Decide from the committed DB state, not the async-mirrored favoriteNames.
80+ if (favoriteDao.isFavoriteNow(streamName)) {
69− favoriteDao.deleteByName(streamName)
81+ favoriteDao.deleteByName(streamName)
70− _snackbarEvent.send("Removed from favorites")
82+ _snackbarEvent.send("Removed from favorites")
71− } else {
83+ } else {
72− favoriteDao.insert(FavoriteStream(streamName = streamName))
84+ favoriteDao.insert(FavoriteStream(streamName = streamName))
73− _snackbarEvent.send("Added to favorites")
85+ _snackbarEvent.send("Added to favorites")
86+ }
74 }
87 }
75 }
88 }
76 }
89 }
77
90
91+ private var refreshJob: Job? = null
92+
78 fun refresh() {
93 fun refresh() {
94+ // Cancel any in-flight refresh so out-of-order completions can't overwrite a newer
95+ // result with a stale one (the init load and a pull-to-refresh, or two quick pulls,
96+ // can otherwise race and leave the older stream list showing). Mirrors the refreshJob
97+ // guard in LogViewerViewModel.
98+ refreshJob?.cancel()
79− viewModelScope.launch {
99+ refreshJob = viewModelScope.launch {
80 _state.update { it.copy(isLoading = true, error = null) }
100 _state.update { it.copy(isLoading = true, error = null) }
81
101
82 try {
102 try {
@@ -93,9 +113,14 @@ class StreamsViewModel @Inject constructor(
93
113
94 when (streamsResult) {
114 when (streamsResult) {
95 is ApiResult.Success -> {
115 is ApiResult.Success -> {
116+ // Prune cached stats/failures for streams that no longer exist so
117+ // deleted or recreated streams don't show stale numbers.
118+ val names = streamsResult.data.mapTo(HashSet()) { it.name }
96 _state.update {
119 _state.update {
97 it.copy(
120 it.copy(
98− streams = streamsResult.data,
121+ streams = streamsResult.data.sortedBy { s -> s.name.lowercase() },
122+ streamStats = it.streamStats.filterKeys { name -> name in names },
123+ failedStats = it.failedStats.filterTo(mutableSetOf()) { name -> name in names },
99 isLoading = false,
124 isLoading = false,
100 )
125 )
101 }
126 }
@@ -124,9 +149,14 @@ class StreamsViewModel @Inject constructor(
124
149
125 private val statsSemaphore = Semaphore(8)
150 private val statsSemaphore = Semaphore(8)
126 private var statsJob: Job? = null
151 private var statsJob: Job? = null
152+ // Per-stream retry jobs, tracked so a new bulk refresh can cancel any in-flight
153+ // retry — otherwise a late retry result could overwrite freshly-loaded stats.
154+ private val retryJobs = mutableMapOf<String, Job>()
127
155
128 private fun loadStreamStats(streams: List<LogStream>) {
156 private fun loadStreamStats(streams: List<LogStream>) {
129 statsJob?.cancel()
157 statsJob?.cancel()
158+ retryJobs.values.forEach { it.cancel() }
159+ retryJobs.clear()
130 statsJob = viewModelScope.launch {
160 statsJob = viewModelScope.launch {
131 streams.forEach { stream ->
161 streams.forEach { stream ->
132 launch {
162 launch {
@@ -139,12 +169,20 @@ class StreamsViewModel @Inject constructor(
139 }
169 }
140
170
141 fun retryStats(streamName: String) {
171 fun retryStats(streamName: String) {
172+ retryJobs[streamName]?.cancel()
142− viewModelScope.launch {
173+ val job = viewModelScope.launch {
143 _state.update { it.copy(failedStats = it.failedStats - streamName) }
174 _state.update { it.copy(failedStats = it.failedStats - streamName) }
144 statsSemaphore.withPermit {
175 statsSemaphore.withPermit {
145 loadSingleStreamStats(streamName)
176 loadSingleStreamStats(streamName)
146 }
177 }
147 }
178 }
179+ retryJobs[streamName] = job
180+ // Drop the entry once the retry finishes so the map doesn't accumulate completed
181+ // jobs for the ViewModel's lifetime. Guard with an identity check so we never remove
182+ // a newer retry that has since replaced this one for the same stream.
183+ job.invokeOnCompletion {
184+ if (retryJobs[streamName] === job) retryJobs.remove(streamName)
185+ }
148 }
186 }
149
187
150 private suspend fun loadSingleStreamStats(streamName: String) {
188 private suspend fun loadSingleStreamStats(streamName: String) {
@@ -160,7 +198,10 @@ class StreamsViewModel @Inject constructor(
160 ?: formatBytes(stats.storage?.lifetimeSize),
198 ?: formatBytes(stats.storage?.lifetimeSize),
161 )
199 )
162 _state.update {
200 _state.update {
201+ // Drop late results for streams a concurrent refresh has already pruned,
202+ // so an in-flight retry can't resurrect stats for a deleted stream.
203+ if (it.streams.none { s -> s.name == streamName }) it
163− it.copy(
204+ else it.copy(
164 streamStats = it.streamStats + (streamName to ui),
205 streamStats = it.streamStats + (streamName to ui),
165 failedStats = it.failedStats - streamName,
206 failedStats = it.failedStats - streamName,
166 )
207 )
@@ -168,7 +209,8 @@ class StreamsViewModel @Inject constructor(
168 }
209 }
169 is ApiResult.Error -> {
210 is ApiResult.Error -> {
170 _state.update {
211 _state.update {
212+ if (it.streams.none { s -> s.name == streamName }) it
171− it.copy(failedStats = it.failedStats + streamName)
213+ else it.copy(failedStats = it.failedStats + streamName)
172 }
214 }
173 }
215 }
174 }
216 }
app/src/main/res/drawable/ic_launcher_background.xmldeleted−10
@@ -1,10 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<vector xmlns:android="http://schemas.android.com/apk/res/android"
3− android:width="108dp"
4− android:height="108dp"
5− android:viewportWidth="108"
6− android:viewportHeight="108">
7− <path
8− android:fillColor="#545BEB"
9− android:pathData="M0,0h108v108h-108z" />
10−</vector>
app/src/main/res/drawable/ic_launcher_foreground.xmldeleted−15
@@ -1,15 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<vector xmlns:android="http://schemas.android.com/apk/res/android"
3− android:width="108dp"
4− android:height="108dp"
5− android:viewportWidth="108"
6− android:viewportHeight="108">
7− <!-- Background circle -->
8− <path
9− android:fillColor="#545BEB"
10− android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0" />
11− <!-- Letter P -->
12− <path
13− android:fillColor="#FFFFFF"
14− android:pathData="M40,35h12c6.6,0 12,5.4 12,12s-5.4,12 -12,12h-6v14h-6V35zM46,53h6c3.3,0 6,-2.7 6,-6s-2.7,-6 -6,-6h-6V53z" />
15−</vector>
app/src/main/res/drawable/ic_launcher_monochrome.xmldeleted−11
@@ -1,11 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<vector xmlns:android="http://schemas.android.com/apk/res/android"
3− android:width="108dp"
4− android:height="108dp"
5− android:viewportWidth="108"
6− android:viewportHeight="108">
7− <!-- Letter P -->
8− <path
9− android:fillColor="#000000"
10− android:pathData="M40,35h12c6.6,0 12,5.4 12,12s-5.4,12 -12,12h-6v14h-6V35zM46,53h6c3.3,0 6,-2.7 6,-6s-2.7,-6 -6,-6h-6V53z" />
11−</vector>
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xmldeleted−6
@@ -1,6 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3− <background android:drawable="@drawable/ic_launcher_background" />
4− <foreground android:drawable="@drawable/ic_launcher_foreground" />
5− <monochrome android:drawable="@drawable/ic_launcher_monochrome" />
6−</adaptive-icon>
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xmldeleted−6
@@ -1,6 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3− <background android:drawable="@drawable/ic_launcher_background" />
4− <foreground android:drawable="@drawable/ic_launcher_foreground" />
5− <monochrome android:drawable="@drawable/ic_launcher_monochrome" />
6−</adaptive-icon>
app/src/main/res/mipmap-hdpi/ic_launcher.webp

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

app/src/test/java/com/parseable/android/AlertsViewModelTest.kt+4
@@ -24,6 +24,10 @@ class AlertsViewModelTest {
24 fun setup() {
24 fun setup() {
25 Dispatchers.setMain(testDispatcher)
25 Dispatchers.setMain(testDispatcher)
26 repository = mockk(relaxed = true)
26 repository = mockk(relaxed = true)
27+ // The ViewModel loads once in init { refresh() }. Give that load a deterministic
28+ // empty-success default so the initial state is clean; tests that exercise refresh
29+ // override this stub and call refresh() again.
30+ coEvery { repository.listAlerts() } returns ApiResult.Success(emptyList())
27 viewModel = AlertsViewModel(repository)
31 viewModel = AlertsViewModel(repository)
28 }
32 }
29
33
app/src/test/java/com/parseable/android/EscapeSqlTest.kt+27
@@ -1,6 +1,7 @@
1package com.parseable.android
1package com.parseable.android
2
2
3import com.parseable.android.data.escapeIdentifier
3import com.parseable.android.data.escapeIdentifier
4+import com.parseable.android.data.escapeLikePattern
4import com.parseable.android.data.escapeSql
5import com.parseable.android.data.escapeSql
5import org.junit.Assert.assertEquals
6import org.junit.Assert.assertEquals
6import org.junit.Test
7import org.junit.Test
@@ -52,6 +53,32 @@ class EscapeSqlTest {
52 assertEquals("''; DROP TABLE users --", escapeSql("'; DROP TABLE users --"))
53 assertEquals("''; DROP TABLE users --", escapeSql("'; DROP TABLE users --"))
53 }
54 }
54
55
56+ @Test
57+ fun `escapeLikePattern escapes percent wildcard`() {
58+ assertEquals("100\\%", escapeLikePattern("100%"))
59+ }
60+
61+ @Test
62+ fun `escapeLikePattern escapes underscore wildcard`() {
63+ assertEquals("a\\_b", escapeLikePattern("a_b"))
64+ }
65+
66+ @Test
67+ fun `escapeLikePattern escapes backslash before wildcards`() {
68+ // Input backslash becomes a doubled backslash; %/_ each gain one escape.
69+ assertEquals("\\\\\\%\\_", escapeLikePattern("\\%_"))
70+ }
71+
72+ @Test
73+ fun `escapeLikePattern also escapes single quotes`() {
74+ assertEquals("it''s 50\\%", escapeLikePattern("it's 50%"))
75+ }
76+
77+ @Test
78+ fun `escapeLikePattern leaves plain text untouched`() {
79+ assertEquals("hello world", escapeLikePattern("hello world"))
80+ }
81+
55 @Test
82 @Test
56 fun `escapeIdentifier doubles double quotes`() {
83 fun `escapeIdentifier doubles double quotes`() {
57 assertEquals("foo\"\"bar", escapeIdentifier("foo\"bar"))
84 assertEquals("foo\"\"bar", escapeIdentifier("foo\"bar"))
app/src/test/java/com/parseable/android/FormatTimestampTest.kt+14
@@ -32,6 +32,20 @@ class FormatTimestampTest {
32 }
32 }
33 }
33 }
34
34
35+ @Test
36+ fun `treats offset-less timestamp as UTC and converts to local`() {
37+ val original = TimeZone.getDefault()
38+ try {
39+ TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"))
40+ // No offset/zone: should be interpreted as UTC (Parseable stores UTC),
41+ // then converted to local (June = EDT = UTC-4).
42+ val result = formatTimestamp("2024-06-15T18:30:45.000")
43+ assertEquals("Jun 15 14:30:45.000", result)
44+ } finally {
45+ TimeZone.setDefault(original)
46+ }
47+ }
48+
35 @Test
49 @Test
36 fun `returns raw string for unparseable timestamp`() {
50 fun `returns raw string for unparseable timestamp`() {
37 val result = formatTimestamp("not-a-timestamp")
51 val result = formatTimestamp("not-a-timestamp")
app/src/test/java/com/parseable/android/LogViewerViewModelTest.kt+57−3
@@ -164,9 +164,9 @@ class LogViewerViewModelTest {
164 startTime = any(),
164 startTime = any(),
165 endTime = any(),
165 endTime = any(),
166 filterSql = match {
166 filterSql = match {
167− it.contains("\"message\" ILIKE '%error%'") &&
167+ it.contains("CAST(\"message\" AS VARCHAR) ILIKE '%error%'") &&
168− it.contains("\"level\" ILIKE '%error%'") &&
168+ it.contains("CAST(\"level\" AS VARCHAR) ILIKE '%error%'") &&
169− !it.contains("p_timestamp ILIKE") // Internal columns excluded
169+ !it.contains("p_timestamp") // Internal columns excluded
170 },
170 },
171 limit = any(),
171 limit = any(),
172 )
172 )
@@ -185,6 +185,24 @@ class LogViewerViewModelTest {
185 assertEquals(500, state.currentLimit) // Reset
185 assertEquals(500, state.currentLimit) // Reset
186 }
186 }
187
187
188+ @Test
189+ fun `pullRefresh is a no-op while streaming`() {
190+ viewModel.initialize("test")
191+ // Page out so currentLimit is no longer at its initial 500.
192+ viewModel.loadMore()
193+ assertEquals(1000, viewModel.state.value.currentLimit)
194+
195+ viewModel.toggleStreaming()
196+ assertTrue(viewModel.state.value.isStreaming)
197+
198+ // While live-tailing, pullRefresh must not run refresh() (which would reset the limit
199+ // to 500 and race the streaming poller). Streaming stays on and the limit is untouched.
200+ viewModel.pullRefresh()
201+
202+ assertTrue(viewModel.state.value.isStreaming)
203+ assertEquals(1000, viewModel.state.value.currentLimit)
204+ }
205+
188 @Test
206 @Test
189 fun `toggleStreaming starts and stops`() {
207 fun `toggleStreaming starts and stops`() {
190 viewModel.initialize("test")
208 viewModel.initialize("test")
@@ -256,6 +274,42 @@ class LogViewerViewModelTest {
256 }
274 }
257 }
275 }
258
276
277+ @Test
278+ fun `executeCustomSql strips trailing semicolon before appending LIMIT`() {
279+ coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
280+
281+ viewModel.initialize("test")
282+ // Without stripping the ';', the appended LIMIT would land after the terminator
283+ // ("...test_stream; LIMIT 5000") and produce invalid SQL.
284+ viewModel.executeCustomSql("SELECT * FROM test_stream;")
285+
286+ coVerify {
287+ repository.queryLogsRaw(
288+ match { it.trim() == "SELECT * FROM test_stream LIMIT 5000" },
289+ any(),
290+ any(),
291+ )
292+ }
293+ }
294+
295+ @Test
296+ fun `executeCustomSql adds outer LIMIT when only a subquery has one`() {
297+ coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
298+
299+ viewModel.initialize("test")
300+ // The inner LIMIT doesn't bound the outer result set, so the safety LIMIT must still
301+ // be appended.
302+ viewModel.executeCustomSql("SELECT * FROM (SELECT * FROM test_stream LIMIT 10) t")
303+
304+ coVerify {
305+ repository.queryLogsRaw(
306+ match { it.contains("LIMIT 10") && it.trimEnd().endsWith("LIMIT 5000") },
307+ any(),
308+ any(),
309+ )
310+ }
311+ }
312+
259 @Test
313 @Test
260 fun `addFilter escapes column names`() {
314 fun `addFilter escapes column names`() {
261 viewModel.initialize("test")
315 viewModel.initialize("test")
app/src/test/java/com/parseable/android/LoginViewModelTest.kt+28−4
@@ -9,6 +9,8 @@ import io.mockk.*
9import kotlinx.coroutines.Dispatchers
9import kotlinx.coroutines.Dispatchers
10import kotlinx.coroutines.ExperimentalCoroutinesApi
10import kotlinx.coroutines.ExperimentalCoroutinesApi
11import kotlinx.coroutines.flow.flowOf
11import kotlinx.coroutines.flow.flowOf
12+import kotlinx.coroutines.flow.toList
13+import kotlinx.coroutines.launch
12import kotlinx.coroutines.test.*
14import kotlinx.coroutines.test.*
13import org.junit.After
15import org.junit.After
14import org.junit.Assert.*
16import org.junit.Assert.*
@@ -37,6 +39,17 @@ class LoginViewModelTest {
37 Dispatchers.resetMain()
39 Dispatchers.resetMain()
38 }
40 }
39
41
42+ /**
43+ * Start collecting the one-shot login-success events into a list (on the eager
44+ * UnconfinedTestDispatcher) before the action under test, so the success emission is observed.
45+ * The collector lives in backgroundScope, which runTest auto-cancels.
46+ */
47+ private fun TestScope.collectSuccessEvents(): MutableList<Unit> {
48+ val events = mutableListOf<Unit>()
49+ backgroundScope.launch(testDispatcher) { viewModel.loginSuccessEvent.toList(events) }
50+ return events
51+ }
52+
40 @Test
53 @Test
41 fun `onLogin validates required fields`() {
54 fun `onLogin validates required fields`() {
42 viewModel.onLogin()
55 viewModel.onLogin()
@@ -65,6 +78,11 @@ class LoginViewModelTest {
65 fun `onLogin succeeds with valid credentials`() = runTest {
78 fun `onLogin succeeds with valid credentials`() = runTest {
66 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
79 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
67 coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo(version = "1.0"))
80 coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo(version = "1.0"))
81+ // Reach the success branch deterministically: the login-success event only fires when the
82+ // credentials were durably saved.
83+ coEvery { settingsRepository.saveServerConfig(any()) } returns true
84+
85+ val events = collectSuccessEvents()
68
86
69 viewModel.onServerUrlChange("https://test.parseable.com")
87 viewModel.onServerUrlChange("https://test.parseable.com")
70 viewModel.onUsernameChange("admin")
88 viewModel.onUsernameChange("admin")
@@ -72,7 +90,7 @@ class LoginViewModelTest {
72 viewModel.onLogin()
90 viewModel.onLogin()
73
91
74 val state = viewModel.state.value
92 val state = viewModel.state.value
75− assertTrue(state.loginSuccess)
93+ assertEquals(1, events.size)
76 assertFalse(state.isLoading)
94 assertFalse(state.isLoading)
77 coVerify { settingsRepository.saveServerConfig(any()) }
95 coVerify { settingsRepository.saveServerConfig(any()) }
78 }
96 }
@@ -81,13 +99,15 @@ class LoginViewModelTest {
81 fun `onLogin fails when connection fails`() = runTest {
99 fun `onLogin fails when connection fails`() = runTest {
82 coEvery { repository.testConnection() } returns ApiResult.Error("Connection refused", 0)
100 coEvery { repository.testConnection() } returns ApiResult.Error("Connection refused", 0)
83
101
102+ val events = collectSuccessEvents()
103+
84 viewModel.onServerUrlChange("https://bad.server")
104 viewModel.onServerUrlChange("https://bad.server")
85 viewModel.onUsernameChange("admin")
105 viewModel.onUsernameChange("admin")
86 viewModel.onPasswordChange("password")
106 viewModel.onPasswordChange("password")
87 viewModel.onLogin()
107 viewModel.onLogin()
88
108
89 val state = viewModel.state.value
109 val state = viewModel.state.value
90− assertFalse(state.loginSuccess)
110+ assertEquals(0, events.size)
91 assertNotNull(state.error)
111 assertNotNull(state.error)
92 assertTrue(state.error!!.contains("Connection failed"))
112 assertTrue(state.error!!.contains("Connection failed"))
93 }
113 }
@@ -97,13 +117,15 @@ class LoginViewModelTest {
97 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
117 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
98 coEvery { repository.verifyServer() } returns ApiResult.Error("not parseable", 404)
118 coEvery { repository.verifyServer() } returns ApiResult.Error("not parseable", 404)
99
119
120+ val events = collectSuccessEvents()
121+
100 viewModel.onServerUrlChange("https://test.com")
122 viewModel.onServerUrlChange("https://test.com")
101 viewModel.onUsernameChange("admin")
123 viewModel.onUsernameChange("admin")
102 viewModel.onPasswordChange("password")
124 viewModel.onPasswordChange("password")
103 viewModel.onLogin()
125 viewModel.onLogin()
104
126
105 val state = viewModel.state.value
127 val state = viewModel.state.value
106− assertFalse(state.loginSuccess)
128+ assertEquals(0, events.size)
107 assertNotNull(state.error)
129 assertNotNull(state.error)
108 assertTrue(state.error!!.contains("doesn't appear to be Parseable"))
130 assertTrue(state.error!!.contains("doesn't appear to be Parseable"))
109 }
131 }
@@ -113,13 +135,15 @@ class LoginViewModelTest {
113 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
135 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
114 coEvery { repository.verifyServer() } returns ApiResult.Error("Unauthorized", 401)
136 coEvery { repository.verifyServer() } returns ApiResult.Error("Unauthorized", 401)
115
137
138+ val events = collectSuccessEvents()
139+
116 viewModel.onServerUrlChange("https://test.parseable.com")
140 viewModel.onServerUrlChange("https://test.parseable.com")
117 viewModel.onUsernameChange("admin")
141 viewModel.onUsernameChange("admin")
118 viewModel.onPasswordChange("wrongpass")
142 viewModel.onPasswordChange("wrongpass")
119 viewModel.onLogin()
143 viewModel.onLogin()
120
144
121 val state = viewModel.state.value
145 val state = viewModel.state.value
122− assertFalse(state.loginSuccess)
146+ assertEquals(0, events.size)
123 assertNotNull(state.error)
147 assertNotNull(state.error)
124 assertTrue(state.error!!.contains("Invalid credentials"))
148 assertTrue(state.error!!.contains("Invalid credentials"))
125 assertFalse(state.error!!.contains("doesn't appear to be Parseable"))
149 assertFalse(state.error!!.contains("doesn't appear to be Parseable"))
app/src/test/java/com/parseable/android/PrivateHostTest.ktnew file+73
@@ -0,0 +1,73 @@
1+package com.parseable.android
2+
3+import com.parseable.android.ui.screens.login.extractHost
4+import com.parseable.android.ui.screens.login.isPrivateHost
5+import org.junit.Assert.assertEquals
6+import org.junit.Assert.assertFalse
7+import org.junit.Assert.assertTrue
8+import org.junit.Test
9+
10+class PrivateHostTest {
11+
12+ @Test
13+ fun `extractHost strips scheme port and path`() {
14+ assertEquals("192.168.1.5", extractHost("http://192.168.1.5:8000/path"))
15+ assertEquals("example.com", extractHost("https://example.com"))
16+ assertEquals("example.com", extractHost("example.com:9000"))
17+ }
18+
19+ @Test
20+ fun `extractHost handles bracketed ipv6`() {
21+ assertEquals("::1", extractHost("http://[::1]:8000"))
22+ assertEquals("fe80::1", extractHost("[fe80::1]"))
23+ }
24+
25+ @Test
26+ fun `extractHost leaves bare ipv6 intact`() {
27+ assertEquals("fe80::1", extractHost("fe80::1"))
28+ }
29+
30+ @Test
31+ fun `private ipv4 ranges are detected`() {
32+ assertTrue(isPrivateHost("10.0.0.1"))
33+ assertTrue(isPrivateHost("127.0.0.1"))
34+ assertTrue(isPrivateHost("192.168.1.10"))
35+ assertTrue(isPrivateHost("172.16.0.1"))
36+ assertTrue(isPrivateHost("172.31.255.255"))
37+ }
38+
39+ @Test
40+ fun `public ipv4 in the 172 gap is not private`() {
41+ assertFalse(isPrivateHost("172.15.0.1"))
42+ assertFalse(isPrivateHost("172.32.0.1"))
43+ assertFalse(isPrivateHost("8.8.8.8"))
44+ }
45+
46+ @Test
47+ fun `public hostnames sharing a private prefix are not private`() {
48+ // The old prefix-string check wrongly classified these as private, hiding the
49+ // plaintext-HTTP warning.
50+ assertFalse(isPrivateHost("10.example.com"))
51+ assertFalse(isPrivateHost("192.168.evil.com"))
52+ }
53+
54+ @Test
55+ fun `localhost is private`() {
56+ assertTrue(isPrivateHost("localhost"))
57+ assertTrue(isPrivateHost("api.localhost"))
58+ assertTrue(isPrivateHost(""))
59+ }
60+
61+ @Test
62+ fun `ipv6 loopback and local ranges are private`() {
63+ assertTrue(isPrivateHost("::1"))
64+ assertTrue(isPrivateHost("fe80::1"))
65+ assertTrue(isPrivateHost("fd00::1"))
66+ assertTrue(isPrivateHost("fc00::1"))
67+ }
68+
69+ @Test
70+ fun `public ipv6 is not private`() {
71+ assertFalse(isPrivateHost("2001:4860:4860::8888"))
72+ }
73+}
app/src/test/java/com/parseable/android/SettingsViewModelTest.kt+10−6
@@ -107,31 +107,35 @@ class SettingsViewModelTest {
107 }
107 }
108
108
109 @Test
109 @Test
110− fun `load sets error when users fail`() = runTest {
110+ fun `load ignores users failure when about succeeds`() = runTest {
111 coEvery { settingsRepository.serverConfig } returns flowOf(null)
111 coEvery { settingsRepository.serverConfig } returns flowOf(null)
112− coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
112+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo(version = "1.2.0"))
113 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
113 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
114
114
115 val viewModel = SettingsViewModel(settingsRepository, repository)
115 val viewModel = SettingsViewModel(settingsRepository, repository)
116
116
117+ // The user list (/user) is secondary and is commonly forbidden for restricted accounts,
118+ // so its failure must not surface a blocking error or hide otherwise-valid server info.
117 val state = viewModel.state.value
119 val state = viewModel.state.value
118− assertNotNull(state.error)
120+ assertNull(state.error)
121+ assertEquals("1.2.0", state.aboutInfo?.version)
119 assertTrue(state.users.isEmpty())
122 assertTrue(state.users.isEmpty())
120 }
123 }
121
124
122 @Test
125 @Test
123− fun `load consolidates errors from both about and users`() = runTest {
126+ fun `load surfaces only the about error, not the users error`() = runTest {
124 coEvery { settingsRepository.serverConfig } returns flowOf(null)
127 coEvery { settingsRepository.serverConfig } returns flowOf(null)
125 coEvery { repository.getAbout() } returns ApiResult.Error("Server error", 500)
128 coEvery { repository.getAbout() } returns ApiResult.Error("Server error", 500)
126 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
129 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
127
130
128 val viewModel = SettingsViewModel(settingsRepository, repository)
131 val viewModel = SettingsViewModel(settingsRepository, repository)
129
132
133+ // Only the primary server-info call (/about) is treated as blocking; the secondary
134+ // /user failure stays silent.
130 val state = viewModel.state.value
135 val state = viewModel.state.value
131 assertNotNull(state.error)
136 assertNotNull(state.error)
132− // Both error messages should be present
133 assertTrue(state.error!!.contains("Server error"))
137 assertTrue(state.error!!.contains("Server error"))
134− assertTrue(state.error!!.contains("Permission denied"))
138+ assertFalse(state.error!!.contains("Permission denied"))
135 }
139 }
136
140
137 @Test
141 @Test
app/src/test/java/com/parseable/android/SeverityDetectionTest.ktnew file+180
@@ -0,0 +1,180 @@
1+package com.parseable.android
2+
3+import com.parseable.android.ui.screens.logviewer.LogSeverity
4+import com.parseable.android.ui.screens.logviewer.detectSeverity
5+import kotlinx.serialization.json.JsonObject
6+import kotlinx.serialization.json.JsonPrimitive
7+import org.junit.Assert.assertEquals
8+import org.junit.Test
9+
10+class SeverityDetectionTest {
11+
12+ private fun logWith(vararg pairs: Pair<String, String>): JsonObject {
13+ return JsonObject(pairs.associate { (k, v) -> k to JsonPrimitive(v) })
14+ }
15+
16+ // --- Field name detection ---
17+
18+ @Test
19+ fun `detects level field`() {
20+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("level" to "error")))
21+ }
22+
23+ @Test
24+ fun `detects severity field`() {
25+ assertEquals(LogSeverity.WARNING, detectSeverity(logWith("severity" to "warn")))
26+ }
27+
28+ @Test
29+ fun `detects loglevel field`() {
30+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("loglevel" to "info")))
31+ }
32+
33+ @Test
34+ fun `detects log_level field`() {
35+ assertEquals(LogSeverity.DEBUG, detectSeverity(logWith("log_level" to "debug")))
36+ }
37+
38+ @Test
39+ fun `field name matching is case-insensitive`() {
40+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("Level" to "ERROR")))
41+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("SEVERITY" to "error")))
42+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("LogLevel" to "ERR")))
43+ }
44+
45+ @Test
46+ fun `returns UNKNOWN when no severity field present`() {
47+ assertEquals(LogSeverity.UNKNOWN, detectSeverity(logWith("message" to "hello", "host" to "server1")))
48+ }
49+
50+ @Test
51+ fun `returns UNKNOWN for empty log entry`() {
52+ assertEquals(LogSeverity.UNKNOWN, detectSeverity(JsonObject(emptyMap())))
53+ }
54+
55+ // --- String value matching ---
56+
57+ @Test
58+ fun `matches fatal variants`() {
59+ for (value in listOf("fatal", "FATAL", "F", "crit", "critical", "emerg", "emergency", "alert", "panic")) {
60+ assertEquals("Expected FATAL for '$value'", LogSeverity.FATAL, detectSeverity(logWith("level" to value)))
61+ }
62+ }
63+
64+ @Test
65+ fun `matches error variants`() {
66+ for (value in listOf("error", "ERROR", "err", "E", "severe", "failure", "fail")) {
67+ assertEquals("Expected ERROR for '$value'", LogSeverity.ERROR, detectSeverity(logWith("level" to value)))
68+ }
69+ }
70+
71+ @Test
72+ fun `matches warning variants`() {
73+ for (value in listOf("warn", "WARNING", "W", "warning")) {
74+ assertEquals("Expected WARNING for '$value'", LogSeverity.WARNING, detectSeverity(logWith("level" to value)))
75+ }
76+ }
77+
78+ @Test
79+ fun `matches info variants`() {
80+ for (value in listOf("info", "INFO", "I", "information", "informational", "notice", "ok", "success")) {
81+ assertEquals("Expected INFO for '$value'", LogSeverity.INFO, detectSeverity(logWith("level" to value)))
82+ }
83+ }
84+
85+ @Test
86+ fun `matches debug variants`() {
87+ for (value in listOf("debug", "DEBUG", "D", "fine", "config", "finer")) {
88+ assertEquals("Expected DEBUG for '$value'", LogSeverity.DEBUG, detectSeverity(logWith("level" to value)))
89+ }
90+ }
91+
92+ @Test
93+ fun `matches trace variants`() {
94+ for (value in listOf("trace", "TRACE", "T", "V", "verbose", "finest", "all")) {
95+ assertEquals("Expected TRACE for '$value'", LogSeverity.TRACE, detectSeverity(logWith("level" to value)))
96+ }
97+ }
98+
99+ @Test
100+ fun `value matching is case-insensitive`() {
101+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("level" to "Error")))
102+ assertEquals(LogSeverity.WARNING, detectSeverity(logWith("level" to "WaRnInG")))
103+ }
104+
105+ @Test
106+ fun `trims whitespace from values`() {
107+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("level" to " error ")))
108+ }
109+
110+ @Test
111+ fun `returns UNKNOWN for unrecognized values`() {
112+ assertEquals(LogSeverity.UNKNOWN, detectSeverity(logWith("level" to "custom_level")))
113+ }
114+
115+ // --- Numeric values ---
116+
117+ @Test
118+ fun `syslog severity numeric values`() {
119+ assertEquals(LogSeverity.FATAL, detectSeverity(logWith("syslog_severity" to "0"))) // emergency
120+ assertEquals(LogSeverity.FATAL, detectSeverity(logWith("syslog_severity" to "1"))) // alert
121+ assertEquals(LogSeverity.FATAL, detectSeverity(logWith("syslog_severity" to "2"))) // critical
122+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("syslog_severity" to "3"))) // error
123+ assertEquals(LogSeverity.WARNING, detectSeverity(logWith("syslog_severity" to "4"))) // warning
124+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("syslog_severity" to "5"))) // notice
125+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("syslog_severity" to "6"))) // informational
126+ assertEquals(LogSeverity.DEBUG, detectSeverity(logWith("syslog_severity" to "7"))) // debug
127+ }
128+
129+ @Test
130+ fun `HTTP status code mapping`() {
131+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "100")))
132+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "200")))
133+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "301")))
134+ assertEquals(LogSeverity.WARNING, detectSeverity(logWith("status" to "404")))
135+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("status" to "500")))
136+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("status" to "503")))
137+ }
138+
139+ @Test
140+ fun `generic numeric level mapping`() {
141+ assertEquals(LogSeverity.FATAL, detectSeverity(logWith("level" to "1000")))
142+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("level" to "900")))
143+ assertEquals(LogSeverity.WARNING, detectSeverity(logWith("level" to "800")))
144+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("level" to "700")))
145+ assertEquals(LogSeverity.DEBUG, detectSeverity(logWith("level" to "500")))
146+ assertEquals(LogSeverity.TRACE, detectSeverity(logWith("level" to "100")))
147+ }
148+
149+ @Test
150+ fun `small-integer (Bunyan-style) numeric level mapping`() {
151+ // Structured JSON loggers like Bunyan emit a numeric level field (trace=10 .. fatal=60,
152+ // higher = more severe). These previously all collapsed into TRACE.
153+ assertEquals(LogSeverity.FATAL, detectSeverity(logWith("level" to "60")))
154+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("level" to "50")))
155+ assertEquals(LogSeverity.WARNING, detectSeverity(logWith("level" to "40")))
156+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("level" to "30")))
157+ assertEquals(LogSeverity.DEBUG, detectSeverity(logWith("level" to "20")))
158+ assertEquals(LogSeverity.TRACE, detectSeverity(logWith("level" to "10")))
159+ }
160+
161+ // --- Priority among fields ---
162+
163+ @Test
164+ fun `level field takes priority over status`() {
165+ val log = JsonObject(mapOf(
166+ "level" to JsonPrimitive("error"),
167+ "status" to JsonPrimitive("200"),
168+ ))
169+ assertEquals(LogSeverity.ERROR, detectSeverity(log))
170+ }
171+
172+ @Test
173+ fun `first matching field with recognized value wins`() {
174+ val log = JsonObject(mapOf(
175+ "level" to JsonPrimitive("unknown_value"),
176+ "severity" to JsonPrimitive("warn"),
177+ ))
178+ assertEquals(LogSeverity.WARNING, detectSeverity(log))
179+ }
180+}
app/src/test/java/com/parseable/android/StreamsViewModelTest.kt+3
@@ -143,6 +143,9 @@ class StreamsViewModelTest {
143 @Test
143 @Test
144 fun `toggleFavorite removes when already favorited`() = runTest {
144 fun `toggleFavorite removes when already favorited`() = runTest {
145 every { favoriteDao.getAllNames() } returns flowOf(listOf("existing"))
145 every { favoriteDao.getAllNames() } returns flowOf(listOf("existing"))
146+ // toggleFavorite decides insert-vs-delete from the committed DB state (isFavoriteNow),
147+ // not the async-mirrored favoriteNames flow, so stub that as the source of truth.
148+ coEvery { favoriteDao.isFavoriteNow("existing") } returns true
146
149
147 viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
150 viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
148 viewModel.toggleFavorite("existing")
151 viewModel.toggleFavorite("existing")
gradle/libs.versions.toml−4
@@ -1,8 +1,4 @@
1[versions]
1[versions]
2−# App versioning
3−appVersionCode = "1"
4−appVersionName = "1.0.0"
5−
6agp = "8.7.3"
2agp = "8.7.3"
7kotlin = "2.1.0"
3kotlin = "2.1.0"
8ksp = "2.1.0-1.0.29"
4ksp = "2.1.0-1.0.29"