git.iko.soy/parseable-android
on 9596377 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−18
@@ -12,8 +12,6 @@ concurrency:
12
12
13permissions:
13permissions:
14 contents: write
14 contents: write
15− id-token: write
16− attestations: write
17
15
18jobs:
16jobs:
19 build:
17 build:
@@ -24,12 +22,6 @@ jobs:
24 - name: Checkout code
22 - name: Checkout code
25 uses: actions/checkout@v4
23 uses: actions/checkout@v4
26
24
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
25 - name: Set up JDK 17
34 uses: actions/setup-java@v4
26 uses: actions/setup-java@v4
35 with:
27 with:
@@ -46,11 +38,6 @@ jobs:
46 run: gradle wrapper --gradle-version 8.9
38 run: gradle wrapper --gradle-version 8.9
47
39
48 - name: Build release APK
40 - 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
41 run: ./gradlew assembleRelease
55
42
56 - name: Run unit tests
43 - name: Run unit tests
@@ -62,11 +49,6 @@ jobs:
62 name: parseable-android-release
49 name: parseable-android-release
63 path: app/build/outputs/apk/release/*.apk
50 path: app/build/outputs/apk/release/*.apk
64
51
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
52 - name: Create Draft Release
71 if: startsWith(github.ref, 'refs/tags/v')
53 if: startsWith(github.ref, 'refs/tags/v')
72 uses: softprops/action-gh-release@v2
54 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.kt+2−19
@@ -2,10 +2,7 @@ package com.parseable.android.data
2
2
3import java.util.Locale
3import java.util.Locale
4
4
5−// "EB" is included so PB is never the last unit: the promotion guard below (which only fires
6−// when a higher unit exists) then handles a PB value in [1023.5, 1024) — promoting it to "1.00 EB"
7−// instead of rendering "1024 PB". The remaining unhandled top-unit case sits at ~1.2e24 bytes.
8−private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
5+private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB")
9
6
10/**
7/**
11 * Converts a raw byte-count string (e.g. "123456789") to a human-readable
8 * Converts a raw byte-count string (e.g. "123456789") to a human-readable
@@ -15,28 +12,14 @@ private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
15fun formatBytes(raw: String?): String? {
12fun formatBytes(raw: String?): String? {
16 if (raw == null) return null
13 if (raw == null) return null
17 val bytes = raw.trim().toDoubleOrNull() ?: return raw
14 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
15 if (bytes < 0) return raw
22− // Use 1023.5, not 1024, as the cutoff: "%.0f" rounds half-up, so a byte count in
23− // [1023.5, 1024) would render as "1024 B" — a value that should promote to "1.00 KB".
24− // Such inputs fall through to the unit-scaling path, where the promotion guard below
25− // (value >= 1023.5) handles them.
26− if (bytes < 1023.5) return String.format(Locale.US, "%.0f B", bytes)
16+ if (bytes < 1024) return String.format(Locale.US, "%.0f B", bytes)
27 var value = bytes
17 var value = bytes
28 var unitIndex = 0
18 var unitIndex = 0
29 while (value >= 1024 && unitIndex < BYTE_UNITS.size - 1) {
19 while (value >= 1024 && unitIndex < BYTE_UNITS.size - 1) {
30 value /= 1024
20 value /= 1024
31 unitIndex++
21 unitIndex++
32 }
22 }
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) {
23 return if (value >= 100) {
41 String.format(Locale.US, "%.0f %s", value, BYTE_UNITS[unitIndex])
24 String.format(Locale.US, "%.0f %s", value, BYTE_UNITS[unitIndex])
42 } else if (value >= 10) {
25 } else if (value >= 10) {
app/src/main/java/com/parseable/android/data/SqlUtils.kt−16
@@ -5,19 +5,3 @@ fun escapeSql(value: String): String = value.replace("'", "''")
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+32−89
@@ -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}")
@@ -427,22 +384,8 @@ class ParseableApiClient @Inject constructor() {
427 suspend fun listFilters(): ApiResult<List<SavedFilter>> {
384 suspend fun listFilters(): ApiResult<List<SavedFilter>> {
428 val request = buildRequest("/api/v1/filters").get().build()
385 val request = buildRequest("/api/v1/filters").get().build()
429 return when (val result = executeRequest(request)) {
386 return when (val result = executeRequest(request)) {
430− is ApiResult.Success -> parseResponse("filters") {
387+ is ApiResult.Success -> parseResponse(result.data, "filters") {
431− val element = json.parseToJsonElement(result.data)
388+ json.decodeFromString<List<SavedFilter>>(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 }
389 }
447 is ApiResult.Error -> result
390 is ApiResult.Error -> result
448 }
391 }
@@ -455,7 +398,7 @@ class ParseableApiClient @Inject constructor() {
455 val encoded = encodePathSegment(filterId)
398 val encoded = encodePathSegment(filterId)
456 val request = buildRequest("/api/v1/filters/$encoded").get().build()
399 val request = buildRequest("/api/v1/filters/$encoded").get().build()
457 return when (val result = executeRequest(request)) {
400 return when (val result = executeRequest(request)) {
458− is ApiResult.Success -> parseResponse("filter") {
401+ is ApiResult.Success -> parseResponse(result.data, "filter") {
459 json.decodeFromString<SavedFilter>(result.data)
402 json.decodeFromString<SavedFilter>(result.data)
460 }
403 }
461 is ApiResult.Error -> result
404 is ApiResult.Error -> result
@@ -472,7 +415,7 @@ class ParseableApiClient @Inject constructor() {
472 .post(requestBody)
415 .post(requestBody)
473 .build()
416 .build()
474 return when (val result = executeRequest(request)) {
417 return when (val result = executeRequest(request)) {
475− is ApiResult.Success -> parseResponse("filter") {
418+ is ApiResult.Success -> parseResponse(result.data, "filter") {
476 json.decodeFromString<SavedFilter>(result.data)
419 json.decodeFromString<SavedFilter>(result.data)
477 }
420 }
478 is ApiResult.Error -> result
421 is ApiResult.Error -> result
@@ -490,7 +433,7 @@ class ParseableApiClient @Inject constructor() {
490 .put(requestBody)
433 .put(requestBody)
491 .build()
434 .build()
492 return when (val result = executeRequest(request)) {
435 return when (val result = executeRequest(request)) {
493− is ApiResult.Success -> parseResponse("filter") {
436+ is ApiResult.Success -> parseResponse(result.data, "filter") {
494 json.decodeFromString<SavedFilter>(result.data)
437 json.decodeFromString<SavedFilter>(result.data)
495 }
438 }
496 is ApiResult.Error -> result
439 is ApiResult.Error -> result
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+2−57
@@ -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
@@ -192,14 +152,11 @@ data class Alert(
192 val datasets: List<String> = emptyList(),
152 val datasets: List<String> = emptyList(),
193) {
153) {
194 /** Best-effort display name from either response format. */
154 /** Best-effort display name from either response format. */
195− val displayName: String
155+ val displayName: String get() = title ?: name ?: "Unnamed Alert"
196− get() = title?.takeIf { it.isNotBlank() }
197− ?: name?.takeIf { it.isNotBlank() }
198− ?: "Unnamed Alert"
199
156
200 /** Whether the alert is active, derived from either response format. */
157 /** Whether the alert is active, derived from either response format. */
201 val isEnabled: Boolean get() = when {
158 val isEnabled: Boolean get() = when {
202− state != null -> !state.equals("Disabled", ignoreCase = true)
159+ state != null -> state != "Disabled"
203 enabled != null -> enabled
160 enabled != null -> enabled
204 else -> true
161 else -> true
205 }
162 }
@@ -221,8 +178,6 @@ sealed class ApiResult<out T> {
221 val isUnauthorized: Boolean get() = code == 401
178 val isUnauthorized: Boolean get() = code == 401
222 val isNotFound: Boolean get() = code == 404
179 val isNotFound: Boolean get() = code == 404
223 val isServerError: Boolean get() = code in 500..599
180 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
181 val isNetworkError: Boolean get() = code == 0
227
182
228 val userMessage: String get() = when {
183 val userMessage: String get() = when {
@@ -240,14 +195,4 @@ sealed class ApiResult<out T> {
240 else -> message
195 else -> message
241 }
196 }
242 }
197 }
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}
198}
app/src/main/java/com/parseable/android/data/repository/ParseableRepository.kt+4−15
@@ -71,23 +71,12 @@ class ParseableRepository @Inject constructor(
71 * Does NOT trigger [checkAuth] so the global auth-error handler won't fire during login.
71 * Does NOT trigger [checkAuth] so the global auth-error handler won't fire during login.
72 */
72 */
73 suspend fun verifyServer(): ApiResult<AboutInfo> {
73 suspend fun verifyServer(): ApiResult<AboutInfo> {
74− return when (val aboutResult = apiClient.getAbout()) {
74+ return when (val result = apiClient.getAbout()) {
75 is ApiResult.Success -> {
75 is ApiResult.Success -> {
76− // GET /about is not auth-gated on Parseable, so a success here only proves
77− // the server is reachable and is Parseable — NOT that the credentials are
78− // valid. Probe an auth-required endpoint (logstream listing, which the app
79− // needs anyway) so wrong credentials are rejected at login instead of being
80− // persisted and only failing on the first real request. Call apiClient
81− // directly to avoid checkAuth firing the global auth-error handler mid-login.
82− when (val authProbe = apiClient.listStreams()) {
83− is ApiResult.Success -> {
84− aboutCache = CacheEntry(aboutResult.data)
76+ aboutCache = CacheEntry(result.data)
85− aboutResult
77+ result
86− }
87− is ApiResult.Error -> authProbe
88− }
89 }
78 }
90− is ApiResult.Error -> aboutResult
79+ is ApiResult.Error -> result
91 }
80 }
92 }
81 }
93
82
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/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+11−88
@@ -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,19 @@ 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()
123 val urlLower = url.lowercase()
188 if (!urlLower.startsWith("http://") && !urlLower.startsWith("https://")) {
124 if (!urlLower.startsWith("http://") && !urlLower.startsWith("https://")) {
189− url = if (allowInsecure) "http://$url" else "https://$url"
125+ url = if (current.allowInsecure) "http://$url" else "https://$url"
190 }
126 }
191
127
192 val isValidUrl = try {
128 val isValidUrl = try {
@@ -207,7 +143,7 @@ class LoginViewModel @Inject constructor(
207
143
208 val config = ServerConfig(
144 val config = ServerConfig(
209 serverUrl = url,
145 serverUrl = url,
210− username = username.trim(),
146+ username = current.username.trim(),
211 password = password,
147 password = password,
212 useTls = url.lowercase().startsWith("https"),
148 useTls = url.lowercase().startsWith("https"),
213 )
149 )
@@ -220,21 +156,8 @@ class LoginViewModel @Inject constructor(
220 // Uses verifyServer() to avoid triggering the global auth-error handler.
156 // Uses verifyServer() to avoid triggering the global auth-error handler.
221 when (val aboutResult = repository.verifyServer()) {
157 when (val aboutResult = repository.verifyServer()) {
222 is ApiResult.Success -> {
158 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)) {
159+ settingsRepository.saveServerConfig(config)
227− settingsRepository.saveServer(config)
228− _state.update { it.copy(isLoading = false, password = "") }
160+ _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 }
161 }
239 is ApiResult.Error -> {
162 is ApiResult.Error -> {
240 val errorMsg = if (aboutResult.isUnauthorized) {
163 val errorMsg = if (aboutResult.isUnauthorized) {
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.kt+71−213
@@ -101,10 +101,6 @@ fun LogViewerScreen(
101 actions = {
101 actions = {
102 StreamingToggleButton(
102 StreamingToggleButton(
103 isStreaming = state.isStreaming,
103 isStreaming = state.isStreaming,
104− // Live-tail re-derives its own default SELECT * query in the poller, so
105− // it can't honor a custom SQL projection/filter. Disable the toggle while
106− // a custom query is active to avoid prepending mismatched rows.
107− enabled = state.customSql.isBlank(),
108 onClick = viewModel::toggleStreaming,
104 onClick = viewModel::toggleStreaming,
109 )
105 )
110 IconButton(onClick = { onStreamInfo(streamName) }) {
106 IconButton(onClick = { onStreamInfo(streamName) }) {
@@ -171,9 +167,7 @@ fun LogViewerScreen(
171 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
167 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
172 keyboardActions = KeyboardActions(onSearch = {
168 keyboardActions = KeyboardActions(onSearch = {
173 keyboardController?.hide()
169 keyboardController?.hide()
174− // Runs the search now and cancels the pending debounce (which stops live-tail
170+ viewModel.refresh()
175− // and refreshes) so we don't fire a second, redundant refresh ~300ms later.
176− viewModel.submitSearch()
177 }),
171 }),
178 modifier = Modifier
172 modifier = Modifier
179 .fillMaxWidth()
173 .fillMaxWidth()
@@ -287,41 +281,9 @@ fun LogViewerScreen(
287 // Log entries
281 // Log entries
288 PullToRefreshBox(
282 PullToRefreshBox(
289 isRefreshing = state.isLoading,
283 isRefreshing = state.isLoading,
290− onRefresh = viewModel::pullRefresh,
284+ onRefresh = viewModel::refresh,
291 modifier = Modifier.fillMaxSize(),
285 modifier = Modifier.fillMaxSize(),
292 ) {
286 ) {
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()) {
287 if (state.error != null && state.logs.isEmpty()) {
326 Box(
288 Box(
327 modifier = Modifier.fillMaxSize(),
289 modifier = Modifier.fillMaxSize(),
@@ -388,6 +350,29 @@ fun LogViewerScreen(
388 }
350 }
389 }
351 }
390 } else {
352 } else {
353+ val listState = rememberLazyListState()
354+ val clipboard = remember {
355+ context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
356+ }
357+
358+ // Auto-load more when scrolling near the bottom
359+ if (state.hasMore && !state.isLoading) {
360+ val shouldLoadMore by remember {
361+ derivedStateOf {
362+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
363+ lastVisible >= listState.layoutInfo.totalItemsCount - 5
364+ }
365+ }
366+ LaunchedEffect(shouldLoadMore) {
367+ if (shouldLoadMore) viewModel.loadMore()
368+ }
369+ }
370+
371+ // Pre-compute stable keys so each log is assigned exactly one key
372+ val logKeys = remember(state.logs) {
373+ stableLogKeys(state.logs)
374+ }
375+
391 LazyColumn(
376 LazyColumn(
392 state = listState,
377 state = listState,
393 contentPadding = PaddingValues(8.dp),
378 contentPadding = PaddingValues(8.dp),
@@ -395,34 +380,14 @@ fun LogViewerScreen(
395 ) {
380 ) {
396 itemsIndexed(
381 itemsIndexed(
397 state.logs,
382 state.logs,
398− key = { index, _ -> state.logKeys.getOrElse(index) { "log_$index" } },
383+ key = { index, _ -> logKeys.getOrElse(index) { "log_$index" } },
399 ) { index, logEntry ->
384 ) { index, logEntry ->
400− val key = state.logKeys.getOrElse(index) { "log_$index" }
385+ val key = logKeys.getOrElse(index) { "log_$index" }
401 LogEntryCard(
386 LogEntryCard(
402 logEntry = logEntry,
387 logEntry = logEntry,
403− stableKey = key,
404 isExpanded = expandedLogKey == key,
388 isExpanded = expandedLogKey == key,
405 onClick = {
389 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
390+ 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 },
391 },
427 clipboardManager = clipboard,
392 clipboardManager = clipboard,
428 onCopied = {
393 onCopied = {
@@ -433,12 +398,6 @@ fun LogViewerScreen(
433 )
398 )
434 }
399 }
435 },
400 },
436− onFilterInclude = { column, value ->
437− viewModel.addFilter(column, "=", value)
438− },
439− onFilterExclude = { column, value ->
440− viewModel.addFilter(column, "!=", value)
441− },
442 )
401 )
443 }
402 }
444
403
@@ -491,7 +450,6 @@ fun LogViewerScreen(
491 if (showFilterSheet) {
450 if (showFilterSheet) {
492 FilterBottomSheet(
451 FilterBottomSheet(
493 columns = state.columns,
452 columns = state.columns,
494− logs = state.logs,
495 onDismiss = { showFilterSheet = false },
453 onDismiss = { showFilterSheet = false },
496 onApplyFilter = { column, operator, value ->
454 onApplyFilter = { column, operator, value ->
497 viewModel.addFilter(column, operator, value)
455 viewModel.addFilter(column, operator, value)
@@ -534,18 +492,10 @@ fun LogViewerScreen(
534 if (!dir.mkdirs() && !dir.isDirectory) {
492 if (!dir.mkdirs() && !dir.isDirectory) {
535 throw java.io.IOException("Failed to create shared_logs directory")
493 throw java.io.IOException("Failed to create shared_logs directory")
536 }
494 }
537− // Clean up STALE exports only. A share chooser opened moments
495+ // 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 ->
496+ dir.listFiles()?.forEach { old -> old.delete() }
545− if (old.lastModified() < cutoff) old.delete()
546− }
547 val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
497 val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
548− val file = java.io.File(dir, "logs_${safeFileName}_${System.currentTimeMillis()}.json")
498+ val file = java.io.File(dir, "logs_$safeFileName.json")
549 file.bufferedWriter().use { writer ->
499 file.bufferedWriter().use { writer ->
550 // Use compact JSON for large exports to reduce memory/file size
500 // Use compact JSON for large exports to reduce memory/file size
551 val encoder = if (logs.size > 1000) Json else prettyJson
501 val encoder = if (logs.size > 1000) Json else prettyJson
@@ -607,18 +557,17 @@ fun LogViewerScreen(
607 val start = dateRangePickerState.selectedStartDateMillis
557 val start = dateRangePickerState.selectedStartDateMillis
608 val end = dateRangePickerState.selectedEndDateMillis
558 val end = dateRangePickerState.selectedEndDateMillis
609 if (start != null && end != null) {
559 if (start != null && end != null) {
610− // DateRangePicker returns midnight UTC for the selected date, so the
560+ // DateRangePicker returns midnight UTC for the selected date.
611− // calendar date must be read back in UTC — reading it in the local zone
561+ // 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
562+ // 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()
563 val localZone = java.time.ZoneId.systemDefault()
615 val endLocalDate = java.time.Instant.ofEpochMilli(end)
564 val endLocalDate = java.time.Instant.ofEpochMilli(end)
616− .atZone(java.time.ZoneOffset.UTC).toLocalDate()
565+ .atZone(localZone).toLocalDate()
617 val endOfDayUtc = endLocalDate.plusDays(1)
566 val endOfDayUtc = endLocalDate.plusDays(1)
618 .atStartOfDay(localZone)
567 .atStartOfDay(localZone)
619 .toInstant().toEpochMilli() - 1
568 .toInstant().toEpochMilli() - 1
620 val startLocalDate = java.time.Instant.ofEpochMilli(start)
569 val startLocalDate = java.time.Instant.ofEpochMilli(start)
621− .atZone(java.time.ZoneOffset.UTC).toLocalDate()
570+ .atZone(localZone).toLocalDate()
622 val startUtc = startLocalDate
571 val startUtc = startLocalDate
623 .atStartOfDay(localZone)
572 .atStartOfDay(localZone)
624 .toInstant().toEpochMilli()
573 .toInstant().toEpochMilli()
@@ -701,10 +650,9 @@ private fun TimeRangeBar(
701@Composable
650@Composable
702private fun StreamingToggleButton(
651private fun StreamingToggleButton(
703 isStreaming: Boolean,
652 isStreaming: Boolean,
704− enabled: Boolean,
705 onClick: () -> Unit,
653 onClick: () -> Unit,
706) {
654) {
707− IconButton(onClick = onClick, enabled = enabled || isStreaming) {
655+ IconButton(onClick = onClick) {
708 if (isStreaming) {
656 if (isStreaming) {
709 val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
657 val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
710 val alpha by infiniteTransition.animateFloat(
658 val alpha by infiniteTransition.animateFloat(
@@ -735,12 +683,9 @@ private fun StreamingToggleButton(
735@Composable
683@Composable
736fun LogEntryCard(
684fun LogEntryCard(
737 logEntry: JsonObject,
685 logEntry: JsonObject,
738− stableKey: String,
739 isExpanded: Boolean,
686 isExpanded: Boolean,
740 onClick: () -> Unit,
687 onClick: () -> Unit,
741 onCopied: () -> Unit = {},
688 onCopied: () -> Unit = {},
742− onFilterInclude: (column: String, value: String) -> Unit = { _, _ -> },
743− onFilterExclude: (column: String, value: String) -> Unit = { _, _ -> },
744 clipboardManager: android.content.ClipboardManager,
689 clipboardManager: android.content.ClipboardManager,
745) {
690) {
746 val severity = remember(logEntry) { detectSeverity(logEntry) }
691 val severity = remember(logEntry) { detectSeverity(logEntry) }
@@ -754,9 +699,7 @@ fun LogEntryCard(
754 ) {
699 ) {
755 Column(modifier = Modifier.padding(12.dp)) {
700 Column(modifier = Modifier.padding(12.dp)) {
756 // Timestamp row
701 // Timestamp row
757− // Key the derived values on the content-stable log key (not the JsonObject
758− // instance), so live-tail re-emissions of unchanged rows don't re-parse them.
759− val rawTimestamp = remember(stableKey) {
702+ val rawTimestamp = remember(logEntry) {
760 try {
703 try {
761 logEntry["p_timestamp"]?.jsonPrimitive?.content
704 logEntry["p_timestamp"]?.jsonPrimitive?.content
762 ?: logEntry["datetime"]?.jsonPrimitive?.content
705 ?: logEntry["datetime"]?.jsonPrimitive?.content
@@ -781,7 +724,7 @@ fun LogEntryCard(
781
724
782 if (!isExpanded) {
725 if (!isExpanded) {
783 // Compact view: show first meaningful fields
726 // Compact view: show first meaningful fields
784− val preview = remember(stableKey) {
727+ val preview = remember(logEntry) {
785 logEntry.entries
728 logEntry.entries
786 .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
729 .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
787 .take(3)
730 .take(3)
@@ -823,55 +766,25 @@ fun LogEntryCard(
823 }
766 }
824 HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
767 HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
825 logEntry.entries.forEach { (key, value) ->
768 logEntry.entries.forEach { (key, value) ->
826− val displayValue = formatJsonValue(value)
827 Row(
769 Row(
828 modifier = Modifier
770 modifier = Modifier
829 .fillMaxWidth()
771 .fillMaxWidth()
830 .padding(vertical = 2.dp),
772 .padding(vertical = 2.dp),
831− verticalAlignment = Alignment.CenterVertically,
832 ) {
773 ) {
833− Column(modifier = Modifier.weight(1f)) {
774+ Text(
834− Row {
835− Text(
836− text = "$key: ",
775+ text = "$key: ",
837− style = MaterialTheme.typography.bodySmall,
776+ style = MaterialTheme.typography.bodySmall,
838− fontWeight = FontWeight.Bold,
777+ fontWeight = FontWeight.Bold,
839− fontFamily = FontFamily.Monospace,
778+ fontFamily = FontFamily.Monospace,
840− color = MaterialTheme.colorScheme.primary,
779+ color = MaterialTheme.colorScheme.primary,
841− fontSize = 12.sp,
780+ fontSize = 12.sp,
842− )
781+ )
843− Text(
782+ Text(
844− text = displayValue,
783+ text = formatJsonValue(value),
845− style = MaterialTheme.typography.bodySmall,
784+ style = MaterialTheme.typography.bodySmall,
846− fontFamily = FontFamily.Monospace,
785+ fontFamily = FontFamily.Monospace,
847− fontSize = 12.sp,
786+ fontSize = 12.sp,
848− )
787+ )
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 }
788 }
876 }
789 }
877 }
790 }
@@ -879,6 +792,24 @@ fun LogEntryCard(
879 }
792 }
880}
793}
881
794
795+/**
796+ * Produces deterministic keys for a list of log entries so that expanded state survives
797+ * list mutations (e.g., new logs prepended during streaming). Keys are content-based
798+ * using p_timestamp + p_metadata + p_tags; duplicate content gets a disambiguation suffix.
799+ */
800+private fun stableLogKeys(logs: List<JsonObject>): List<String> {
801+ val seen = mutableMapOf<String, Int>()
802+ return logs.map { log ->
803+ val ts = log["p_timestamp"]?.toString()
804+ val meta = log["p_metadata"]?.toString()
805+ val tag = log["p_tags"]?.toString()
806+ val base = if (ts != null) "$ts|${meta.orEmpty()}|${tag.orEmpty()}" else log.hashCode().toString()
807+ val count = seen.getOrDefault(base, 0)
808+ seen[base] = count + 1
809+ if (count == 0) base else "$base|$count"
810+ }
811+}
812+
882private fun formatJsonValue(element: JsonElement): String {
813private fun formatJsonValue(element: JsonElement): String {
883 return when (element) {
814 return when (element) {
884 is JsonPrimitive -> element.content
815 is JsonPrimitive -> element.content
@@ -891,22 +822,10 @@ private fun formatJsonValue(element: JsonElement): String {
891@Composable
822@Composable
892fun FilterBottomSheet(
823fun FilterBottomSheet(
893 columns: List<String>,
824 columns: List<String>,
894− logs: List<JsonObject> = emptyList(),
895 onDismiss: () -> Unit,
825 onDismiss: () -> Unit,
896 onApplyFilter: (column: String, operator: String, value: String) -> Unit,
826 onApplyFilter: (column: String, operator: String, value: String) -> Unit,
897) {
827) {
898 var selectedColumn by remember { mutableStateOf(columns.firstOrNull() ?: "") }
828 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("=") }
829 var selectedOperator by remember { mutableStateOf("=") }
911 var filterValue by remember { mutableStateOf("") }
830 var filterValue by remember { mutableStateOf("") }
912 var columnDropdownExpanded by remember { mutableStateOf(false) }
831 var columnDropdownExpanded by remember { mutableStateOf(false) }
@@ -914,28 +833,6 @@ fun FilterBottomSheet(
914
833
915 val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
834 val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
916
835
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) {
836 ModalBottomSheet(onDismissRequest = onDismiss) {
940 Column(
837 Column(
941 modifier = Modifier.padding(16.dp),
838 modifier = Modifier.padding(16.dp),
@@ -971,7 +868,6 @@ fun FilterBottomSheet(
971 text = { Text(column) },
868 text = { Text(column) },
972 onClick = {
869 onClick = {
973 selectedColumn = column
870 selectedColumn = column
974− filterValue = ""
975 columnDropdownExpanded = false
871 columnDropdownExpanded = false
976 },
872 },
977 )
873 )
@@ -1040,44 +936,6 @@ fun FilterBottomSheet(
1040 }
936 }
1041 }),
937 }),
1042 )
938 )
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 }
939 }
1082
940
1083 val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
941 val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerViewModel.kt+113−415
@@ -3,7 +3,6 @@ package com.parseable.android.ui.screens.logviewer
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
8import com.parseable.android.data.model.*
7import com.parseable.android.data.model.*
9import com.parseable.android.data.repository.ParseableRepository
8import com.parseable.android.data.repository.ParseableRepository
@@ -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)
@@ -76,9 +59,7 @@ data class SavedFiltersState(
76data class LogViewerState(
59data class LogViewerState(
77 val streamName: String = "",
60 val streamName: String = "",
78 val logs: List<JsonObject> = emptyList(),
61 val logs: List<JsonObject> = emptyList(),
79− val logKeys: List<String> = emptyList(),
80 val columns: List<String> = emptyList(),
62 val columns: List<String> = emptyList(),
81− val searchableColumns: List<String> = emptyList(),
82 val isLoading: Boolean = false,
63 val isLoading: Boolean = false,
83 val error: String? = null,
64 val error: String? = null,
84 val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
65 val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
@@ -89,10 +70,6 @@ data class LogViewerState(
89 val hasMore: Boolean = false,
70 val hasMore: Boolean = false,
90 val customStartTime: Long? = null,
71 val customStartTime: Long? = null,
91 val customEndTime: Long? = null,
72 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) {
73) {
97 // Convenience accessors for backward compatibility with Screen
74 // Convenience accessors for backward compatibility with Screen
98 val searchQuery: String get() = filters.searchQuery
75 val searchQuery: String get() = filters.searchQuery
@@ -120,53 +97,17 @@ class LogViewerViewModel @Inject constructor(
120 @Volatile private var streamingGeneration: Int = 0
97 @Volatile private var streamingGeneration: Int = 0
121 @Volatile private var lastSeenTimestamp: String? = null
98 @Volatile private var lastSeenTimestamp: String? = null
122 @Volatile private var consecutiveStreamingErrors: Int = 0
99 @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
100 private var searchJob: Job? = null
136 private var schemaJob: Job? = null
101 private var schemaJob: Job? = null
137− private var refreshJob: Job? = null
138
102
139 companion object {
103 companion object {
140 private const val STREAMING_BASE_INTERVAL_MS = 3000L
104 private const val STREAMING_BASE_INTERVAL_MS = 3000L
141 private const val STREAMING_MAX_INTERVAL_MS = 30000L
105 private const val STREAMING_MAX_INTERVAL_MS = 30000L
142 private const val STREAMING_MAX_LOGS = 1000
106 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
107 private const val MAX_LOAD_LIMIT = 5000
147 private const val LOAD_MORE_INCREMENT = 500
108 private const val LOAD_MORE_INCREMENT = 500
148 private const val MAX_STREAMING_ERRORS = 5
109 private const val MAX_STREAMING_ERRORS = 5
149 private val ALLOWED_OPERATORS = setOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
110 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 }
111 }
171
112
172 fun initialize(streamName: String) {
113 fun initialize(streamName: String) {
@@ -182,9 +123,8 @@ class LogViewerViewModel @Inject constructor(
182 schemaJob = viewModelScope.launch {
123 schemaJob = viewModelScope.launch {
183 when (val result = repository.getStreamSchema(streamName)) {
124 when (val result = repository.getStreamSchema(streamName)) {
184 is ApiResult.Success -> {
125 is ApiResult.Success -> {
185− val columns = result.data.fields.map { it.name }.sorted()
126+ 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) }
127+ _state.update { it.copy(columns = columns) }
188 }
128 }
189 is ApiResult.Error -> { /* Schema loading failure is non-fatal */ }
129 is ApiResult.Error -> { /* Schema loading failure is non-fatal */ }
190 }
130 }
@@ -218,11 +158,7 @@ class LogViewerViewModel @Inject constructor(
218 }
158 }
219
159
220 fun onSearchQueryChange(query: String) {
160 fun onSearchQueryChange(query: String) {
221− // A new query invalidates the live-tail baseline and the paged-up limit, matching
222− // the time-range / custom-SQL handlers.
223− stopStreaming()
224− // Searching returns to the filter-builder query path, so drop any active custom SQL.
225− _state.update { it.copy(currentLimit = 500, filters = it.filters.copy(searchQuery = query, customSql = "", isSearching = query.isNotBlank())) }
161+ _state.update { it.copy(filters = it.filters.copy(searchQuery = query, isSearching = query.isNotBlank())) }
226 searchJob?.cancel()
162 searchJob?.cancel()
227 searchJob = viewModelScope.launch {
163 searchJob = viewModelScope.launch {
228 delay(300)
164 delay(300)
@@ -231,55 +167,28 @@ class LogViewerViewModel @Inject constructor(
231 }
167 }
232 }
168 }
233
169
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) {
170 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
171 if (operator !in ALLOWED_OPERATORS) return
252 val safeColumn = escapeIdentifier(column)
172 val safeColumn = escapeIdentifier(column)
173+ val safeValue = escapeSql(value)
253 val clause = when (operator) {
174 val clause = when (operator) {
254 "IS NULL", "IS NOT NULL" -> "\"$safeColumn\" $operator"
175 "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 '\\'"
176+ "LIKE", "ILIKE" -> "\"$safeColumn\" $operator '%$safeValue%'"
258− else -> "\"$safeColumn\" $operator '${escapeSql(value)}'"
177+ 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 }
178 }
266 val display = when (operator) {
179 val display = when (operator) {
267 "IS NULL", "IS NOT NULL" -> "$column $operator"
180 "IS NULL", "IS NOT NULL" -> "$column $operator"
268 else -> "$column $operator $value"
181 else -> "$column $operator $value"
269 }
182 }
270− // A new filter invalidates the live-tail baseline and the paged-up limit.
271− stopStreaming()
272 _state.update {
183 _state.update {
273 it.copy(
184 it.copy(
274− currentLimit = 500,
275 filters = it.filters.copy(
185 filters = it.filters.copy(
276 filterClauses = it.filters.filterClauses + clause,
186 filterClauses = it.filters.filterClauses + clause,
277 activeFilters = it.filters.activeFilters + display,
187 activeFilters = it.filters.activeFilters + display,
278− filterConditions = it.filters.filterConditions + FilterCondition(column, operator, conditionValue),
279− customSql = "",
280 ),
188 ),
281 )
189 )
282 }
190 }
191+ refresh()
283 }
192 }
284
193
285 fun removeFilter(display: String) {
194 fun removeFilter(display: String) {
@@ -289,93 +198,81 @@ class LogViewerViewModel @Inject constructor(
289 if (index >= 0 && index < it.filters.filterClauses.size) {
198 if (index >= 0 && index < it.filters.filterClauses.size) {
290 removed = true
199 removed = true
291 it.copy(
200 it.copy(
292− currentLimit = 500,
293 filters = it.filters.copy(
201 filters = it.filters.copy(
294 activeFilters = it.filters.activeFilters.filterIndexed { i, _ -> i != index },
202 activeFilters = it.filters.activeFilters.filterIndexed { i, _ -> i != index },
295 filterClauses = it.filters.filterClauses.filterIndexed { i, _ -> i != index },
203 filterClauses = it.filters.filterClauses.filterIndexed { i, _ -> i != index },
296− filterConditions = it.filters.filterConditions.filterIndexed { i, _ -> i != index },
297− customSql = "",
298 ),
204 ),
299 )
205 )
300 } else {
206 } else {
301 it
207 it
302 }
208 }
303 }
209 }
304− if (removed) {
210+ if (removed) refresh()
305− // A changed filter set invalidates the live-tail baseline.
306− stopStreaming()
307− refresh()
308− }
309 }
211 }
310
212
311 fun clearFilters() {
213 fun clearFilters() {
312− stopStreaming()
313 _state.update {
214 _state.update {
314− it.copy(currentLimit = 500, filters = it.filters.copy(activeFilters = emptyList(), filterClauses = emptyList(), filterConditions = emptyList(), customSql = "", searchQuery = "", isSearching = false))
215+ it.copy(filters = it.filters.copy(activeFilters = emptyList(), filterClauses = emptyList()))
315 }
216 }
316 refresh()
217 refresh()
317 }
218 }
318
219
319 fun executeCustomSql(sql: String) {
220 fun executeCustomSql(sql: String) {
320− // Custom SQL changes the query basis, so cancel any pending search debounce that would
321− // otherwise fire a redundant refresh() ~300ms later. (Unlike the filter/time-range
322− // handlers, this path doesn't route through stopStreaming().)
323− searchJob?.cancel()
324− // Drop a trailing statement terminator before we append anything — otherwise
325− // "SELECT * FROM x;" would become "SELECT * FROM x; LIMIT 5000", a syntax error.
326− val trimmed = sql.trim().trimEnd(';').trim()
221+ val trimmed = sql.trim()
327 if (!trimmed.uppercase().startsWith("SELECT")) {
222 if (!trimmed.uppercase().startsWith("SELECT")) {
328 _state.update { it.copy(error = "Only SELECT queries are allowed") }
223 _state.update { it.copy(error = "Only SELECT queries are allowed") }
329 return
224 return
330 }
225 }
331− // Enforce a LIMIT to prevent OOM from unbounded queries. Only honor a *top-level*
226+ // Enforce a LIMIT to prevent OOM from unbounded queries
332− // `LIMIT <n>` (optionally with OFFSET) anchored at the end of the statement — a
227+ 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"
228 "$trimmed LIMIT $MAX_LOAD_LIMIT"
340 } else {
229 } else {
341 trimmed
230 trimmed
342 }
231 }
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) }
232 _state.update { it.copy(filters = it.filters.copy(customSql = safeSql), currentLimit = 500) }
347− refresh()
233+ viewModelScope.launch {
234+ _state.update { it.copy(isLoading = true, error = null) }
235+
236+ val (startTime, endTime) = getTimeRange()
237+ when (val result = repository.queryLogsRaw(safeSql, startTime, endTime)) {
238+ is ApiResult.Success -> {
239+ _state.update {
240+ it.copy(
241+ logs = result.data,
242+ isLoading = false,
243+ hasMore = result.data.size >= it.currentLimit,
244+ )
245+ }
246+ }
247+ is ApiResult.Error -> {
248+ _state.update {
249+ it.copy(isLoading = false, error = result.userMessage)
250+ }
251+ }
252+ }
253+ }
348 }
254 }
349
255
350− private fun buildSearchClause(searchableColumns: List<String>, searchQuery: String): String? {
256+ private fun buildSearchClause(columns: List<String>, searchQuery: String): String? {
351 if (searchQuery.isBlank()) return null
257 if (searchQuery.isBlank()) return null
258+ val safeSearch = escapeSql(searchQuery)
259+ // Search across ALL non-internal columns (not just 5)
260+ val searchFields = columns.filter { !it.startsWith("p_") }
352− if (searchableColumns.isEmpty()) return null
261+ if (searchFields.isEmpty()) return null
353− val safeSearch = escapeLikePattern(searchQuery)
354− return searchableColumns.joinToString(" OR ") {
262+ return searchFields.joinToString(" OR ") {
355− "CAST(\"${escapeIdentifier(it)}\" AS VARCHAR) ILIKE '%$safeSearch%' ESCAPE '\\'"
263+ "\"${escapeIdentifier(it)}\" ILIKE '%$safeSearch%'"
356 }
264 }
357 }
265 }
358
266
359 fun refresh() {
267 fun refresh() {
360 if (_state.value.streamName.isEmpty()) return
268 if (_state.value.streamName.isEmpty()) return
361
269
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 {
270+ 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 {
271 _state.update {
374 it.copy(
272 it.copy(
375 isLoading = true,
273 isLoading = true,
376 error = null,
274 error = null,
377 streaming = it.streaming.copy(streamingError = null),
275 streaming = it.streaming.copy(streamingError = null),
378− pageAnchorTime = anchor,
379 )
276 )
380 }
277 }
381
278
@@ -383,24 +280,10 @@ class LogViewerViewModel @Inject constructor(
383 val current = _state.value
280 val current = _state.value
384 val (startTime, endTime) = getTimeRange()
281 val (startTime, endTime) = getTimeRange()
385
282
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
283 // Build WHERE clause from filters + search
401 val clauses = current.filters.filterClauses.toMutableList()
284 val clauses = current.filters.filterClauses.toMutableList()
402 if (current.filters.searchQuery.isNotBlank()) {
285 if (current.filters.searchQuery.isNotBlank()) {
403− val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
286+ val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
404 if (searchClause != null) {
287 if (searchClause != null) {
405 clauses.add("($searchClause)")
288 clauses.add("($searchClause)")
406 } else {
289 } else {
@@ -413,26 +296,20 @@ class LogViewerViewModel @Inject constructor(
413
296
414 val filterSql = clauses.joinToString(" AND ")
297 val filterSql = clauses.joinToString(" AND ")
415
298
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(
299 when (val result = repository.queryLogs(
421 stream = current.streamName,
300 stream = current.streamName,
422 startTime = startTime,
301 startTime = startTime,
423 endTime = endTime,
302 endTime = endTime,
424 filterSql = filterSql,
303 filterSql = filterSql,
425− limit = requestLimit,
304+ limit = current.currentLimit,
426 )) {
305 )) {
427 is ApiResult.Success -> {
306 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 {
307 _state.update {
435− it.copy(logs = logs, logKeys = computeLogKeys(logs), isLoading = false, hasMore = hasMore)
308+ it.copy(
309+ logs = result.data,
310+ isLoading = false,
311+ hasMore = result.data.size >= it.currentLimit,
312+ )
436 }
313 }
437 }
314 }
438 is ApiResult.Error -> {
315 is ApiResult.Error -> {
@@ -444,29 +321,8 @@ class LogViewerViewModel @Inject constructor(
444 }
321 }
445 }
322 }
446
323
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() {
324 fun loadMore() {
466 val current = _state.value
325 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) {
326 if (current.currentLimit >= MAX_LOAD_LIMIT) {
471 _state.update { it.copy(hasMore = false) }
327 _state.update { it.copy(hasMore = false) }
472 return
328 return
@@ -489,9 +345,7 @@ class LogViewerViewModel @Inject constructor(
489 )
345 )
490 return Pair(start.format(dateFormatter), end.format(dateFormatter))
346 return Pair(start.format(dateFormatter), end.format(dateFormatter))
491 }
347 }
492− val now = current.pageAnchorTime
493− ?.let { ZonedDateTime.ofInstant(java.time.Instant.ofEpochMilli(it), ZoneOffset.UTC) }
494− ?: ZonedDateTime.now(ZoneOffset.UTC)
348+ val now = ZonedDateTime.now(ZoneOffset.UTC)
495 val start = now.minusMinutes(current.selectedTimeRange.minutes)
349 val start = now.minusMinutes(current.selectedTimeRange.minutes)
496 return Pair(
350 return Pair(
497 start.format(dateFormatter),
351 start.format(dateFormatter),
@@ -499,17 +353,6 @@ class LogViewerViewModel @Inject constructor(
499 )
353 )
500 }
354 }
501
355
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() {
356 fun toggleStreaming() {
514 if (_state.value.streaming.isStreaming) {
357 if (_state.value.streaming.isStreaming) {
515 stopStreaming()
358 stopStreaming()
@@ -519,10 +362,6 @@ class LogViewerViewModel @Inject constructor(
519 }
362 }
520
363
521 private fun startStreaming() {
364 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()
365 stopStreaming()
527 consecutiveStreamingErrors = 0
366 consecutiveStreamingErrors = 0
528 val generation = ++streamingGeneration
367 val generation = ++streamingGeneration
@@ -530,12 +369,6 @@ class LogViewerViewModel @Inject constructor(
530 it.copy(streaming = StreamingState(isStreaming = true, streamingNewCount = 0))
369 it.copy(streaming = StreamingState(isStreaming = true, streamingNewCount = 0))
531 }
370 }
532
371
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
372 // Set the baseline timestamp to "now" so we only poll for new logs
540 lastSeenTimestamp = ZonedDateTime.now(ZoneOffset.UTC).format(dateFormatter)
373 lastSeenTimestamp = ZonedDateTime.now(ZoneOffset.UTC).format(dateFormatter)
541
374
@@ -558,13 +391,6 @@ class LogViewerViewModel @Inject constructor(
558 }
391 }
559
392
560 fun stopStreaming() {
393 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++
394 streamingGeneration++
569 streamingJob?.cancel()
395 streamingJob?.cancel()
570 streamingJob = null
396 streamingJob = null
@@ -579,49 +405,6 @@ class LogViewerViewModel @Inject constructor(
579 }
405 }
580 }
406 }
581
407
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() {
408 private suspend fun pollNewLogs() {
626 val current = _state.value
409 val current = _state.value
627 if (current.streamName.isEmpty()) return
410 if (current.streamName.isEmpty()) return
@@ -632,118 +415,46 @@ class LogViewerViewModel @Inject constructor(
632 // Build WHERE clause from active filters + search
415 // Build WHERE clause from active filters + search
633 val clauses = current.filters.filterClauses.toMutableList()
416 val clauses = current.filters.filterClauses.toMutableList()
634 if (current.filters.searchQuery.isNotBlank()) {
417 if (current.filters.searchQuery.isNotBlank()) {
635− val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
418+ val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
636 if (searchClause != null) {
419 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)")
420+ 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 }
421 }
654 }
422 }
655
423
656 val whereClause = if (clauses.isNotEmpty()) " WHERE ${clauses.joinToString(" AND ")}" else ""
424 val whereClause = if (clauses.isNotEmpty()) " WHERE ${clauses.joinToString(" AND ")}" else ""
657 val safeName = escapeIdentifier(current.streamName)
425 val safeName = escapeIdentifier(current.streamName)
658− // Fetch up to the in-memory display cap. With a small limit (e.g. 200), a burst of
659− // more than that many logs in one interval would advance lastSeenTimestamp past the
660− // un-fetched rows and drop them permanently. Capping at STREAMING_MAX_LOGS means we
661− // never skip rows we'd actually keep (anything older is evicted by the cap anyway).
662− val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT $STREAMING_MAX_LOGS"
426+ val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT 200"
663
427
664 when (val result = repository.queryLogsRaw(sql, startTime, endTime)) {
428 when (val result = repository.queryLogsRaw(sql, startTime, endTime)) {
665 is ApiResult.Success -> {
429 is ApiResult.Success -> {
666 val newLogs = result.data
430 val newLogs = result.data
667 if (newLogs.isNotEmpty()) {
431 if (newLogs.isNotEmpty()) {
668− // Advance the last-seen boundary. Rows are ordered newest-first, so
432+ // 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
433+ val newestTimestamp = try {
670− // have capped off matching rows OLDER than the newest in this batch
434+ 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
435+ ?.get("p_timestamp")?.jsonPrimitive?.content
679 } catch (_: Exception) {
436 } catch (_: Exception) {
680 null
437 null
681 }
438 }
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 {
439+ 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
440+ lastSeenTimestamp = newestTimestamp
708 }
441 }
709
442
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 ->
443+ _state.update { state ->
723− // Prepend new logs, cap total — avoid full intermediate list allocation
444+ // Prepend new logs, cap total
724− val remaining = (STREAMING_MAX_LOGS - freshLogs.size).coerceAtLeast(0)
445+ val combined = newLogs + state.logs
725− val capped = ArrayList<JsonObject>(freshLogs.size + remaining).apply {
446+ val capped = if (combined.size > STREAMING_MAX_LOGS) {
726− addAll(freshLogs)
447+ 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 {
448+ } else {
741− state.streaming.streamingNewCount + freshLogs.size
449+ combined
742− },
743− streamingError = null,
744− ),
745− )
746 }
450 }
451+ state.copy(
452+ logs = capped,
453+ streaming = state.streaming.copy(
454+ streamingNewCount = state.streaming.streamingNewCount + newLogs.size,
455+ streamingError = null,
456+ ),
457+ )
747 }
458 }
748 }
459 }
749 consecutiveStreamingErrors = 0
460 consecutiveStreamingErrors = 0
@@ -770,37 +481,19 @@ class LogViewerViewModel @Inject constructor(
770
481
771 // --- Saved Filters (synced with Parseable server) ---
482 // --- Saved Filters (synced with Parseable server) ---
772
483
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() {
484 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 {
485 viewModelScope.launch {
785− savedFiltersMutex.withLock {
786− _state.update { it.copy(savedFilters = it.savedFilters.copy(isLoading = true, error = null)) }
486+ _state.update { it.copy(savedFilters = it.savedFilters.copy(isLoading = true, error = null)) }
787− when (val result = repository.listFilters()) {
487+ when (val result = repository.listFilters()) {
788− is ApiResult.Success -> {
488+ 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
489+ val streamFilters = result.data.filter { it.streamName == _state.value.streamName }
794− .filter { it.streamName == stream }
795− .sortedBy { it.filterName.lowercase() }
796− _state.update {
490+ _state.update {
797− it.copy(savedFilters = it.savedFilters.copy(filters = streamFilters, isLoading = false))
491+ it.copy(savedFilters = it.savedFilters.copy(filters = streamFilters, isLoading = false))
798− }
799 }
492 }
493+ }
800− is ApiResult.Error -> {
494+ is ApiResult.Error -> {
801− _state.update {
495+ _state.update {
802− it.copy(savedFilters = it.savedFilters.copy(isLoading = false, error = result.userMessage))
496+ it.copy(savedFilters = it.savedFilters.copy(isLoading = false, error = result.userMessage))
803− }
804 }
497 }
805 }
498 }
806 }
499 }
@@ -812,7 +505,6 @@ class LogViewerViewModel @Inject constructor(
812 if (current.streamName.isEmpty()) return
505 if (current.streamName.isEmpty()) return
813
506
814 viewModelScope.launch {
507 viewModelScope.launch {
815− savedFiltersMutex.withLock {
816 _state.update { it.copy(savedFilters = it.savedFilters.copy(isSaving = true, error = null)) }
508 _state.update { it.copy(savedFilters = it.savedFilters.copy(isSaving = true, error = null)) }
817
509
818 val query = if (current.filters.customSql.isNotBlank()) {
510 val query = if (current.filters.customSql.isNotBlank()) {
@@ -821,13 +513,15 @@ class LogViewerViewModel @Inject constructor(
821 filterQuery = current.filters.customSql,
513 filterQuery = current.filters.customSql,
822 )
514 )
823 } else {
515 } else {
824− val ruleGroups = current.filters.filterConditions
516+ val ruleGroups = current.filters.activeFilters.zip(current.filters.filterClauses)
825− .mapIndexed { index, condition ->
517+ .mapIndexed { index, (display, _) ->
518+ // Parse display back to field/operator/value
519+ val parts = parseFilterDisplay(display)
826 FilterRule(
520 FilterRule(
827 id = "rule_$index",
521 id = "rule_$index",
828− field = condition.column,
522+ field = parts.first,
829− value = condition.value,
523+ value = parts.third,
830− operator = condition.operator,
524+ operator = parts.second,
831 )
525 )
832 }
526 }
833 val builder = if (ruleGroups.isNotEmpty()) {
527 val builder = if (ruleGroups.isNotEmpty()) {
@@ -874,7 +568,6 @@ class LogViewerViewModel @Inject constructor(
874 }
568 }
875 }
569 }
876 }
570 }
877− }
878 }
571 }
879 }
572 }
880
573
@@ -884,15 +577,9 @@ class LogViewerViewModel @Inject constructor(
884 when (filter.query.filterType) {
577 when (filter.query.filterType) {
885 "sql" -> {
578 "sql" -> {
886 val sql = filter.query.filterQuery ?: return
579 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 {
580 _state.update {
894 it.copy(
581 it.copy(
895− filters = FilterState(),
582+ filters = FilterState(customSql = sql),
896 currentLimit = 500,
583 currentLimit = 500,
897 )
584 )
898 }
585 }
@@ -928,7 +615,7 @@ class LogViewerViewModel @Inject constructor(
928 for (group in builder.rules) {
615 for (group in builder.rules) {
929 for (rule in group.rules) {
616 for (rule in group.rules) {
930 if (rule.field.isNotBlank()) {
617 if (rule.field.isNotBlank()) {
931− addFilterInternal(rule.field, rule.operator, rule.value)
618+ addFilter(rule.field, rule.operator, rule.value)
932 }
619 }
933 }
620 }
934 }
621 }
@@ -936,21 +623,32 @@ class LogViewerViewModel @Inject constructor(
936
623
937 fun deleteSavedFilter(filterId: String) {
624 fun deleteSavedFilter(filterId: String) {
938 viewModelScope.launch {
625 viewModelScope.launch {
939− savedFiltersMutex.withLock {
940− when (repository.deleteFilter(filterId)) {
626+ when (repository.deleteFilter(filterId)) {
941− is ApiResult.Success -> {
627+ is ApiResult.Success -> {
942− _state.update {
628+ _state.update {
943− it.copy(
629+ it.copy(
944− savedFilters = it.savedFilters.copy(
630+ savedFilters = it.savedFilters.copy(
945− filters = it.savedFilters.filters.filter { f -> f.filterId != filterId },
631+ filters = it.savedFilters.filters.filter { f -> f.filterId != filterId },
946− ),
947− )
632+ ),
948− }
633+ )
949 }
634 }
950− is ApiResult.Error -> { /* Silently ignore — will show on next reload */ }
951 }
635 }
636+ is ApiResult.Error -> { /* Silently ignore — will show on next reload */ }
637+ }
638+ }
639+ }
640+
641+ /** Parse a display string like "column = value" back into (field, operator, value) */
642+ private fun parseFilterDisplay(display: String): Triple<String, String, String> {
643+ for (op in listOf("IS NOT NULL", "IS NULL", "ILIKE", "LIKE", "!=", ">=", "<=", "=", ">", "<")) {
644+ val idx = display.indexOf(" $op")
645+ if (idx >= 0) {
646+ val field = display.substring(0, idx)
647+ val value = display.substring(idx + op.length + 1).trim()
648+ return Triple(field, op, value)
952 }
649 }
953 }
650 }
651+ return Triple(display, "=", "")
954 }
652 }
955
653
956 override fun onCleared() {
654 override fun onCleared() {
app/src/main/java/com/parseable/android/ui/screens/logviewer/SeverityColor.kt+8−37
@@ -65,7 +65,8 @@ private fun parseSeverityValue(raw: String, fieldName: String): LogSeverity {
65 trimmed.toIntOrNull()?.let { num ->
65 trimmed.toIntOrNull()?.let { num ->
66 return when {
66 return when {
67 // Syslog severity (0 = emergency .. 7 = debug)
67 // Syslog severity (0 = emergency .. 7 = debug)
68− fieldName.equals("syslog_severity", ignoreCase = true) -> when (num) {
68+ fieldName.equals("syslog_severity", ignoreCase = true) ||
69+ fieldName.equals("priority", ignoreCase = true) -> when (num) {
69 0, 1, 2 -> LogSeverity.FATAL // emergency, alert, critical
70 0, 1, 2 -> LogSeverity.FATAL // emergency, alert, critical
70 3 -> LogSeverity.ERROR
71 3 -> LogSeverity.ERROR
71 4 -> LogSeverity.WARNING
72 4 -> LogSeverity.WARNING
@@ -74,50 +75,20 @@ private fun parseSeverityValue(raw: String, fieldName: String): LogSeverity {
74 7 -> LogSeverity.DEBUG
75 7 -> LogSeverity.DEBUG
75 else -> LogSeverity.UNKNOWN
76 else -> LogSeverity.UNKNOWN
76 }
77 }
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):
78+ // HTTP status codes
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) {
79+ fieldName.equals("status", ignoreCase = true) -> when {
94− in 500..599 -> LogSeverity.ERROR
80+ num >= 500 -> LogSeverity.ERROR
95− in 400..499 -> LogSeverity.WARNING
81+ num >= 400 -> 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
82+ num in 200..399 -> LogSeverity.INFO
99 else -> LogSeverity.UNKNOWN
83 else -> LogSeverity.UNKNOWN
100 }
84 }
101− // Generic numeric (higher = more severe). Two scales coexist under a bare numeric
85+ // Generic numeric (Java util logging style: higher = more severe)
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 {
86 else -> when {
110 num >= 1000 -> LogSeverity.FATAL
87 num >= 1000 -> LogSeverity.FATAL
111 num >= 900 -> LogSeverity.ERROR
88 num >= 900 -> LogSeverity.ERROR
112 num >= 800 -> LogSeverity.WARNING
89 num >= 800 -> LogSeverity.WARNING
113 num >= 700 -> LogSeverity.INFO
90 num >= 700 -> LogSeverity.INFO
114 num >= 400 -> LogSeverity.DEBUG
91 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
92 num > 0 -> LogSeverity.TRACE
122 else -> LogSeverity.UNKNOWN
93 else -> LogSeverity.UNKNOWN
123 }
94 }
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoScreen.kt+86−100
@@ -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,7 +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
20+import androidx.lifecycle.repeatOnLifecycle
19import com.parseable.android.data.formatBytes
21import com.parseable.android.data.formatBytes
20import kotlinx.serialization.json.Json
22import kotlinx.serialization.json.Json
21import kotlinx.serialization.json.JsonObject
23import kotlinx.serialization.json.JsonObject
@@ -33,10 +35,11 @@ fun StreamInfoScreen(
33 val state by viewModel.state.collectAsStateWithLifecycle()
35 val state by viewModel.state.collectAsStateWithLifecycle()
34 var showDeleteConfirmation by remember { mutableStateOf(false) }
36 var showDeleteConfirmation by remember { mutableStateOf(false) }
35
37
36− // Load once per stream. Reloading on every RESUME would refetch all four endpoints on
38+ val lifecycleOwner = LocalLifecycleOwner.current
37− // each return and could re-query a stream that was just deleted (spurious error card).
38− LaunchedEffect(streamName) {
39+ LaunchedEffect(streamName, lifecycleOwner) {
40+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
39− viewModel.load(streamName)
41+ viewModel.load(streamName)
42+ }
40 }
43 }
41
44
42 LaunchedEffect(state.deleteSuccess) {
45 LaunchedEffect(state.deleteSuccess) {
@@ -70,114 +73,97 @@ fun StreamInfoScreen(
70 )
73 )
71 },
74 },
72 ) { padding ->
75 ) { 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,
76+ if (state.isLoading) {
81− onRefresh = viewModel::refresh,
82− modifier = Modifier
83− .fillMaxSize()
84− .padding(padding),
85− ) {
86− if (state.isLoading && !hasContent) {
87− Box(
77+ Box(
88− modifier = Modifier.fillMaxSize(),
78+ modifier = Modifier
79+ .fillMaxSize()
80+ .padding(padding),
89− contentAlignment = Alignment.Center,
81+ contentAlignment = Alignment.Center,
90− ) {
82+ ) {
91− CircularProgressIndicator()
83+ CircularProgressIndicator()
92− }
84+ }
93− } else {
85+ } else {
94− Column(
86+ Column(
95− modifier = Modifier
87+ modifier = Modifier
96− .fillMaxSize()
88+ .fillMaxSize()
89+ .padding(padding)
97− .verticalScroll(rememberScrollState())
90+ .verticalScroll(rememberScrollState())
98− .padding(16.dp),
91+ .padding(16.dp),
99− verticalArrangement = Arrangement.spacedBy(16.dp),
92+ verticalArrangement = Arrangement.spacedBy(16.dp),
100− ) {
93+ ) {
101− // Stats section
94+ // Stats section
102− state.stats?.let { stats ->
95+ state.stats?.let { stats ->
103− InfoSection(title = "Statistics") {
96+ 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())
97+ 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")
98+ InfoRow("Ingestion Size", formatBytes(stats.ingestion?.size) ?: formatBytes(stats.ingestion?.lifetimeSize) ?: "N/A")
112− InfoRow("Storage Size", formatBytes(stats.storage?.size) ?: formatBytes(stats.storage?.lifetimeSize) ?: "N/A")
99+ InfoRow("Storage Size", formatBytes(stats.storage?.size) ?: formatBytes(stats.storage?.lifetimeSize) ?: "N/A")
113− if (stats.ingestion?.lifetimeCount != null) {
100+ if (stats.ingestion?.lifetimeCount != null) {
114− InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
101+ InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
115− }
102+ }
116− if (stats.ingestion?.deletedCount != null) {
103+ if (stats.ingestion?.deletedCount != null) {
117− InfoRow("Deleted Events", stats.ingestion.deletedCount.toString())
104+ InfoRow("Deleted Events", stats.ingestion.deletedCount.toString())
118− }
119 }
105 }
120 }
106 }
107+ }
121
108
122− // Schema section
109+ // Schema section
123− if (state.schema.isNotEmpty()) {
110+ if (state.schema.isNotEmpty()) {
124− InfoSection(title = "Schema (${state.schema.size} fields)") {
111+ InfoSection(title = "Schema (${state.schema.size} fields)") {
125− state.schema.forEach { field ->
112+ state.schema.forEach { field ->
126− InfoRow(
113+ InfoRow(
127− field.name,
114+ field.name,
128− field.dataType?.toString()?.trim('"') ?: "Unknown",
115+ 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 )
116 )
139 }
117 }
140 }
118 }
119+ } else if (state.schemaFailed) {
120+ InfoSection(title = "Schema") {
121+ Text(
122+ text = "Schema unavailable",
123+ style = MaterialTheme.typography.bodyMedium,
124+ color = MaterialTheme.colorScheme.onSurfaceVariant,
125+ )
126+ }
127+ }
141
128
142− // Retention section
129+ // Retention section
143− if (state.retention.isNotEmpty()) {
130+ if (state.retention.isNotEmpty()) {
144− InfoSection(title = "Retention") {
131+ InfoSection(title = "Retention") {
145− state.retention.forEach { r ->
132+ state.retention.forEach { r ->
146− InfoRow("Duration", r.duration ?: "N/A")
133+ InfoRow("Duration", r.duration ?: "N/A")
147− InfoRow("Action", r.action ?: "N/A")
134+ InfoRow("Action", r.action ?: "N/A")
148− if (r.description != null) {
135+ if (r.description != null) {
149− InfoRow("Description", r.description)
136+ InfoRow("Description", r.description)
150− }
151 }
137 }
152 }
138 }
153 }
139 }
140+ }
154
141
155− // Raw info section
142+ // Raw info section
156− state.rawInfo?.let { rawInfo ->
143+ state.rawInfo?.let { rawInfo ->
157− val prettyInfo = remember(rawInfo) {
144+ val prettyInfo = remember(rawInfo) {
158− prettyJson.encodeToString(JsonObject.serializer(), rawInfo)
145+ 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 }
146 }
147+ InfoSection(title = "Stream Info") {
148+ Text(
149+ text = prettyInfo,
150+ style = MaterialTheme.typography.bodySmall,
151+ fontFamily = FontFamily.Monospace,
152+ )
153+ }
154+ }
168
155
169− state.error?.let { errorText ->
156+ state.error?.let { errorText ->
170− Card(
157+ Card(
171− colors = CardDefaults.cardColors(
158+ colors = CardDefaults.cardColors(
172− containerColor = MaterialTheme.colorScheme.errorContainer,
159+ containerColor = MaterialTheme.colorScheme.errorContainer,
173− ),
160+ ),
174− ) {
161+ ) {
175− Text(
162+ Text(
176− text = errorText,
163+ text = errorText,
177− modifier = Modifier.padding(16.dp),
164+ modifier = Modifier.padding(16.dp),
178− color = MaterialTheme.colorScheme.onErrorContainer,
165+ color = MaterialTheme.colorScheme.onErrorContainer,
179− )
166+ )
180− }
181 }
167 }
182 }
168 }
183 }
169 }
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+2−193
@@ -12,12 +12,10 @@ 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
21import kotlinx.serialization.json.contentOrNull
19import kotlinx.serialization.json.contentOrNull
22import kotlinx.serialization.json.jsonPrimitive
20import kotlinx.serialization.json.jsonPrimitive
23
21
@@ -25,25 +23,10 @@ import kotlinx.serialization.json.jsonPrimitive
25@Composable
23@Composable
26fun SettingsScreen(
24fun SettingsScreen(
27 onBack: () -> Unit,
25 onBack: () -> Unit,
28− onAboutClick: () -> Unit,
29− onServerSwitched: () -> Unit,
30− onLoggedOut: () -> Unit,
31 viewModel: SettingsViewModel = hiltViewModel(),
26 viewModel: SettingsViewModel = hiltViewModel(),
32) {
27) {
33 val state by viewModel.state.collectAsStateWithLifecycle()
28 val state by viewModel.state.collectAsStateWithLifecycle()
34
29
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(
30 Scaffold(
48 topBar = {
31 topBar = {
49 TopAppBar(
32 TopAppBar(
@@ -96,21 +79,8 @@ fun SettingsScreen(
96 }
79 }
97 }
80 }
98
81
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
82+ // 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) {
83+ if (state.isLoading) {
114 Box(
84 Box(
115 modifier = Modifier
85 modifier = Modifier
116 .fillMaxWidth()
86 .fillMaxWidth()
@@ -248,167 +218,6 @@ fun SettingsScreen(
248 SettingsRow("Platform", "Android")
218 SettingsRow("Platform", "Android")
249 }
219 }
250 }
220 }
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 }
221 }
413 }
222 }
414 }
223 }
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+15−20
@@ -10,13 +10,15 @@ import androidx.compose.material.icons.filled.*
10import androidx.compose.material3.*
10import androidx.compose.material3.*
11import androidx.compose.material3.pulltorefresh.PullToRefreshBox
11import androidx.compose.material3.pulltorefresh.PullToRefreshBox
12import androidx.compose.runtime.*
12import androidx.compose.runtime.*
13−import androidx.compose.runtime.saveable.rememberSaveable
14import androidx.compose.ui.Alignment
13import androidx.compose.ui.Alignment
15import androidx.compose.ui.Modifier
14import androidx.compose.ui.Modifier
15+import androidx.lifecycle.compose.LocalLifecycleOwner
16import androidx.compose.ui.text.font.FontWeight
16import androidx.compose.ui.text.font.FontWeight
17import androidx.compose.ui.unit.dp
17import androidx.compose.ui.unit.dp
18import androidx.hilt.navigation.compose.hiltViewModel
18import androidx.hilt.navigation.compose.hiltViewModel
19+import androidx.lifecycle.Lifecycle
19import androidx.lifecycle.compose.collectAsStateWithLifecycle
20import androidx.lifecycle.compose.collectAsStateWithLifecycle
21+import androidx.lifecycle.repeatOnLifecycle
20import kotlinx.coroutines.flow.collectLatest
22import kotlinx.coroutines.flow.collectLatest
21import kotlinx.coroutines.launch
23import kotlinx.coroutines.launch
22
24
@@ -32,21 +34,16 @@ fun StreamsScreen(
32 val state by viewModel.state.collectAsStateWithLifecycle()
34 val state by viewModel.state.collectAsStateWithLifecycle()
33 val scope = rememberCoroutineScope()
35 val scope = rememberCoroutineScope()
34 var showLogoutConfirmation by remember { mutableStateOf(false) }
36 var showLogoutConfirmation by remember { mutableStateOf(false) }
35− var searchQuery by rememberSaveable { mutableStateOf("") }
37+ var searchQuery by remember { mutableStateOf("") }
36 val snackbarHostState = remember { SnackbarHostState() }
38 val snackbarHostState = remember { SnackbarHostState() }
37
39
38 val listState = rememberLazyListState()
40 val listState = rememberLazyListState()
39
41
40− // Scroll to top only when a refresh actually completes (isLoading transitions true -> false
42+ // Scroll to top when a refresh completes (isLoading transitions to 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) {
43 LaunchedEffect(state.isLoading) {
46− if (wasLoading && !state.isLoading) {
44+ if (!state.isLoading) {
47 listState.scrollToItem(0)
45 listState.scrollToItem(0)
48 }
46 }
49− wasLoading = state.isLoading
50 }
47 }
51
48
52 val filteredStreams = remember(state.streams, searchQuery, state.favoriteNames) {
49 val filteredStreams = remember(state.streams, searchQuery, state.favoriteNames) {
@@ -58,11 +55,14 @@ fun StreamsScreen(
58 }
55 }
59 // Partition by favorites instead of full sort for O(n) vs O(n log n)
56 // 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 }
57 val (favs, rest) = filtered.partition { it.name in favorites }
61− // `favorites` iterates most-recently-favorited first (DAO ORDER BY addedAt DESC
58+ favs + rest
62− // preserved through LinkedHashSet); order favs to match so recency is reflected.
59+ }
63− val favRank = favorites.withIndex().associate { (i, name) -> name to i }
60+
64− val sortedFavs = favs.sortedBy { favRank[it.name] ?: Int.MAX_VALUE }
61+ val lifecycleOwner = LocalLifecycleOwner.current
65− sortedFavs + rest
62+ LaunchedEffect(lifecycleOwner) {
63+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
64+ viewModel.refresh()
65+ }
66 }
66 }
67
67
68 LaunchedEffect(Unit) {
68 LaunchedEffect(Unit) {
@@ -157,12 +157,7 @@ fun StreamsScreen(
157 }
157 }
158 } else {
158 } else {
159 Column {
159 Column {
160− // Keep the search field visible whenever a query is active, even if the
161− // total stream count has since dropped to <=5 (e.g. after a refresh or a
162− // server-side deletion). Otherwise the field would vanish while the stale
163− // query keeps filtering `filteredStreams`, stranding the user with no way
164− // to clear it.
165− if (state.streams.size > 5 || searchQuery.isNotEmpty()) {
160+ if (state.streams.size > 5) {
166 OutlinedTextField(
161 OutlinedTextField(
167 value = searchQuery,
162 value = searchQuery,
168 onValueChange = { searchQuery = it },
163 onValueChange = { searchQuery = it },
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsViewModel.kt+11−53
@@ -22,9 +22,7 @@ import kotlinx.coroutines.flow.asStateFlow
22import kotlinx.coroutines.flow.receiveAsFlow
22import kotlinx.coroutines.flow.receiveAsFlow
23import kotlinx.coroutines.flow.update
23import kotlinx.coroutines.flow.update
24import kotlinx.coroutines.launch
24import kotlinx.coroutines.launch
25−import kotlinx.coroutines.sync.Mutex
26import kotlinx.coroutines.sync.Semaphore
25import kotlinx.coroutines.sync.Semaphore
27−import kotlinx.coroutines.sync.withLock
28import kotlinx.coroutines.sync.withPermit
26import kotlinx.coroutines.sync.withPermit
29import javax.inject.Inject
27import javax.inject.Inject
30
28
@@ -63,40 +61,22 @@ class StreamsViewModel @Inject constructor(
63 _state.update { it.copy(favoriteNames = names.toSet()) }
61 _state.update { it.copy(favoriteNames = names.toSet()) }
64 }
62 }
65 }
63 }
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 }
64 }
70
65
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) {
66 fun toggleFavorite(streamName: String) {
77 viewModelScope.launch {
67 viewModelScope.launch {
78− favoriteMutex.withLock {
68+ 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)
69+ favoriteDao.deleteByName(streamName)
82− _snackbarEvent.send("Removed from favorites")
70+ _snackbarEvent.send("Removed from favorites")
83− } else {
71+ } else {
84− favoriteDao.insert(FavoriteStream(streamName = streamName))
72+ favoriteDao.insert(FavoriteStream(streamName = streamName))
85− _snackbarEvent.send("Added to favorites")
73+ _snackbarEvent.send("Added to favorites")
86− }
87 }
74 }
88 }
75 }
89 }
76 }
90
77
91− private var refreshJob: Job? = null
92−
93 fun refresh() {
78 fun refresh() {
94− // Cancel any in-flight refresh so out-of-order completions can't overwrite a newer
95− // result with a stale one (the init load and a pull-to-refresh, or two quick pulls,
96− // can otherwise race and leave the older stream list showing). Mirrors the refreshJob
97− // guard in LogViewerViewModel.
98− refreshJob?.cancel()
99− refreshJob = viewModelScope.launch {
79+ viewModelScope.launch {
100 _state.update { it.copy(isLoading = true, error = null) }
80 _state.update { it.copy(isLoading = true, error = null) }
101
81
102 try {
82 try {
@@ -113,14 +93,9 @@ class StreamsViewModel @Inject constructor(
113
93
114 when (streamsResult) {
94 when (streamsResult) {
115 is ApiResult.Success -> {
95 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 {
96 _state.update {
120 it.copy(
97 it.copy(
121− streams = streamsResult.data.sortedBy { s -> s.name.lowercase() },
98+ 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,
99 isLoading = false,
125 )
100 )
126 }
101 }
@@ -149,14 +124,9 @@ class StreamsViewModel @Inject constructor(
149
124
150 private val statsSemaphore = Semaphore(8)
125 private val statsSemaphore = Semaphore(8)
151 private var statsJob: Job? = null
126 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
127
156 private fun loadStreamStats(streams: List<LogStream>) {
128 private fun loadStreamStats(streams: List<LogStream>) {
157 statsJob?.cancel()
129 statsJob?.cancel()
158− retryJobs.values.forEach { it.cancel() }
159− retryJobs.clear()
160 statsJob = viewModelScope.launch {
130 statsJob = viewModelScope.launch {
161 streams.forEach { stream ->
131 streams.forEach { stream ->
162 launch {
132 launch {
@@ -169,20 +139,12 @@ class StreamsViewModel @Inject constructor(
169 }
139 }
170
140
171 fun retryStats(streamName: String) {
141 fun retryStats(streamName: String) {
172− retryJobs[streamName]?.cancel()
173− val job = viewModelScope.launch {
142+ viewModelScope.launch {
174 _state.update { it.copy(failedStats = it.failedStats - streamName) }
143 _state.update { it.copy(failedStats = it.failedStats - streamName) }
175 statsSemaphore.withPermit {
144 statsSemaphore.withPermit {
176 loadSingleStreamStats(streamName)
145 loadSingleStreamStats(streamName)
177 }
146 }
178 }
147 }
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 }
148 }
187
149
188 private suspend fun loadSingleStreamStats(streamName: String) {
150 private suspend fun loadSingleStreamStats(streamName: String) {
@@ -198,10 +160,7 @@ class StreamsViewModel @Inject constructor(
198 ?: formatBytes(stats.storage?.lifetimeSize),
160 ?: formatBytes(stats.storage?.lifetimeSize),
199 )
161 )
200 _state.update {
162 _state.update {
201− // Drop late results for streams a concurrent refresh has already pruned,
202− // so an in-flight retry can't resurrect stats for a deleted stream.
203− if (it.streams.none { s -> s.name == streamName }) it
204− else it.copy(
163+ it.copy(
205 streamStats = it.streamStats + (streamName to ui),
164 streamStats = it.streamStats + (streamName to ui),
206 failedStats = it.failedStats - streamName,
165 failedStats = it.failedStats - streamName,
207 )
166 )
@@ -209,8 +168,7 @@ class StreamsViewModel @Inject constructor(
209 }
168 }
210 is ApiResult.Error -> {
169 is ApiResult.Error -> {
211 _state.update {
170 _state.update {
212− if (it.streams.none { s -> s.name == streamName }) it
213− else it.copy(failedStats = it.failedStats + streamName)
171+ it.copy(failedStats = it.failedStats + streamName)
214 }
172 }
215 }
173 }
216 }
174 }
app/src/main/res/drawable/ic_launcher_background.xmlnew file+10
@@ -0,0 +1,10 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="108dp"
4+ android:height="108dp"
5+ android:viewportWidth="108"
6+ android:viewportHeight="108">
7+ <path
8+ android:fillColor="#545BEB"
9+ android:pathData="M0,0h108v108h-108z" />
10+</vector>
app/src/main/res/drawable/ic_launcher_foreground.xmlnew file+15
@@ -0,0 +1,15 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="108dp"
4+ android:height="108dp"
5+ android:viewportWidth="108"
6+ android:viewportHeight="108">
7+ <!-- Background circle -->
8+ <path
9+ android:fillColor="#545BEB"
10+ android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0" />
11+ <!-- Letter P -->
12+ <path
13+ android:fillColor="#FFFFFF"
14+ android:pathData="M40,35h12c6.6,0 12,5.4 12,12s-5.4,12 -12,12h-6v14h-6V35zM46,53h6c3.3,0 6,-2.7 6,-6s-2.7,-6 -6,-6h-6V53z" />
15+</vector>
app/src/main/res/drawable/ic_launcher_monochrome.xmlnew file+11
@@ -0,0 +1,11 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="108dp"
4+ android:height="108dp"
5+ android:viewportWidth="108"
6+ android:viewportHeight="108">
7+ <!-- Letter P -->
8+ <path
9+ android:fillColor="#000000"
10+ android:pathData="M40,35h12c6.6,0 12,5.4 12,12s-5.4,12 -12,12h-6v14h-6V35zM46,53h6c3.3,0 6,-2.7 6,-6s-2.7,-6 -6,-6h-6V53z" />
11+</vector>
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xmlnew file+6
@@ -0,0 +1,6 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3+ <background android:drawable="@drawable/ic_launcher_background" />
4+ <foreground android:drawable="@drawable/ic_launcher_foreground" />
5+ <monochrome android:drawable="@drawable/ic_launcher_monochrome" />
6+</adaptive-icon>
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xmlnew file+6
@@ -0,0 +1,6 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3+ <background android:drawable="@drawable/ic_launcher_background" />
4+ <foreground android:drawable="@drawable/ic_launcher_foreground" />
5+ <monochrome android:drawable="@drawable/ic_launcher_monochrome" />
6+</adaptive-icon>
app/src/main/res/mipmap-hdpi/ic_launcher.webpdeleted

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

app/src/test/java/com/parseable/android/AlertsViewModelTest.kt−4
@@ -24,10 +24,6 @@ class AlertsViewModelTest {
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
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/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+4−28
@@ -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()
@@ -78,11 +65,6 @@ class LoginViewModelTest {
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.verifyServer() } returns ApiResult.Success(AboutInfo(version = "1.0"))
81− // Reach the success branch deterministically: the login-success event only fires when the
82− // credentials were durably saved.
83− coEvery { settingsRepository.saveServerConfig(any()) } returns true
84−
85− val events = collectSuccessEvents()
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 }
@@ -117,15 +97,13 @@ class LoginViewModelTest {
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.verifyServer() } returns ApiResult.Error("not parseable", 404)
119
99
120− val events = collectSuccessEvents()
121−
122 viewModel.onServerUrlChange("https://test.com")
100 viewModel.onServerUrlChange("https://test.com")
123 viewModel.onUsernameChange("admin")
101 viewModel.onUsernameChange("admin")
124 viewModel.onPasswordChange("password")
102 viewModel.onPasswordChange("password")
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 }
@@ -135,15 +113,13 @@ class LoginViewModelTest {
135 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
113 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
136 coEvery { repository.verifyServer() } returns ApiResult.Error("Unauthorized", 401)
114 coEvery { repository.verifyServer() } returns ApiResult.Error("Unauthorized", 401)
137
115
138− val events = collectSuccessEvents()
139−
140 viewModel.onServerUrlChange("https://test.parseable.com")
116 viewModel.onServerUrlChange("https://test.parseable.com")
141 viewModel.onUsernameChange("admin")
117 viewModel.onUsernameChange("admin")
142 viewModel.onPasswordChange("wrongpass")
118 viewModel.onPasswordChange("wrongpass")
143 viewModel.onLogin()
119 viewModel.onLogin()
144
120
145 val state = viewModel.state.value
121 val state = viewModel.state.value
146− assertEquals(0, events.size)
122+ assertFalse(state.loginSuccess)
147 assertNotNull(state.error)
123 assertNotNull(state.error)
148 assertTrue(state.error!!.contains("Invalid credentials"))
124 assertTrue(state.error!!.contains("Invalid credentials"))
149 assertFalse(state.error!!.contains("doesn't appear to be Parseable"))
125 assertFalse(state.error!!.contains("doesn't appear to be Parseable"))
app/src/test/java/com/parseable/android/PrivateHostTest.ktdeleted−73
@@ -1,73 +0,0 @@
1−package com.parseable.android
2−
3−import com.parseable.android.ui.screens.login.extractHost
4−import com.parseable.android.ui.screens.login.isPrivateHost
5−import org.junit.Assert.assertEquals
6−import org.junit.Assert.assertFalse
7−import org.junit.Assert.assertTrue
8−import org.junit.Test
9−
10−class PrivateHostTest {
11−
12− @Test
13− fun `extractHost strips scheme port and path`() {
14− assertEquals("192.168.1.5", extractHost("http://192.168.1.5:8000/path"))
15− assertEquals("example.com", extractHost("https://example.com"))
16− assertEquals("example.com", extractHost("example.com:9000"))
17− }
18−
19− @Test
20− fun `extractHost handles bracketed ipv6`() {
21− assertEquals("::1", extractHost("http://[::1]:8000"))
22− assertEquals("fe80::1", extractHost("[fe80::1]"))
23− }
24−
25− @Test
26− fun `extractHost leaves bare ipv6 intact`() {
27− assertEquals("fe80::1", extractHost("fe80::1"))
28− }
29−
30− @Test
31− fun `private ipv4 ranges are detected`() {
32− assertTrue(isPrivateHost("10.0.0.1"))
33− assertTrue(isPrivateHost("127.0.0.1"))
34− assertTrue(isPrivateHost("192.168.1.10"))
35− assertTrue(isPrivateHost("172.16.0.1"))
36− assertTrue(isPrivateHost("172.31.255.255"))
37− }
38−
39− @Test
40− fun `public ipv4 in the 172 gap is not private`() {
41− assertFalse(isPrivateHost("172.15.0.1"))
42− assertFalse(isPrivateHost("172.32.0.1"))
43− assertFalse(isPrivateHost("8.8.8.8"))
44− }
45−
46− @Test
47− fun `public hostnames sharing a private prefix are not private`() {
48− // The old prefix-string check wrongly classified these as private, hiding the
49− // plaintext-HTTP warning.
50− assertFalse(isPrivateHost("10.example.com"))
51− assertFalse(isPrivateHost("192.168.evil.com"))
52− }
53−
54− @Test
55− fun `localhost is private`() {
56− assertTrue(isPrivateHost("localhost"))
57− assertTrue(isPrivateHost("api.localhost"))
58− assertTrue(isPrivateHost(""))
59− }
60−
61− @Test
62− fun `ipv6 loopback and local ranges are private`() {
63− assertTrue(isPrivateHost("::1"))
64− assertTrue(isPrivateHost("fe80::1"))
65− assertTrue(isPrivateHost("fd00::1"))
66− assertTrue(isPrivateHost("fc00::1"))
67− }
68−
69− @Test
70− fun `public ipv6 is not private`() {
71− assertFalse(isPrivateHost("2001:4860:4860::8888"))
72− }
73−}
app/src/test/java/com/parseable/android/SettingsViewModelTest.kt+6−10
@@ -107,35 +107,31 @@ class SettingsViewModelTest {
107 }
107 }
108
108
109 @Test
109 @Test
110− fun `load ignores users failure when about succeeds`() = runTest {
110+ fun `load sets error when users fail`() = runTest {
111 coEvery { settingsRepository.serverConfig } returns flowOf(null)
111 coEvery { settingsRepository.serverConfig } returns flowOf(null)
112− coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo(version = "1.2.0"))
112+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
113 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
113 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
114
114
115 val viewModel = SettingsViewModel(settingsRepository, repository)
115 val viewModel = SettingsViewModel(settingsRepository, repository)
116
116
117− // The user list (/user) is secondary and is commonly forbidden for restricted accounts,
118− // so its failure must not surface a blocking error or hide otherwise-valid server info.
119 val state = viewModel.state.value
117 val state = viewModel.state.value
120− assertNull(state.error)
118+ assertNotNull(state.error)
121− assertEquals("1.2.0", state.aboutInfo?.version)
122 assertTrue(state.users.isEmpty())
119 assertTrue(state.users.isEmpty())
123 }
120 }
124
121
125 @Test
122 @Test
126− fun `load surfaces only the about error, not the users error`() = runTest {
123+ fun `load consolidates errors from both about and users`() = runTest {
127 coEvery { settingsRepository.serverConfig } returns flowOf(null)
124 coEvery { settingsRepository.serverConfig } returns flowOf(null)
128 coEvery { repository.getAbout() } returns ApiResult.Error("Server error", 500)
125 coEvery { repository.getAbout() } returns ApiResult.Error("Server error", 500)
129 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
126 coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
130
127
131 val viewModel = SettingsViewModel(settingsRepository, repository)
128 val viewModel = SettingsViewModel(settingsRepository, repository)
132
129
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
130 val state = viewModel.state.value
136 assertNotNull(state.error)
131 assertNotNull(state.error)
132+ // Both error messages should be present
137 assertTrue(state.error!!.contains("Server error"))
133 assertTrue(state.error!!.contains("Server error"))
138− assertFalse(state.error!!.contains("Permission denied"))
134+ assertTrue(state.error!!.contains("Permission denied"))
139 }
135 }
140
136
141 @Test
137 @Test
app/src/test/java/com/parseable/android/SeverityDetectionTest.kt−13
@@ -128,7 +128,6 @@ class SeverityDetectionTest {
128
128
129 @Test
129 @Test
130 fun `HTTP status code mapping`() {
130 fun `HTTP status code mapping`() {
131− assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "100")))
132 assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "200")))
131 assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "200")))
133 assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "301")))
132 assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "301")))
134 assertEquals(LogSeverity.WARNING, detectSeverity(logWith("status" to "404")))
133 assertEquals(LogSeverity.WARNING, detectSeverity(logWith("status" to "404")))
@@ -146,18 +145,6 @@ class SeverityDetectionTest {
146 assertEquals(LogSeverity.TRACE, detectSeverity(logWith("level" to "100")))
145 assertEquals(LogSeverity.TRACE, detectSeverity(logWith("level" to "100")))
147 }
146 }
148
147
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 ---
148 // --- Priority among fields ---
162
149
163 @Test
150 @Test
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"