git.iko.soy/parseable-android
on 7cd2159 help sign in
.dockerignoredeleted−15
since splitsnapshots
unifiedsplit
@@ -1,15 +0,0 @@
1−# Keep the build context small, but DO keep .git — build.gradle.kts derives the
2−# app version from `git log -1`, so the repository history must be present.
3−build/
4−app/build/
5−.gradle/
6−*.iml
7−.idea/
8−.DS_Store
9−local.properties
10−captures/
11−.externalNativeBuild/
12−.cxx/
13−*.keystore
14−*.jks
15−.jj/
.github/workflows/build-release.yml−22
@@ -6,14 +6,8 @@ on:
6 - 'v*'
6 - 'v*'
7 workflow_dispatch:
7 workflow_dispatch:
8
8
9−concurrency:
10− group: release
11− cancel-in-progress: false
12−
13permissions:
9permissions:
14 contents: write
10 contents: write
15− id-token: write
16− attestations: write
17
11
18jobs:
12jobs:
19 build:
13 build:
@@ -24,12 +18,6 @@ jobs:
24 - name: Checkout code
18 - name: Checkout code
25 uses: actions/checkout@v4
19 uses: actions/checkout@v4
26
20
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−
33 - name: Set up JDK 17
21 - name: Set up JDK 17
34 uses: actions/setup-java@v4
22 uses: actions/setup-java@v4
35 with:
23 with:
@@ -46,11 +34,6 @@ jobs:
46 run: gradle wrapper --gradle-version 8.9
34 run: gradle wrapper --gradle-version 8.9
47
35
48 - name: Build release APK
36 - 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 }}
54 run: ./gradlew assembleRelease
37 run: ./gradlew assembleRelease
55
38
56 - name: Run unit tests
39 - name: Run unit tests
@@ -62,11 +45,6 @@ jobs:
62 name: parseable-android-release
45 name: parseable-android-release
63 path: app/build/outputs/apk/release/*.apk
46 path: app/build/outputs/apk/release/*.apk
64
47
65− - name: Attest release APK
66− uses: actions/attest-build-provenance@v2
67− with:
68− subject-path: app/build/outputs/apk/release/*.apk
69−
70 - name: Create Draft Release
48 - name: Create Draft Release
71 if: startsWith(github.ref, 'refs/tags/v')
49 if: startsWith(github.ref, 'refs/tags/v')
72 uses: softprops/action-gh-release@v2
50 uses: softprops/action-gh-release@v2
.github/workflows/ci.yml+25−47
@@ -6,24 +6,19 @@ 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+
9jobs:
13jobs:
10 build:
14 build:
11 name: Build & Lint
15 name: Build & Lint
12 runs-on: ubuntu-latest
16 runs-on: ubuntu-latest
13− concurrency:
14− group: ci-build-${{ github.ref }}
15− cancel-in-progress: true
16
17
17 steps:
18 steps:
18 - name: Checkout code
19 - name: Checkout code
19 uses: actions/checkout@v4
20 uses: actions/checkout@v4
20
21
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−
27 - name: Set up JDK 17
22 - name: Set up JDK 17
28 uses: actions/setup-java@v4
23 uses: actions/setup-java@v4
29 with:
24 with:
@@ -40,11 +35,6 @@ jobs:
40 run: gradle wrapper --gradle-version 8.9
35 run: gradle wrapper --gradle-version 8.9
41
36
42 - name: Build release APK
37 - 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 }}
48 run: ./gradlew assembleRelease
38 run: ./gradlew assembleRelease
49
39
50 - name: Run unit tests
40 - name: Run unit tests
@@ -69,34 +59,18 @@ jobs:
69 path: app/build/reports/lint-results-release.html
59 path: app/build/reports/lint-results-release.html
70 retention-days: 14
60 retention-days: 14
71
61
72− release:
62+ prerelease:
73− name: Create Release
63+ name: Create Prerelease
74 needs: build
64 needs: build
75 if: github.event_name == 'push'
65 if: github.event_name == 'push'
76 runs-on: ubuntu-latest
66 runs-on: ubuntu-latest
77− concurrency:
78− group: release
79− cancel-in-progress: false
80 permissions:
67 permissions:
81 contents: write
68 contents: write
82− id-token: write
83− attestations: write
84
69
85 steps:
70 steps:
86 - name: Checkout code
71 - name: Checkout code
87 uses: actions/checkout@v4
72 uses: actions/checkout@v4
88
73
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−
100 - name: Set up JDK 17
74 - name: Set up JDK 17
101 uses: actions/setup-java@v4
75 uses: actions/setup-java@v4
102 with:
76 with:
@@ -113,29 +87,33 @@ jobs:
113 run: gradle wrapper --gradle-version 8.9
87 run: gradle wrapper --gradle-version 8.9
114
88
115 - name: Build release APK
89 - 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 }}
121 run: ./gradlew assembleRelease
90 run: ./gradlew assembleRelease
122
91
123− - name: Rename APK with version
92+ - name: Rename APK with commit SHA
124 run: |
93 run: |
94+ SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
125 for apk in app/build/outputs/apk/release/*.apk; do
95 for apk in app/build/outputs/apk/release/*.apk; do
126− mv "$apk" "app/build/outputs/apk/release/parseable-android-${VERSION_NAME}.apk"
96+ mv "$apk" "app/build/outputs/apk/release/parseable-android-${SHORT_SHA}.apk"
127 done
97 done
128
98
129− - name: Attest release APK
99+ - name: Delete existing latest-master tag and release
130− uses: actions/attest-build-provenance@v2
100+ env:
131− with:
101+ GH_TOKEN: ${{ github.token }}
132− subject-path: app/build/outputs/apk/release/*.apk
102+ run: |
103+ gh release delete latest-master --yes || true
104+ git push origin :refs/tags/latest-master || true
133
105
134− - name: Create release
106+ - name: Create prerelease
135 uses: softprops/action-gh-release@v2
107 uses: softprops/action-gh-release@v2
136 with:
108 with:
137− tag_name: v${{ env.VERSION_NAME }}
109+ tag_name: latest-master
138− name: v${{ env.VERSION_NAME }}
110+ name: "Latest Master Build (${{ github.sha }})"
139− generate_release_notes: true
111+ prerelease: true
112+ body: |
113+ Automated prerelease from commit ${{ github.sha }}.
114+
115+ **Commit:** [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})
116+ **Branch:** `${{ github.ref_name }}`
117+ **Triggered by:** @${{ github.actor }}
140 files: |
118 files: |
141 app/build/outputs/apk/release/*.apk
119 app/build/outputs/apk/release/*.apk
.gitignore−2
@@ -10,5 +10,3 @@
10local.properties
10local.properties
11app/build/
11app/build/
12gradle/wrapper/gradle-wrapper.jar
12gradle/wrapper/gradle-wrapper.jar
13−*.keystore
14−*.jks
Dockerfiledeleted−72
@@ -1,72 +0,0 @@
1−# syntax=docker/dockerfile:1
2−
3−# ---- Build stage -------------------------------------------------------------
4−# Builds the Parseable Android APK from source. Mirrors the GitHub Actions CI:
5−# JDK 17, Android SDK (compileSdk 35), Gradle 8.9, with the wrapper generated
6−# on the fly (gradle-wrapper.jar is gitignored). The app's build.gradle.kts
7−# derives its version from `git log`, so git + the .git directory are required.
8−#
9−# Pinned to linux/amd64: the Android toolchain (aapt2) ships only x86-64 Linux
10−# binaries. On an arm64 host (Apple Silicon) a native arm64 image can't exec
11−# them ("rosetta error: failed to open elf .../ld-linux-x86-64.so.2"); a full
12−# amd64 image gives Rosetta/QEMU a complete x86-64 userland to emulate. On an
13−# amd64 host this is native. Override with --build-arg BUILD_PLATFORM=... .
14−ARG BUILD_PLATFORM=linux/amd64
15−FROM --platform=${BUILD_PLATFORM} eclipse-temurin:17-jdk-jammy AS build
16−
17−# Versions (override at build time with --build-arg if needed)
18−ARG GRADLE_VERSION=8.9
19−ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708
20−ARG ANDROID_PLATFORM=35
21−ARG ANDROID_BUILD_TOOLS=35.0.0
22−# Gradle task to run: assembleDebug (default) or assembleRelease
23−ARG GRADLE_TASK=assembleDebug
24−
25−ENV ANDROID_SDK_ROOT=/opt/android-sdk \
26− ANDROID_HOME=/opt/android-sdk \
27− GRADLE_USER_HOME=/root/.gradle \
28− DEBIAN_FRONTEND=noninteractive
29−
30−# Base tooling: git (version stamp), unzip/curl (fetching SDK + Gradle)
31−RUN apt-get update && \
32− apt-get install -y --no-install-recommends git unzip curl ca-certificates && \
33− rm -rf /var/lib/apt/lists/*
34−
35−# Android SDK command-line tools
36−RUN mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools" && \
37− curl -fsSL "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" -o /tmp/cmdline-tools.zip && \
38− unzip -q /tmp/cmdline-tools.zip -d "${ANDROID_SDK_ROOT}/cmdline-tools" && \
39− mv "${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools" "${ANDROID_SDK_ROOT}/cmdline-tools/latest" && \
40− rm /tmp/cmdline-tools.zip
41−
42−ENV PATH="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${PATH}"
43−
44−# Accept licenses and install required SDK packages
45−RUN yes | sdkmanager --licenses >/dev/null && \
46− sdkmanager --install \
47− "platform-tools" \
48− "platforms;android-${ANDROID_PLATFORM}" \
49− "build-tools;${ANDROID_BUILD_TOOLS}" >/dev/null
50−
51−# Gradle (used to generate the wrapper, then the wrapper drives the build)
52−RUN curl -fsSL "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" -o /tmp/gradle.zip && \
53− unzip -q /tmp/gradle.zip -d /opt && \
54− rm /tmp/gradle.zip
55−ENV PATH="/opt/gradle-${GRADLE_VERSION}/bin:${PATH}"
56−
57−WORKDIR /workspace
58−
59−# Copy the full project (including .git for the version stamp; see .dockerignore)
60−COPY . .
61−
62−# Generate the Gradle wrapper, then build the APK
63−RUN gradle wrapper --gradle-version "${GRADLE_VERSION}" && \
64− ./gradlew --no-daemon "${GRADLE_TASK}"
65−
66−# ---- Export stage ------------------------------------------------------------
67−# Minimal final image holding just the built APK(s) under /apks.
68−# Extract them to ./out with:
69−# docker build --target export --output type=local,dest=./out .
70−# (A scratch image has no shell, so use --output, not docker create/cp.)
71−FROM scratch AS export
72−COPY --from=build /workspace/app/build/outputs/apk/ /apks/
README.md−11
@@ -43,17 +43,6 @@ An Android client for [Parseable](https://www.parseable.com/), the open-source l
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−
57## Building
46## Building
58
47
59```bash
48```bash
app/build.gradle.kts+3−25
@@ -1,7 +1,3 @@
1−import java.time.Instant
2−import java.time.ZoneOffset
3−import java.time.format.DateTimeFormatter
4−
5plugins {
1plugins {
6 alias(libs.plugins.android.application)
2 alias(libs.plugins.android.application)
7 alias(libs.plugins.kotlin.android)
3 alias(libs.plugins.kotlin.android)
@@ -11,13 +7,6 @@ plugins {
11 alias(libs.plugins.ksp)
7 alias(libs.plugins.ksp)
12}
8}
13
9
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−
21android {
10android {
22 namespace = "com.parseable.android"
11 namespace = "com.parseable.android"
23 compileSdk = 35
12 compileSdk = 35
@@ -26,28 +15,17 @@ android {
26 applicationId = "com.parseable.android"
15 applicationId = "com.parseable.android"
27 minSdk = 26
16 minSdk = 26
28 targetSdk = 35
17 targetSdk = 35
29− versionCode = dateVersionCode
18+ versionCode = libs.versions.appVersionCode.get().toInt()
30− versionName = dateVersionName
19+ versionName = libs.versions.appVersionName.get()
31
20
32 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
21 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
33 }
22 }
34
23
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−
46 buildTypes {
24 buildTypes {
47 release {
25 release {
48 isMinifyEnabled = true
26 isMinifyEnabled = true
49 isShrinkResources = true
27 isShrinkResources = true
50− signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
28+ signingConfig = signingConfigs.getByName("debug")
51 proguardFiles(
29 proguardFiles(
52 getDefaultProguardFile("proguard-android-optimize.txt"),
30 getDefaultProguardFile("proguard-android-optimize.txt"),
53 "proguard-rules.pro"
31 "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"
13+ android:roundIcon="@mipmap/ic_launcher_round"
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+1−21
@@ -51,17 +51,6 @@ 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− }
65 }
54 }
66
55
67 val dest = startDestination
56 val dest = startDestination
@@ -72,17 +61,8 @@ class MainActivity : ComponentActivity() {
72 LaunchedEffect(navController) {
61 LaunchedEffect(navController) {
73 repository.authErrors
62 repository.authErrors
74 .onEach {
63 .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
82 settingsRepository.clearConfig()
64 settingsRepository.clearConfig()
83− // The login screen shows the "session expired" message
65+ errorHandler.showError("Session expired. Please log in again.")
84− // itself via the sessionExpired flag; showing it here too
85− // would queue a duplicate snackbar.
86 navController.navigate(Routes.login(sessionExpired = true)) {
66 navController.navigate(Routes.login(sessionExpired = true)) {
87 popUpTo(0) { inclusive = true }
67 popUpTo(0) { inclusive = true }
88 }
68 }
app/src/main/java/com/parseable/android/data/FormatUtils.ktdeleted−47
@@ -1,47 +0,0 @@
1−package com.parseable.android.data
2−
3−import java.util.Locale
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.
8−private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
9−
10−/**
11− * Converts a raw byte-count string (e.g. "123456789") to a human-readable
12− * size (e.g. "117.7 MB"). If the string is not a valid number, it is
13− * returned unchanged so pre-formatted values from the API pass through.
14− */
15−fun formatBytes(raw: String?): String? {
16− if (raw == null) return null
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
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.
26− if (bytes < 1023.5) return String.format(Locale.US, "%.0f B", bytes)
27− var value = bytes
28− var unitIndex = 0
29− while (value >= 1024 && unitIndex < BYTE_UNITS.size - 1) {
30− value /= 1024
31− unitIndex++
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− }
40− return if (value >= 100) {
41− String.format(Locale.US, "%.0f %s", value, BYTE_UNITS[unitIndex])
42− } else if (value >= 10) {
43− String.format(Locale.US, "%.1f %s", value, BYTE_UNITS[unitIndex])
44− } else {
45− String.format(Locale.US, "%.2f %s", value, BYTE_UNITS[unitIndex])
46− }
47−}
app/src/main/java/com/parseable/android/data/SqlUtils.kt−16
@@ -5,19 +5,3 @@ 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+14−57
@@ -1,69 +1,26 @@
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
6import java.time.ZoneId
4import java.time.ZoneId
7−import java.time.ZoneOffset
8import java.time.ZonedDateTime
5import java.time.ZonedDateTime
9import java.time.format.DateTimeFormatter
6import java.time.format.DateTimeFormatter
10−import java.util.Locale
11
7
12−// Pin to Locale.US so the MMM month token is always rendered as a stable English
13−// abbreviation (e.g. "Jun") regardless of the device's locale.
14−private val displayFormatter = DateTimeFormatter.ofPattern("MMM dd HH:mm:ss.SSS", Locale.US)
8+private val displayFormatter = DateTimeFormatter.ofPattern("MMM dd HH:mm:ss.SSS")
15
9
16fun formatTimestamp(raw: String): String {
10fun formatTimestamp(raw: String): String {
17− // Parseable/DataFusion sometimes renders p_timestamp with a space between the date and
11+ return try {
18− // time ("2026-06-20 12:00:00.000") instead of the ISO 'T', and may append a zone/offset
12+ val instant = Instant.parse(raw)
19− // after another space ("... +00:00", "... UTC"). Normalize both so the parsers below
20− // accept them; values already in canonical ISO form are left untouched.
21− var iso = raw.trim()
22− // Convert the FIRST space (date↔time separator) to 'T', unless a 'T' already precedes it
23− // (in which case that space sits before a zone token, handled next).
24− val firstSpace = iso.indexOf(' ')
25− if (firstSpace != -1 && 'T' !in iso.substring(0, firstSpace + 1)) {
26− iso = iso.substring(0, firstSpace) + "T" + iso.substring(firstSpace + 1)
27− }
28− // Drop a space sitting between the time and a trailing offset/zone token:
29− // "...12:00:00 +00:00" → "...12:00:00+00:00", "...12:00:00 UTC" → "...12:00:00UTC".
30− iso = iso.replace(Regex("(?<=\\d)\\s+(?=[+\\-Z]|UTC|GMT)"), "")
31− // Map a textual "UTC"/"GMT" suffix to the ISO 'Z' the parsers understand (GMT is exactly
32− // UTC, so this is a safe rewrite). Other textual zones (e.g. "PST") are ambiguous, so they
33− // are left untouched and fall through to the raw-string passthrough rather than risk
34− // displaying a wrong local time.
35− if (iso.endsWith("UTC")) iso = iso.removeSuffix("UTC") + "Z"
36− else if (iso.endsWith("GMT")) iso = iso.removeSuffix("GMT") + "Z"
37−
38− // Fast path: an ISO-8601 instant in UTC (e.g. "2026-06-20T12:00:00Z").
39− try {
40− val instant = Instant.parse(iso)
41− return ZonedDateTime.ofInstant(instant, ZoneId.systemDefault()).format(displayFormatter)
13+ val local = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
42− } catch (_: Exception) {
43− // Fall through to the offset-aware parser.
44− }
45−
46− // General path: any ISO-8601 timestamp carrying an explicit offset, including
47− // "+00:00" and non-UTC offsets like "+05:30" that Instant.parse rejects.
48− try {
49− val offset = OffsetDateTime.parse(iso)
50− return offset.atZoneSameInstant(ZoneId.systemDefault()).format(displayFormatter)
14+ local.format(displayFormatter)
51 } catch (_: Exception) {
15 } catch (_: Exception) {
52− // Fall through to the naive (offset-less) parser.
16+ // Try alternate format with +00:00 suffix
17+ try {
18+ val normalized = raw.replace("+00:00", "Z")
19+ val instant = Instant.parse(normalized)
20+ val local = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
21+ local.format(displayFormatter)
22+ } catch (_: Exception) {
23+ raw
24+ }
53 }
25 }
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
69}
26}
app/src/main/java/com/parseable/android/data/api/ParseableApiClient.kt+27−155
@@ -39,7 +39,7 @@ class ParseableApiClient @Inject constructor() {
39 coerceInputValues = true
39 coerceInputValues = true
40 }
40 }
41
41
42− private val sharedPool = okhttp3.ConnectionPool(16, 5, TimeUnit.MINUTES)
42+ private val sharedPool = okhttp3.ConnectionPool(5, 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,18 +77,11 @@ 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 {
85− val credentials = "${serverConfig.username}:${serverConfig.password}"
80+ val credentials = "${serverConfig.username}:${serverConfig.password}"
86− "Basic " + android.util.Base64.encodeToString(
81+ val auth = "Basic " + android.util.Base64.encodeToString(
87− // Explicit UTF-8 per RFC 7617 rather than the platform default charset.
88− credentials.toByteArray(Charsets.UTF_8),
82+ credentials.toByteArray(),
89− android.util.Base64.NO_WRAP
83+ android.util.Base64.NO_WRAP
90− )
84+ )
91− }
92 config = ClientConfig(
85 config = ClientConfig(
93 baseUrl = url,
86 baseUrl = url,
94 authHeader = auth,
87 authHeader = auth,
@@ -103,48 +96,30 @@ class ParseableApiClient @Inject constructor() {
103 }
96 }
104
97
105 fun shutdown() {
98 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.
110 sharedPool.evictAll()
99 sharedPool.evictAll()
100+ sharedDispatcher.executorService.shutdown()
111 }
101 }
112
102
113 private fun encodePathSegment(segment: String): String =
103 private fun encodePathSegment(segment: String): String =
114 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
104 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
115
105
116 private fun buildRequest(path: String): Request.Builder {
106 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.
122 val snapshot = config
107 val snapshot = config
123− val selectedClient = if (snapshot.allowInsecure) insecureClient else secureClient
124− val builder = Request.Builder()
108+ return Request.Builder()
125 .url("${snapshot.baseUrl}$path")
109 .url("${snapshot.baseUrl}$path")
126− .tag(OkHttpClient::class.java, selectedClient)
127− // Don't send a literal empty "Authorization:" header for unconfigured calls
128− // (e.g. pre-login connectivity probes) — strict servers/proxies may reject it.
129− if (snapshot.authHeader.isNotEmpty()) {
130− builder.header("Authorization", snapshot.authHeader)
110+ .header("Authorization", snapshot.authHeader)
131− }
132− return builder
133 }
111 }
134
112
135 private suspend fun executeRequest(request: Request): ApiResult<String> =
113 private suspend fun executeRequest(request: Request): ApiResult<String> =
136 withContext(Dispatchers.IO) {
114 withContext(Dispatchers.IO) {
137 try {
115 try {
138− val callClient = request.tag(OkHttpClient::class.java) ?: client
139− callClient.newCall(request).execute().use { response ->
116+ client.newCall(request).execute().use { response ->
140 val body = response.body?.string() ?: ""
117 val body = response.body?.string() ?: ""
141 if (response.isSuccessful) {
118 if (response.isSuccessful) {
142 ApiResult.Success(body)
119 ApiResult.Success(body)
143 } else {
120 } else {
144 ApiResult.Error(
121 ApiResult.Error(
145− // ifBlank, not ifEmpty: a whitespace-only error body would otherwise
146− // be shown verbatim as a blank message instead of the status reason.
147− message = body.ifBlank { response.message },
122+ message = body.ifEmpty { response.message },
148 code = response.code
123 code = response.code
149 )
124 )
150 }
125 }
@@ -152,7 +127,7 @@ class ParseableApiClient @Inject constructor() {
152 } catch (e: SocketTimeoutException) {
127 } catch (e: SocketTimeoutException) {
153 ApiResult.Error(message = "Connection timed out")
128 ApiResult.Error(message = "Connection timed out")
154 } catch (e: UnknownHostException) {
129 } catch (e: UnknownHostException) {
155− ApiResult.Error(message = "Unable to resolve host")
130+ ApiResult.Error(message = "Unable to resolve host \"${e.message}\"")
156 } catch (e: ConnectException) {
131 } catch (e: ConnectException) {
157 ApiResult.Error(message = "Connection refused. Is the server running?")
132 ApiResult.Error(message = "Connection refused. Is the server running?")
158 } catch (e: SSLException) {
133 } catch (e: SSLException) {
@@ -165,21 +140,13 @@ class ParseableApiClient @Inject constructor() {
165 }
140 }
166 }
141 }
167
142
168− private fun <T> parseResponse(description: String, parse: () -> T): ApiResult<T> {
143+ private fun <T> parseResponse(body: String, description: String, parse: () -> T): ApiResult<T> {
169 return try {
144 return try {
170 ApiResult.Success(parse())
145 ApiResult.Success(parse())
171 } catch (e: SerializationException) {
146 } catch (e: SerializationException) {
172− ApiResult.Error("Invalid response format from server", code = ApiResult.PARSE_ERROR_CODE)
147+ ApiResult.Error("Invalid response format from server")
173 } catch (e: IllegalArgumentException) {
148 } catch (e: IllegalArgumentException) {
174− ApiResult.Error("Unexpected response format for $description", code = ApiResult.PARSE_ERROR_CODE)
149+ ApiResult.Error("Unexpected response format for $description")
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)
183 }
150 }
184 }
151 }
185
152
@@ -189,7 +156,7 @@ class ParseableApiClient @Inject constructor() {
189 suspend fun getAbout(): ApiResult<AboutInfo> {
156 suspend fun getAbout(): ApiResult<AboutInfo> {
190 val request = buildRequest("/api/v1/about").get().build()
157 val request = buildRequest("/api/v1/about").get().build()
191 return when (val result = executeRequest(request)) {
158 return when (val result = executeRequest(request)) {
192− is ApiResult.Success -> parseResponse("about info") {
159+ is ApiResult.Success -> parseResponse(result.data, "about info") {
193 json.decodeFromString<AboutInfo>(result.data)
160 json.decodeFromString<AboutInfo>(result.data)
194 }
161 }
195 is ApiResult.Error -> result
162 is ApiResult.Error -> result
@@ -230,7 +197,7 @@ class ParseableApiClient @Inject constructor() {
230 }
197 }
231 ApiResult.Success(streams)
198 ApiResult.Success(streams)
232 } catch (e2: Exception) {
199 } catch (e2: Exception) {
233− ApiResult.Error("Failed to parse streams: ${e2.message}", code = ApiResult.PARSE_ERROR_CODE)
200+ ApiResult.Error("Failed to parse streams: ${e2.message}")
234 }
201 }
235 }
202 }
236 }
203 }
@@ -245,7 +212,7 @@ class ParseableApiClient @Inject constructor() {
245 val encoded = encodePathSegment(stream)
212 val encoded = encodePathSegment(stream)
246 val request = buildRequest("/api/v1/logstream/$encoded/schema").get().build()
213 val request = buildRequest("/api/v1/logstream/$encoded/schema").get().build()
247 return when (val result = executeRequest(request)) {
214 return when (val result = executeRequest(request)) {
248− is ApiResult.Success -> parseResponse("schema") {
215+ is ApiResult.Success -> parseResponse(result.data, "schema") {
249 json.decodeFromString<StreamSchema>(result.data)
216 json.decodeFromString<StreamSchema>(result.data)
250 }
217 }
251 is ApiResult.Error -> result
218 is ApiResult.Error -> result
@@ -259,7 +226,7 @@ class ParseableApiClient @Inject constructor() {
259 val encoded = encodePathSegment(stream)
226 val encoded = encodePathSegment(stream)
260 val request = buildRequest("/api/v1/logstream/$encoded/stats").get().build()
227 val request = buildRequest("/api/v1/logstream/$encoded/stats").get().build()
261 return when (val result = executeRequest(request)) {
228 return when (val result = executeRequest(request)) {
262− is ApiResult.Success -> parseResponse("stats") {
229+ is ApiResult.Success -> parseResponse(result.data, "stats") {
263 json.decodeFromString<StreamStats>(result.data)
230 json.decodeFromString<StreamStats>(result.data)
264 }
231 }
265 is ApiResult.Error -> result
232 is ApiResult.Error -> result
@@ -273,7 +240,7 @@ class ParseableApiClient @Inject constructor() {
273 val encoded = encodePathSegment(stream)
240 val encoded = encodePathSegment(stream)
274 val request = buildRequest("/api/v1/logstream/$encoded/info").get().build()
241 val request = buildRequest("/api/v1/logstream/$encoded/info").get().build()
275 return when (val result = executeRequest(request)) {
242 return when (val result = executeRequest(request)) {
276− is ApiResult.Success -> parseResponse("stream info") {
243+ is ApiResult.Success -> parseResponse(result.data, "stream info") {
277 val element = json.parseToJsonElement(result.data)
244 val element = json.parseToJsonElement(result.data)
278 (element as? JsonObject)
245 (element as? JsonObject)
279 ?: throw IllegalArgumentException("Expected JSON object, got ${element::class.simpleName}")
246 ?: throw IllegalArgumentException("Expected JSON object, got ${element::class.simpleName}")
@@ -293,15 +260,8 @@ class ParseableApiClient @Inject constructor() {
293 try {
260 try {
294 ApiResult.Success(json.decodeFromString<List<RetentionConfig>>(result.data))
261 ApiResult.Success(json.decodeFromString<List<RetentionConfig>>(result.data))
295 } catch (_: Exception) {
262 } catch (_: Exception) {
296− parseResponse("retention config") {
263+ parseResponse(result.data, "retention config") {
297− val single = json.decodeFromString<RetentionConfig>(result.data)
264+ listOf(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− }
305 }
265 }
306 }
266 }
307 }
267 }
@@ -331,7 +291,7 @@ class ParseableApiClient @Inject constructor() {
331 .build()
291 .build()
332
292
333 return when (val result = executeRequest(request)) {
293 return when (val result = executeRequest(request)) {
334− is ApiResult.Success -> parseResponse("query results") {
294+ is ApiResult.Success -> parseResponse(result.data, "query results") {
335 val element = json.parseToJsonElement(result.data)
295 val element = json.parseToJsonElement(result.data)
336 val elements = (element as? JsonArray)
296 val elements = (element as? JsonArray)
337 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
297 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
@@ -356,14 +316,11 @@ class ParseableApiClient @Inject constructor() {
356 suspend fun listAlerts(): ApiResult<List<Alert>> {
316 suspend fun listAlerts(): ApiResult<List<Alert>> {
357 val request = buildRequest("/api/v1/alerts").get().build()
317 val request = buildRequest("/api/v1/alerts").get().build()
358 return when (val result = executeRequest(request)) {
318 return when (val result = executeRequest(request)) {
359− is ApiResult.Success -> parseResponse("alerts") {
319+ is ApiResult.Success -> parseResponse(result.data, "alerts") {
360 val element = json.parseToJsonElement(result.data)
320 val element = json.parseToJsonElement(result.data)
361 val alertsArray = when (element) {
321 val alertsArray = when (element) {
362 is JsonArray -> element
322 is JsonArray -> element
363− // Use a safe cast, not the `.jsonArray` accessor: a present-but-non-array
364− // value (e.g. {"alerts": null} or an object) makes `.jsonArray` throw, which
365− // would surface as a parse error instead of degrading to an empty list.
366− is JsonObject -> (element["alerts"] as? JsonArray) ?: JsonArray(emptyList())
323+ is JsonObject -> element["alerts"]?.jsonArray ?: JsonArray(emptyList())
367 else -> JsonArray(emptyList())
324 else -> JsonArray(emptyList())
368 }
325 }
369 alertsArray.mapNotNull { alertElement ->
326 alertsArray.mapNotNull { alertElement ->
@@ -398,7 +355,7 @@ class ParseableApiClient @Inject constructor() {
398 .post(requestBody)
355 .post(requestBody)
399 .build()
356 .build()
400 return when (val result = executeRequest(request)) {
357 return when (val result = executeRequest(request)) {
401− is ApiResult.Success -> parseResponse("alert") {
358+ is ApiResult.Success -> parseResponse(result.data, "alert") {
402 json.decodeFromString<Alert>(result.data)
359 json.decodeFromString<Alert>(result.data)
403 }
360 }
404 is ApiResult.Error -> result
361 is ApiResult.Error -> result
@@ -411,7 +368,7 @@ class ParseableApiClient @Inject constructor() {
411 suspend fun listUsers(): ApiResult<List<JsonObject>> {
368 suspend fun listUsers(): ApiResult<List<JsonObject>> {
412 val request = buildRequest("/api/v1/user").get().build()
369 val request = buildRequest("/api/v1/user").get().build()
413 return when (val result = executeRequest(request)) {
370 return when (val result = executeRequest(request)) {
414− is ApiResult.Success -> parseResponse("users") {
371+ is ApiResult.Success -> parseResponse(result.data, "users") {
415 val element = json.parseToJsonElement(result.data)
372 val element = json.parseToJsonElement(result.data)
416 val elements = (element as? JsonArray)
373 val elements = (element as? JsonArray)
417 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
374 ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
@@ -420,89 +377,4 @@ class ParseableApiClient @Inject constructor() {
420 is ApiResult.Error -> result
377 is ApiResult.Error -> result
421 }
378 }
422 }
379 }
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− }
508}
380}
app/src/main/java/com/parseable/android/data/local/FavoriteStreamDao.kt+2−12
@@ -12,14 +12,10 @@ 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).
17− @Query("SELECT streamName FROM favorite_streams ORDER BY addedAt DESC")
15+ @Query("SELECT streamName FROM favorite_streams")
18 fun getAllNames(): Flow<List<String>>
16 fun getAllNames(): Flow<List<String>>
19
17
20− // IGNORE (not REPLACE): re-favoriting an existing stream must keep its original
21− // addedAt so the getAll() "ORDER BY addedAt DESC" ordering stays stable.
22− @Insert(onConflict = OnConflictStrategy.IGNORE)
18+ @Insert(onConflict = OnConflictStrategy.REPLACE)
23 suspend fun insert(favorite: FavoriteStream)
19 suspend fun insert(favorite: FavoriteStream)
24
20
25 @Delete
21 @Delete
@@ -30,10 +26,4 @@ interface FavoriteStreamDao {
30
26
31 @Query("SELECT EXISTS(SELECT 1 FROM favorite_streams WHERE streamName = :name)")
27 @Query("SELECT EXISTS(SELECT 1 FROM favorite_streams WHERE streamName = :name)")
32 fun isFavorite(name: String): Flow<Boolean>
28 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
39}
29}
app/src/main/java/com/parseable/android/data/local/ParseableDatabase.kt+2−3
@@ -4,11 +4,10 @@ import androidx.room.Database
4import androidx.room.RoomDatabase
4import androidx.room.RoomDatabase
5
5
6@Database(
6@Database(
7− entities = [FavoriteStream::class, SavedServer::class],
7+ entities = [FavoriteStream::class],
8− version = 3,
8+ version = 1,
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
14}
13}
app/src/main/java/com/parseable/android/data/local/SavedServer.ktdeleted−23
@@ -1,23 +0,0 @@
1−package com.parseable.android.data.local
2−
3−import androidx.room.Entity
4−import androidx.room.Index
5−import androidx.room.PrimaryKey
6−
7−@Entity(
8− tableName = "saved_servers",
9− // One logical server per (url, username). The dedupe in SettingsRepository.saveServer
10− // relies on this invariant; enforcing it in the schema means REPLACE actually upserts on
11− // the natural key instead of silently inserting a duplicate row if the read-back misses.
12− indices = [Index(value = ["serverUrl", "username"], unique = true)],
13−)
14−data class SavedServer(
15− @PrimaryKey(autoGenerate = true)
16− val id: Long = 0,
17− val serverUrl: String,
18− val username: String,
19− val useTls: Boolean = true,
20− /** Key into EncryptedSharedPreferences for this server's password. */
21− val passwordKey: String,
22− val addedAt: Long = System.currentTimeMillis(),
23−)
app/src/main/java/com/parseable/android/data/local/SavedServerDao.ktdeleted−25
@@ -1,25 +0,0 @@
1−package com.parseable.android.data.local
2−
3−import androidx.room.Dao
4−import androidx.room.Insert
5−import androidx.room.OnConflictStrategy
6−import androidx.room.Query
7−import kotlinx.coroutines.flow.Flow
8−
9−@Dao
10−interface SavedServerDao {
11− @Query("SELECT * FROM saved_servers ORDER BY addedAt DESC")
12− fun getAll(): Flow<List<SavedServer>>
13−
14− @Query("SELECT * FROM saved_servers WHERE id = :id")
15− suspend fun getById(id: Long): SavedServer?
16−
17− @Query("SELECT * FROM saved_servers WHERE serverUrl = :url AND username = :username LIMIT 1")
18− suspend fun findByUrlAndUsername(url: String, username: String): SavedServer?
19−
20− @Insert(onConflict = OnConflictStrategy.REPLACE)
21− suspend fun insert(server: SavedServer): Long
22−
23− @Query("DELETE FROM saved_servers WHERE id = :id")
24− suspend fun deleteById(id: Long)
25−}
app/src/main/java/com/parseable/android/data/model/Models.kt+3−94
@@ -1,43 +1,9 @@
1package com.parseable.android.data.model
1package com.parseable.android.data.model
2
2
3−import kotlinx.serialization.KSerializer
4import kotlinx.serialization.SerialName
3import kotlinx.serialization.SerialName
5import kotlinx.serialization.Serializable
4import 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
12import kotlinx.serialization.json.JsonElement
5import kotlinx.serialization.json.JsonElement
13−import kotlinx.serialization.json.JsonNull
14import kotlinx.serialization.json.JsonObject
6import 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−}
41
7
42/**
8/**
43 * Server connection configuration stored in DataStore.
9 * Server connection configuration stored in DataStore.
@@ -81,31 +47,25 @@ data class StreamStats(
81@Serializable
47@Serializable
82data class IngestionStats(
48data class IngestionStats(
83 val count: Long? = null,
49 val count: Long? = null,
84− @Serializable(with = FlexibleStringSerializer::class)
85 val size: String? = null,
50 val size: String? = null,
86 val format: String? = null,
51 val format: String? = null,
87 @SerialName("lifetime_count")
52 @SerialName("lifetime_count")
88 val lifetimeCount: Long? = null,
53 val lifetimeCount: Long? = null,
89 @SerialName("lifetime_size")
54 @SerialName("lifetime_size")
90− @Serializable(with = FlexibleStringSerializer::class)
91 val lifetimeSize: String? = null,
55 val lifetimeSize: String? = null,
92 @SerialName("deleted_count")
56 @SerialName("deleted_count")
93 val deletedCount: Long? = null,
57 val deletedCount: Long? = null,
94 @SerialName("deleted_size")
58 @SerialName("deleted_size")
95− @Serializable(with = FlexibleStringSerializer::class)
96 val deletedSize: String? = null,
59 val deletedSize: String? = null,
97)
60)
98
61
99@Serializable
62@Serializable
100data class StorageStats(
63data class StorageStats(
101− @Serializable(with = FlexibleStringSerializer::class)
102 val size: String? = null,
64 val size: String? = null,
103 val format: String? = null,
65 val format: String? = null,
104 @SerialName("lifetime_size")
66 @SerialName("lifetime_size")
105− @Serializable(with = FlexibleStringSerializer::class)
106 val lifetimeSize: String? = null,
67 val lifetimeSize: String? = null,
107 @SerialName("deleted_size")
68 @SerialName("deleted_size")
108− @Serializable(with = FlexibleStringSerializer::class)
109 val deletedSize: String? = null,
69 val deletedSize: String? = null,
110)
70)
111
71
@@ -147,7 +107,7 @@ data class AboutInfo(
147 val commit: String? = null,
107 val commit: String? = null,
148 @SerialName("deploymentId")
108 @SerialName("deploymentId")
149 val deploymentId: String? = null,
109 val deploymentId: String? = null,
150− val license: JsonObject? = null,
110+ val license: String? = null,
151 val mode: String? = null,
111 val mode: String? = null,
152 val staging: String? = null,
112 val staging: String? = null,
153 val store: JsonObject? = null,
113 val store: JsonObject? = null,
@@ -159,58 +119,19 @@ data class AboutInfo(
159)
119)
160
120
161/**
121/**
162− * Alert summary returned by GET /api/v1/alerts.
122+ * Alert configuration
163− *
164− * The global alerts endpoint returns summary objects with fields like `title`, `state`,
165− * `severity`, and `datasets`. The per-stream endpoint uses `name` and `alerts[]` nesting.
166− * Both shapes are supported via optional fields.
167 */
123 */
168@Serializable
124@Serializable
169data class Alert(
125data class Alert(
170 val id: String? = null,
126 val id: String? = null,
171 val version: String? = null,
127 val version: String? = null,
172− // Per-stream alert format uses "name"; global summary uses "title"
173 val name: String? = null,
128 val name: String? = null,
174− val title: String? = null,
175 val message: String? = null,
129 val message: String? = null,
176 val rule: JsonObject? = null,
130 val rule: JsonObject? = null,
177 val targets: List<JsonObject> = emptyList(),
131 val targets: List<JsonObject> = emptyList(),
178− // Per-stream alert format
179 val stream: String? = null,
132 val stream: String? = null,
180 val enabled: Boolean? = null,
133 val enabled: Boolean? = null,
181− // Global summary fields
134+)
182− val state: String? = null,
183− val severity: String? = null,
184− @SerialName("alertType")
185− val alertType: String? = null,
186− @SerialName("notificationState")
187− val notificationState: String? = null,
188− val created: String? = null,
189− @SerialName("lastTriggeredAt")
190− val lastTriggeredAt: String? = null,
191− val tags: List<String> = emptyList(),
192− val datasets: List<String> = emptyList(),
193−) {
194− /** Best-effort display name from either response format. */
195− val displayName: String
196− get() = title?.takeIf { it.isNotBlank() }
197− ?: name?.takeIf { it.isNotBlank() }
198− ?: "Unnamed Alert"
199−
200− /** Whether the alert is active, derived from either response format. */
201− val isEnabled: Boolean get() = when {
202− state != null -> !state.equals("Disabled", ignoreCase = true)
203− enabled != null -> enabled
204− else -> true
205− }
206−
207− /** Stream(s) this alert is configured for. */
208− val streamDisplay: String? get() = when {
209− datasets.isNotEmpty() -> datasets.joinToString(", ")
210− stream != null -> stream
211− else -> null
212− }
213−}
214
135
215/**
136/**
216 * Result wrapper for API calls
137 * Result wrapper for API calls
@@ -221,8 +142,6 @@ sealed class ApiResult<out T> {
221 val isUnauthorized: Boolean get() = code == 401
142 val isUnauthorized: Boolean get() = code == 401
222 val isNotFound: Boolean get() = code == 404
143 val isNotFound: Boolean get() = code == 404
223 val isServerError: Boolean get() = code in 500..599
144 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.
226 val isNetworkError: Boolean get() = code == 0
145 val isNetworkError: Boolean get() = code == 0
227
146
228 val userMessage: String get() = when {
147 val userMessage: String get() = when {
@@ -240,14 +159,4 @@ sealed class ApiResult<out T> {
240 else -> message
159 else -> message
241 }
160 }
242 }
161 }
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− }
253}
162}
app/src/main/java/com/parseable/android/data/model/SavedFilter.ktdeleted−61
@@ -1,61 +0,0 @@
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−39
@@ -66,31 +66,6 @@ class ParseableRepository @Inject constructor(
66
66
67 suspend fun testConnection(): ApiResult<String> = apiClient.checkLiveness()
67 suspend fun testConnection(): ApiResult<String> = apiClient.checkLiveness()
68
68
69− /**
70− * Verify this is a Parseable server and credentials are valid.
71− * Does NOT trigger [checkAuth] so the global auth-error handler won't fire during login.
72− */
73− suspend fun verifyServer(): ApiResult<AboutInfo> {
74− return when (val aboutResult = apiClient.getAbout()) {
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 -> {
84− aboutCache = CacheEntry(aboutResult.data)
85− aboutResult
86− }
87− is ApiResult.Error -> authProbe
88− }
89− }
90− is ApiResult.Error -> aboutResult
91− }
92− }
93−
94 suspend fun getAbout(): ApiResult<AboutInfo> {
69 suspend fun getAbout(): ApiResult<AboutInfo> {
95 aboutCache?.let { if (it.isValid(ABOUT_TTL_MS)) return ApiResult.Success(it.data) }
70 aboutCache?.let { if (it.isValid(ABOUT_TTL_MS)) return ApiResult.Success(it.data) }
96 return checkAuth(apiClient.getAbout()).also { result ->
71 return checkAuth(apiClient.getAbout()).also { result ->
@@ -173,18 +148,4 @@ class ParseableRepository @Inject constructor(
173 checkAuth(apiClient.createAlert(alert))
148 checkAuth(apiClient.createAlert(alert))
174
149
175 suspend fun listUsers(): ApiResult<List<JsonObject>> = checkAuth(apiClient.listUsers())
150 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))
190}
151}
app/src/main/java/com/parseable/android/data/repository/SettingsRepository.kt+8−238
@@ -8,8 +8,6 @@ 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
13import com.parseable.android.data.model.ServerConfig
11import com.parseable.android.data.model.ServerConfig
14import dagger.hilt.android.qualifiers.ApplicationContext
12import dagger.hilt.android.qualifiers.ApplicationContext
15import kotlinx.coroutines.Dispatchers
13import kotlinx.coroutines.Dispatchers
@@ -29,12 +27,10 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
29@Singleton
27@Singleton
30class SettingsRepository @Inject constructor(
28class SettingsRepository @Inject constructor(
31 @ApplicationContext private val context: Context,
29 @ApplicationContext private val context: Context,
32− private val savedServerDao: SavedServerDao,
33) {
30) {
34 private val serverUrlKey = stringPreferencesKey("server_url")
31 private val serverUrlKey = stringPreferencesKey("server_url")
35 private val usernameKey = stringPreferencesKey("username")
32 private val usernameKey = stringPreferencesKey("username")
36 private val useTlsKey = booleanPreferencesKey("use_tls")
33 private val useTlsKey = booleanPreferencesKey("use_tls")
37− private val activeServerIdKey = longPreferencesKey("active_server_id")
38
34
39 /** Guards concurrent reads/writes across DataStore and EncryptedSharedPreferences. */
35 /** Guards concurrent reads/writes across DataStore and EncryptedSharedPreferences. */
40 private val configMutex = Mutex()
36 private val configMutex = Mutex()
@@ -66,24 +62,13 @@ class SettingsRepository @Inject constructor(
66 )
62 )
67 }
63 }
68
64
69− // ── Active server config (current connection) ──────────────────────
70−
71 val serverConfig: Flow<ServerConfig?> = context.dataStore.data
65 val serverConfig: Flow<ServerConfig?> = context.dataStore.data
72 .catch { e ->
66 .catch { e ->
73 Timber.e(e, "Failed to read DataStore")
67 Timber.e(e, "Failed to read DataStore")
74 emit(emptyPreferences())
68 emit(emptyPreferences())
75 }
69 }
76− .map {
70+ .map { prefs ->
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.
85 configMutex.withLock {
71 configMutex.withLock {
86− val prefs = context.dataStore.data.first()
87 val url = prefs[serverUrlKey] ?: return@withLock null
72 val url = prefs[serverUrlKey] ?: return@withLock null
88 val user = prefs[usernameKey] ?: return@withLock null
73 val user = prefs[usernameKey] ?: return@withLock null
89 val pass = try {
74 val pass = try {
@@ -102,43 +87,21 @@ class SettingsRepository @Inject constructor(
102 }
87 }
103 .flowOn(Dispatchers.IO)
88 .flowOn(Dispatchers.IO)
104
89
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−
113 suspend fun getSavedPassword(): ServerConfig? = serverConfig.first()
90 suspend fun getSavedPassword(): ServerConfig? = serverConfig.first()
114
91
115− /**
116− * Persist the server config. Returns true only if the credentials were durably saved.
117− * Callers (e.g. login) must not report success when this returns false — otherwise the
118− * session would appear logged in but vanish on the next launch.
119− */
120− suspend fun saveServerConfig(config: ServerConfig): Boolean {
92+ suspend fun saveServerConfig(config: ServerConfig) {
121− return configMutex.withLock {
93+ configMutex.withLock {
122 try {
94 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− }
133 context.dataStore.edit { prefs ->
95 context.dataStore.edit { prefs ->
134 prefs[serverUrlKey] = config.serverUrl
96 prefs[serverUrlKey] = config.serverUrl
135 prefs[usernameKey] = config.username
97 prefs[usernameKey] = config.username
136 prefs[useTlsKey] = config.useTls
98 prefs[useTlsKey] = config.useTls
137 }
99 }
138− true
100+ withContext(Dispatchers.IO) {
101+ encryptedPrefs.edit().putString("password", config.password).apply()
102+ }
139 } catch (e: Exception) {
103 } catch (e: Exception) {
140 Timber.e(e, "Failed to save server config")
104 Timber.e(e, "Failed to save server config")
141− false
142 }
105 }
143 }
106 }
144 }
107 }
@@ -146,206 +109,13 @@ class SettingsRepository @Inject constructor(
146 suspend fun clearConfig() {
109 suspend fun clearConfig() {
147 configMutex.withLock {
110 configMutex.withLock {
148 try {
111 try {
149− context.dataStore.edit { prefs ->
112+ context.dataStore.edit { it.clear() }
150− prefs.remove(serverUrlKey)
151− prefs.remove(usernameKey)
152− prefs.remove(useTlsKey)
153− prefs.remove(activeServerIdKey)
154− }
155 withContext(Dispatchers.IO) {
113 withContext(Dispatchers.IO) {
156− // commit() (not apply()) so the cleared password is durably gone before we
157− // return — a queued async clear could survive into the next session.
158− encryptedPrefs.edit().remove("password").commit()
114+ encryptedPrefs.edit().clear().apply()
159 }
115 }
160 } catch (e: Exception) {
116 } catch (e: Exception) {
161 Timber.e(e, "Failed to clear config")
117 Timber.e(e, "Failed to clear config")
162 }
118 }
163 }
119 }
164 }
120 }
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− }
351}
121}
app/src/main/java/com/parseable/android/di/AppModule.kt−59
@@ -2,11 +2,8 @@ package com.parseable.android.di
2
2
3import android.content.Context
3import android.content.Context
4import androidx.room.Room
4import androidx.room.Room
5−import androidx.room.migration.Migration
6−import androidx.sqlite.db.SupportSQLiteDatabase
7import com.parseable.android.data.local.FavoriteStreamDao
5import com.parseable.android.data.local.FavoriteStreamDao
8import com.parseable.android.data.local.ParseableDatabase
6import com.parseable.android.data.local.ParseableDatabase
9−import com.parseable.android.data.local.SavedServerDao
10import dagger.Module
7import dagger.Module
11import dagger.Provides
8import dagger.Provides
12import dagger.hilt.InstallIn
9import dagger.hilt.InstallIn
@@ -18,61 +15,10 @@ import javax.inject.Singleton
18@InstallIn(SingletonComponent::class)
15@InstallIn(SingletonComponent::class)
19object AppModule {
16object AppModule {
20
17
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−
66 @Provides
18 @Provides
67 @Singleton
19 @Singleton
68 fun provideDatabase(@ApplicationContext context: Context): ParseableDatabase =
20 fun provideDatabase(@ApplicationContext context: Context): ParseableDatabase =
69 Room.databaseBuilder(context, ParseableDatabase::class.java, "parseable.db")
21 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)
76 .fallbackToDestructiveMigration()
22 .fallbackToDestructiveMigration()
77 .build()
23 .build()
78
24
@@ -80,9 +26,4 @@ object AppModule {
80 @Singleton
26 @Singleton
81 fun provideFavoriteStreamDao(db: ParseableDatabase): FavoriteStreamDao =
27 fun provideFavoriteStreamDao(db: ParseableDatabase): FavoriteStreamDao =
82 db.favoriteStreamDao()
28 db.favoriteStreamDao()
83−
84− @Provides
85− @Singleton
86− fun provideSavedServerDao(db: ParseableDatabase): SavedServerDao =
87− db.savedServerDao()
88}
29}
app/src/main/java/com/parseable/android/ui/ErrorHandler.kt+5−5
@@ -1,8 +1,8 @@
1package com.parseable.android.ui
1package com.parseable.android.ui
2
2
3import androidx.compose.foundation.layout.Box
3import androidx.compose.foundation.layout.Box
4−import androidx.compose.foundation.layout.WindowInsets
5import androidx.compose.foundation.layout.fillMaxSize
4import androidx.compose.foundation.layout.fillMaxSize
5+import androidx.compose.foundation.layout.padding
6import androidx.compose.material3.Scaffold
6import androidx.compose.material3.Scaffold
7import androidx.compose.material3.Snackbar
7import androidx.compose.material3.Snackbar
8import androidx.compose.material3.SnackbarDuration
8import androidx.compose.material3.SnackbarDuration
@@ -45,7 +45,6 @@ val LocalErrorHandler = compositionLocalOf<ErrorHandler> {
45 * posted via the provided [ErrorHandler]. The handler is exposed to the
45 * posted via the provided [ErrorHandler]. The handler is exposed to the
46 * entire subtree through [LocalErrorHandler].
46 * entire subtree through [LocalErrorHandler].
47 */
47 */
48−@Suppress("UnusedMaterial3ScaffoldPaddingParameter")
49@Composable
48@Composable
50fun GlobalErrorBoundary(
49fun GlobalErrorBoundary(
51 errorHandler: ErrorHandler = remember { ErrorHandler() },
50 errorHandler: ErrorHandler = remember { ErrorHandler() },
@@ -72,10 +71,11 @@ fun GlobalErrorBoundary(
72 Snackbar(snackbarData = data)
71 Snackbar(snackbarData = data)
73 }
72 }
74 },
73 },
75− contentWindowInsets = WindowInsets(0, 0, 0, 0),
74+ ) { innerPadding ->
76− ) {
77 Box(
75 Box(
78− modifier = Modifier.fillMaxSize(),
76+ modifier = Modifier
77+ .fillMaxSize()
78+ .padding(innerPadding),
79 ) {
79 ) {
80 content()
80 content()
81 }
81 }
app/src/main/java/com/parseable/android/ui/navigation/NavGraph.kt+10−54
@@ -13,9 +13,9 @@ import com.parseable.android.ui.screens.alerts.AlertsScreen
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
17import com.parseable.android.ui.screens.settings.SettingsScreen
16import com.parseable.android.ui.screens.settings.SettingsScreen
18import com.parseable.android.ui.screens.streams.StreamsScreen
17import 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,21 +25,13 @@ 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"
29
28
30 fun login(sessionExpired: Boolean = false) =
29 fun login(sessionExpired: Boolean = false) =
31 if (sessionExpired) "login?sessionExpired=true" else LOGIN
30 if (sessionExpired) "login?sessionExpired=true" else LOGIN
32 fun logViewer(streamName: String) =
31 fun logViewer(streamName: String) =
33− "log_viewer/${encodeArg(streamName)}"
32+ "log_viewer/${URLEncoder.encode(streamName, "UTF-8")}"
34 fun streamInfo(streamName: String) =
33 fun streamInfo(streamName: String) =
35− "stream_info/${encodeArg(streamName)}"
34+ "stream_info/${URLEncoder.encode(streamName, "UTF-8")}"
36−
37− // URLEncoder maps space to "+", but Navigation decodes captured path args with Uri.decode,
38− // which only understands "%20" and leaves "+" untouched (it would survive as a literal
39− // "+"). Normalize to "%20" so the name Navigation hands back matches the original. Do NOT
40− // decode again at the call site — Navigation already does that one decode for us.
41− private fun encodeArg(value: String) =
42− URLEncoder.encode(value, "UTF-8").replace("+", "%20")
43}
35}
44
36
45@Composable
37@Composable
@@ -120,8 +112,9 @@ fun ParseableNavGraph(
120 }
112 }
121 return@composable
113 return@composable
122 }
114 }
123− // Navigation already URI-decodes captured path args, so use the value as-is.
115+ val streamName = URLDecoder.decode(
124− val streamName = backStackEntry.arguments?.getString("streamName") ?: ""
116+ backStackEntry.arguments?.getString("streamName") ?: "", "UTF-8"
117+ )
125 LogViewerScreen(
118 LogViewerScreen(
126 streamName = streamName,
119 streamName = streamName,
127 onBack = { navController.popBackStack() },
120 onBack = { navController.popBackStack() },
@@ -137,22 +130,14 @@ fun ParseableNavGraph(
137 route = Routes.STREAM_INFO,
130 route = Routes.STREAM_INFO,
138 arguments = listOf(navArgument("streamName") { type = NavType.StringType }),
131 arguments = listOf(navArgument("streamName") { type = NavType.StringType }),
139 ) { backStackEntry ->
132 ) { backStackEntry ->
140− // Navigation already URI-decodes captured path args, so use the value as-is.
133+ val streamName = URLDecoder.decode(
141− val streamName = backStackEntry.arguments?.getString("streamName") ?: ""
134+ backStackEntry.arguments?.getString("streamName") ?: "", "UTF-8"
135+ )
142 StreamInfoScreen(
136 StreamInfoScreen(
143 streamName = streamName,
137 streamName = streamName,
144 onBack = { navController.popBackStack() },
138 onBack = { navController.popBackStack() },
145 onStreamDeleted = {
139 onStreamDeleted = {
146− // popBackStack no-ops (returns false) when there's no streams entry on
147− // the back stack — e.g. when the user arrived via the deep link. In that
148− // case navigate to streams fresh so we don't strand them on the info
149− // screen for a now-deleted stream.
150− val popped = navController.popBackStack(Routes.STREAMS, inclusive = false)
140+ navController.popBackStack(Routes.STREAMS, inclusive = false)
151− if (!popped) {
152− navController.navigate(Routes.STREAMS) {
153− popUpTo(0) { inclusive = true }
154− }
155− }
156 },
141 },
157 )
142 )
158 }
143 }
@@ -185,35 +170,6 @@ fun ParseableNavGraph(
185 }
170 }
186 SettingsScreen(
171 SettingsScreen(
187 onBack = { navController.popBackStack() },
172 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() },
217 )
173 )
218 }
174 }
219 }
175 }
app/src/main/java/com/parseable/android/ui/screens/about/AboutScreen.ktdeleted−178
@@ -1,178 +0,0 @@
1−package com.parseable.android.ui.screens.about
2−
3−import android.content.Intent
4−import android.net.Uri
5−import androidx.compose.foundation.Image
6−import androidx.compose.foundation.layout.*
7−import androidx.compose.foundation.rememberScrollState
8−import androidx.compose.foundation.verticalScroll
9−import androidx.compose.material.icons.Icons
10−import androidx.compose.material.icons.automirrored.filled.ArrowBack
11−import androidx.compose.material.icons.filled.*
12−import androidx.compose.material3.*
13−import androidx.compose.runtime.Composable
14−import androidx.compose.ui.Alignment
15−import androidx.compose.ui.Modifier
16−import androidx.compose.ui.platform.LocalContext
17−import androidx.compose.ui.res.painterResource
18−import androidx.compose.ui.text.font.FontWeight
19−import androidx.compose.ui.unit.dp
20−import com.parseable.android.BuildConfig
21−import com.parseable.android.R
22−
23−private const val GITHUB_REPO_URL = "https://github.com/parseablehq/parseable-android"
24−private const val PARSEABLE_WEBSITE_URL = "https://www.parseable.com"
25−
26−@OptIn(ExperimentalMaterial3Api::class)
27−@Composable
28−fun AboutScreen(
29− onBack: () -> Unit,
30−) {
31− val context = LocalContext.current
32−
33− Scaffold(
34− topBar = {
35− TopAppBar(
36− title = { Text("About") },
37− navigationIcon = {
38− IconButton(onClick = onBack) {
39− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
40− }
41− },
42− )
43− },
44− ) { padding ->
45− Column(
46− modifier = Modifier
47− .fillMaxSize()
48− .padding(padding)
49− .verticalScroll(rememberScrollState())
50− .padding(16.dp),
51− verticalArrangement = Arrangement.spacedBy(16.dp),
52− ) {
53− // App header
54− Card(
55− modifier = Modifier.fillMaxWidth(),
56− elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
57− ) {
58− Column(
59− modifier = Modifier
60− .fillMaxWidth()
61− .padding(24.dp),
62− horizontalAlignment = Alignment.CenterHorizontally,
63− ) {
64− Image(
65− painter = painterResource(id = R.mipmap.ic_launcher),
66− contentDescription = "Parseable logo",
67− modifier = Modifier.size(80.dp),
68− )
69− Spacer(modifier = Modifier.height(12.dp))
70− Text(
71− text = "Parseable",
72− style = MaterialTheme.typography.headlineSmall,
73− fontWeight = FontWeight.Bold,
74− )
75− Text(
76− text = "Android Client",
77− style = MaterialTheme.typography.titleMedium,
78− color = MaterialTheme.colorScheme.onSurfaceVariant,
79− )
80− Spacer(modifier = Modifier.height(8.dp))
81− Text(
82− text = "Version ${BuildConfig.VERSION_NAME}",
83− style = MaterialTheme.typography.bodyMedium,
84− color = MaterialTheme.colorScheme.onSurfaceVariant,
85− )
86− }
87− }
88−
89− // Description
90− Card(
91− modifier = Modifier.fillMaxWidth(),
92− elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
93− ) {
94− Column(modifier = Modifier.padding(16.dp)) {
95− Row(verticalAlignment = Alignment.CenterVertically) {
96− Icon(
97− Icons.Filled.Info,
98− contentDescription = null,
99− tint = MaterialTheme.colorScheme.primary,
100− )
101− Spacer(modifier = Modifier.width(8.dp))
102− Text(
103− text = "About",
104− style = MaterialTheme.typography.titleMedium,
105− fontWeight = FontWeight.Bold,
106− )
107− }
108− Spacer(modifier = Modifier.height(12.dp))
109− Text(
110− text = "Parseable is an open-source log analytics platform. " +
111− "This app connects to a Parseable server instance and lets you " +
112− "browse, search, filter, and live-tail log data.",
113− style = MaterialTheme.typography.bodyMedium,
114− )
115− }
116− }
117−
118− // Links
119− Card(
120− modifier = Modifier.fillMaxWidth(),
121− elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
122− ) {
123− Column(modifier = Modifier.padding(16.dp)) {
124− Row(verticalAlignment = Alignment.CenterVertically) {
125− Icon(
126− Icons.Filled.Link,
127− contentDescription = null,
128− tint = MaterialTheme.colorScheme.primary,
129− )
130− Spacer(modifier = Modifier.width(8.dp))
131− Text(
132− text = "Links",
133− style = MaterialTheme.typography.titleMedium,
134− fontWeight = FontWeight.Bold,
135− )
136− }
137− Spacer(modifier = Modifier.height(12.dp))
138−
139− OutlinedButton(
140− onClick = {
141− context.startActivity(
142− Intent(Intent.ACTION_VIEW, Uri.parse(GITHUB_REPO_URL))
143− )
144− },
145− modifier = Modifier.fillMaxWidth(),
146− ) {
147− Icon(
148− Icons.Filled.Code,
149− contentDescription = null,
150− modifier = Modifier.size(18.dp),
151− )
152− Spacer(modifier = Modifier.width(8.dp))
153− Text("GitHub Repository")
154− }
155−
156− Spacer(modifier = Modifier.height(8.dp))
157−
158− OutlinedButton(
159− onClick = {
160− context.startActivity(
161− Intent(Intent.ACTION_VIEW, Uri.parse(PARSEABLE_WEBSITE_URL))
162− )
163− },
164− modifier = Modifier.fillMaxWidth(),
165− ) {
166− Icon(
167− Icons.Filled.Language,
168− contentDescription = null,
169− modifier = Modifier.size(18.dp),
170− )
171− Spacer(modifier = Modifier.width(8.dp))
172− Text("Parseable Website")
173− }
174− }
175− }
176− }
177− }
178−}
app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsScreen.kt

Binary file not shown.

app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsViewModel.kt+10−43
@@ -6,7 +6,6 @@ import com.parseable.android.data.model.Alert
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
10import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.StateFlow
10import kotlinx.coroutines.flow.StateFlow
12import kotlinx.coroutines.flow.asStateFlow
11import kotlinx.coroutines.flow.asStateFlow
@@ -29,40 +28,16 @@ class AlertsViewModel @Inject constructor(
29 private val _state = MutableStateFlow(AlertsState())
28 private val _state = MutableStateFlow(AlertsState())
30 val state: StateFlow<AlertsState> = _state.asStateFlow()
29 val state: StateFlow<AlertsState> = _state.asStateFlow()
31
30
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−
39 fun refresh() {
31 fun refresh() {
40− // Cancel any in-flight load so out-of-order completions can't overwrite a newer alert
41− // list with a stale one (init load, pull-to-refresh and deleteAlert->refresh can overlap).
42− refreshJob?.cancel()
43− refreshJob = viewModelScope.launch {
32+ viewModelScope.launch {
44 _state.update { it.copy(isLoading = true, error = null) }
33 _state.update { it.copy(isLoading = true, error = null) }
45− loadAlerts()
46− }
47− }
48−
49− /**
50− * Fetches the alert list and writes a terminal state update (clearing isLoading). Callers set
51− * isLoading = true before invoking. Extracted so deleteAlert can reload within its own
52− * (never-cancelled) job rather than delegating to refresh(), whose refreshJob can be cancelled
53− * by a concurrent refresh before it clears the spinner.
54− */
55− private suspend fun loadAlerts() {
56− when (val result = repository.listAlerts()) {
34+ when (val result = repository.listAlerts()) {
57− is ApiResult.Success -> {
35+ is ApiResult.Success -> {
58− // Sort by the same field the UI shows (displayName = title ?: name), otherwise
36+ _state.update { it.copy(alerts = result.data, isLoading = false) }
59− // alerts that carry both fields, or a mix of per-stream and global formats,
60− // render in an order that doesn't match their visible labels.
61− val sorted = result.data.sortedBy { it.displayName.lowercase() }
62− _state.update { it.copy(alerts = sorted, isLoading = false) }
63− }
37+ }
64− is ApiResult.Error -> {
38+ is ApiResult.Error -> {
65− _state.update { it.copy(isLoading = false, error = result.userMessage) }
39+ _state.update { it.copy(isLoading = false, error = result.userMessage) }
40+ }
66 }
41 }
67 }
42 }
68 }
43 }
@@ -75,19 +50,11 @@ class AlertsViewModel @Inject constructor(
75 _state.update { it.copy(alertToDelete = null) }
50 _state.update { it.copy(alertToDelete = null) }
76 }
51 }
77
52
78− private var deleteJob: Job? = null
79−
80 fun deleteAlert(alertId: String) {
53 fun deleteAlert(alertId: String) {
81− // Guard against a double-tap on the confirm button firing two concurrent DELETEs.
82− if (deleteJob?.isActive == true) return
83− deleteJob = viewModelScope.launch {
54+ viewModelScope.launch {
84 _state.update { it.copy(alertToDelete = null, isLoading = true, error = null) }
55 _state.update { it.copy(alertToDelete = null, isLoading = true, error = null) }
85 when (val result = repository.deleteAlert(alertId)) {
56 when (val result = repository.deleteAlert(alertId)) {
86− // Reload inline rather than via refresh(): refresh() runs on the cancellable
87− // refreshJob, so a pull-to-refresh landing right after could cancel it before it
88− // clears isLoading, leaving the spinner stuck. This job is never cancelled by a
89− // refresh, so its terminal state update always runs.
90− is ApiResult.Success -> loadAlerts()
57+ is ApiResult.Success -> refresh()
91 is ApiResult.Error -> {
58 is ApiResult.Error -> {
92 _state.update { it.copy(isLoading = false, error = result.userMessage) }
59 _state.update { it.copy(isLoading = false, error = result.userMessage) }
93 }
60 }
app/src/main/java/com/parseable/android/ui/screens/login/LoginScreen.kt+8−64
@@ -1,6 +1,5 @@
1package com.parseable.android.ui.screens.login
1package com.parseable.android.ui.screens.login
2
2
3−import androidx.compose.foundation.Image
4import androidx.compose.foundation.layout.*
3import androidx.compose.foundation.layout.*
5import androidx.compose.foundation.rememberScrollState
4import androidx.compose.foundation.rememberScrollState
6import androidx.compose.foundation.text.KeyboardActions
5import androidx.compose.foundation.text.KeyboardActions
@@ -16,9 +15,7 @@ import androidx.compose.ui.Alignment
16import androidx.compose.ui.Modifier
15import androidx.compose.ui.Modifier
17import androidx.compose.ui.focus.FocusDirection
16import androidx.compose.ui.focus.FocusDirection
18import androidx.compose.ui.platform.LocalFocusManager
17import androidx.compose.ui.platform.LocalFocusManager
19−import androidx.compose.ui.res.painterResource
20import androidx.compose.ui.text.font.FontWeight
18import androidx.compose.ui.text.font.FontWeight
21−import com.parseable.android.R
22import com.parseable.android.ui.LocalErrorHandler
19import com.parseable.android.ui.LocalErrorHandler
23import androidx.compose.ui.text.input.ImeAction
20import androidx.compose.ui.text.input.ImeAction
24import androidx.compose.ui.text.input.KeyboardType
21import androidx.compose.ui.text.input.KeyboardType
@@ -40,8 +37,8 @@ fun LoginScreen(
40 var passwordVisible by rememberSaveable { mutableStateOf(false) }
37 var passwordVisible by rememberSaveable { mutableStateOf(false) }
41 val errorHandler = LocalErrorHandler.current
38 val errorHandler = LocalErrorHandler.current
42
39
43− LaunchedEffect(Unit) {
40+ LaunchedEffect(state.loginSuccess) {
44− viewModel.loginSuccessEvent.collect {
41+ if (state.loginSuccess) {
45 onLoginSuccess()
42 onLoginSuccess()
46 }
43 }
47 }
44 }
@@ -62,12 +59,6 @@ fun LoginScreen(
62 horizontalAlignment = Alignment.CenterHorizontally,
59 horizontalAlignment = Alignment.CenterHorizontally,
63 verticalArrangement = Arrangement.Center,
60 verticalArrangement = Arrangement.Center,
64 ) {
61 ) {
65− Image(
66− painter = painterResource(id = R.mipmap.ic_launcher),
67− contentDescription = "Parseable logo",
68− modifier = Modifier.size(120.dp),
69− )
70−
71 Text(
62 Text(
72 text = "Parseable",
63 text = "Parseable",
73 style = MaterialTheme.typography.headlineLarge,
64 style = MaterialTheme.typography.headlineLarge,
@@ -178,7 +169,12 @@ fun LoginScreen(
178
169
179 if (state.allowInsecure) {
170 if (state.allowInsecure) {
180 val isPrivateNetwork = remember(state.serverUrl) {
171 val isPrivateNetwork = remember(state.serverUrl) {
181− isPrivateHost(extractHost(state.serverUrl))
172+ val host = state.serverUrl
173+ .removePrefix("http://").removePrefix("https://")
174+ .substringBefore("/").substringBefore(":")
175+ host.isBlank() || host == "localhost" || host.startsWith("127.") ||
176+ host.startsWith("10.") || host.startsWith("192.168.") ||
177+ host.matches(Regex("^172\\.(1[6-9]|2[0-9]|3[01])\\..*"))
182 }
178 }
183 if (!isPrivateNetwork) {
179 if (!isPrivateNetwork) {
184 Card(
180 Card(
@@ -250,55 +246,3 @@ fun LoginScreen(
250 }
246 }
251 }
247 }
252}
248}
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+19−100
@@ -7,12 +7,10 @@ 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
11import kotlinx.coroutines.flow.MutableStateFlow
10import kotlinx.coroutines.flow.MutableStateFlow
12import kotlinx.coroutines.flow.StateFlow
11import kotlinx.coroutines.flow.StateFlow
13import kotlinx.coroutines.flow.asStateFlow
12import kotlinx.coroutines.flow.asStateFlow
14import kotlinx.coroutines.flow.first
13import kotlinx.coroutines.flow.first
15−import kotlinx.coroutines.flow.receiveAsFlow
16import kotlinx.coroutines.flow.update
14import kotlinx.coroutines.flow.update
17import kotlinx.coroutines.launch
15import kotlinx.coroutines.launch
18import javax.inject.Inject
16import javax.inject.Inject
@@ -24,6 +22,7 @@ data class LoginState(
24 val allowInsecure: Boolean = false,
22 val allowInsecure: Boolean = false,
25 val isLoading: Boolean = false,
23 val isLoading: Boolean = false,
26 val error: String? = null,
24 val error: String? = null,
25+ val loginSuccess: Boolean = false,
27 val serverUrlError: String? = null,
26 val serverUrlError: String? = null,
28 val usernameError: String? = null,
27 val usernameError: String? = null,
29 val passwordError: String? = null,
28 val passwordError: String? = null,
@@ -39,15 +38,6 @@ class LoginViewModel @Inject constructor(
39 private val _state = MutableStateFlow(LoginState())
38 private val _state = MutableStateFlow(LoginState())
40 val state: StateFlow<LoginState> = _state.asStateFlow()
39 val state: StateFlow<LoginState> = _state.asStateFlow()
41
40
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−
51 init {
41 init {
52 viewModelScope.launch {
42 viewModelScope.launch {
53 val config = settingsRepository.serverConfig.first()
43 val config = settingsRepository.serverConfig.first()
@@ -65,39 +55,9 @@ class LoginViewModel @Inject constructor(
65 }
55 }
66
56
67 fun loginWithSavedCredentials() {
57 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) }
73 viewModelScope.launch {
58 viewModelScope.launch {
74− val config = settingsRepository.getSavedPassword()
59+ val config = settingsRepository.getSavedPassword() ?: return@launch
75− if (config == null) {
76− // URL/username are present but the saved password can't be read (e.g. the
77− // Keystore entry was lost). Surface this instead of silently stopping the
78− // spinner, and hide the now-useless "use saved credentials" button.
79− _state.update {
80− it.copy(
81− isLoading = false,
82− hasSavedCredentials = false,
83− error = "Saved credentials could not be read. Please log in again.",
84− )
85− }
86− return@launch
87− }
88− // Restore the saved URL/username so the saved password is paired with the
89− // server/user it was saved for, even if the user edited those fields.
90− _state.update {
91− it.copy(
92− serverUrl = config.serverUrl,
93− username = config.username,
94− allowInsecure = !config.useTls,
95− serverUrlError = null,
96− usernameError = null,
97− passwordError = null,
98− )
99− }
100− performLogin(config.serverUrl, config.username, config.password, !config.useTls)
60+ performLogin(config.password)
101 }
61 }
102 }
62 }
103
63
@@ -120,15 +80,6 @@ class LoginViewModel @Inject constructor(
120 fun onLogin() {
80 fun onLogin() {
121 val current = _state.value
81 val current = _state.value
122
82
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−
132 // Validate
83 // Validate
133 var hasError = false
84 var hasError = false
134 var urlError: String? = null
85 var urlError: String? = null
@@ -159,34 +110,18 @@ class LoginViewModel @Inject constructor(
159 return
110 return
160 }
111 }
161
112
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.
168 viewModelScope.launch {
113 viewModelScope.launch {
169− performLogin(current.serverUrl, current.username, current.password, current.allowInsecure)
114+ performLogin(current.password)
170 }
115 }
171 }
116 }
172
117
173− private suspend fun performLogin(
118+ private suspend fun performLogin(password: String) {
174− serverUrl: String,
119+ val current = _state.value
175− username: String,
176− password: String,
177− allowInsecure: Boolean,
178− ) {
179 _state.update { it.copy(isLoading = true, error = null) }
120 _state.update { it.copy(isLoading = true, error = null) }
180
121
181− // Strip trailing slashes so the persisted URL matches the canonical form the API
182− // client uses (ParseableApiClient.configure also does trimEnd('/')). Without this,
183− // "http://host:8000" and "http://host:8000/" persist as distinct strings, defeating
184− // saveServer's (serverUrl, username) dedup and the UNIQUE index — the same server
185− // ends up as two saved-server rows.
186− var url = serverUrl.trim().trimEnd('/')
122+ var url = current.serverUrl.trim()
187− val urlLower = url.lowercase()
188− if (!urlLower.startsWith("http://") && !urlLower.startsWith("https://")) {
123+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
189− url = if (allowInsecure) "http://$url" else "https://$url"
124+ url = if (current.allowInsecure) "http://$url" else "https://$url"
190 }
125 }
191
126
192 val isValidUrl = try {
127 val isValidUrl = try {
@@ -207,43 +142,27 @@ class LoginViewModel @Inject constructor(
207
142
208 val config = ServerConfig(
143 val config = ServerConfig(
209 serverUrl = url,
144 serverUrl = url,
210− username = username.trim(),
145+ username = current.username.trim(),
211 password = password,
146 password = password,
212− useTls = url.lowercase().startsWith("https"),
147+ useTls = url.startsWith("https"),
213 )
148 )
214
149
215 repository.configure(config)
150 repository.configure(config)
216
151
217 when (val result = repository.testConnection()) {
152 when (val result = repository.testConnection()) {
218 is ApiResult.Success -> {
153 is ApiResult.Success -> {
219− // Verify this is actually a Parseable server and credentials are valid.
154+ // Verify this is actually a Parseable server by fetching /about
220− // Uses verifyServer() to avoid triggering the global auth-error handler.
221− when (val aboutResult = repository.verifyServer()) {
155+ when (val aboutResult = repository.getAbout()) {
222 is ApiResult.Success -> {
156 is ApiResult.Success -> {
223− // Only report success if the credentials were durably persisted.
224− // Otherwise the app would navigate in and clear the typed password,
225− // yet bounce back to login on next launch with nothing to restore.
226− if (settingsRepository.saveServerConfig(config)) {
157+ settingsRepository.saveServerConfig(config)
227− settingsRepository.saveServer(config)
228− _state.update { it.copy(isLoading = false, password = "") }
158+ _state.update { it.copy(isLoading = false, loginSuccess = true, 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− }
238 }
159 }
239 is ApiResult.Error -> {
160 is ApiResult.Error -> {
240− val errorMsg = if (aboutResult.isUnauthorized) {
241− "Invalid credentials. Check your username and password."
242− } else {
243− "Server responded but doesn't appear to be Parseable: ${aboutResult.userMessage}"
244− }
245 _state.update {
161 _state.update {
162+ it.copy(
246− it.copy(isLoading = false, error = errorMsg)
163+ isLoading = false,
164+ error = "Server responded but doesn't appear to be Parseable: ${aboutResult.userMessage}",
165+ )
247 }
166 }
248 }
167 }
249 }
168 }
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.kt+72−501
@@ -56,8 +56,6 @@ 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) }
61 var expandedLogKey by remember { mutableStateOf<String?>(null) }
59 var expandedLogKey by remember { mutableStateOf<String?>(null) }
62 var showDateRangePicker by remember { mutableStateOf(false) }
60 var showDateRangePicker by remember { mutableStateOf(false) }
63 var showShareWarning by remember { mutableStateOf(false) }
61 var showShareWarning by remember { mutableStateOf(false) }
@@ -101,10 +99,6 @@ fun LogViewerScreen(
101 actions = {
99 actions = {
102 StreamingToggleButton(
100 StreamingToggleButton(
103 isStreaming = state.isStreaming,
101 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(),
108 onClick = viewModel::toggleStreaming,
102 onClick = viewModel::toggleStreaming,
109 )
103 )
110 IconButton(onClick = { onStreamInfo(streamName) }) {
104 IconButton(onClick = { onStreamInfo(streamName) }) {
@@ -116,12 +110,6 @@ fun LogViewerScreen(
116 IconButton(onClick = { showSqlSheet = true }) {
110 IconButton(onClick = { showSqlSheet = true }) {
117 Icon(Icons.Filled.Code, contentDescription = "SQL Query")
111 Icon(Icons.Filled.Code, contentDescription = "SQL Query")
118 }
112 }
119− IconButton(onClick = {
120− viewModel.loadSavedFilters()
121− showSavedFiltersSheet = true
122− }) {
123− Icon(Icons.Filled.BookmarkBorder, contentDescription = "Saved Filters")
124− }
125 IconButton(
113 IconButton(
126 onClick = { showShareWarning = true },
114 onClick = { showShareWarning = true },
127 enabled = state.logs.isNotEmpty(),
115 enabled = state.logs.isNotEmpty(),
@@ -171,9 +159,7 @@ fun LogViewerScreen(
171 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
159 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
172 keyboardActions = KeyboardActions(onSearch = {
160 keyboardActions = KeyboardActions(onSearch = {
173 keyboardController?.hide()
161 keyboardController?.hide()
174− // Runs the search now and cancels the pending debounce (which stops live-tail
162+ viewModel.refresh()
175− // and refreshes) so we don't fire a second, redundant refresh ~300ms later.
176− viewModel.submitSearch()
177 }),
163 }),
178 modifier = Modifier
164 modifier = Modifier
179 .fillMaxWidth()
165 .fillMaxWidth()
@@ -287,41 +273,9 @@ fun LogViewerScreen(
287 // Log entries
273 // Log entries
288 PullToRefreshBox(
274 PullToRefreshBox(
289 isRefreshing = state.isLoading,
275 isRefreshing = state.isLoading,
290− onRefresh = viewModel::pullRefresh,
276+ onRefresh = viewModel::refresh,
291 modifier = Modifier.fillMaxSize(),
277 modifier = Modifier.fillMaxSize(),
292 ) {
278 ) {
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−
325 if (state.error != null && state.logs.isEmpty()) {
279 if (state.error != null && state.logs.isEmpty()) {
326 Box(
280 Box(
327 modifier = Modifier.fillMaxSize(),
281 modifier = Modifier.fillMaxSize(),
@@ -347,13 +301,6 @@ fun LogViewerScreen(
347 }
301 }
348 }
302 }
349 }
303 }
350− } else if (state.logs.isEmpty() && state.isLoading) {
351− Box(
352− modifier = Modifier.fillMaxSize(),
353− contentAlignment = Alignment.Center,
354− ) {
355− CircularProgressIndicator()
356− }
357 } else if (state.logs.isEmpty() && !state.isLoading) {
304 } else if (state.logs.isEmpty() && !state.isLoading) {
358 val emptyMessage = when {
305 val emptyMessage = when {
359 state.isStreaming ->
306 state.isStreaming ->
@@ -388,6 +335,29 @@ fun LogViewerScreen(
388 }
335 }
389 }
336 }
390 } else {
337 } else {
338+ val listState = rememberLazyListState()
339+ val clipboard = remember {
340+ context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
341+ }
342+
343+ // Auto-load more when scrolling near the bottom
344+ if (state.hasMore && !state.isLoading) {
345+ val shouldLoadMore by remember {
346+ derivedStateOf {
347+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
348+ lastVisible >= listState.layoutInfo.totalItemsCount - 5
349+ }
350+ }
351+ LaunchedEffect(shouldLoadMore) {
352+ if (shouldLoadMore) viewModel.loadMore()
353+ }
354+ }
355+
356+ // Pre-compute stable keys so each log is assigned exactly one key
357+ val logKeys = remember(state.logs) {
358+ stableLogKeys(state.logs)
359+ }
360+
391 LazyColumn(
361 LazyColumn(
392 state = listState,
362 state = listState,
393 contentPadding = PaddingValues(8.dp),
363 contentPadding = PaddingValues(8.dp),
@@ -395,34 +365,14 @@ fun LogViewerScreen(
395 ) {
365 ) {
396 itemsIndexed(
366 itemsIndexed(
397 state.logs,
367 state.logs,
398− key = { index, _ -> state.logKeys.getOrElse(index) { "log_$index" } },
368+ key = { index, _ -> logKeys[index] },
399 ) { index, logEntry ->
369 ) { index, logEntry ->
400− val key = state.logKeys.getOrElse(index) { "log_$index" }
370+ val key = logKeys[index]
401 LogEntryCard(
371 LogEntryCard(
402 logEntry = logEntry,
372 logEntry = logEntry,
403− stableKey = key,
404 isExpanded = expandedLogKey == key,
373 isExpanded = expandedLogKey == key,
405 onClick = {
374 onClick = {
406− if (expandedLogKey == key) {
407− // Collapsing: if the top of the entry is scrolled
408− // above the viewport, jump to it so the user sees
409− // the collapsed card instead of a random position.
410− // Resolve the row by its stable key, not the captured
411− // index — live-tail prepends shift every row's index,
412− // so the closed-over index can point at the wrong row.
413− val itemInfo = listState.layoutInfo.visibleItemsInfo
414− .firstOrNull { it.key == key }
415− val needsScroll = itemInfo == null || itemInfo.offset < 0
416− expandedLogKey = null
375+ expandedLogKey = if (expandedLogKey == key) null else key
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− }
426 },
376 },
427 clipboardManager = clipboard,
377 clipboardManager = clipboard,
428 onCopied = {
378 onCopied = {
@@ -433,12 +383,6 @@ fun LogViewerScreen(
433 )
383 )
434 }
384 }
435 },
385 },
436− onFilterInclude = { column, value ->
437− viewModel.addFilter(column, "=", value)
438− },
439− onFilterExclude = { column, value ->
440− viewModel.addFilter(column, "!=", value)
441− },
442 )
386 )
443 }
387 }
444
388
@@ -491,7 +435,6 @@ fun LogViewerScreen(
491 if (showFilterSheet) {
435 if (showFilterSheet) {
492 FilterBottomSheet(
436 FilterBottomSheet(
493 columns = state.columns,
437 columns = state.columns,
494− logs = state.logs,
495 onDismiss = { showFilterSheet = false },
438 onDismiss = { showFilterSheet = false },
496 onApplyFilter = { column, operator, value ->
439 onApplyFilter = { column, operator, value ->
497 viewModel.addFilter(column, operator, value)
440 viewModel.addFilter(column, operator, value)
@@ -534,18 +477,10 @@ fun LogViewerScreen(
534 if (!dir.mkdirs() && !dir.isDirectory) {
477 if (!dir.mkdirs() && !dir.isDirectory) {
535 throw java.io.IOException("Failed to create shared_logs directory")
478 throw java.io.IOException("Failed to create shared_logs directory")
536 }
479 }
537− // Clean up STALE exports only. A share chooser opened moments
480+ // Clean up previous exports
538− // ago may still be reading a recent file via its FileProvider
539− // URI; deleting every file (as before) could pull one out from
540− // under an in-flight share. Keep anything written in the last
541− // hour and give each export a unique name so concurrent shares
542− // never collide.
543− val cutoff = System.currentTimeMillis() - 60 * 60 * 1000L
544− dir.listFiles()?.forEach { old ->
481+ dir.listFiles()?.forEach { old -> old.delete() }
545− if (old.lastModified() < cutoff) old.delete()
546− }
547 val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
482 val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
548− val file = java.io.File(dir, "logs_${safeFileName}_${System.currentTimeMillis()}.json")
483+ val file = java.io.File(dir, "logs_$safeFileName.json")
549 file.bufferedWriter().use { writer ->
484 file.bufferedWriter().use { writer ->
550 // Use compact JSON for large exports to reduce memory/file size
485 // Use compact JSON for large exports to reduce memory/file size
551 val encoder = if (logs.size > 1000) Json else prettyJson
486 val encoder = if (logs.size > 1000) Json else prettyJson
@@ -607,18 +542,17 @@ fun LogViewerScreen(
607 val start = dateRangePickerState.selectedStartDateMillis
542 val start = dateRangePickerState.selectedStartDateMillis
608 val end = dateRangePickerState.selectedEndDateMillis
543 val end = dateRangePickerState.selectedEndDateMillis
609 if (start != null && end != null) {
544 if (start != null && end != null) {
610− // DateRangePicker returns midnight UTC for the selected date, so the
545+ // DateRangePicker returns midnight UTC for the selected date.
611− // calendar date must be read back in UTC — reading it in the local zone
546+ // Adjust to cover the full end day in the user's local timezone:
612− // would roll west-of-UTC users to the previous day. Once we have the
547+ // Convert to local date, get end-of-day, convert back to UTC millis.
613− // tapped date, build the boundaries to cover the full day locally.
614 val localZone = java.time.ZoneId.systemDefault()
548 val localZone = java.time.ZoneId.systemDefault()
615 val endLocalDate = java.time.Instant.ofEpochMilli(end)
549 val endLocalDate = java.time.Instant.ofEpochMilli(end)
616− .atZone(java.time.ZoneOffset.UTC).toLocalDate()
550+ .atZone(localZone).toLocalDate()
617 val endOfDayUtc = endLocalDate.plusDays(1)
551 val endOfDayUtc = endLocalDate.plusDays(1)
618 .atStartOfDay(localZone)
552 .atStartOfDay(localZone)
619 .toInstant().toEpochMilli() - 1
553 .toInstant().toEpochMilli() - 1
620 val startLocalDate = java.time.Instant.ofEpochMilli(start)
554 val startLocalDate = java.time.Instant.ofEpochMilli(start)
621− .atZone(java.time.ZoneOffset.UTC).toLocalDate()
555+ .atZone(localZone).toLocalDate()
622 val startUtc = startLocalDate
556 val startUtc = startLocalDate
623 .atStartOfDay(localZone)
557 .atStartOfDay(localZone)
624 .toInstant().toEpochMilli()
558 .toInstant().toEpochMilli()
@@ -644,37 +578,6 @@ fun LogViewerScreen(
644 )
578 )
645 }
579 }
646 }
580 }
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− }
678}
581}
679
582
680@Composable
583@Composable
@@ -701,10 +604,9 @@ private fun TimeRangeBar(
701@Composable
604@Composable
702private fun StreamingToggleButton(
605private fun StreamingToggleButton(
703 isStreaming: Boolean,
606 isStreaming: Boolean,
704− enabled: Boolean,
705 onClick: () -> Unit,
607 onClick: () -> Unit,
706) {
608) {
707− IconButton(onClick = onClick, enabled = enabled || isStreaming) {
609+ IconButton(onClick = onClick) {
708 if (isStreaming) {
610 if (isStreaming) {
709 val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
611 val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
710 val alpha by infiniteTransition.animateFloat(
612 val alpha by infiniteTransition.animateFloat(
@@ -735,17 +637,12 @@ private fun StreamingToggleButton(
735@Composable
637@Composable
736fun LogEntryCard(
638fun LogEntryCard(
737 logEntry: JsonObject,
639 logEntry: JsonObject,
738− stableKey: String,
739 isExpanded: Boolean,
640 isExpanded: Boolean,
740 onClick: () -> Unit,
641 onClick: () -> Unit,
741 onCopied: () -> Unit = {},
642 onCopied: () -> Unit = {},
742− onFilterInclude: (column: String, value: String) -> Unit = { _, _ -> },
743− onFilterExclude: (column: String, value: String) -> Unit = { _, _ -> },
744 clipboardManager: android.content.ClipboardManager,
643 clipboardManager: android.content.ClipboardManager,
745) {
644) {
746− val severity = remember(logEntry) { detectSeverity(logEntry) }
747− val severityTint = severityBackgroundColor(severity)
748− val containerColor = severityTint ?: MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
645+ val containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
749 val cardColors = CardDefaults.cardColors(containerColor = containerColor)
646 val cardColors = CardDefaults.cardColors(containerColor = containerColor)
750 Card(
647 Card(
751 onClick = onClick,
648 onClick = onClick,
@@ -754,9 +651,7 @@ fun LogEntryCard(
754 ) {
651 ) {
755 Column(modifier = Modifier.padding(12.dp)) {
652 Column(modifier = Modifier.padding(12.dp)) {
756 // Timestamp row
653 // Timestamp row
757− // Key the derived values on the content-stable log key (not the JsonObject
758− // instance), so live-tail re-emissions of unchanged rows don't re-parse them.
759− val rawTimestamp = remember(stableKey) {
654+ val rawTimestamp = remember(logEntry) {
760 try {
655 try {
761 logEntry["p_timestamp"]?.jsonPrimitive?.content
656 logEntry["p_timestamp"]?.jsonPrimitive?.content
762 ?: logEntry["datetime"]?.jsonPrimitive?.content
657 ?: logEntry["datetime"]?.jsonPrimitive?.content
@@ -781,7 +676,7 @@ fun LogEntryCard(
781
676
782 if (!isExpanded) {
677 if (!isExpanded) {
783 // Compact view: show first meaningful fields
678 // Compact view: show first meaningful fields
784− val preview = remember(stableKey) {
679+ val preview = remember(logEntry) {
785 logEntry.entries
680 logEntry.entries
786 .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
681 .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
787 .take(3)
682 .take(3)
@@ -823,55 +718,25 @@ fun LogEntryCard(
823 }
718 }
824 HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
719 HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
825 logEntry.entries.forEach { (key, value) ->
720 logEntry.entries.forEach { (key, value) ->
826− val displayValue = formatJsonValue(value)
827 Row(
721 Row(
828 modifier = Modifier
722 modifier = Modifier
829 .fillMaxWidth()
723 .fillMaxWidth()
830 .padding(vertical = 2.dp),
724 .padding(vertical = 2.dp),
831− verticalAlignment = Alignment.CenterVertically,
832 ) {
725 ) {
833− Column(modifier = Modifier.weight(1f)) {
726+ Text(
834− Row {
835− Text(
836− text = "$key: ",
727+ text = "$key: ",
837− style = MaterialTheme.typography.bodySmall,
728+ style = MaterialTheme.typography.bodySmall,
838− fontWeight = FontWeight.Bold,
729+ fontWeight = FontWeight.Bold,
839− fontFamily = FontFamily.Monospace,
730+ fontFamily = FontFamily.Monospace,
840− color = MaterialTheme.colorScheme.primary,
731+ color = MaterialTheme.colorScheme.primary,
841− fontSize = 12.sp,
732+ fontSize = 12.sp,
842− )
733+ )
843− Text(
734+ Text(
844− text = displayValue,
735+ text = formatJsonValue(value),
845− style = MaterialTheme.typography.bodySmall,
736+ style = MaterialTheme.typography.bodySmall,
846− fontFamily = FontFamily.Monospace,
737+ fontFamily = FontFamily.Monospace,
847− fontSize = 12.sp,
738+ fontSize = 12.sp,
848− )
739+ )
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− }
875 }
740 }
876 }
741 }
877 }
742 }
@@ -879,6 +744,24 @@ fun LogEntryCard(
879 }
744 }
880}
745}
881
746
747+/**
748+ * Produces deterministic keys for a list of log entries so that expanded state survives
749+ * list mutations (e.g., new logs prepended during streaming). Keys are content-based
750+ * using p_timestamp + p_metadata + p_tags; duplicate content gets a disambiguation suffix.
751+ */
752+private fun stableLogKeys(logs: List<JsonObject>): List<String> {
753+ val seen = mutableMapOf<String, Int>()
754+ return logs.map { log ->
755+ val ts = log["p_timestamp"]?.toString()
756+ val meta = log["p_metadata"]?.toString()
757+ val tag = log["p_tags"]?.toString()
758+ val base = if (ts != null) "$ts|${meta.orEmpty()}|${tag.orEmpty()}" else log.hashCode().toString()
759+ val count = seen.getOrDefault(base, 0)
760+ seen[base] = count + 1
761+ if (count == 0) base else "$base|$count"
762+ }
763+}
764+
882private fun formatJsonValue(element: JsonElement): String {
765private fun formatJsonValue(element: JsonElement): String {
883 return when (element) {
766 return when (element) {
884 is JsonPrimitive -> element.content
767 is JsonPrimitive -> element.content
@@ -891,22 +774,10 @@ private fun formatJsonValue(element: JsonElement): String {
891@Composable
774@Composable
892fun FilterBottomSheet(
775fun FilterBottomSheet(
893 columns: List<String>,
776 columns: List<String>,
894− logs: List<JsonObject> = emptyList(),
895 onDismiss: () -> Unit,
777 onDismiss: () -> Unit,
896 onApplyFilter: (column: String, operator: String, value: String) -> Unit,
778 onApplyFilter: (column: String, operator: String, value: String) -> Unit,
897) {
779) {
898 var selectedColumn by remember { mutableStateOf(columns.firstOrNull() ?: "") }
780 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− }
910 var selectedOperator by remember { mutableStateOf("=") }
781 var selectedOperator by remember { mutableStateOf("=") }
911 var filterValue by remember { mutableStateOf("") }
782 var filterValue by remember { mutableStateOf("") }
912 var columnDropdownExpanded by remember { mutableStateOf(false) }
783 var columnDropdownExpanded by remember { mutableStateOf(false) }
@@ -914,28 +785,6 @@ fun FilterBottomSheet(
914
785
915 val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
786 val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
916
787
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−
939 ModalBottomSheet(onDismissRequest = onDismiss) {
788 ModalBottomSheet(onDismissRequest = onDismiss) {
940 Column(
789 Column(
941 modifier = Modifier.padding(16.dp),
790 modifier = Modifier.padding(16.dp),
@@ -971,7 +820,6 @@ fun FilterBottomSheet(
971 text = { Text(column) },
820 text = { Text(column) },
972 onClick = {
821 onClick = {
973 selectedColumn = column
822 selectedColumn = column
974− filterValue = ""
975 columnDropdownExpanded = false
823 columnDropdownExpanded = false
976 },
824 },
977 )
825 )
@@ -1040,44 +888,6 @@ fun FilterBottomSheet(
1040 }
888 }
1041 }),
889 }),
1042 )
890 )
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− }
1081 }
891 }
1082
892
1083 val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
893 val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
@@ -1182,242 +992,3 @@ fun SqlQueryBottomSheet(
1182 }
992 }
1183 }
993 }
1184}
994}
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+73−555
@@ -3,9 +3,8 @@ 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
7import com.parseable.android.data.escapeSql
6import com.parseable.android.data.escapeSql
8−import com.parseable.android.data.model.*
7+import com.parseable.android.data.model.ApiResult
9import com.parseable.android.data.repository.ParseableRepository
8import com.parseable.android.data.repository.ParseableRepository
10import dagger.hilt.android.lifecycle.HiltViewModel
9import dagger.hilt.android.lifecycle.HiltViewModel
11import kotlinx.coroutines.Job
10import kotlinx.coroutines.Job
@@ -16,13 +15,8 @@ import kotlinx.coroutines.flow.asStateFlow
16import kotlinx.coroutines.flow.update
15import kotlinx.coroutines.flow.update
17import kotlinx.coroutines.isActive
16import kotlinx.coroutines.isActive
18import kotlinx.coroutines.launch
17import kotlinx.coroutines.launch
19−import kotlinx.coroutines.sync.Mutex
20−import kotlinx.coroutines.sync.withLock
21import kotlinx.serialization.json.JsonObject
18import kotlinx.serialization.json.JsonObject
22import kotlinx.serialization.json.jsonPrimitive
19import kotlinx.serialization.json.jsonPrimitive
23−import java.time.Instant
24−import java.time.LocalDateTime
25−import java.time.OffsetDateTime
26import java.time.ZoneOffset
20import java.time.ZoneOffset
27import java.time.ZonedDateTime
21import java.time.ZonedDateTime
28import java.time.format.DateTimeFormatter
22import java.time.format.DateTimeFormatter
@@ -40,21 +34,10 @@ enum class TimeRange(val label: String, val minutes: Long) {
40 CUSTOM("Custom", 0),
34 CUSTOM("Custom", 0),
41}
35}
42
36
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−
50data class FilterState(
37data class FilterState(
51 val searchQuery: String = "",
38 val searchQuery: String = "",
52 val activeFilters: List<String> = emptyList(),
39 val activeFilters: List<String> = emptyList(),
53 val filterClauses: List<String> = emptyList(),
40 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(),
58 val customSql: String = "",
41 val customSql: String = "",
59 val isSearching: Boolean = false,
42 val isSearching: Boolean = false,
60)
43)
@@ -66,33 +49,19 @@ data class StreamingState(
66 val currentIntervalMs: Long = 3000L,
49 val currentIntervalMs: Long = 3000L,
67)
50)
68
51
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−
76data class LogViewerState(
52data class LogViewerState(
77 val streamName: String = "",
53 val streamName: String = "",
78 val logs: List<JsonObject> = emptyList(),
54 val logs: List<JsonObject> = emptyList(),
79− val logKeys: List<String> = emptyList(),
80 val columns: List<String> = emptyList(),
55 val columns: List<String> = emptyList(),
81− val searchableColumns: List<String> = emptyList(),
82 val isLoading: Boolean = false,
56 val isLoading: Boolean = false,
83 val error: String? = null,
57 val error: String? = null,
84 val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
58 val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
85 val filters: FilterState = FilterState(),
59 val filters: FilterState = FilterState(),
86 val streaming: StreamingState = StreamingState(),
60 val streaming: StreamingState = StreamingState(),
87− val savedFilters: SavedFiltersState = SavedFiltersState(),
88 val currentLimit: Int = 500,
61 val currentLimit: Int = 500,
89 val hasMore: Boolean = false,
62 val hasMore: Boolean = false,
90 val customStartTime: Long? = null,
63 val customStartTime: Long? = null,
91 val customEndTime: Long? = null,
64 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,
96) {
65) {
97 // Convenience accessors for backward compatibility with Screen
66 // Convenience accessors for backward compatibility with Screen
98 val searchQuery: String get() = filters.searchQuery
67 val searchQuery: String get() = filters.searchQuery
@@ -120,53 +89,17 @@ class LogViewerViewModel @Inject constructor(
120 @Volatile private var streamingGeneration: Int = 0
89 @Volatile private var streamingGeneration: Int = 0
121 @Volatile private var lastSeenTimestamp: String? = null
90 @Volatile private var lastSeenTimestamp: String? = null
122 @Volatile private var consecutiveStreamingErrors: Int = 0
91 @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>()
135 private var searchJob: Job? = null
92 private var searchJob: Job? = null
136 private var schemaJob: Job? = null
93 private var schemaJob: Job? = null
137− private var refreshJob: Job? = null
138
94
139 companion object {
95 companion object {
140 private const val STREAMING_BASE_INTERVAL_MS = 3000L
96 private const val STREAMING_BASE_INTERVAL_MS = 3000L
141 private const val STREAMING_MAX_INTERVAL_MS = 30000L
97 private const val STREAMING_MAX_INTERVAL_MS = 30000L
142 private const val STREAMING_MAX_LOGS = 1000
98 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
146 private const val MAX_LOAD_LIMIT = 5000
99 private const val MAX_LOAD_LIMIT = 5000
147 private const val LOAD_MORE_INCREMENT = 500
100 private const val LOAD_MORE_INCREMENT = 500
148 private const val MAX_STREAMING_ERRORS = 5
101 private const val MAX_STREAMING_ERRORS = 5
149 private val ALLOWED_OPERATORS = setOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
102 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− }
170 }
103 }
171
104
172 fun initialize(streamName: String) {
105 fun initialize(streamName: String) {
@@ -182,9 +115,8 @@ class LogViewerViewModel @Inject constructor(
182 schemaJob = viewModelScope.launch {
115 schemaJob = viewModelScope.launch {
183 when (val result = repository.getStreamSchema(streamName)) {
116 when (val result = repository.getStreamSchema(streamName)) {
184 is ApiResult.Success -> {
117 is ApiResult.Success -> {
185− val columns = result.data.fields.map { it.name }.sorted()
118+ val columns = result.data.fields.map { it.name }
186− val searchable = columns.filter { !it.startsWith("p_") }
187− _state.update { it.copy(columns = columns, searchableColumns = searchable) }
119+ _state.update { it.copy(columns = columns) }
188 }
120 }
189 is ApiResult.Error -> { /* Schema loading failure is non-fatal */ }
121 is ApiResult.Error -> { /* Schema loading failure is non-fatal */ }
190 }
122 }
@@ -218,11 +150,7 @@ class LogViewerViewModel @Inject constructor(
218 }
150 }
219
151
220 fun onSearchQueryChange(query: String) {
152 fun onSearchQueryChange(query: String) {
221− // A new query invalidates the live-tail baseline and the paged-up limit, matching
222− // the time-range / custom-SQL handlers.
223− stopStreaming()
224− // Searching returns to the filter-builder query path, so drop any active custom SQL.
225− _state.update { it.copy(currentLimit = 500, filters = it.filters.copy(searchQuery = query, customSql = "", isSearching = query.isNotBlank())) }
153+ _state.update { it.copy(filters = it.filters.copy(searchQuery = query, isSearching = query.isNotBlank())) }
226 searchJob?.cancel()
154 searchJob?.cancel()
227 searchJob = viewModelScope.launch {
155 searchJob = viewModelScope.launch {
228 delay(300)
156 delay(300)
@@ -231,55 +159,28 @@ class LogViewerViewModel @Inject constructor(
231 }
159 }
232 }
160 }
233
161
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−
245 fun addFilter(column: String, operator: String, value: String) {
162 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) {
251 if (operator !in ALLOWED_OPERATORS) return
163 if (operator !in ALLOWED_OPERATORS) return
252 val safeColumn = escapeIdentifier(column)
164 val safeColumn = escapeIdentifier(column)
165+ val safeValue = escapeSql(value)
253 val clause = when (operator) {
166 val clause = when (operator) {
254 "IS NULL", "IS NOT NULL" -> "\"$safeColumn\" $operator"
167 "IS NULL", "IS NOT NULL" -> "\"$safeColumn\" $operator"
255− // %/_ in the user's value must match literally, so escape the LIKE wildcards
256− // and declare the escape character. Exact comparisons keep %/_ verbatim.
257− "LIKE", "ILIKE" -> "\"$safeColumn\" $operator '%${escapeLikePattern(value)}%' ESCAPE '\\'"
168+ "LIKE", "ILIKE" -> "\"$safeColumn\" $operator '%$safeValue%'"
258− else -> "\"$safeColumn\" $operator '${escapeSql(value)}'"
169+ else -> "\"$safeColumn\" $operator '$safeValue'"
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
265 }
170 }
266 val display = when (operator) {
171 val display = when (operator) {
267 "IS NULL", "IS NOT NULL" -> "$column $operator"
172 "IS NULL", "IS NOT NULL" -> "$column $operator"
268 else -> "$column $operator $value"
173 else -> "$column $operator $value"
269 }
174 }
270− // A new filter invalidates the live-tail baseline and the paged-up limit.
271− stopStreaming()
272 _state.update {
175 _state.update {
273 it.copy(
176 it.copy(
274− currentLimit = 500,
275 filters = it.filters.copy(
177 filters = it.filters.copy(
276 filterClauses = it.filters.filterClauses + clause,
178 filterClauses = it.filters.filterClauses + clause,
277 activeFilters = it.filters.activeFilters + display,
179 activeFilters = it.filters.activeFilters + display,
278− filterConditions = it.filters.filterConditions + FilterCondition(column, operator, conditionValue),
279− customSql = "",
280 ),
180 ),
281 )
181 )
282 }
182 }
183+ refresh()
283 }
184 }
284
185
285 fun removeFilter(display: String) {
186 fun removeFilter(display: String) {
@@ -289,93 +190,81 @@ class LogViewerViewModel @Inject constructor(
289 if (index >= 0 && index < it.filters.filterClauses.size) {
190 if (index >= 0 && index < it.filters.filterClauses.size) {
290 removed = true
191 removed = true
291 it.copy(
192 it.copy(
292− currentLimit = 500,
293 filters = it.filters.copy(
193 filters = it.filters.copy(
294 activeFilters = it.filters.activeFilters.filterIndexed { i, _ -> i != index },
194 activeFilters = it.filters.activeFilters.filterIndexed { i, _ -> i != index },
295 filterClauses = it.filters.filterClauses.filterIndexed { i, _ -> i != index },
195 filterClauses = it.filters.filterClauses.filterIndexed { i, _ -> i != index },
296− filterConditions = it.filters.filterConditions.filterIndexed { i, _ -> i != index },
297− customSql = "",
298 ),
196 ),
299 )
197 )
300 } else {
198 } else {
301 it
199 it
302 }
200 }
303 }
201 }
304− if (removed) {
202+ if (removed) refresh()
305− // A changed filter set invalidates the live-tail baseline.
306− stopStreaming()
307− refresh()
308− }
309 }
203 }
310
204
311 fun clearFilters() {
205 fun clearFilters() {
312− stopStreaming()
313 _state.update {
206 _state.update {
314− it.copy(currentLimit = 500, filters = it.filters.copy(activeFilters = emptyList(), filterClauses = emptyList(), filterConditions = emptyList(), customSql = "", searchQuery = "", isSearching = false))
207+ it.copy(filters = it.filters.copy(activeFilters = emptyList(), filterClauses = emptyList()))
315 }
208 }
316 refresh()
209 refresh()
317 }
210 }
318
211
319 fun executeCustomSql(sql: String) {
212 fun executeCustomSql(sql: String) {
320− // Custom SQL changes the query basis, so cancel any pending search debounce that would
321− // otherwise fire a redundant refresh() ~300ms later. (Unlike the filter/time-range
322− // handlers, this path doesn't route through stopStreaming().)
323− searchJob?.cancel()
324− // Drop a trailing statement terminator before we append anything — otherwise
325− // "SELECT * FROM x;" would become "SELECT * FROM x; LIMIT 5000", a syntax error.
326− val trimmed = sql.trim().trimEnd(';').trim()
213+ val trimmed = sql.trim()
327 if (!trimmed.uppercase().startsWith("SELECT")) {
214 if (!trimmed.uppercase().startsWith("SELECT")) {
328 _state.update { it.copy(error = "Only SELECT queries are allowed") }
215 _state.update { it.copy(error = "Only SELECT queries are allowed") }
329 return
216 return
330 }
217 }
331− // Enforce a LIMIT to prevent OOM from unbounded queries. Only honor a *top-level*
218+ // Enforce a LIMIT to prevent OOM from unbounded queries
332− // `LIMIT <n>` (optionally with OFFSET) anchored at the end of the statement — a
219+ val safeSql = if (!trimmed.uppercase().contains("LIMIT")) {
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) {
339 "$trimmed LIMIT $MAX_LOAD_LIMIT"
220 "$trimmed LIMIT $MAX_LOAD_LIMIT"
340 } else {
221 } else {
341 trimmed
222 trimmed
342 }
223 }
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.
346 _state.update { it.copy(filters = it.filters.copy(customSql = safeSql), currentLimit = 500) }
224 _state.update { it.copy(filters = it.filters.copy(customSql = safeSql), currentLimit = 500) }
347− refresh()
225+ viewModelScope.launch {
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+ }
348 }
246 }
349
247
350− private fun buildSearchClause(searchableColumns: List<String>, searchQuery: String): String? {
248+ private fun buildSearchClause(columns: List<String>, searchQuery: String): String? {
351 if (searchQuery.isBlank()) return null
249 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_") }
352− if (searchableColumns.isEmpty()) return null
253+ if (searchFields.isEmpty()) return null
353− val safeSearch = escapeLikePattern(searchQuery)
354− return searchableColumns.joinToString(" OR ") {
254+ return searchFields.joinToString(" OR ") {
355− "CAST(\"${escapeIdentifier(it)}\" AS VARCHAR) ILIKE '%$safeSearch%' ESCAPE '\\'"
255+ "\"${escapeIdentifier(it)}\" ILIKE '%$safeSearch%'"
356 }
256 }
357 }
257 }
358
258
359 fun refresh() {
259 fun refresh() {
360 if (_state.value.streamName.isEmpty()) return
260 if (_state.value.streamName.isEmpty()) return
361
261
362− // Cancel any in-flight query so out-of-order completions can't overwrite
363− // fresh results with stale ones (filter/search/time-range/loadMore all call refresh()).
364− refreshJob?.cancel()
365− refreshJob = viewModelScope.launch {
262+ 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− }
373 _state.update {
263 _state.update {
374 it.copy(
264 it.copy(
375 isLoading = true,
265 isLoading = true,
376 error = null,
266 error = null,
377 streaming = it.streaming.copy(streamingError = null),
267 streaming = it.streaming.copy(streamingError = null),
378− pageAnchorTime = anchor,
379 )
268 )
380 }
269 }
381
270
@@ -383,24 +272,10 @@ class LogViewerViewModel @Inject constructor(
383 val current = _state.value
272 val current = _state.value
384 val (startTime, endTime) = getTimeRange()
273 val (startTime, endTime) = getTimeRange()
385
274
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−
400 // Build WHERE clause from filters + search
275 // Build WHERE clause from filters + search
401 val clauses = current.filters.filterClauses.toMutableList()
276 val clauses = current.filters.filterClauses.toMutableList()
402 if (current.filters.searchQuery.isNotBlank()) {
277 if (current.filters.searchQuery.isNotBlank()) {
403− val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
278+ val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
404 if (searchClause != null) {
279 if (searchClause != null) {
405 clauses.add("($searchClause)")
280 clauses.add("($searchClause)")
406 } else {
281 } else {
@@ -413,26 +288,20 @@ class LogViewerViewModel @Inject constructor(
413
288
414 val filterSql = clauses.joinToString(" AND ")
289 val filterSql = clauses.joinToString(" AND ")
415
290
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
420 when (val result = repository.queryLogs(
291 when (val result = repository.queryLogs(
421 stream = current.streamName,
292 stream = current.streamName,
422 startTime = startTime,
293 startTime = startTime,
423 endTime = endTime,
294 endTime = endTime,
424 filterSql = filterSql,
295 filterSql = filterSql,
425− limit = requestLimit,
296+ limit = current.currentLimit,
426 )) {
297 )) {
427 is ApiResult.Success -> {
298 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− }
434 _state.update {
299 _state.update {
435− it.copy(logs = logs, logKeys = computeLogKeys(logs), isLoading = false, hasMore = hasMore)
300+ it.copy(
301+ logs = result.data,
302+ isLoading = false,
303+ hasMore = result.data.size >= it.currentLimit,
304+ )
436 }
305 }
437 }
306 }
438 is ApiResult.Error -> {
307 is ApiResult.Error -> {
@@ -444,29 +313,8 @@ class LogViewerViewModel @Inject constructor(
444 }
313 }
445 }
314 }
446
315
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−
465 fun loadMore() {
316 fun loadMore() {
466 val current = _state.value
317 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
470 if (current.currentLimit >= MAX_LOAD_LIMIT) {
318 if (current.currentLimit >= MAX_LOAD_LIMIT) {
471 _state.update { it.copy(hasMore = false) }
319 _state.update { it.copy(hasMore = false) }
472 return
320 return
@@ -489,9 +337,7 @@ class LogViewerViewModel @Inject constructor(
489 )
337 )
490 return Pair(start.format(dateFormatter), end.format(dateFormatter))
338 return Pair(start.format(dateFormatter), end.format(dateFormatter))
491 }
339 }
492− val now = current.pageAnchorTime
493− ?.let { ZonedDateTime.ofInstant(java.time.Instant.ofEpochMilli(it), ZoneOffset.UTC) }
494− ?: ZonedDateTime.now(ZoneOffset.UTC)
340+ val now = ZonedDateTime.now(ZoneOffset.UTC)
495 val start = now.minusMinutes(current.selectedTimeRange.minutes)
341 val start = now.minusMinutes(current.selectedTimeRange.minutes)
496 return Pair(
342 return Pair(
497 start.format(dateFormatter),
343 start.format(dateFormatter),
@@ -499,17 +345,6 @@ class LogViewerViewModel @Inject constructor(
499 )
345 )
500 }
346 }
501
347
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−
513 fun toggleStreaming() {
348 fun toggleStreaming() {
514 if (_state.value.streaming.isStreaming) {
349 if (_state.value.streaming.isStreaming) {
515 stopStreaming()
350 stopStreaming()
@@ -519,10 +354,6 @@ class LogViewerViewModel @Inject constructor(
519 }
354 }
520
355
521 private fun startStreaming() {
356 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
526 stopStreaming()
357 stopStreaming()
527 consecutiveStreamingErrors = 0
358 consecutiveStreamingErrors = 0
528 val generation = ++streamingGeneration
359 val generation = ++streamingGeneration
@@ -530,12 +361,6 @@ class LogViewerViewModel @Inject constructor(
530 it.copy(streaming = StreamingState(isStreaming = true, streamingNewCount = 0))
361 it.copy(streaming = StreamingState(isStreaming = true, streamingNewCount = 0))
531 }
362 }
532
363
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−
539 // Set the baseline timestamp to "now" so we only poll for new logs
364 // Set the baseline timestamp to "now" so we only poll for new logs
540 lastSeenTimestamp = ZonedDateTime.now(ZoneOffset.UTC).format(dateFormatter)
365 lastSeenTimestamp = ZonedDateTime.now(ZoneOffset.UTC).format(dateFormatter)
541
366
@@ -558,13 +383,6 @@ class LogViewerViewModel @Inject constructor(
558 }
383 }
559
384
560 fun stopStreaming() {
385 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()
568 streamingGeneration++
386 streamingGeneration++
569 streamingJob?.cancel()
387 streamingJob?.cancel()
570 streamingJob = null
388 streamingJob = null
@@ -579,49 +397,6 @@ class LogViewerViewModel @Inject constructor(
579 }
397 }
580 }
398 }
581
399
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−
625 private suspend fun pollNewLogs() {
400 private suspend fun pollNewLogs() {
626 val current = _state.value
401 val current = _state.value
627 if (current.streamName.isEmpty()) return
402 if (current.streamName.isEmpty()) return
@@ -632,118 +407,46 @@ class LogViewerViewModel @Inject constructor(
632 // Build WHERE clause from active filters + search
407 // Build WHERE clause from active filters + search
633 val clauses = current.filters.filterClauses.toMutableList()
408 val clauses = current.filters.filterClauses.toMutableList()
634 if (current.filters.searchQuery.isNotBlank()) {
409 if (current.filters.searchQuery.isNotBlank()) {
635− val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
410+ val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
636 if (searchClause != null) {
411 if (searchClause != null) {
637− // Parenthesize: the search clause is OR-joined and must not break the
638− // precedence of the AND-joined filter clauses around it.
639− clauses.add("($searchClause)")
412+ 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
653 }
413 }
654 }
414 }
655
415
656 val whereClause = if (clauses.isNotEmpty()) " WHERE ${clauses.joinToString(" AND ")}" else ""
416 val whereClause = if (clauses.isNotEmpty()) " WHERE ${clauses.joinToString(" AND ")}" else ""
657 val safeName = escapeIdentifier(current.streamName)
417 val safeName = escapeIdentifier(current.streamName)
658− // Fetch up to the in-memory display cap. With a small limit (e.g. 200), a burst of
659− // more than that many logs in one interval would advance lastSeenTimestamp past the
660− // un-fetched rows and drop them permanently. Capping at STREAMING_MAX_LOGS means we
661− // never skip rows we'd actually keep (anything older is evicted by the cap anyway).
662− val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT $STREAMING_MAX_LOGS"
418+ val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT 200"
663
419
664 when (val result = repository.queryLogsRaw(sql, startTime, endTime)) {
420 when (val result = repository.queryLogsRaw(sql, startTime, endTime)) {
665 is ApiResult.Success -> {
421 is ApiResult.Success -> {
666 val newLogs = result.data
422 val newLogs = result.data
667 if (newLogs.isNotEmpty()) {
423 if (newLogs.isNotEmpty()) {
668− // Advance the last-seen boundary. Rows are ordered newest-first, so
424+ // Update the last seen timestamp to the most recent log
669− // normally we jump to the newest. But a full page means the LIMIT may
425+ val newestTimestamp = try {
670− // have capped off matching rows OLDER than the newest in this batch
426+ newLogs.firstOrNull()
671− // (possible once filters/search narrow a high-volume stream) — advancing
672− // to the newest would skip them forever. In that case advance only to the
673− // oldest row we did fetch, so the next poll re-queries forward and closes
674− // the gap (re-fetched rows are deduped below).
675− val fullPage = newLogs.size >= STREAMING_MAX_LOGS
676− val boundaryLog = if (fullPage) newLogs.lastOrNull() else newLogs.firstOrNull()
677− val boundaryTimestamp = try {
678− boundaryLog?.get("p_timestamp")?.jsonPrimitive?.content
427+ ?.get("p_timestamp")?.jsonPrimitive?.content
679 } catch (_: Exception) {
428 } catch (_: Exception) {
680 null
429 null
681 }
430 }
682− // Re-canonicalize the server's p_timestamp before reusing it as the next
683− // query's startTime. The raw value can be space-separated or offset-less,
684− // which the query API may reject or misparse — that would stall live-tail
685− // after the first batch. If it can't be parsed, leave lastSeenTimestamp as-is
686− // (the inclusive startTime simply re-fetches the window; rows are deduped).
687− var normalized = boundaryTimestamp?.let { normalizeBoundaryTimestamp(it) }
688− // On a saturated (full) page the boundary is the OLDEST fetched row so the
689− // next poll re-queries forward and closes the LIMIT gap. But if that oldest
690− // row carries the SAME timestamp we're already at — e.g. a burst of more than
691− // STREAMING_MAX_LOGS rows sharing one millisecond, or the parse failing — the
692− // boundary wouldn't move and we'd re-query the identical window forever. Fall
693− // back to the NEWEST row's timestamp to guarantee forward progress; the
694− // skipped backlog would be evicted by the display cap anyway.
695− if (fullPage && (normalized == null || normalized == lastSeenTimestamp)) {
696− val newestTimestamp = try {
431+ if (newestTimestamp != null) {
697− newLogs.firstOrNull()?.get("p_timestamp")?.jsonPrimitive?.content
698− } catch (_: Exception) {
699− null
700− }
701− val newestNormalized = newestTimestamp?.let { normalizeBoundaryTimestamp(it) }
702− if (newestNormalized != null && newestNormalized != lastSeenTimestamp) {
703− normalized = newestNormalized
704− }
705− }
706− if (normalized != null) {
707− lastSeenTimestamp = normalized
432+ lastSeenTimestamp = newestTimestamp
708 }
433 }
709
434
710− // The query's startTime is inclusive, so rows sitting on the previous
711− // boundary timestamp get re-fetched. Dedup against everything seen this
712− // session (independent of the display cap) to avoid duplicate rows and an
713− // inflated count. Computed BEFORE the state update — and the seen-set
714− // mutated here, not inside the update lambda, which may re-run.
715− val freshLogs = newLogs.filterNot { logIdentity(it) in seenStreamingKeys }
716− newLogs.forEach { seenStreamingKeys.add(logIdentity(it)) }
717− trimSeenStreamingKeys()
718−
719− if (freshLogs.isEmpty()) {
720− _state.update { it.copy(streaming = it.streaming.copy(streamingError = null)) }
721− } else {
722− _state.update { state ->
435+ _state.update { state ->
723− // Prepend new logs, cap total — avoid full intermediate list allocation
436+ // Prepend new logs, cap total
724− val remaining = (STREAMING_MAX_LOGS - freshLogs.size).coerceAtLeast(0)
437+ val combined = newLogs + state.logs
725− val capped = ArrayList<JsonObject>(freshLogs.size + remaining).apply {
438+ val capped = if (combined.size > STREAMING_MAX_LOGS) {
726− addAll(freshLogs)
439+ combined.take(STREAMING_MAX_LOGS)
727− val oldLogs = state.logs
728− addAll(oldLogs.subList(0, remaining.coerceAtMost(oldLogs.size)))
729− }
730− state.copy(
731− logs = capped,
732− logKeys = computeLogKeys(capped),
733− streaming = state.streaming.copy(
734− // Accumulate new rows while the user is scrolled away; reset
735− // to 0 when they're at the top (where prepended rows are seen
736− // live). The previous cap was against total list size, so the
737− // badge saturated at the display cap and never cleared.
738− streamingNewCount = if (viewingTop) {
739− 0
740− } else {
440+ } else {
741− state.streaming.streamingNewCount + freshLogs.size
441+ combined
742− },
743− streamingError = null,
744− ),
745− )
746 }
442 }
443+ state.copy(
444+ logs = capped,
445+ streaming = state.streaming.copy(
446+ streamingNewCount = state.streaming.streamingNewCount + newLogs.size,
447+ streamingError = null,
448+ ),
449+ )
747 }
450 }
748 }
451 }
749 consecutiveStreamingErrors = 0
452 consecutiveStreamingErrors = 0
@@ -768,191 +471,6 @@ class LogViewerViewModel @Inject constructor(
768 }
471 }
769 }
472 }
770
473
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−
956 override fun onCleared() {
474 override fun onCleared() {
957 super.onCleared()
475 super.onCleared()
958 schemaJob?.cancel()
476 schemaJob?.cancel()
app/src/main/java/com/parseable/android/ui/screens/logviewer/SeverityColor.ktdeleted−169
@@ -1,169 +0,0 @@
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+86−101
@@ -7,7 +7,6 @@ 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
11import androidx.compose.runtime.*
10import androidx.compose.runtime.*
12import androidx.compose.ui.Alignment
11import androidx.compose.ui.Alignment
13import androidx.compose.ui.Modifier
12import androidx.compose.ui.Modifier
@@ -15,8 +14,10 @@ import androidx.compose.ui.text.font.FontFamily
15import androidx.compose.ui.text.font.FontWeight
14import androidx.compose.ui.text.font.FontWeight
16import androidx.compose.ui.unit.dp
15import androidx.compose.ui.unit.dp
17import androidx.hilt.navigation.compose.hiltViewModel
16import androidx.hilt.navigation.compose.hiltViewModel
17+import androidx.lifecycle.Lifecycle
18+import androidx.lifecycle.compose.LocalLifecycleOwner
18import androidx.lifecycle.compose.collectAsStateWithLifecycle
19import androidx.lifecycle.compose.collectAsStateWithLifecycle
19−import com.parseable.android.data.formatBytes
20+import androidx.lifecycle.repeatOnLifecycle
20import kotlinx.serialization.json.Json
21import kotlinx.serialization.json.Json
21import kotlinx.serialization.json.JsonObject
22import kotlinx.serialization.json.JsonObject
22
23
@@ -33,10 +34,11 @@ fun StreamInfoScreen(
33 val state by viewModel.state.collectAsStateWithLifecycle()
34 val state by viewModel.state.collectAsStateWithLifecycle()
34 var showDeleteConfirmation by remember { mutableStateOf(false) }
35 var showDeleteConfirmation by remember { mutableStateOf(false) }
35
36
36− // Load once per stream. Reloading on every RESUME would refetch all four endpoints on
37+ val lifecycleOwner = LocalLifecycleOwner.current
37− // each return and could re-query a stream that was just deleted (spurious error card).
38− LaunchedEffect(streamName) {
38+ LaunchedEffect(streamName, lifecycleOwner) {
39+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
39− viewModel.load(streamName)
40+ viewModel.load(streamName)
41+ }
40 }
42 }
41
43
42 LaunchedEffect(state.deleteSuccess) {
44 LaunchedEffect(state.deleteSuccess) {
@@ -70,114 +72,97 @@ fun StreamInfoScreen(
70 )
72 )
71 },
73 },
72 ) { padding ->
74 ) { 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,
75+ if (state.isLoading) {
81− onRefresh = viewModel::refresh,
82− modifier = Modifier
83− .fillMaxSize()
84− .padding(padding),
85− ) {
86− if (state.isLoading && !hasContent) {
87− Box(
76+ Box(
88− modifier = Modifier.fillMaxSize(),
77+ modifier = Modifier
78+ .fillMaxSize()
79+ .padding(padding),
89− contentAlignment = Alignment.Center,
80+ contentAlignment = Alignment.Center,
90− ) {
81+ ) {
91− CircularProgressIndicator()
82+ CircularProgressIndicator()
92− }
83+ }
93− } else {
84+ } else {
94− Column(
85+ Column(
95− modifier = Modifier
86+ modifier = Modifier
96− .fillMaxSize()
87+ .fillMaxSize()
88+ .padding(padding)
97− .verticalScroll(rememberScrollState())
89+ .verticalScroll(rememberScrollState())
98− .padding(16.dp),
90+ .padding(16.dp),
99− verticalArrangement = Arrangement.spacedBy(16.dp),
91+ verticalArrangement = Arrangement.spacedBy(16.dp),
100− ) {
92+ ) {
101− // Stats section
93+ // Stats section
102− state.stats?.let { stats ->
94+ state.stats?.let { stats ->
103− InfoSection(title = "Statistics") {
95+ InfoSection(title = "Statistics") {
104− // Show the current-window count when available. Fall back to "N/A"
105− // only when there's no lifetime count either — otherwise the lifetime
106− // figure is shown in its own row below, so don't duplicate it here.
107− when {
108− stats.ingestion?.count != null -> InfoRow("Event Count", stats.ingestion.count.toString())
96+ InfoRow("Event Count", stats.ingestion?.count?.toString() ?: stats.ingestion?.lifetimeCount?.toString() ?: "N/A")
109− stats.ingestion?.lifetimeCount == null -> InfoRow("Event Count", "N/A")
110− }
111− InfoRow("Ingestion Size", formatBytes(stats.ingestion?.size) ?: formatBytes(stats.ingestion?.lifetimeSize) ?: "N/A")
97+ InfoRow("Ingestion Size", stats.ingestion?.size ?: stats.ingestion?.lifetimeSize ?: "N/A")
112− InfoRow("Storage Size", formatBytes(stats.storage?.size) ?: formatBytes(stats.storage?.lifetimeSize) ?: "N/A")
98+ InfoRow("Storage Size", stats.storage?.size ?: stats.storage?.lifetimeSize ?: "N/A")
113− if (stats.ingestion?.lifetimeCount != null) {
99+ if (stats.ingestion?.lifetimeCount != null) {
114− InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
100+ InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
115− }
101+ }
116− if (stats.ingestion?.deletedCount != null) {
102+ if (stats.ingestion?.deletedCount != null) {
117− InfoRow("Deleted Events", stats.ingestion.deletedCount.toString())
103+ InfoRow("Deleted Events", stats.ingestion.deletedCount.toString())
118− }
119 }
104 }
120 }
105 }
106+ }
121
107
122− // Schema section
108+ // Schema section
123− if (state.schema.isNotEmpty()) {
109+ if (state.schema.isNotEmpty()) {
124− InfoSection(title = "Schema (${state.schema.size} fields)") {
110+ InfoSection(title = "Schema (${state.schema.size} fields)") {
125− state.schema.forEach { field ->
111+ state.schema.forEach { field ->
126− InfoRow(
112+ InfoRow(
127− field.name,
113+ field.name,
128− field.dataType?.toString()?.trim('"') ?: "Unknown",
114+ 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,
138 )
115 )
139 }
116 }
140 }
117 }
118+ } else if (state.schemaFailed) {
119+ InfoSection(title = "Schema") {
120+ Text(
121+ text = "Schema unavailable",
122+ style = MaterialTheme.typography.bodyMedium,
123+ color = MaterialTheme.colorScheme.onSurfaceVariant,
124+ )
125+ }
126+ }
141
127
142− // Retention section
128+ // Retention section
143− if (state.retention.isNotEmpty()) {
129+ if (state.retention.isNotEmpty()) {
144− InfoSection(title = "Retention") {
130+ InfoSection(title = "Retention") {
145− state.retention.forEach { r ->
131+ state.retention.forEach { r ->
146− InfoRow("Duration", r.duration ?: "N/A")
132+ InfoRow("Duration", r.duration ?: "N/A")
147− InfoRow("Action", r.action ?: "N/A")
133+ InfoRow("Action", r.action ?: "N/A")
148− if (r.description != null) {
134+ if (r.description != null) {
149− InfoRow("Description", r.description)
135+ InfoRow("Description", r.description)
150− }
151 }
136 }
152 }
137 }
153 }
138 }
139+ }
154
140
155− // Raw info section
141+ // Raw info section
156− state.rawInfo?.let { rawInfo ->
142+ state.rawInfo?.let { rawInfo ->
157− val prettyInfo = remember(rawInfo) {
143+ val prettyInfo = remember(rawInfo) {
158− prettyJson.encodeToString(JsonObject.serializer(), rawInfo)
144+ prettyJson.encodeToString(JsonObject.serializer(), rawInfo)
159− }
160− InfoSection(title = "Stream Info") {
161− Text(
162− text = prettyInfo,
163− style = MaterialTheme.typography.bodySmall,
164− fontFamily = FontFamily.Monospace,
165− )
166− }
167 }
145 }
146+ InfoSection(title = "Stream Info") {
147+ Text(
148+ text = prettyInfo,
149+ style = MaterialTheme.typography.bodySmall,
150+ fontFamily = FontFamily.Monospace,
151+ )
152+ }
153+ }
168
154
169− state.error?.let { errorText ->
155+ state.error?.let { errorText ->
170− Card(
156+ Card(
171− colors = CardDefaults.cardColors(
157+ colors = CardDefaults.cardColors(
172− containerColor = MaterialTheme.colorScheme.errorContainer,
158+ containerColor = MaterialTheme.colorScheme.errorContainer,
173− ),
159+ ),
174− ) {
160+ ) {
175− Text(
161+ Text(
176− text = errorText,
162+ text = errorText,
177− modifier = Modifier.padding(16.dp),
163+ modifier = Modifier.padding(16.dp),
178− color = MaterialTheme.colorScheme.onErrorContainer,
164+ color = MaterialTheme.colorScheme.onErrorContainer,
179− )
165+ )
180− }
181 }
166 }
182 }
167 }
183 }
168 }
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoViewModel.kt+10−59
@@ -8,7 +8,6 @@ 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
12import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.MutableStateFlow
13import kotlinx.coroutines.flow.StateFlow
12import kotlinx.coroutines.flow.StateFlow
14import kotlinx.coroutines.flow.asStateFlow
13import kotlinx.coroutines.flow.asStateFlow
@@ -39,61 +38,21 @@ class StreamInfoViewModel @Inject constructor(
39 val state: StateFlow<StreamInfoState> = _state.asStateFlow()
38 val state: StateFlow<StreamInfoState> = _state.asStateFlow()
40 private var loadJob: Job? = null
39 private var loadJob: Job? = null
41
40
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−
50 fun load(streamName: String) {
41 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) {
70 loadJob?.cancel()
42 loadJob?.cancel()
71 _state.update { it.copy(streamName = streamName, isLoading = true, error = null) }
43 _state.update { it.copy(streamName = streamName, isLoading = true, error = null) }
72
44
73 loadJob = viewModelScope.launch {
45 loadJob = viewModelScope.launch {
74 try {
46 try {
75− // Wrap the parallel fetches in coroutineScope so the four async children
76− // form their own structured-concurrency scope (matching StreamsViewModel /
77− // SettingsViewModel). The repository returns ApiResult.Error rather than
78− // throwing, but if any call did throw unexpectedly this keeps the failure
79− // contained to this load instead of it propagating oddly through the launch.
80− val results = coroutineScope {
81− val statsDeferred = async { repository.getStreamStats(streamName) }
47+ val statsDeferred = async { repository.getStreamStats(streamName) }
82− val schemaDeferred = async { repository.getStreamSchema(streamName) }
48+ val schemaDeferred = async { repository.getStreamSchema(streamName) }
83− val retentionDeferred = async { repository.getStreamRetention(streamName) }
49+ val retentionDeferred = async { repository.getStreamRetention(streamName) }
84− val infoDeferred = async { repository.getStreamInfo(streamName) }
50+ val infoDeferred = async { repository.getStreamInfo(streamName) }
85
51
86− LoadResults(
87− stats = statsDeferred.await(),
52+ val statsResult = statsDeferred.await()
88− schema = schemaDeferred.await(),
53+ val schemaResult = schemaDeferred.await()
89− retention = retentionDeferred.await(),
54+ val retentionResult = retentionDeferred.await()
90− info = infoDeferred.await(),
55+ val infoResult = infoDeferred.await()
91− )
92− }
93− val statsResult = results.stats
94− val schemaResult = results.schema
95− val retentionResult = results.retention
96− val infoResult = results.info
97
56
98 _state.update {
57 _state.update {
99 it.copy(
58 it.copy(
@@ -127,17 +86,9 @@ class StreamInfoViewModel @Inject constructor(
127
86
128 fun deleteStream() {
87 fun deleteStream() {
129 val name = _state.value.streamName
88 val name = _state.value.streamName
130− // Guard against a no-op or a second tap firing a duplicate DELETE while one is in flight.
131− if (name.isEmpty() || _state.value.isDeleting) return
89+ if (name.isEmpty()) 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()
135 viewModelScope.launch {
90 viewModelScope.launch {
136− // Clear isLoading here too: cancelling loadJob above kills any in-flight fetch before
137− // its terminal update can flip isLoading back to false, so without this a load that was
138− // running when delete started would leave the pull-to-refresh spinner stuck on the
139− // error path (delete only manages isDeleting).
140− _state.update { it.copy(isDeleting = true, isLoading = false, error = null) }
91+ _state.update { it.copy(isDeleting = true, error = null) }
141 when (val result = repository.deleteStream(name)) {
92 when (val result = repository.deleteStream(name)) {
142 is ApiResult.Success -> {
93 is ApiResult.Success -> {
143 _state.update { it.copy(isDeleting = false, deleteSuccess = true) }
94 _state.update { it.copy(isDeleting = false, deleteSuccess = true) }
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsScreen.kt+3−196
@@ -12,38 +12,19 @@ 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
16import androidx.compose.ui.unit.dp
15import androidx.compose.ui.unit.dp
17import androidx.hilt.navigation.compose.hiltViewModel
16import androidx.hilt.navigation.compose.hiltViewModel
18import androidx.lifecycle.compose.collectAsStateWithLifecycle
17import androidx.lifecycle.compose.collectAsStateWithLifecycle
19import com.parseable.android.BuildConfig
18import com.parseable.android.BuildConfig
20−import com.parseable.android.data.local.SavedServer
21−import kotlinx.serialization.json.contentOrNull
22−import kotlinx.serialization.json.jsonPrimitive
23
19
24@OptIn(ExperimentalMaterial3Api::class)
20@OptIn(ExperimentalMaterial3Api::class)
25@Composable
21@Composable
26fun SettingsScreen(
22fun SettingsScreen(
27 onBack: () -> Unit,
23 onBack: () -> Unit,
28− onAboutClick: () -> Unit,
29− onServerSwitched: () -> Unit,
30− onLoggedOut: () -> Unit,
31 viewModel: SettingsViewModel = hiltViewModel(),
24 viewModel: SettingsViewModel = hiltViewModel(),
32) {
25) {
33 val state by viewModel.state.collectAsStateWithLifecycle()
26 val state by viewModel.state.collectAsStateWithLifecycle()
34
27
35− LaunchedEffect(Unit) {
36− viewModel.switchEvent.collect {
37− onServerSwitched()
38− }
39− }
40−
41− LaunchedEffect(Unit) {
42− viewModel.loggedOutEvent.collect {
43− onLoggedOut()
44− }
45− }
46−
47 Scaffold(
28 Scaffold(
48 topBar = {
29 topBar = {
49 TopAppBar(
30 TopAppBar(
@@ -96,21 +77,8 @@ fun SettingsScreen(
96 }
77 }
97 }
78 }
98
79
99− // Saved servers
100− if (state.savedServers.size > 1) {
101− SavedServersCard(
102− servers = state.savedServers,
103− activeServerId = state.activeServerId,
104− isSwitching = state.isSwitching,
105− onSwitch = viewModel::switchToServer,
106− onDelete = viewModel::deleteServer,
107− )
108− }
109−
110− // Loading indicator for server data — only on the initial load. During a
80+ // Loading indicator for server data
111− // pull-to-refresh the PullToRefreshBox already shows its own spinner and
112− // aboutInfo is still populated, so gating on isLoading alone double-spins.
113− if (state.isLoading && state.aboutInfo == null) {
81+ if (state.isLoading) {
114 Box(
82 Box(
115 modifier = Modifier
83 modifier = Modifier
116 .fillMaxWidth()
84 .fillMaxWidth()
@@ -185,7 +153,7 @@ fun SettingsScreen(
185 about.version?.let { SettingsRow("Version", it) }
153 about.version?.let { SettingsRow("Version", it) }
186 about.commit?.let { SettingsRow("Commit", it.take(12)) }
154 about.commit?.let { SettingsRow("Commit", it.take(12)) }
187 about.mode?.let { SettingsRow("Mode", it) }
155 about.mode?.let { SettingsRow("Mode", it) }
188− about.license?.get("name")?.jsonPrimitive?.contentOrNull?.let { SettingsRow("License", it) }
156+ about.license?.let { SettingsRow("License", it) }
189 about.deploymentId?.let { SettingsRow("Deployment ID", it.take(12)) }
157 about.deploymentId?.let { SettingsRow("Deployment ID", it.take(12)) }
190 about.queryEngine?.let { SettingsRow("Query Engine", it) }
158 about.queryEngine?.let { SettingsRow("Query Engine", it) }
191 }
159 }
@@ -248,167 +216,6 @@ fun SettingsScreen(
248 SettingsRow("Platform", "Android")
216 SettingsRow("Platform", "Android")
249 }
217 }
250 }
218 }
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− )
412 }
219 }
413 }
220 }
414 }
221 }
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsViewModel.kt+8−126
@@ -2,22 +2,18 @@ package com.parseable.android.ui.screens.settings
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
6import com.parseable.android.data.model.AboutInfo
5import com.parseable.android.data.model.AboutInfo
7import com.parseable.android.data.model.ApiResult
6import com.parseable.android.data.model.ApiResult
8import com.parseable.android.data.repository.ParseableRepository
7import com.parseable.android.data.repository.ParseableRepository
9import com.parseable.android.data.repository.SettingsRepository
8import com.parseable.android.data.repository.SettingsRepository
10import dagger.hilt.android.lifecycle.HiltViewModel
9import dagger.hilt.android.lifecycle.HiltViewModel
11import kotlinx.coroutines.CancellationException
10import kotlinx.coroutines.CancellationException
12−import kotlinx.coroutines.Job
13import kotlinx.coroutines.async
11import kotlinx.coroutines.async
14−import kotlinx.coroutines.channels.Channel
15import kotlinx.coroutines.coroutineScope
12import kotlinx.coroutines.coroutineScope
16import kotlinx.coroutines.flow.MutableStateFlow
13import kotlinx.coroutines.flow.MutableStateFlow
17import kotlinx.coroutines.flow.StateFlow
14import kotlinx.coroutines.flow.StateFlow
18import kotlinx.coroutines.flow.asStateFlow
15import kotlinx.coroutines.flow.asStateFlow
19import kotlinx.coroutines.flow.first
16import kotlinx.coroutines.flow.first
20−import kotlinx.coroutines.flow.receiveAsFlow
21import kotlinx.coroutines.flow.update
17import kotlinx.coroutines.flow.update
22import kotlinx.coroutines.launch
18import kotlinx.coroutines.launch
23import kotlinx.serialization.json.jsonPrimitive
19import kotlinx.serialization.json.jsonPrimitive
@@ -31,10 +27,6 @@ data class SettingsState(
31 val users: List<String> = emptyList(),
27 val users: List<String> = emptyList(),
32 val isLoading: Boolean = false,
28 val isLoading: Boolean = false,
33 val error: String? = null,
29 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,
38)
30)
39
31
40@HiltViewModel
32@HiltViewModel
@@ -46,44 +38,12 @@ class SettingsViewModel @Inject constructor(
46 private val _state = MutableStateFlow(SettingsState())
38 private val _state = MutableStateFlow(SettingsState())
47 val state: StateFlow<SettingsState> = _state.asStateFlow()
39 val state: StateFlow<SettingsState> = _state.asStateFlow()
48
40
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−
69 init {
41 init {
70 load()
42 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− }
81 }
43 }
82
44
83 fun load() {
45 fun load() {
84− // Cancel any in-flight load so a slower completion can't overwrite a newer one.
85− loadJob?.cancel()
86− loadJob = viewModelScope.launch {
46+ viewModelScope.launch {
87 _state.update { it.copy(isLoading = true, error = null) }
47 _state.update { it.copy(isLoading = true, error = null) }
88
48
89 val config = settingsRepository.serverConfig.first()
49 val config = settingsRepository.serverConfig.first()
@@ -106,24 +66,24 @@ class SettingsViewModel @Inject constructor(
106
66
107 val userNames = (usersResult as? ApiResult.Success)?.data?.mapNotNull { obj ->
67 val userNames = (usersResult as? ApiResult.Success)?.data?.mapNotNull { obj ->
108 try {
68 try {
109− obj["username"]?.jsonPrimitive?.content
69+ obj["id"]?.jsonPrimitive?.content
110− ?: obj["id"]?.jsonPrimitive?.content
70+ ?: obj["username"]?.jsonPrimitive?.content
111 } catch (_: IllegalStateException) {
71 } catch (_: IllegalStateException) {
112 null
72 null
113 }
73 }
114 } ?: emptyList()
74 } ?: emptyList()
115
75
116− // Only treat a failure of the primary server-info call (/about) as a blocking
76+ val errors = listOfNotNull(
117− // error. The user list (/user) is secondary and is commonly forbidden for
118− // restricted accounts — failing it shouldn't hide otherwise-valid server info.
119− val error = (aboutResult as? ApiResult.Error)?.userMessage
77+ (aboutResult as? ApiResult.Error)?.userMessage,
78+ (usersResult as? ApiResult.Error)?.userMessage,
79+ ).distinct().joinToString("\n").ifEmpty { null }
120
80
121 _state.update {
81 _state.update {
122 it.copy(
82 it.copy(
123 aboutInfo = (aboutResult as? ApiResult.Success)?.data,
83 aboutInfo = (aboutResult as? ApiResult.Success)?.data,
124 users = userNames,
84 users = userNames,
125 isLoading = false,
85 isLoading = false,
126− error = error,
86+ error = errors,
127 )
87 )
128 }
88 }
129 } catch (e: CancellationException) {
89 } catch (e: CancellationException) {
@@ -136,82 +96,4 @@ class SettingsViewModel @Inject constructor(
136 }
96 }
137 }
97 }
138 }
98 }
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− }
217}
99}
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsScreen.kt+14−30
@@ -3,20 +3,21 @@ 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
7import androidx.compose.material.icons.Icons
6import androidx.compose.material.icons.Icons
8import androidx.compose.material.icons.automirrored.filled.Logout
7import androidx.compose.material.icons.automirrored.filled.Logout
9import androidx.compose.material.icons.filled.*
8import androidx.compose.material.icons.filled.*
10import androidx.compose.material3.*
9import androidx.compose.material3.*
11import androidx.compose.material3.pulltorefresh.PullToRefreshBox
10import androidx.compose.material3.pulltorefresh.PullToRefreshBox
12import androidx.compose.runtime.*
11import androidx.compose.runtime.*
13−import androidx.compose.runtime.saveable.rememberSaveable
14import androidx.compose.ui.Alignment
12import androidx.compose.ui.Alignment
15import androidx.compose.ui.Modifier
13import androidx.compose.ui.Modifier
14+import androidx.lifecycle.compose.LocalLifecycleOwner
16import androidx.compose.ui.text.font.FontWeight
15import androidx.compose.ui.text.font.FontWeight
17import androidx.compose.ui.unit.dp
16import androidx.compose.ui.unit.dp
18import androidx.hilt.navigation.compose.hiltViewModel
17import 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
20import kotlinx.coroutines.flow.collectLatest
21import kotlinx.coroutines.flow.collectLatest
21import kotlinx.coroutines.launch
22import kotlinx.coroutines.launch
22
23
@@ -32,23 +33,9 @@ fun StreamsScreen(
32 val state by viewModel.state.collectAsStateWithLifecycle()
33 val state by viewModel.state.collectAsStateWithLifecycle()
33 val scope = rememberCoroutineScope()
34 val scope = rememberCoroutineScope()
34 var showLogoutConfirmation by remember { mutableStateOf(false) }
35 var showLogoutConfirmation by remember { mutableStateOf(false) }
35− var searchQuery by rememberSaveable { mutableStateOf("") }
36+ var searchQuery by remember { mutableStateOf("") }
36 val snackbarHostState = remember { SnackbarHostState() }
37 val snackbarHostState = remember { SnackbarHostState() }
37
38
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−
52 val filteredStreams = remember(state.streams, searchQuery, state.favoriteNames) {
39 val filteredStreams = remember(state.streams, searchQuery, state.favoriteNames) {
53 val favorites = state.favoriteNames
40 val favorites = state.favoriteNames
54 val filtered = if (searchQuery.isBlank()) {
41 val filtered = if (searchQuery.isBlank()) {
@@ -58,11 +45,14 @@ fun StreamsScreen(
58 }
45 }
59 // Partition by favorites instead of full sort for O(n) vs O(n log n)
46 // Partition by favorites instead of full sort for O(n) vs O(n log n)
60 val (favs, rest) = filtered.partition { it.name in favorites }
47 val (favs, rest) = filtered.partition { it.name in favorites }
61− // `favorites` iterates most-recently-favorited first (DAO ORDER BY addedAt DESC
48+ favs + rest
62− // preserved through LinkedHashSet); order favs to match so recency is reflected.
49+ }
63− val favRank = favorites.withIndex().associate { (i, name) -> name to i }
50+
64− val sortedFavs = favs.sortedBy { favRank[it.name] ?: Int.MAX_VALUE }
51+ val lifecycleOwner = LocalLifecycleOwner.current
65− sortedFavs + rest
52+ LaunchedEffect(lifecycleOwner) {
53+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
54+ viewModel.refresh()
55+ }
66 }
56 }
67
57
68 LaunchedEffect(Unit) {
58 LaunchedEffect(Unit) {
@@ -83,7 +73,7 @@ fun StreamsScreen(
83 Text("Parseable", fontWeight = FontWeight.Bold)
73 Text("Parseable", fontWeight = FontWeight.Bold)
84 if (state.aboutInfo != null) {
74 if (state.aboutInfo != null) {
85 Text(
75 Text(
86− text = "${state.aboutInfo?.version ?: "?"} - ${state.aboutInfo?.mode ?: ""}",
76+ text = "v${state.aboutInfo?.version ?: "?"} - ${state.aboutInfo?.mode ?: ""}",
87 style = MaterialTheme.typography.bodySmall,
77 style = MaterialTheme.typography.bodySmall,
88 color = MaterialTheme.colorScheme.onSurfaceVariant,
78 color = MaterialTheme.colorScheme.onSurfaceVariant,
89 )
79 )
@@ -157,12 +147,7 @@ fun StreamsScreen(
157 }
147 }
158 } else {
148 } else {
159 Column {
149 Column {
160− // Keep the search field visible whenever a query is active, even if the
161− // total stream count has since dropped to <=5 (e.g. after a refresh or a
162− // server-side deletion). Otherwise the field would vanish while the stale
163− // query keeps filtering `filteredStreams`, stranding the user with no way
164− // to clear it.
165− if (state.streams.size > 5 || searchQuery.isNotEmpty()) {
150+ if (state.streams.size > 5) {
166 OutlinedTextField(
151 OutlinedTextField(
167 value = searchQuery,
152 value = searchQuery,
168 onValueChange = { searchQuery = it },
153 onValueChange = { searchQuery = it },
@@ -184,7 +169,6 @@ fun StreamsScreen(
184 )
169 )
185 }
170 }
186 LazyColumn(
171 LazyColumn(
187− state = listState,
188 contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
172 contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
189 verticalArrangement = Arrangement.spacedBy(8.dp),
173 verticalArrangement = Arrangement.spacedBy(8.dp),
190 modifier = Modifier.weight(1f),
174 modifier = Modifier.weight(1f),
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsViewModel.kt+15−58
@@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5import com.parseable.android.data.local.FavoriteStream
5import com.parseable.android.data.local.FavoriteStream
6import com.parseable.android.data.local.FavoriteStreamDao
6import com.parseable.android.data.local.FavoriteStreamDao
7−import com.parseable.android.data.formatBytes
8import com.parseable.android.data.model.AboutInfo
7import com.parseable.android.data.model.AboutInfo
9import com.parseable.android.data.model.ApiResult
8import com.parseable.android.data.model.ApiResult
10import com.parseable.android.data.model.LogStream
9import com.parseable.android.data.model.LogStream
@@ -22,9 +21,7 @@ import kotlinx.coroutines.flow.asStateFlow
22import kotlinx.coroutines.flow.receiveAsFlow
21import kotlinx.coroutines.flow.receiveAsFlow
23import kotlinx.coroutines.flow.update
22import kotlinx.coroutines.flow.update
24import kotlinx.coroutines.launch
23import kotlinx.coroutines.launch
25−import kotlinx.coroutines.sync.Mutex
26import kotlinx.coroutines.sync.Semaphore
24import kotlinx.coroutines.sync.Semaphore
27−import kotlinx.coroutines.sync.withLock
28import kotlinx.coroutines.sync.withPermit
25import kotlinx.coroutines.sync.withPermit
29import javax.inject.Inject
26import javax.inject.Inject
30
27
@@ -63,40 +60,22 @@ class StreamsViewModel @Inject constructor(
63 _state.update { it.copy(favoriteNames = names.toSet()) }
60 _state.update { it.copy(favoriteNames = names.toSet()) }
64 }
61 }
65 }
62 }
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()
69 }
63 }
70
64
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−
76 fun toggleFavorite(streamName: String) {
65 fun toggleFavorite(streamName: String) {
77 viewModelScope.launch {
66 viewModelScope.launch {
78− favoriteMutex.withLock {
67+ if (streamName in _state.value.favoriteNames) {
79− // Decide from the committed DB state, not the async-mirrored favoriteNames.
80− if (favoriteDao.isFavoriteNow(streamName)) {
81− favoriteDao.deleteByName(streamName)
68+ favoriteDao.deleteByName(streamName)
82− _snackbarEvent.send("Removed from favorites")
69+ _snackbarEvent.send("Removed from favorites")
83− } else {
70+ } else {
84− favoriteDao.insert(FavoriteStream(streamName = streamName))
71+ favoriteDao.insert(FavoriteStream(streamName = streamName))
85− _snackbarEvent.send("Added to favorites")
72+ _snackbarEvent.send("Added to favorites")
86− }
87 }
73 }
88 }
74 }
89 }
75 }
90
76
91− private var refreshJob: Job? = null
92−
93 fun refresh() {
77 fun refresh() {
94− // Cancel any in-flight refresh so out-of-order completions can't overwrite a newer
95− // result with a stale one (the init load and a pull-to-refresh, or two quick pulls,
96− // can otherwise race and leave the older stream list showing). Mirrors the refreshJob
97− // guard in LogViewerViewModel.
98− refreshJob?.cancel()
99− refreshJob = viewModelScope.launch {
78+ viewModelScope.launch {
100 _state.update { it.copy(isLoading = true, error = null) }
79 _state.update { it.copy(isLoading = true, error = null) }
101
80
102 try {
81 try {
@@ -113,14 +92,9 @@ class StreamsViewModel @Inject constructor(
113
92
114 when (streamsResult) {
93 when (streamsResult) {
115 is ApiResult.Success -> {
94 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 }
119 _state.update {
95 _state.update {
120 it.copy(
96 it.copy(
121− streams = streamsResult.data.sortedBy { s -> s.name.lowercase() },
97+ streams = streamsResult.data,
122− streamStats = it.streamStats.filterKeys { name -> name in names },
123− failedStats = it.failedStats.filterTo(mutableSetOf()) { name -> name in names },
124 isLoading = false,
98 isLoading = false,
125 )
99 )
126 }
100 }
@@ -149,14 +123,9 @@ class StreamsViewModel @Inject constructor(
149
123
150 private val statsSemaphore = Semaphore(8)
124 private val statsSemaphore = Semaphore(8)
151 private var statsJob: Job? = null
125 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>()
155
126
156 private fun loadStreamStats(streams: List<LogStream>) {
127 private fun loadStreamStats(streams: List<LogStream>) {
157 statsJob?.cancel()
128 statsJob?.cancel()
158− retryJobs.values.forEach { it.cancel() }
159− retryJobs.clear()
160 statsJob = viewModelScope.launch {
129 statsJob = viewModelScope.launch {
161 streams.forEach { stream ->
130 streams.forEach { stream ->
162 launch {
131 launch {
@@ -169,20 +138,12 @@ class StreamsViewModel @Inject constructor(
169 }
138 }
170
139
171 fun retryStats(streamName: String) {
140 fun retryStats(streamName: String) {
172− retryJobs[streamName]?.cancel()
173− val job = viewModelScope.launch {
141+ viewModelScope.launch {
174 _state.update { it.copy(failedStats = it.failedStats - streamName) }
142 _state.update { it.copy(failedStats = it.failedStats - streamName) }
175 statsSemaphore.withPermit {
143 statsSemaphore.withPermit {
176 loadSingleStreamStats(streamName)
144 loadSingleStreamStats(streamName)
177 }
145 }
178 }
146 }
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− }
186 }
147 }
187
148
188 private suspend fun loadSingleStreamStats(streamName: String) {
149 private suspend fun loadSingleStreamStats(streamName: String) {
@@ -192,16 +153,13 @@ class StreamsViewModel @Inject constructor(
192 val ui = StreamStatsUi(
153 val ui = StreamStatsUi(
193 eventCount = stats.ingestion?.count
154 eventCount = stats.ingestion?.count
194 ?: stats.ingestion?.lifetimeCount,
155 ?: stats.ingestion?.lifetimeCount,
195− ingestionSize = formatBytes(stats.ingestion?.size)
156+ ingestionSize = stats.ingestion?.size
196− ?: formatBytes(stats.ingestion?.lifetimeSize),
157+ ?: stats.ingestion?.lifetimeSize,
197− storageSize = formatBytes(stats.storage?.size)
158+ storageSize = stats.storage?.size
198− ?: formatBytes(stats.storage?.lifetimeSize),
159+ ?: stats.storage?.lifetimeSize,
199 )
160 )
200 _state.update {
161 _state.update {
201− // Drop late results for streams a concurrent refresh has already pruned,
202− // so an in-flight retry can't resurrect stats for a deleted stream.
203− if (it.streams.none { s -> s.name == streamName }) it
204− else it.copy(
162+ it.copy(
205 streamStats = it.streamStats + (streamName to ui),
163 streamStats = it.streamStats + (streamName to ui),
206 failedStats = it.failedStats - streamName,
164 failedStats = it.failedStats - streamName,
207 )
165 )
@@ -209,8 +167,7 @@ class StreamsViewModel @Inject constructor(
209 }
167 }
210 is ApiResult.Error -> {
168 is ApiResult.Error -> {
211 _state.update {
169 _state.update {
212− if (it.streams.none { s -> s.name == streamName }) it
213− else it.copy(failedStats = it.failedStats + streamName)
170+ it.copy(failedStats = it.failedStats + streamName)
214 }
171 }
215 }
172 }
216 }
173 }
app/src/main/res/drawable/ic_launcher_background.xmlnew file+10
@@ -0,0 +1,10 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="108dp"
4+ android:height="108dp"
5+ android:viewportWidth="108"
6+ android:viewportHeight="108">
7+ <path
8+ android:fillColor="#545BEB"
9+ android:pathData="M0,0h108v108h-108z" />
10+</vector>
app/src/main/res/drawable/ic_launcher_foreground.xmlnew file+15
@@ -0,0 +1,15 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="108dp"
4+ android:height="108dp"
5+ android:viewportWidth="108"
6+ android:viewportHeight="108">
7+ <!-- Background circle -->
8+ <path
9+ android:fillColor="#545BEB"
10+ android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0" />
11+ <!-- Letter P -->
12+ <path
13+ android:fillColor="#FFFFFF"
14+ android:pathData="M40,35h12c6.6,0 12,5.4 12,12s-5.4,12 -12,12h-6v14h-6V35zM46,53h6c3.3,0 6,-2.7 6,-6s-2.7,-6 -6,-6h-6V53z" />
15+</vector>
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xmlnew file+5
@@ -0,0 +1,5 @@
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+</adaptive-icon>
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xmlnew file+5
@@ -0,0 +1,5 @@
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+</adaptive-icon>
app/src/main/res/mipmap-hdpi/ic_launcher.webpdeleted

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

app/src/test/java/com/parseable/android/AlertsViewModelTest.kt+5−9
@@ -24,10 +24,6 @@ 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())
31 viewModel = AlertsViewModel(repository)
27 viewModel = AlertsViewModel(repository)
32 }
28 }
33
29
@@ -48,8 +44,8 @@ class AlertsViewModelTest {
48 @Test
44 @Test
49 fun `refresh loads alerts on success`() = runTest {
45 fun `refresh loads alerts on success`() = runTest {
50 val alerts = listOf(
46 val alerts = listOf(
51− Alert(id = "1", title = "Alert 1", datasets = listOf("stream1"), state = "NotTriggered"),
47+ Alert(id = "1", name = "Alert 1", stream = "stream1"),
52− Alert(id = "2", title = "Alert 2", datasets = listOf("stream2"), state = "Triggered"),
48+ Alert(id = "2", name = "Alert 2", stream = "stream2"),
53 )
49 )
54 coEvery { repository.listAlerts() } returns ApiResult.Success(alerts)
50 coEvery { repository.listAlerts() } returns ApiResult.Success(alerts)
55
51
@@ -75,7 +71,7 @@ class AlertsViewModelTest {
75
71
76 @Test
72 @Test
77 fun `requestDelete sets alertToDelete`() {
73 fun `requestDelete sets alertToDelete`() {
78− val alert = Alert(id = "1", title = "Alert 1", datasets = listOf("stream1"))
74+ val alert = Alert(id = "1", name = "Alert 1", stream = "stream1")
79
75
80 viewModel.requestDelete(alert)
76 viewModel.requestDelete(alert)
81
77
@@ -85,7 +81,7 @@ class AlertsViewModelTest {
85
81
86 @Test
82 @Test
87 fun `cancelDelete clears alertToDelete`() {
83 fun `cancelDelete clears alertToDelete`() {
88− val alert = Alert(id = "1", title = "Alert 1", datasets = listOf("stream1"))
84+ val alert = Alert(id = "1", name = "Alert 1", stream = "stream1")
89 viewModel.requestDelete(alert)
85 viewModel.requestDelete(alert)
90 assertEquals(alert, viewModel.state.value.alertToDelete)
86 assertEquals(alert, viewModel.state.value.alertToDelete)
91
87
@@ -97,7 +93,7 @@ class AlertsViewModelTest {
97 @Test
93 @Test
98 fun `deleteAlert on success refreshes list`() = runTest {
94 fun `deleteAlert on success refreshes list`() = runTest {
99 val alerts = listOf(
95 val alerts = listOf(
100− Alert(id = "2", title = "Alert 2", datasets = listOf("stream2")),
96+ Alert(id = "2", name = "Alert 2", stream = "stream2"),
101 )
97 )
102 coEvery { repository.deleteAlert("1") } returns ApiResult.Success("deleted")
98 coEvery { repository.deleteAlert("1") } returns ApiResult.Success("deleted")
103 coEvery { repository.listAlerts() } returns ApiResult.Success(alerts)
99 coEvery { repository.listAlerts() } returns ApiResult.Success(alerts)
app/src/test/java/com/parseable/android/EscapeSqlTest.kt−27
@@ -1,7 +1,6 @@
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
5import com.parseable.android.data.escapeSql
4import com.parseable.android.data.escapeSql
6import org.junit.Assert.assertEquals
5import org.junit.Assert.assertEquals
7import org.junit.Test
6import org.junit.Test
@@ -53,32 +52,6 @@ class EscapeSqlTest {
53 assertEquals("''; DROP TABLE users --", escapeSql("'; DROP TABLE users --"))
52 assertEquals("''; DROP TABLE users --", escapeSql("'; DROP TABLE users --"))
54 }
53 }
55
54
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−
82 @Test
55 @Test
83 fun `escapeIdentifier doubles double quotes`() {
56 fun `escapeIdentifier doubles double quotes`() {
84 assertEquals("foo\"\"bar", escapeIdentifier("foo\"bar"))
57 assertEquals("foo\"\"bar", escapeIdentifier("foo\"bar"))
app/src/test/java/com/parseable/android/FormatBytesTest.ktdeleted−80
@@ -1,80 +0,0 @@
1−package com.parseable.android
2−
3−import com.parseable.android.data.formatBytes
4−import org.junit.Assert.*
5−import org.junit.Test
6−
7−class FormatBytesTest {
8−
9− @Test
10− fun `null input returns null`() {
11− assertNull(formatBytes(null))
12− }
13−
14− @Test
15− fun `zero bytes`() {
16− assertEquals("0 B", formatBytes("0"))
17− }
18−
19− @Test
20− fun `small byte values`() {
21− assertEquals("512 B", formatBytes("512"))
22− assertEquals("1 B", formatBytes("1"))
23− }
24−
25− @Test
26− fun `kilobytes`() {
27− assertEquals("1.00 KB", formatBytes("1024"))
28− assertEquals("1.50 KB", formatBytes("1536"))
29− }
30−
31− @Test
32− fun `megabytes`() {
33− assertEquals("1.00 MB", formatBytes("1048576"))
34− assertEquals("5.25 MB", formatBytes("5505024"))
35− }
36−
37− @Test
38− fun `gigabytes`() {
39− assertEquals("1.00 GB", formatBytes("1073741824"))
40− assertEquals("2.50 GB", formatBytes("2684354560"))
41− }
42−
43− @Test
44− fun `terabytes`() {
45− assertEquals("1.00 TB", formatBytes("1099511627776"))
46− }
47−
48− @Test
49− fun `large values use no decimals`() {
50− // 150 GB = 161061273600
51− assertEquals("150 GB", formatBytes("161061273600"))
52− }
53−
54− @Test
55− fun `mid-range values use one decimal`() {
56− // 15.5 MB = 16252928
57− assertEquals("15.5 MB", formatBytes("16252928"))
58− }
59−
60− @Test
61− fun `non-numeric string passes through unchanged`() {
62− assertEquals("already formatted", formatBytes("already formatted"))
63− assertEquals("1.5 GiB", formatBytes("1.5 GiB"))
64− }
65−
66− @Test
67− fun `negative value passes through unchanged`() {
68− assertEquals("-1024", formatBytes("-1024"))
69− }
70−
71− @Test
72− fun `whitespace is trimmed before parsing`() {
73− assertEquals("1.00 KB", formatBytes(" 1024 "))
74− }
75−
76− @Test
77− fun `fractional bytes from API`() {
78− assertEquals("1.50 KB", formatBytes("1536.0"))
79− }
80−}
app/src/test/java/com/parseable/android/FormatTimestampTest.kt−14
@@ -32,20 +32,6 @@ 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−
49 @Test
35 @Test
50 fun `returns raw string for unparseable timestamp`() {
36 fun `returns raw string for unparseable timestamp`() {
51 val result = formatTimestamp("not-a-timestamp")
37 val result = formatTimestamp("not-a-timestamp")
app/src/test/java/com/parseable/android/LogViewerViewModelTest.kt+3−57
@@ -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("CAST(\"message\" AS VARCHAR) ILIKE '%error%'") &&
167+ it.contains("\"message\" ILIKE '%error%'") &&
168− it.contains("CAST(\"level\" AS VARCHAR) ILIKE '%error%'") &&
168+ it.contains("\"level\" ILIKE '%error%'") &&
169− !it.contains("p_timestamp") // Internal columns excluded
169+ !it.contains("p_timestamp ILIKE") // Internal columns excluded
170 },
170 },
171 limit = any(),
171 limit = any(),
172 )
172 )
@@ -185,24 +185,6 @@ 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−
206 @Test
188 @Test
207 fun `toggleStreaming starts and stops`() {
189 fun `toggleStreaming starts and stops`() {
208 viewModel.initialize("test")
190 viewModel.initialize("test")
@@ -274,42 +256,6 @@ class LogViewerViewModelTest {
274 }
256 }
275 }
257 }
276
258
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−
313 @Test
259 @Test
314 fun `addFilter escapes column names`() {
260 fun `addFilter escapes column names`() {
315 viewModel.initialize("test")
261 viewModel.initialize("test")
app/src/test/java/com/parseable/android/LoginViewModelTest.kt+7−83
@@ -9,8 +9,6 @@ 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
14import kotlinx.coroutines.test.*
12import kotlinx.coroutines.test.*
15import org.junit.After
13import org.junit.After
16import org.junit.Assert.*
14import org.junit.Assert.*
@@ -39,17 +37,6 @@ class LoginViewModelTest {
39 Dispatchers.resetMain()
37 Dispatchers.resetMain()
40 }
38 }
41
39
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−
53 @Test
40 @Test
54 fun `onLogin validates required fields`() {
41 fun `onLogin validates required fields`() {
55 viewModel.onLogin()
42 viewModel.onLogin()
@@ -77,12 +64,7 @@ class LoginViewModelTest {
77 @Test
64 @Test
78 fun `onLogin succeeds with valid credentials`() = runTest {
65 fun `onLogin succeeds with valid credentials`() = runTest {
79 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
66 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
80− coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo(version = "1.0"))
67+ coEvery { repository.getAbout() } 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()
86
68
87 viewModel.onServerUrlChange("https://test.parseable.com")
69 viewModel.onServerUrlChange("https://test.parseable.com")
88 viewModel.onUsernameChange("admin")
70 viewModel.onUsernameChange("admin")
@@ -90,7 +72,7 @@ class LoginViewModelTest {
90 viewModel.onLogin()
72 viewModel.onLogin()
91
73
92 val state = viewModel.state.value
74 val state = viewModel.state.value
93− assertEquals(1, events.size)
75+ assertTrue(state.loginSuccess)
94 assertFalse(state.isLoading)
76 assertFalse(state.isLoading)
95 coVerify { settingsRepository.saveServerConfig(any()) }
77 coVerify { settingsRepository.saveServerConfig(any()) }
96 }
78 }
@@ -99,15 +81,13 @@ class LoginViewModelTest {
99 fun `onLogin fails when connection fails`() = runTest {
81 fun `onLogin fails when connection fails`() = runTest {
100 coEvery { repository.testConnection() } returns ApiResult.Error("Connection refused", 0)
82 coEvery { repository.testConnection() } returns ApiResult.Error("Connection refused", 0)
101
83
102− val events = collectSuccessEvents()
103−
104 viewModel.onServerUrlChange("https://bad.server")
84 viewModel.onServerUrlChange("https://bad.server")
105 viewModel.onUsernameChange("admin")
85 viewModel.onUsernameChange("admin")
106 viewModel.onPasswordChange("password")
86 viewModel.onPasswordChange("password")
107 viewModel.onLogin()
87 viewModel.onLogin()
108
88
109 val state = viewModel.state.value
89 val state = viewModel.state.value
110− assertEquals(0, events.size)
90+ assertFalse(state.loginSuccess)
111 assertNotNull(state.error)
91 assertNotNull(state.error)
112 assertTrue(state.error!!.contains("Connection failed"))
92 assertTrue(state.error!!.contains("Connection failed"))
113 }
93 }
@@ -115,9 +95,7 @@ class LoginViewModelTest {
115 @Test
95 @Test
116 fun `onLogin fails when about check fails`() = runTest {
96 fun `onLogin fails when about check fails`() = runTest {
117 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
97 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
118− coEvery { repository.verifyServer() } returns ApiResult.Error("not parseable", 404)
98+ coEvery { repository.getAbout() } returns ApiResult.Error("not parseable", 404)
119−
120− val events = collectSuccessEvents()
121
99
122 viewModel.onServerUrlChange("https://test.com")
100 viewModel.onServerUrlChange("https://test.com")
123 viewModel.onUsernameChange("admin")
101 viewModel.onUsernameChange("admin")
@@ -125,30 +103,11 @@ class LoginViewModelTest {
125 viewModel.onLogin()
103 viewModel.onLogin()
126
104
127 val state = viewModel.state.value
105 val state = viewModel.state.value
128− assertEquals(0, events.size)
106+ assertFalse(state.loginSuccess)
129 assertNotNull(state.error)
107 assertNotNull(state.error)
130 assertTrue(state.error!!.contains("doesn't appear to be Parseable"))
108 assertTrue(state.error!!.contains("doesn't appear to be Parseable"))
131 }
109 }
132
110
133− @Test
134− fun `onLogin shows invalid credentials on 401`() = runTest {
135− coEvery { repository.testConnection() } returns ApiResult.Success("ok")
136− coEvery { repository.verifyServer() } returns ApiResult.Error("Unauthorized", 401)
137−
138− val events = collectSuccessEvents()
139−
140− viewModel.onServerUrlChange("https://test.parseable.com")
141− viewModel.onUsernameChange("admin")
142− viewModel.onPasswordChange("wrongpass")
143− viewModel.onLogin()
144−
145− val state = viewModel.state.value
146− assertEquals(0, events.size)
147− assertNotNull(state.error)
148− assertTrue(state.error!!.contains("Invalid credentials"))
149− assertFalse(state.error!!.contains("doesn't appear to be Parseable"))
150− }
151−
152 @Test
111 @Test
153 fun `onServerUrlChange clears error`() {
112 fun `onServerUrlChange clears error`() {
154 viewModel.onLogin() // triggers error
113 viewModel.onLogin() // triggers error
@@ -188,7 +147,7 @@ class LoginViewModelTest {
188 @Test
147 @Test
189 fun `onLogin prepends https when not insecure`() = runTest {
148 fun `onLogin prepends https when not insecure`() = runTest {
190 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
149 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
191− coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
150+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
192
151
193 viewModel.onServerUrlChange("test.parseable.com")
152 viewModel.onServerUrlChange("test.parseable.com")
194 viewModel.onUsernameChange("admin")
153 viewModel.onUsernameChange("admin")
@@ -203,7 +162,7 @@ class LoginViewModelTest {
203 @Test
162 @Test
204 fun `onLogin prepends http when insecure`() = runTest {
163 fun `onLogin prepends http when insecure`() = runTest {
205 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
164 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
206− coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
165+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
207
166
208 viewModel.onServerUrlChange("test.parseable.com")
167 viewModel.onServerUrlChange("test.parseable.com")
209 viewModel.onAllowInsecureChange(true)
168 viewModel.onAllowInsecureChange(true)
@@ -215,39 +174,4 @@ class LoginViewModelTest {
215 repository.configure(match { it.serverUrl == "http://test.parseable.com" })
174 repository.configure(match { it.serverUrl == "http://test.parseable.com" })
216 }
175 }
217 }
176 }
218−
219− @Test
220− fun `onLogin handles uppercase URL scheme`() = runTest {
221− coEvery { repository.testConnection() } returns ApiResult.Success("ok")
222− coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
223−
224− viewModel.onServerUrlChange("HTTPS://test.parseable.com")
225− viewModel.onUsernameChange("admin")
226− viewModel.onPasswordChange("pass")
227− viewModel.onLogin()
228−
229− coVerify {
230− repository.configure(match {
231− it.serverUrl == "HTTPS://test.parseable.com" && it.useTls
232− })
233− }
234− }
235−
236− @Test
237− fun `onLogin handles mixed case URL scheme`() = runTest {
238− coEvery { repository.testConnection() } returns ApiResult.Success("ok")
239− coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
240−
241− viewModel.onServerUrlChange("Http://test.parseable.com")
242− viewModel.onAllowInsecureChange(true)
243− viewModel.onUsernameChange("admin")
244− viewModel.onPasswordChange("pass")
245− viewModel.onLogin()
246−
247− coVerify {
248− repository.configure(match {
249− it.serverUrl == "Http://test.parseable.com" && !it.useTls
250− })
251− }
252− }
253}
177}
app/src/test/java/com/parseable/android/PrivateHostTest.ktdeleted−73
@@ -1,73 +0,0 @@
1−package com.parseable.android
2−
3−import com.parseable.android.ui.screens.login.extractHost
4−import com.parseable.android.ui.screens.login.isPrivateHost
5−import org.junit.Assert.assertEquals
6−import org.junit.Assert.assertFalse
7−import org.junit.Assert.assertTrue
8−import org.junit.Test
9−
10−class PrivateHostTest {
11−
12− @Test
13− fun `extractHost strips scheme port and path`() {
14− assertEquals("192.168.1.5", extractHost("http://192.168.1.5:8000/path"))
15− assertEquals("example.com", extractHost("https://example.com"))
16− assertEquals("example.com", extractHost("example.com:9000"))
17− }
18−
19− @Test
20− fun `extractHost handles bracketed ipv6`() {
21− assertEquals("::1", extractHost("http://[::1]:8000"))
22− assertEquals("fe80::1", extractHost("[fe80::1]"))
23− }
24−
25− @Test
26− fun `extractHost leaves bare ipv6 intact`() {
27− assertEquals("fe80::1", extractHost("fe80::1"))
28− }
29−
30− @Test
31− fun `private ipv4 ranges are detected`() {
32− assertTrue(isPrivateHost("10.0.0.1"))
33− assertTrue(isPrivateHost("127.0.0.1"))
34− assertTrue(isPrivateHost("192.168.1.10"))
35− assertTrue(isPrivateHost("172.16.0.1"))
36− assertTrue(isPrivateHost("172.31.255.255"))
37− }
38−
39− @Test
40− fun `public ipv4 in the 172 gap is not private`() {
41− assertFalse(isPrivateHost("172.15.0.1"))
42− assertFalse(isPrivateHost("172.32.0.1"))
43− assertFalse(isPrivateHost("8.8.8.8"))
44− }
45−
46− @Test
47− fun `public hostnames sharing a private prefix are not private`() {
48− // The old prefix-string check wrongly classified these as private, hiding the
49− // plaintext-HTTP warning.
50− assertFalse(isPrivateHost("10.example.com"))
51− assertFalse(isPrivateHost("192.168.evil.com"))
52− }
53−
54− @Test
55− fun `localhost is private`() {
56− assertTrue(isPrivateHost("localhost"))
57− assertTrue(isPrivateHost("api.localhost"))
58− assertTrue(isPrivateHost(""))
59− }
60−
61− @Test
62− fun `ipv6 loopback and local ranges are private`() {
63− assertTrue(isPrivateHost("::1"))
64− assertTrue(isPrivateHost("fe80::1"))
65− assertTrue(isPrivateHost("fd00::1"))
66− assertTrue(isPrivateHost("fc00::1"))
67− }
68−
69− @Test
70− fun `public ipv6 is not private`() {
71− assertFalse(isPrivateHost("2001:4860:4860::8888"))
72− }
73−}
app/src/test/java/com/parseable/android/SettingsViewModelTest.kt+7−13
@@ -3,8 +3,6 @@ package com.parseable.android
3import com.parseable.android.data.model.AboutInfo
3import com.parseable.android.data.model.AboutInfo
4import com.parseable.android.data.model.ApiResult
4import com.parseable.android.data.model.ApiResult
5import com.parseable.android.data.model.ServerConfig
5import com.parseable.android.data.model.ServerConfig
6−import kotlinx.serialization.json.buildJsonObject
7−import kotlinx.serialization.json.put
8import com.parseable.android.data.repository.ParseableRepository
6import com.parseable.android.data.repository.ParseableRepository
9import com.parseable.android.data.repository.SettingsRepository
7import com.parseable.android.data.repository.SettingsRepository
10import com.parseable.android.ui.screens.settings.SettingsViewModel
8import com.parseable.android.ui.screens.settings.SettingsViewModel
@@ -62,7 +60,7 @@ class SettingsViewModelTest {
62
60
63 @Test
61 @Test
64 fun `load sets about info on success`() = runTest {
62 fun `load sets about info on success`() = runTest {
65− val about = AboutInfo(version = "1.2.0", mode = "standalone", license = buildJsonObject { put("name", "AGPL") })
63+ val about = AboutInfo(version = "1.2.0", mode = "standalone", license = "AGPL")
66 coEvery { settingsRepository.serverConfig } returns flowOf(null)
64 coEvery { settingsRepository.serverConfig } returns flowOf(null)
67 coEvery { repository.getAbout() } returns ApiResult.Success(about)
65 coEvery { repository.getAbout() } returns ApiResult.Success(about)
68 coEvery { repository.listUsers() } returns ApiResult.Success(emptyList())
66 coEvery { repository.listUsers() } returns ApiResult.Success(emptyList())
@@ -107,35 +105,31 @@ class SettingsViewModelTest {
107 }
105 }
108
106
109 @Test
107 @Test
110− fun `load ignores users failure when about succeeds`() = runTest {
108+ fun `load sets error when users fail`() = runTest {
111 coEvery { settingsRepository.serverConfig } returns flowOf(null)
109 coEvery { settingsRepository.serverConfig } returns flowOf(null)
112− coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo(version = "1.2.0"))
110+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
113 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
111 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
114
112
115 val viewModel = SettingsViewModel(settingsRepository, repository)
113 val viewModel = SettingsViewModel(settingsRepository, repository)
116
114
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.
119 val state = viewModel.state.value
115 val state = viewModel.state.value
120− assertNull(state.error)
116+ assertNotNull(state.error)
121− assertEquals("1.2.0", state.aboutInfo?.version)
122 assertTrue(state.users.isEmpty())
117 assertTrue(state.users.isEmpty())
123 }
118 }
124
119
125 @Test
120 @Test
126− fun `load surfaces only the about error, not the users error`() = runTest {
121+ fun `load consolidates errors from both about and users`() = runTest {
127 coEvery { settingsRepository.serverConfig } returns flowOf(null)
122 coEvery { settingsRepository.serverConfig } returns flowOf(null)
128 coEvery { repository.getAbout() } returns ApiResult.Error("Server error", 500)
123 coEvery { repository.getAbout() } returns ApiResult.Error("Server error", 500)
129 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
124 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
130
125
131 val viewModel = SettingsViewModel(settingsRepository, repository)
126 val viewModel = SettingsViewModel(settingsRepository, repository)
132
127
133− // Only the primary server-info call (/about) is treated as blocking; the secondary
134− // /user failure stays silent.
135 val state = viewModel.state.value
128 val state = viewModel.state.value
136 assertNotNull(state.error)
129 assertNotNull(state.error)
130+ // Both error messages should be present
137 assertTrue(state.error!!.contains("Server error"))
131 assertTrue(state.error!!.contains("Server error"))
138− assertFalse(state.error!!.contains("Permission denied"))
132+ assertTrue(state.error!!.contains("Permission denied"))
139 }
133 }
140
134
141 @Test
135 @Test
app/src/test/java/com/parseable/android/SeverityDetectionTest.ktdeleted−180
@@ -1,180 +0,0 @@
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,9 +143,6 @@ 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
149
146
150 viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
147 viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
151 viewModel.toggleFavorite("existing")
148 viewModel.toggleFavorite("existing")
gradle/libs.versions.toml+4
@@ -1,4 +1,8 @@
1[versions]
1[versions]
2+# App versioning
3+appVersionCode = "1"
4+appVersionName = "1.0.0"
5+
2agp = "8.7.3"
6agp = "8.7.3"
3kotlin = "2.1.0"
7kotlin = "2.1.0"
4ksp = "2.1.0-1.0.29"
8ksp = "2.1.0-1.0.29"