git.iko.soy/parseable-android
on master help sign in
74 files changed+10681
since splitsnapshots
unifiedsplit
.dockerignore+15 .github/workflows/build-release.yml+77 .github/workflows/ci.yml+141 .gitignore+14 CLAUDE.md+82 Dockerfile+72 IMPROVEMENTS.md+176 README.md+71 app/build.gradle.kts+142 app/proguard-rules.pro+61 app/schemas/.gitkeep app/src/main/AndroidManifest.xml+43 app/src/main/java/com/parseable/android/MainActivity.kt+110 app/src/main/java/com/parseable/android/ParseableApp.kt+29 app/src/main/java/com/parseable/android/data/FormatUtils.kt+47 app/src/main/java/com/parseable/android/data/SqlUtils.kt+23 app/src/main/java/com/parseable/android/data/TimestampUtils.kt+69 app/src/main/java/com/parseable/android/data/api/ParseableApiClient.kt+508 app/src/main/java/com/parseable/android/data/local/FavoriteStream.kt+11 app/src/main/java/com/parseable/android/data/local/FavoriteStreamDao.kt+39 app/src/main/java/com/parseable/android/data/local/ParseableDatabase.kt+14 app/src/main/java/com/parseable/android/data/local/SavedServer.kt+23 app/src/main/java/com/parseable/android/data/local/SavedServerDao.kt+25 app/src/main/java/com/parseable/android/data/model/Models.kt+253 app/src/main/java/com/parseable/android/data/model/SavedFilter.kt+61 app/src/main/java/com/parseable/android/data/repository/ParseableRepository.kt+190 app/src/main/java/com/parseable/android/data/repository/SettingsRepository.kt+351 app/src/main/java/com/parseable/android/di/AppModule.kt+88 app/src/main/java/com/parseable/android/ui/ErrorHandler.kt+84 app/src/main/java/com/parseable/android/ui/navigation/NavGraph.kt+220 app/src/main/java/com/parseable/android/ui/screens/about/AboutScreen.kt+178 app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsScreen.kt app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsViewModel.kt+97 app/src/main/java/com/parseable/android/ui/screens/login/LoginScreen.kt+304 app/src/main/java/com/parseable/android/ui/screens/login/LoginViewModel.kt+261 app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.kt+1423 app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerViewModel.kt+964 app/src/main/java/com/parseable/android/ui/screens/logviewer/SeverityColor.kt+169 app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoScreen.kt+269 app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoViewModel.kt+153 app/src/main/java/com/parseable/android/ui/screens/settings/SettingsScreen.kt+436 app/src/main/java/com/parseable/android/ui/screens/settings/SettingsViewModel.kt+217 app/src/main/java/com/parseable/android/ui/screens/streams/StreamsScreen.kt+374 app/src/main/java/com/parseable/android/ui/screens/streams/StreamsViewModel.kt+223 app/src/main/java/com/parseable/android/ui/theme/Theme.kt+67 app/src/main/res/mipmap-hdpi/ic_launcher.webp app/src/main/res/mipmap-mdpi/ic_launcher.webp app/src/main/res/mipmap-xhdpi/ic_launcher.webp app/src/main/res/mipmap-xxhdpi/ic_launcher.webp app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp app/src/main/res/values/strings.xml+78 app/src/main/res/values/themes.xml+4 app/src/main/res/xml/file_paths.xml+4 app/src/main/res/xml/network_security_config.xml+19 app/src/test/java/com/parseable/android/AlertsViewModelTest.kt+127 app/src/test/java/com/parseable/android/ApiResultTest.kt+89 app/src/test/java/com/parseable/android/EscapeSqlTest.kt+101 app/src/test/java/com/parseable/android/FormatBytesTest.kt+80 app/src/test/java/com/parseable/android/FormatTimestampTest.kt+73 app/src/test/java/com/parseable/android/LogViewerViewModelTest.kt+340 app/src/test/java/com/parseable/android/LoginViewModelTest.kt+253 app/src/test/java/com/parseable/android/ParseableRepositoryTest.kt+173 app/src/test/java/com/parseable/android/PrivateHostTest.kt+73 app/src/test/java/com/parseable/android/SettingsViewModelTest.kt+172 app/src/test/java/com/parseable/android/SeverityDetectionTest.kt+180 app/src/test/java/com/parseable/android/StreamInfoViewModelTest.kt+204 app/src/test/java/com/parseable/android/StreamsViewModelTest.kt+155 build.gradle.kts+8 gradle.properties+5 gradle/libs.versions.toml+89 gradle/wrapper/gradle-wrapper.properties+7 gradlew+168 gradlew.bat+81 settings.gradle.kts+24
.dockerignorenew file+15
@@ -0,0 +1,15 @@
1+# Keep the build context small, but DO keep .git — build.gradle.kts derives the
2+# app version from `git log -1`, so the repository history must be present.
3+build/
4+app/build/
5+.gradle/
6+*.iml
7+.idea/
8+.DS_Store
9+local.properties
10+captures/
11+.externalNativeBuild/
12+.cxx/
13+*.keystore
14+*.jks
15+.jj/
.github/workflows/build-release.ymlnew file+77
@@ -0,0 +1,77 @@
1+name: Build & Release
2+
3+on:
4+ push:
5+ tags:
6+ - 'v*'
7+ workflow_dispatch:
8+
9+concurrency:
10+ group: release
11+ cancel-in-progress: false
12+
13+permissions:
14+ contents: write
15+ id-token: write
16+ attestations: write
17+
18+jobs:
19+ build:
20+ name: Build APK
21+ runs-on: ubuntu-latest
22+
23+ steps:
24+ - name: Checkout code
25+ uses: actions/checkout@v4
26+
27+ - name: Decode release keystore
28+ if: env.RELEASE_KEYSTORE_BASE64 != ''
29+ env:
30+ RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
31+ run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > app/release.keystore
32+
33+ - name: Set up JDK 17
34+ uses: actions/setup-java@v4
35+ with:
36+ java-version: '17'
37+ distribution: 'temurin'
38+
39+ - name: Setup Gradle
40+ uses: gradle/actions/setup-gradle@v4
41+
42+ - name: Setup Android SDK
43+ uses: android-actions/setup-android@v3
44+
45+ - name: Generate Gradle wrapper
46+ run: gradle wrapper --gradle-version 8.9
47+
48+ - name: Build release APK
49+ env:
50+ RELEASE_KEYSTORE_FILE: ${{ secrets.RELEASE_KEYSTORE_BASE64 != '' && 'release.keystore' || '' }}
51+ RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
52+ RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
53+ RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
54+ run: ./gradlew assembleRelease
55+
56+ - name: Run unit tests
57+ run: ./gradlew testReleaseUnitTest testDebugUnitTest
58+
59+ - name: Upload release APK artifact
60+ uses: actions/upload-artifact@v4
61+ with:
62+ name: parseable-android-release
63+ path: app/build/outputs/apk/release/*.apk
64+
65+ - name: Attest release APK
66+ uses: actions/attest-build-provenance@v2
67+ with:
68+ subject-path: app/build/outputs/apk/release/*.apk
69+
70+ - name: Create Draft Release
71+ if: startsWith(github.ref, 'refs/tags/v')
72+ uses: softprops/action-gh-release@v2
73+ with:
74+ draft: true
75+ generate_release_notes: true
76+ files: |
77+ app/build/outputs/apk/release/*.apk
.github/workflows/ci.ymlnew file+141
@@ -0,0 +1,141 @@
1+name: CI
2+
3+on:
4+ pull_request:
5+ branches: [ master, main ]
6+ push:
7+ branches: [ master, main ]
8+
9+jobs:
10+ build:
11+ name: Build & Lint
12+ runs-on: ubuntu-latest
13+ concurrency:
14+ group: ci-build-${{ github.ref }}
15+ cancel-in-progress: true
16+
17+ steps:
18+ - name: Checkout code
19+ uses: actions/checkout@v4
20+
21+ - name: Decode release keystore
22+ if: env.RELEASE_KEYSTORE_BASE64 != ''
23+ env:
24+ RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
25+ run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > app/release.keystore
26+
27+ - name: Set up JDK 17
28+ uses: actions/setup-java@v4
29+ with:
30+ java-version: '17'
31+ distribution: 'temurin'
32+
33+ - name: Setup Gradle
34+ uses: gradle/actions/setup-gradle@v4
35+
36+ - name: Setup Android SDK
37+ uses: android-actions/setup-android@v3
38+
39+ - name: Generate Gradle wrapper
40+ run: gradle wrapper --gradle-version 8.9
41+
42+ - name: Build release APK
43+ env:
44+ RELEASE_KEYSTORE_FILE: ${{ secrets.RELEASE_KEYSTORE_BASE64 != '' && 'release.keystore' || '' }}
45+ RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
46+ RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
47+ RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
48+ run: ./gradlew assembleRelease
49+
50+ - name: Run unit tests
51+ run: ./gradlew testReleaseUnitTest testDebugUnitTest
52+
53+ - name: Run Android Lint
54+ run: ./gradlew lintRelease
55+
56+ - name: Upload release APK
57+ if: github.event_name == 'pull_request'
58+ uses: actions/upload-artifact@v4
59+ with:
60+ name: parseable-android-release
61+ path: app/build/outputs/apk/release/*.apk
62+ retention-days: 14
63+
64+ - name: Upload lint results
65+ if: always()
66+ uses: actions/upload-artifact@v4
67+ with:
68+ name: lint-results
69+ path: app/build/reports/lint-results-release.html
70+ retention-days: 14
71+
72+ release:
73+ name: Create Release
74+ needs: build
75+ if: github.event_name == 'push'
76+ runs-on: ubuntu-latest
77+ concurrency:
78+ group: release
79+ cancel-in-progress: false
80+ permissions:
81+ contents: write
82+ id-token: write
83+ attestations: write
84+
85+ steps:
86+ - name: Checkout code
87+ uses: actions/checkout@v4
88+
89+ - name: Decode release keystore
90+ if: env.RELEASE_KEYSTORE_BASE64 != ''
91+ env:
92+ RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
93+ run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > app/release.keystore
94+
95+ - name: Set version from commit timestamp
96+ run: |
97+ VERSION_NAME=$(TZ=UTC git log -1 --format='%cd' --date=format-local:'%Y.%m.%d.%H%M')
98+ echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
99+
100+ - name: Set up JDK 17
101+ uses: actions/setup-java@v4
102+ with:
103+ java-version: '17'
104+ distribution: 'temurin'
105+
106+ - name: Setup Gradle
107+ uses: gradle/actions/setup-gradle@v4
108+
109+ - name: Setup Android SDK
110+ uses: android-actions/setup-android@v3
111+
112+ - name: Generate Gradle wrapper
113+ run: gradle wrapper --gradle-version 8.9
114+
115+ - name: Build release APK
116+ env:
117+ RELEASE_KEYSTORE_FILE: ${{ secrets.RELEASE_KEYSTORE_BASE64 != '' && 'release.keystore' || '' }}
118+ RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
119+ RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
120+ RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
121+ run: ./gradlew assembleRelease
122+
123+ - name: Rename APK with version
124+ run: |
125+ for apk in app/build/outputs/apk/release/*.apk; do
126+ mv "$apk" "app/build/outputs/apk/release/parseable-android-${VERSION_NAME}.apk"
127+ done
128+
129+ - name: Attest release APK
130+ uses: actions/attest-build-provenance@v2
131+ with:
132+ subject-path: app/build/outputs/apk/release/*.apk
133+
134+ - name: Create release
135+ uses: softprops/action-gh-release@v2
136+ with:
137+ tag_name: v${{ env.VERSION_NAME }}
138+ name: v${{ env.VERSION_NAME }}
139+ generate_release_notes: true
140+ files: |
141+ app/build/outputs/apk/release/*.apk
.gitignorenew file+14
@@ -0,0 +1,14 @@
1+*.iml
2+.gradle
3+/local.properties
4+/.idea
5+.DS_Store
6+/build
7+/captures
8+.externalNativeBuild
9+.cxx
10+local.properties
11+app/build/
12+gradle/wrapper/gradle-wrapper.jar
13+*.keystore
14+*.jks
CLAUDE.mdnew file+82
@@ -0,0 +1,82 @@
1+# CLAUDE.md
2+
3+## Project Overview
4+
5+Android client for [Parseable](https://www.parseable.com/), an open-source log analytics platform. The app connects to a Parseable server instance and lets users browse, search, filter, and live-tail log data.
6+
7+## Build & Test
8+
9+```bash
10+./gradlew assembleDebug # Build debug APK
11+./gradlew assembleRelease # Build release APK (minified)
12+./gradlew testDebugUnitTest # Run unit tests
13+./gradlew lintDebug # Run Android lint
14+```
15+
16+Requires JDK 17 and the Android SDK (compileSdk 35, minSdk 26).
17+
18+## Architecture
19+
20+Single-module Android app (`app/`) using MVVM with Jetpack Compose.
21+
22+### Layers
23+
24+- **`data/api/`** — `ParseableApiClient`: OkHttp-based REST client wrapping all Parseable API endpoints. Uses Basic Auth. Returns `ApiResult<T>` (sealed class: `Success`/`Error`).
25+- **`data/model/`** — Kotlinx Serialization data classes for API request/response types (`LogStream`, `StreamStats`, `StreamSchema`, `Alert`, `QueryRequest`, etc.).
26+- **`data/repository/`** — `ParseableRepository` (domain facade over the API client) and `SettingsRepository` (DataStore-backed credential persistence).
27+- **`di/`** — Hilt DI module. All singletons are constructor-injected.
28+- **`ui/screens/`** — One package per screen, each containing a `*Screen.kt` (Composable) and `*ViewModel.kt` (Hilt ViewModel with `StateFlow`).
29+- **`ui/navigation/`** — `NavGraph.kt` defines all routes. Navigation uses string-based routes with `{streamName}` path params.
30+- **`ui/theme/`** — Material 3 theme with Parseable brand colors (purple `#545BEB`). Supports dark/light.
31+
32+### Screen Map
33+
34+| Route | Screen | ViewModel |
35+|---|---|---|
36+| `login` | `LoginScreen` | `LoginViewModel` |
37+| `streams` | `StreamsScreen` | `StreamsViewModel` |
38+| `log_viewer/{streamName}` | `LogViewerScreen` | `LogViewerViewModel` |
39+| `stream_info/{streamName}` | `StreamInfoScreen` | `StreamInfoViewModel` |
40+| `alerts` | `AlertsScreen` | `AlertsViewModel` |
41+| `settings` | `SettingsScreen` | `SettingsViewModel` |
42+
43+### Parseable API Endpoints Used
44+
45+All endpoints are under `/api/v1/`. Auth is Basic Auth via `Authorization` header.
46+
47+- `GET /liveness` — connection test
48+- `GET /about` — server version/mode info
49+- `GET /logstream` — list all streams
50+- `GET /logstream/{name}/schema` — stream field schema
51+- `GET /logstream/{name}/stats` — ingestion/storage stats
52+- `GET /logstream/{name}/info` — stream metadata
53+- `GET /logstream/{name}/retention` — retention config
54+- `DELETE /logstream/{name}` — delete stream
55+- `POST /query` — SQL log queries (body: `{query, startTime, endTime}`)
56+- `GET /alerts` — list alerts
57+- `GET /user` — list users
58+
59+API docs: https://www.parseable.com/docs/api
60+
61+### Key Dependencies
62+
63+- Jetpack Compose with Material 3 (BOM 2024.12.01)
64+- Hilt 2.53.1 (DI via KSP)
65+- OkHttp 4.12.0
66+- Kotlinx Serialization 1.7.3
67+- Navigation Compose 2.8.5
68+- DataStore Preferences 1.1.1
69+
70+## Code Conventions
71+
72+- Kotlin with Jetpack Compose — no XML layouts
73+- State is exposed as `StateFlow` from ViewModels, collected via `collectAsStateWithLifecycle()`
74+- API responses use `ApiResult<T>` sealed class — always handle both `Success` and `Error` branches
75+- Network calls use `Dispatchers.IO` via `withContext` inside the API client
76+- Parseable queries use PostgreSQL-compatible SQL; column names are double-quoted in generated SQL
77+- Cleartext HTTP is allowed via `network_security_config.xml` (many Parseable instances are self-hosted without TLS)
78+
79+## CI/CD
80+
81+- **`.github/workflows/ci.yml`** — Runs on PRs and pushes to main/master. Builds debug APK, runs tests and lint.
82+- **`.github/workflows/build-release.yml`** — Runs on `v*` tags. Builds debug+release APKs and creates a draft GitHub release.
Dockerfilenew file+72
@@ -0,0 +1,72 @@
1+# syntax=docker/dockerfile:1
2+
3+# ---- Build stage -------------------------------------------------------------
4+# Builds the Parseable Android APK from source. Mirrors the GitHub Actions CI:
5+# JDK 17, Android SDK (compileSdk 35), Gradle 8.9, with the wrapper generated
6+# on the fly (gradle-wrapper.jar is gitignored). The app's build.gradle.kts
7+# derives its version from `git log`, so git + the .git directory are required.
8+#
9+# Pinned to linux/amd64: the Android toolchain (aapt2) ships only x86-64 Linux
10+# binaries. On an arm64 host (Apple Silicon) a native arm64 image can't exec
11+# them ("rosetta error: failed to open elf .../ld-linux-x86-64.so.2"); a full
12+# amd64 image gives Rosetta/QEMU a complete x86-64 userland to emulate. On an
13+# amd64 host this is native. Override with --build-arg BUILD_PLATFORM=... .
14+ARG BUILD_PLATFORM=linux/amd64
15+FROM --platform=${BUILD_PLATFORM} eclipse-temurin:17-jdk-jammy AS build
16+
17+# Versions (override at build time with --build-arg if needed)
18+ARG GRADLE_VERSION=8.9
19+ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708
20+ARG ANDROID_PLATFORM=35
21+ARG ANDROID_BUILD_TOOLS=35.0.0
22+# Gradle task to run: assembleDebug (default) or assembleRelease
23+ARG GRADLE_TASK=assembleDebug
24+
25+ENV ANDROID_SDK_ROOT=/opt/android-sdk \
26+ ANDROID_HOME=/opt/android-sdk \
27+ GRADLE_USER_HOME=/root/.gradle \
28+ DEBIAN_FRONTEND=noninteractive
29+
30+# Base tooling: git (version stamp), unzip/curl (fetching SDK + Gradle)
31+RUN apt-get update && \
32+ apt-get install -y --no-install-recommends git unzip curl ca-certificates && \
33+ rm -rf /var/lib/apt/lists/*
34+
35+# Android SDK command-line tools
36+RUN mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools" && \
37+ curl -fsSL "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" -o /tmp/cmdline-tools.zip && \
38+ unzip -q /tmp/cmdline-tools.zip -d "${ANDROID_SDK_ROOT}/cmdline-tools" && \
39+ mv "${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools" "${ANDROID_SDK_ROOT}/cmdline-tools/latest" && \
40+ rm /tmp/cmdline-tools.zip
41+
42+ENV PATH="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${PATH}"
43+
44+# Accept licenses and install required SDK packages
45+RUN yes | sdkmanager --licenses >/dev/null && \
46+ sdkmanager --install \
47+ "platform-tools" \
48+ "platforms;android-${ANDROID_PLATFORM}" \
49+ "build-tools;${ANDROID_BUILD_TOOLS}" >/dev/null
50+
51+# Gradle (used to generate the wrapper, then the wrapper drives the build)
52+RUN curl -fsSL "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" -o /tmp/gradle.zip && \
53+ unzip -q /tmp/gradle.zip -d /opt && \
54+ rm /tmp/gradle.zip
55+ENV PATH="/opt/gradle-${GRADLE_VERSION}/bin:${PATH}"
56+
57+WORKDIR /workspace
58+
59+# Copy the full project (including .git for the version stamp; see .dockerignore)
60+COPY . .
61+
62+# Generate the Gradle wrapper, then build the APK
63+RUN gradle wrapper --gradle-version "${GRADLE_VERSION}" && \
64+ ./gradlew --no-daemon "${GRADLE_TASK}"
65+
66+# ---- Export stage ------------------------------------------------------------
67+# Minimal final image holding just the built APK(s) under /apks.
68+# Extract them to ./out with:
69+# docker build --target export --output type=local,dest=./out .
70+# (A scratch image has no shell, so use --output, not docker create/cp.)
71+FROM scratch AS export
72+COPY --from=build /workspace/app/build/outputs/apk/ /apks/
IMPROVEMENTS.mdnew file+176
@@ -0,0 +1,176 @@
1+# Parseable Android — Improvements
2+
3+Summary of all improvements made to the codebase, organized by category.
4+
5+---
6+
7+## Security
8+
9+### SQL Injection Prevention
10+- **LogViewerViewModel**: All user-supplied column names are escaped with `escapeIdentifier()` (double-quote escaping) and filter values use single-quote escaping (`'` → `''`) to prevent SQL injection in generated WHERE clauses.
11+- **Custom SQL Queries**: Only `SELECT` statements are allowed; `DELETE`, `DROP`, etc. are rejected. Queries without a `LIMIT` clause automatically get `LIMIT 5000` appended to prevent OOM from unbounded result sets.
12+- **ParseableRepository**: Stream names in generated SQL are escaped with `escapeIdentifier()`.
13+
14+### Credential Storage
15+- **SettingsRepository**: Passwords are stored in `EncryptedSharedPreferences` (AES-256-GCM) backed by Android Keystore, separate from other preferences in DataStore.
16+- **Corruption Recovery**: If `EncryptedSharedPreferences` becomes corrupted (common after backup/restore), the file is deleted and recreated automatically with a Timber log.
17+- **Mutex Protection**: A `configMutex` guards concurrent reads/writes across DataStore and EncryptedSharedPreferences.
18+
19+### Network Security
20+- **network_security_config.xml**: Cleartext HTTP is explicitly allowed (required for self-hosted Parseable instances without TLS) with a proper XML config rather than a blanket manifest flag.
21+- **Private IP Detection**: LoginScreen warns users when enabling HTTP for non-private IP addresses (checks `localhost`, `127.*`, `10.*`, `192.168.*`, `172.16-31.*`).
22+- **Clipboard Safety**: Log export catches `SecurityException` when accessing the clipboard on Android 13+ where background clipboard access may be restricted.
23+
24+### Input Validation
25+- **URL Validation**: Server URLs are validated with `Patterns.WEB_URL`, trimmed of whitespace, and checked for blank hosts before connection attempts.
26+- **Filter Operators**: Only operators from an `ALLOWED_OPERATORS` allowlist are accepted for filter clauses.
27+
28+---
29+
30+## Concurrency & Reliability
31+
32+### CancellationException Handling
33+- **All ViewModels** (`SettingsViewModel`, `StreamsViewModel`, `StreamInfoViewModel`, `LogViewerViewModel`): `async/await` blocks are wrapped in `try-catch` with explicit `CancellationException` handling. On cancellation (e.g., user navigates away), `isLoading` is reset to `false` before re-throwing, preventing the UI from getting stuck in a permanent loading state.
34+
35+### Structured Concurrency
36+- **LogViewerViewModel**: Schema loading uses a dedicated `schemaJob` that is cancelled on re-initialization or `onCleared()`. Search debounce uses a separate `searchJob`. Streaming uses `streamingJob` with a generation counter to prevent stale poll results.
37+- **StreamsViewModel**: Stats loading uses a `statsJob` that is cancelled on refresh. A `Semaphore(8)` limits concurrent stats requests to 8 at a time.
38+- **StreamInfoViewModel**: A `loadJob` is cancelled when `load()` is called again, preventing duplicate parallel loads.
39+
40+### Thread Safety
41+- **LogViewerViewModel**: `streamingGeneration`, `lastSeenTimestamp`, and `consecutiveStreamingErrors` are all `@Volatile` to ensure visibility across coroutines.
42+- **ParseableApiClient**: The `config` field is `@Volatile` and `buildRequest()` captures a local snapshot to avoid reading a partially-updated config during concurrent requests.
43+
44+### Resource Cleanup
45+- **ParseableApiClient**: HTTP responses use `.use { }` blocks to ensure the response body is always closed, even on exceptions. A `shutdown()` method evicts the connection pool and shuts down the dispatcher.
46+- **ViewModels**: All `onCleared()` implementations cancel active jobs (`schemaJob`, `searchJob`, `streamingJob`).
47+- **Streaming**: Consecutive errors during streaming trigger an exponential backoff (1s → 2s → 4s) and auto-stop after 10 consecutive failures with an error message.
48+
49+### Logout Race Condition
50+- **StreamsViewModel**: `logout()` calls `clearConfig()` and `clearCredentials()` sequentially to prevent auth errors from triggering a second logout navigation.
51+- **ParseableRepository**: Auth errors (401) are detected via `checkAuth()` which clears credentials and sends to `authErrors` channel (conflated to prevent duplicate navigations).
52+
53+---
54+
55+## Error Handling & UX
56+
57+### Error Messages
58+- **ParseableApiClient**: Specific exception types produce targeted messages:
59+ - `SocketTimeoutException` → "Connection timed out"
60+ - `UnknownHostException` → "Unable to resolve host"
61+ - `ConnectException` → "Connection refused. Is the server running?"
62+ - `SSLException` → "SSL error: ... Check server certificate."
63+ - Generic `IOException` → the exception message or "Network error"
64+- **SettingsScreen**: Error card is now visible even during a reload (removed the `!isLoading` gate that hid errors during pull-to-refresh).
65+
66+### Empty States
67+- **LogViewerScreen**: Context-aware empty messages:
68+ - During streaming: "Waiting for new logs…"
69+ - With search + filters: "No logs match \"query\" with the active filters"
70+ - With search only: "No logs match \"query\""
71+ - With filters only: "No logs match the active filters"
72+ - Default: "No logs found for the selected time range"
73+- **Result Cap Warning**: When results hit the 5,000 limit, shows "Showing max 5,000 results. Refine your query to see more." in primary color instead of misleading "End of results".
74+
75+### Streaming Error Persistence
76+- **LogViewerViewModel**: `stopStreaming()` now preserves the streaming error in state (only clears `isStreaming`). A separate `dismissStreamingError()` method lets users explicitly dismiss the error banner. Errors are also cleared on `refresh()`.
77+
78+### Schema Failure Visibility
79+- **StreamInfoViewModel**: A `schemaFailed` boolean flag tracks whether the schema request specifically failed (separate from the combined error string).
80+- **StreamInfoScreen**: Shows a "Schema unavailable" card when schema loading fails, instead of silently hiding the entire section.
81+
82+### Alert Deletion Safety
83+- **AlertsScreen**: Alerts without an `id` field have their delete button disabled at the card level, preventing them from reaching the delete confirmation dialog.
84+
85+---
86+
87+## Accessibility
88+
89+### Touch Targets
90+- All interactive elements meet the 48dp minimum touch target size:
91+ - **StreamsScreen**: Stats retry `TextButton` uses `defaultMinSize(minHeight = 48.dp)` instead of a fixed 24dp height.
92+ - **LogViewerScreen**: Filter chips, time range buttons, and all `IconButton` elements meet the 48dp minimum.
93+ - **LoginScreen**: Both login buttons use `defaultMinSize(minHeight = 50.dp)` instead of fixed `.height(50.dp)`, allowing growth for landscape or accessibility text scaling.
94+
95+### Content Descriptions
96+- **StreamsScreen**: The decorative `ChevronRight` icon uses `contentDescription = null` since the parent `Card` already handles the click action (avoids duplicate screen reader announcements).
97+- All other icons have meaningful `contentDescription` values that describe the action, not the icon.
98+
99+### Keyboard / IME
100+- **LogViewerScreen**: Pressing the keyboard Search button (`ImeAction.Search`) now triggers `viewModel.refresh()` in addition to hiding the keyboard.
101+- **Filter Bottom Sheet**: The filter value field has `ImeAction.Done` with a `keyboardActions` handler that applies the filter directly from the keyboard.
102+- **LoginScreen**: IME actions chain correctly: URL → Next → Username → Next → Password → Done (triggers login).
103+
104+### Configuration Changes
105+- **LoginScreen**: `passwordVisible` uses `rememberSaveable` instead of `remember`, so the "show password" toggle survives device rotation.
106+
107+---
108+
109+## Performance
110+
111+### List Operations
112+- **LogViewerViewModel**: `removeFilter()` uses `filterIndexed` instead of `toMutableList().apply { removeAt() }`, avoiding temporary mutable list allocations.
113+- **LazyColumn Keys**: All `LazyColumn` lists use deterministic, stable keys (e.g., `alert.id ?: alert.name ?: "alert_$index"`) to prevent unnecessary recompositions.
114+
115+### Streaming Backpressure
116+- **LogViewerViewModel**: Streaming caps at `STREAMING_MAX_LOGS = 1000` entries, dropping oldest when the cap is reached. A generation counter prevents stale poll results from being applied.
117+
118+### Caching
119+- **ParseableRepository**: TTL-based caching with `ConcurrentHashMap`:
120+ - Streams: 30s
121+ - Schema: 2min
122+ - Stats: 30s
123+ - About: 5min
124+- Force-refresh bypasses the cache when explicitly requested (e.g., pull-to-refresh).
125+
126+### R8 Full Mode
127+- **build.gradle.kts**: `isMinifyEnabled = true` with R8 full mode enabled for release builds.
128+- **proguard-rules.pro**: Comprehensive keep rules for kotlinx.serialization, Room entities, ViewModel state classes, OkHttp, EncryptedSharedPreferences, Google Tink, and Protobuf.
129+
130+---
131+
132+## Testing
133+
134+### Unit Test Coverage
135+Every ViewModel has a dedicated test class:
136+
137+| Test Class | Tests | Covers |
138+|---|---|---|
139+| `LogViewerViewModelTest` | 17 | Initialize, search, filters, time ranges, streaming, custom SQL LIMIT, SQL escaping, IS NULL, error states |
140+| `StreamsViewModelTest` | 8 | Refresh, error handling, stats loading, favorites CRUD, exception resilience |
141+| `SettingsViewModelTest` | 8 | Config loading, about info, user parsing, error consolidation, exception resilience, refresh |
142+| `StreamInfoViewModelTest` | 9 | Parallel loading, partial errors, schemaFailed flag, delete, cancel, idempotency |
143+| `AlertsViewModelTest` | 7 | Refresh, error, delete flow, cancel delete |
144+| `LoginViewModelTest` | 9+ | Validation, URL normalization, login flow, saved credentials |
145+| `ParseableRepositoryTest` | 9+ | Caching, TTL, auth error detection, cache invalidation |
146+
147+### CI
148+- **ci.yml**: Runs `testDebugUnitTest` and `lintDebug` on PRs and pushes to main/master.
149+- **build-release.yml**: Builds both debug and release APKs on `v*` tags and creates a draft GitHub release.
150+
151+---
152+
153+## Architecture & Code Quality
154+
155+### Error Handler
156+- **ErrorHandler**: A `CompositionLocal`-based error handler (`LocalErrorHandler`) provides a centralized `SnackbarHostState` for showing error messages from any screen. Used for session-expired notifications on the login screen.
157+
158+### Navigation
159+- **NavGraph**: All authenticated routes (`streams`, `log_viewer`, `stream_info`, `alerts`, `settings`) have auth guards that redirect to login if the API client isn't configured.
160+- **URL Encoding**: Stream names are URL-encoded/decoded in navigation routes to handle special characters.
161+- **Deep Links**: Supported for `parseable://streams`, `parseable://stream/{streamName}`, and `parseable://alerts`.
162+
163+### Favorites (Room)
164+- **FavoriteStream**: Room entity with a DAO for persisting favorite streams locally.
165+- **ParseableDatabase**: Room database with `fallbackToDestructiveMigration()` and schema export directory.
166+- **StreamsScreen**: Favorite streams are sorted to the top of the list. Toggle via star icon with snackbar feedback.
167+
168+### Dependency Management
169+- **libs.versions.toml**: All dependency versions are centralized in a Gradle version catalog.
170+- **Timber**: Debug logging via `Timber.DebugTree()` planted only in debug builds. Used throughout the API client and repository layer for error logging.
171+
172+---
173+
174+## Files Changed
175+
176+41 files modified across the codebase, with approximately **2,981 lines added** and **664 lines removed**.
README.mdnew file+71
@@ -0,0 +1,71 @@
1+# Parseable Android
2+
3+An Android client for [Parseable](https://www.parseable.com/), the open-source log analytics platform. Browse, search, and filter your log data from your phone.
4+
5+## Features
6+
7+- **Server Connection** — Connect to any Parseable instance with Basic Auth. Supports both HTTP and HTTPS. Credentials are persisted locally.
8+- **Stream Browser** — View all log streams with ingestion/storage stats at a glance.
9+- **Log Viewer** — Browse log entries with expandable detail cards. Tap any entry to see all fields.
10+- **Time Range Filtering** — Quick-select time ranges from 5 minutes to 30 days.
11+- **Text Search** — Full-text search across log fields with automatic fallback strategies.
12+- **Column Filters** — Add SQL-based column filters with operators (=, !=, LIKE, ILIKE, >, <, IS NULL, etc.).
13+- **Custom SQL Queries** — Write and execute raw PostgreSQL-compatible SQL directly against your log data.
14+- **Stream Details** — View schema, stats (event count, ingestion size, storage size), and retention config per stream.
15+- **Alerts** — Browse configured alerts with rule and target details.
16+- **Settings** — View server info (version, mode, deployment ID, query engine) and user list.
17+- **Dark Mode** — Full Material 3 dark/light theme support following system preference.
18+- **Pull-to-Refresh** — Pull to refresh on all list screens.
19+
20+## Tech Stack
21+
22+- Kotlin
23+- Jetpack Compose with Material 3
24+- Hilt for dependency injection
25+- OkHttp for networking
26+- Kotlinx Serialization for JSON
27+- DataStore for local preferences
28+- Navigation Compose for routing
29+
30+## Parseable API Coverage
31+
32+| Endpoint | Method | Used For |
33+|---|---|---|
34+| `/api/v1/liveness` | GET | Connection test |
35+| `/api/v1/about` | GET | Server info |
36+| `/api/v1/logstream` | GET | List streams |
37+| `/api/v1/logstream/{name}/schema` | GET | Stream schema |
38+| `/api/v1/logstream/{name}/stats` | GET | Stream statistics |
39+| `/api/v1/logstream/{name}/info` | GET | Stream details |
40+| `/api/v1/logstream/{name}/retention` | GET | Retention config |
41+| `/api/v1/logstream/{name}` | DELETE | Delete stream |
42+| `/api/v1/query` | POST | SQL log queries |
43+| `/api/v1/alerts` | GET | List alerts |
44+| `/api/v1/user` | GET | List users |
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
58+
59+```bash
60+./gradlew assembleDebug
61+```
62+
63+The debug APK will be at `app/build/outputs/apk/debug/`.
64+
65+## CI/CD
66+
67+The GitHub Actions workflow (`.github/workflows/build-release.yml`) builds both debug and release APKs. Pushing a tag matching `v*` creates a draft GitHub release with the APKs attached. You can also trigger the workflow manually.
68+
69+## License
70+
71+MIT
app/build.gradle.ktsnew file+142
@@ -0,0 +1,142 @@
1+import java.time.Instant
2+import java.time.ZoneOffset
3+import java.time.format.DateTimeFormatter
4+
5+plugins {
6+ alias(libs.plugins.android.application)
7+ alias(libs.plugins.kotlin.android)
8+ alias(libs.plugins.kotlin.compose)
9+ alias(libs.plugins.kotlin.serialization)
10+ alias(libs.plugins.hilt)
11+ alias(libs.plugins.ksp)
12+}
13+
14+val commitEpoch = providers.exec {
15+ commandLine("git", "log", "-1", "--format=%ct")
16+}.standardOutput.asText.get().trim().toLong()
17+val commitTime = Instant.ofEpochSecond(commitEpoch).atZone(ZoneOffset.UTC)
18+val dateVersionName = commitTime.format(DateTimeFormatter.ofPattern("yyyy.MM.dd.HHmm"))
19+val dateVersionCode = (commitEpoch / 60).toInt()
20+
21+android {
22+ namespace = "com.parseable.android"
23+ compileSdk = 35
24+
25+ defaultConfig {
26+ applicationId = "com.parseable.android"
27+ minSdk = 26
28+ targetSdk = 35
29+ versionCode = dateVersionCode
30+ versionName = dateVersionName
31+
32+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
33+ }
34+
35+ signingConfigs {
36+ if (System.getenv("RELEASE_KEYSTORE_FILE") != null) {
37+ create("release") {
38+ storeFile = file(System.getenv("RELEASE_KEYSTORE_FILE"))
39+ storePassword = System.getenv("RELEASE_KEYSTORE_PASSWORD")
40+ keyAlias = System.getenv("RELEASE_KEY_ALIAS")
41+ keyPassword = System.getenv("RELEASE_KEY_PASSWORD")
42+ }
43+ }
44+ }
45+
46+ buildTypes {
47+ release {
48+ isMinifyEnabled = true
49+ isShrinkResources = true
50+ signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
51+ proguardFiles(
52+ getDefaultProguardFile("proguard-android-optimize.txt"),
53+ "proguard-rules.pro"
54+ )
55+ }
56+ debug {
57+ isMinifyEnabled = false
58+ applicationIdSuffix = ".debug"
59+ }
60+ }
61+
62+ compileOptions {
63+ sourceCompatibility = JavaVersion.VERSION_17
64+ targetCompatibility = JavaVersion.VERSION_17
65+ }
66+
67+ kotlinOptions {
68+ jvmTarget = "17"
69+ }
70+
71+ buildFeatures {
72+ compose = true
73+ buildConfig = true
74+ }
75+
76+ testOptions {
77+ unitTests.isReturnDefaultValues = true
78+ }
79+}
80+
81+ksp {
82+ arg("room.schemaLocation", "$projectDir/schemas")
83+}
84+
85+dependencies {
86+ // Core Android
87+ implementation(libs.core.ktx)
88+ implementation(libs.lifecycle.runtime)
89+ implementation(libs.lifecycle.viewmodel.compose)
90+ implementation(libs.activity.compose)
91+
92+ // Compose BOM
93+ implementation(platform(libs.compose.bom))
94+ implementation(libs.compose.ui)
95+ implementation(libs.compose.ui.graphics)
96+ implementation(libs.compose.ui.tooling.preview)
97+ implementation(libs.compose.material3)
98+ implementation(libs.compose.material.icons)
99+
100+ // Navigation
101+ implementation(libs.navigation.compose)
102+
103+ // Hilt DI
104+ implementation(libs.hilt.android)
105+ ksp(libs.hilt.compiler)
106+ implementation(libs.hilt.navigation.compose)
107+
108+ // Networking
109+ implementation(libs.okhttp)
110+
111+ // Serialization
112+ implementation(libs.kotlinx.serialization.json)
113+
114+ // DataStore for preferences
115+ implementation(libs.datastore.preferences)
116+
117+ // Encrypted storage — alpha06 is the latest available; stable 1.1.0 has not shipped.
118+ // Fallback to 1.0.0 is not viable as it uses the deprecated MasterKeys API.
119+ implementation(libs.security.crypto)
120+
121+ // Room database for favorites
122+ implementation(libs.room.runtime)
123+ implementation(libs.room.ktx)
124+ ksp(libs.room.compiler)
125+
126+ // Coroutines
127+ implementation(libs.coroutines.android)
128+
129+ // Logging
130+ implementation(libs.timber)
131+
132+ // Testing
133+ testImplementation(libs.junit)
134+ testImplementation(libs.coroutines.test)
135+ testImplementation(libs.mockk)
136+ androidTestImplementation(libs.android.test.ext)
137+ androidTestImplementation(libs.espresso.core)
138+ androidTestImplementation(platform(libs.compose.bom))
139+ androidTestImplementation(libs.compose.ui.test)
140+ debugImplementation(libs.compose.ui.tooling)
141+ debugImplementation(libs.compose.ui.test.manifest)
142+}
app/proguard-rules.pronew file+61
@@ -0,0 +1,61 @@
1+# Parseable Android ProGuard Rules
2+
3+# Keep kotlinx serialization
4+-keepattributes *Annotation*, InnerClasses, Signature
5+-dontnote kotlinx.serialization.AnnotationsKt
6+
7+-keepclassmembers class kotlinx.serialization.json.** {
8+ *** Companion;
9+}
10+-keepclasseswithmembers class kotlinx.serialization.json.** {
11+ kotlinx.serialization.KSerializer serializer(...);
12+}
13+
14+# Keep all @Serializable model classes and their generated serializers
15+-keep,includedescriptorclasses class com.parseable.android.**$$serializer { *; }
16+-keepclassmembers class com.parseable.android.** {
17+ *** Companion;
18+}
19+-keepclasseswithmembers class com.parseable.android.** {
20+ kotlinx.serialization.KSerializer serializer(...);
21+}
22+# Keep @Serializable data classes (fields needed for JSON encoding/decoding)
23+-keep @kotlinx.serialization.Serializable class com.parseable.android.data.model.** { *; }
24+
25+# Keep ViewModel state data classes (used with StateFlow/collectAsState)
26+-keep class com.parseable.android.ui.screens.**State { *; }
27+-keep class com.parseable.android.ui.screens.logviewer.FilterState { *; }
28+-keep class com.parseable.android.ui.screens.logviewer.StreamingState { *; }
29+-keep class com.parseable.android.ui.screens.streams.StreamsViewModel$StreamStatsUi { *; }
30+
31+# Room entities
32+-keep class com.parseable.android.data.local.** { *; }
33+
34+# OkHttp (library ships its own consumer rules; only suppress warnings)
35+-dontwarn okhttp3.**
36+-dontwarn okio.**
37+
38+# Keep the custom X509TrustManager used for insecure TLS config
39+-keep class com.parseable.android.data.api.ParseableApiClient { *; }
40+
41+# EncryptedSharedPreferences (androidx.security:security-crypto)
42+-keep class androidx.security.crypto.** { *; }
43+-keep class * extends android.content.SharedPreferences { *; }
44+
45+# Google Tink (transitive dependency of androidx.security:security-crypto)
46+# Tink's complex class hierarchy and use of Protobuf causes R8 crashes
47+# in security-crypto alpha versions that ship incomplete consumer rules.
48+-keep class com.google.crypto.tink.** { *; }
49+-dontwarn com.google.crypto.tink.**
50+
51+# Protobuf Lite (Tink dependency)
52+-keep class com.google.protobuf.** { *; }
53+-dontwarn com.google.protobuf.**
54+
55+# Annotation libraries referenced by Tink/Google libs but not always on classpath
56+-dontwarn com.google.errorprone.annotations.**
57+-dontwarn javax.annotation.**
58+-dontwarn org.checkerframework.**
59+-dontwarn com.google.api.**
60+-dontwarn com.google.j2objc.annotations.**
61+-dontwarn sun.misc.Unsafe
app/schemas/.gitkeepnew file

No changes to the content.

app/src/main/AndroidManifest.xmlnew file+43
@@ -0,0 +1,43 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3+ xmlns:tools="http://schemas.android.com/tools">
4+
5+ <uses-permission android:name="android.permission.INTERNET" />
6+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
7+
8+ <application
9+ android:name=".ParseableApp"
10+ android:allowBackup="false"
11+ android:icon="@mipmap/ic_launcher"
12+ android:label="@string/app_name"
13+ android:roundIcon="@mipmap/ic_launcher"
14+ android:supportsRtl="true"
15+ android:theme="@style/Theme.ParseableAndroid"
16+ android:networkSecurityConfig="@xml/network_security_config"
17+ tools:targetApi="35">
18+ <activity
19+ android:name=".MainActivity"
20+ android:exported="true"
21+ android:theme="@style/Theme.ParseableAndroid">
22+ <intent-filter>
23+ <action android:name="android.intent.action.MAIN" />
24+ <category android:name="android.intent.category.LAUNCHER" />
25+ </intent-filter>
26+ <intent-filter>
27+ <action android:name="android.intent.action.VIEW" />
28+ <category android:name="android.intent.category.DEFAULT" />
29+ <category android:name="android.intent.category.BROWSABLE" />
30+ <data android:scheme="parseable" />
31+ </intent-filter>
32+ </activity>
33+ <provider
34+ android:name="androidx.core.content.FileProvider"
35+ android:authorities="${applicationId}.fileprovider"
36+ android:exported="false"
37+ android:grantUriPermissions="true">
38+ <meta-data
39+ android:name="android.support.FILE_PROVIDER_PATHS"
40+ android:resource="@xml/file_paths" />
41+ </provider>
42+ </application>
43+</manifest>
app/src/main/java/com/parseable/android/MainActivity.ktnew file+110
@@ -0,0 +1,110 @@
1+package com.parseable.android
2+
3+import android.os.Bundle
4+import androidx.activity.ComponentActivity
5+import androidx.activity.compose.setContent
6+import androidx.activity.enableEdgeToEdge
7+import androidx.compose.foundation.layout.Box
8+import androidx.compose.foundation.layout.fillMaxSize
9+import androidx.compose.material3.CircularProgressIndicator
10+import androidx.compose.runtime.*
11+import androidx.compose.ui.Alignment
12+import androidx.compose.ui.Modifier
13+import androidx.navigation.compose.rememberNavController
14+import com.parseable.android.data.model.ServerConfig
15+import com.parseable.android.data.repository.ParseableRepository
16+import com.parseable.android.data.repository.SettingsRepository
17+import com.parseable.android.ui.ErrorHandler
18+import com.parseable.android.ui.GlobalErrorBoundary
19+import com.parseable.android.ui.navigation.ParseableNavGraph
20+import com.parseable.android.ui.navigation.Routes
21+import com.parseable.android.ui.theme.ParseableTheme
22+import dagger.hilt.android.AndroidEntryPoint
23+import kotlinx.coroutines.flow.first
24+import kotlinx.coroutines.flow.launchIn
25+import kotlinx.coroutines.flow.onEach
26+import javax.inject.Inject
27+
28+@AndroidEntryPoint
29+class MainActivity : ComponentActivity() {
30+
31+ @Inject
32+ lateinit var settingsRepository: SettingsRepository
33+
34+ @Inject
35+ lateinit var repository: ParseableRepository
36+
37+ private val errorHandler = ErrorHandler()
38+
39+ override fun onCreate(savedInstanceState: Bundle?) {
40+ super.onCreate(savedInstanceState)
41+ enableEdgeToEdge()
42+
43+ setContent {
44+ ParseableTheme {
45+ GlobalErrorBoundary(errorHandler = errorHandler) {
46+ var startDestination by remember { mutableStateOf<String?>(null) }
47+
48+ LaunchedEffect(Unit) {
49+ val savedConfig: ServerConfig? = settingsRepository.serverConfig.first()
50+ if (savedConfig != null) {
51+ repository.configure(savedConfig)
52+ }
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+ }
66+
67+ val dest = startDestination
68+ if (dest != null) {
69+ val navController = rememberNavController()
70+
71+ // Navigate to login on 401 auth errors
72+ LaunchedEffect(navController) {
73+ repository.authErrors
74+ .onEach {
75+ // Several parallel requests can each surface a 401 in quick
76+ // succession. Skip re-navigating (and re-clearing) when we're
77+ // already on the login screen, so a burst can't thrash the
78+ // back stack or re-trigger the "session expired" message.
79+ val onLogin = navController.currentDestination
80+ ?.route?.startsWith(Routes.LOGIN) == true
81+ if (onLogin) return@onEach
82+ settingsRepository.clearConfig()
83+ // The login screen shows the "session expired" message
84+ // itself via the sessionExpired flag; showing it here too
85+ // would queue a duplicate snackbar.
86+ navController.navigate(Routes.login(sessionExpired = true)) {
87+ popUpTo(0) { inclusive = true }
88+ }
89+ }
90+ .launchIn(this)
91+ }
92+
93+ ParseableNavGraph(
94+ navController = navController,
95+ startDestination = dest,
96+ repository = repository,
97+ )
98+ } else {
99+ Box(
100+ modifier = Modifier.fillMaxSize(),
101+ contentAlignment = Alignment.Center,
102+ ) {
103+ CircularProgressIndicator()
104+ }
105+ }
106+ }
107+ }
108+ }
109+ }
110+}
app/src/main/java/com/parseable/android/ParseableApp.ktnew file+29
@@ -0,0 +1,29 @@
1+package com.parseable.android
2+
3+import android.app.Application
4+import dagger.hilt.android.HiltAndroidApp
5+import timber.log.Timber
6+
7+@HiltAndroidApp
8+class ParseableApp : Application() {
9+
10+ override fun onCreate() {
11+ super.onCreate()
12+
13+ if (BuildConfig.DEBUG) {
14+ Timber.plant(Timber.DebugTree())
15+ }
16+
17+ installUncaughtExceptionHandler()
18+ }
19+
20+ private fun installUncaughtExceptionHandler() {
21+ val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
22+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
23+ Timber.e(throwable, "Uncaught exception on thread ${thread.name}")
24+ // Delegate to the default handler so the system can still terminate
25+ // the process for truly fatal errors (e.g. OOM).
26+ defaultHandler?.uncaughtException(thread, throwable)
27+ }
28+ }
29+}
app/src/main/java/com/parseable/android/data/FormatUtils.ktnew file+47
@@ -0,0 +1,47 @@
1+package com.parseable.android.data
2+
3+import java.util.Locale
4+
5+// "EB" is included so PB is never the last unit: the promotion guard below (which only fires
6+// when a higher unit exists) then handles a PB value in [1023.5, 1024) — promoting it to "1.00 EB"
7+// instead of rendering "1024 PB". The remaining unhandled top-unit case sits at ~1.2e24 bytes.
8+private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
9+
10+/**
11+ * Converts a raw byte-count string (e.g. "123456789") to a human-readable
12+ * size (e.g. "117.7 MB"). If the string is not a valid number, it is
13+ * returned unchanged so pre-formatted values from the API pass through.
14+ */
15+fun formatBytes(raw: String?): String? {
16+ if (raw == null) return null
17+ val bytes = raw.trim().toDoubleOrNull() ?: return raw
18+ // toDoubleOrNull accepts "NaN"/"Infinity"; those slip past the range guards below and
19+ // would format as "NaN B" / "Infinity PB". Pass them through unchanged instead.
20+ if (!bytes.isFinite()) return raw
21+ if (bytes < 0) return raw
22+ // Use 1023.5, not 1024, as the cutoff: "%.0f" rounds half-up, so a byte count in
23+ // [1023.5, 1024) would render as "1024 B" — a value that should promote to "1.00 KB".
24+ // Such inputs fall through to the unit-scaling path, where the promotion guard below
25+ // (value >= 1023.5) handles them.
26+ if (bytes < 1023.5) return String.format(Locale.US, "%.0f B", bytes)
27+ var value = bytes
28+ var unitIndex = 0
29+ while (value >= 1024 && unitIndex < BYTE_UNITS.size - 1) {
30+ value /= 1024
31+ unitIndex++
32+ }
33+ // A value just under 1024 (e.g. 1023.999 KB) is formatted with "%.0f", which rounds it to
34+ // "1024 KB" — a unit that should never display. Promote to the next unit so it renders as
35+ // "1.00 MB". The "%.1f"/"%.2f" branches operate far below 1024, so only this case crosses.
36+ if (value >= 1023.5 && unitIndex < BYTE_UNITS.size - 1) {
37+ value /= 1024
38+ unitIndex++
39+ }
40+ return if (value >= 100) {
41+ String.format(Locale.US, "%.0f %s", value, BYTE_UNITS[unitIndex])
42+ } else if (value >= 10) {
43+ String.format(Locale.US, "%.1f %s", value, BYTE_UNITS[unitIndex])
44+ } else {
45+ String.format(Locale.US, "%.2f %s", value, BYTE_UNITS[unitIndex])
46+ }
47+}
app/src/main/java/com/parseable/android/data/SqlUtils.ktnew file+23
@@ -0,0 +1,23 @@
1+package com.parseable.android.data
2+
3+/** Escape single quotes in user input to prevent SQL injection in string literals. */
4+fun escapeSql(value: String): String = value.replace("'", "''")
5+
6+/** Escape a SQL identifier (table/column name) for use inside double quotes. */
7+fun 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.ktnew file+69
@@ -0,0 +1,69 @@
1+package com.parseable.android.data
2+
3+import java.time.Instant
4+import java.time.LocalDateTime
5+import java.time.OffsetDateTime
6+import java.time.ZoneId
7+import java.time.ZoneOffset
8+import java.time.ZonedDateTime
9+import java.time.format.DateTimeFormatter
10+import java.util.Locale
11+
12+// Pin to Locale.US so the MMM month token is always rendered as a stable English
13+// abbreviation (e.g. "Jun") regardless of the device's locale.
14+private val displayFormatter = DateTimeFormatter.ofPattern("MMM dd HH:mm:ss.SSS", Locale.US)
15+
16+fun formatTimestamp(raw: String): String {
17+ // Parseable/DataFusion sometimes renders p_timestamp with a space between the date and
18+ // time ("2026-06-20 12:00:00.000") instead of the ISO 'T', and may append a zone/offset
19+ // after another space ("... +00:00", "... UTC"). Normalize both so the parsers below
20+ // accept them; values already in canonical ISO form are left untouched.
21+ var iso = raw.trim()
22+ // Convert the FIRST space (date↔time separator) to 'T', unless a 'T' already precedes it
23+ // (in which case that space sits before a zone token, handled next).
24+ val firstSpace = iso.indexOf(' ')
25+ if (firstSpace != -1 && 'T' !in iso.substring(0, firstSpace + 1)) {
26+ iso = iso.substring(0, firstSpace) + "T" + iso.substring(firstSpace + 1)
27+ }
28+ // Drop a space sitting between the time and a trailing offset/zone token:
29+ // "...12:00:00 +00:00" → "...12:00:00+00:00", "...12:00:00 UTC" → "...12:00:00UTC".
30+ iso = iso.replace(Regex("(?<=\\d)\\s+(?=[+\\-Z]|UTC|GMT)"), "")
31+ // Map a textual "UTC"/"GMT" suffix to the ISO 'Z' the parsers understand (GMT is exactly
32+ // UTC, so this is a safe rewrite). Other textual zones (e.g. "PST") are ambiguous, so they
33+ // are left untouched and fall through to the raw-string passthrough rather than risk
34+ // displaying a wrong local time.
35+ if (iso.endsWith("UTC")) iso = iso.removeSuffix("UTC") + "Z"
36+ else if (iso.endsWith("GMT")) iso = iso.removeSuffix("GMT") + "Z"
37+
38+ // Fast path: an ISO-8601 instant in UTC (e.g. "2026-06-20T12:00:00Z").
39+ try {
40+ val instant = Instant.parse(iso)
41+ return ZonedDateTime.ofInstant(instant, ZoneId.systemDefault()).format(displayFormatter)
42+ } catch (_: Exception) {
43+ // Fall through to the offset-aware parser.
44+ }
45+
46+ // General path: any ISO-8601 timestamp carrying an explicit offset, including
47+ // "+00:00" and non-UTC offsets like "+05:30" that Instant.parse rejects.
48+ try {
49+ val offset = OffsetDateTime.parse(iso)
50+ return offset.atZoneSameInstant(ZoneId.systemDefault()).format(displayFormatter)
51+ } catch (_: Exception) {
52+ // Fall through to the naive (offset-less) parser.
53+ }
54+
55+ // Final path: an offset-less ISO-8601 timestamp (e.g. "2026-06-20T12:00:00.000").
56+ // Parseable stores p_timestamp in UTC, so interpret a naive timestamp as UTC and
57+ // convert to the device's zone — otherwise these would render in UTC while
58+ // offset-carrying values render in local time, an inconsistent mix.
59+ try {
60+ val local = LocalDateTime.parse(iso)
61+ return local.atOffset(ZoneOffset.UTC)
62+ .atZoneSameInstant(ZoneId.systemDefault())
63+ .format(displayFormatter)
64+ } catch (_: Exception) {
65+ // Unparseable — show the server value verbatim rather than nothing.
66+ }
67+
68+ return raw
69+}
app/src/main/java/com/parseable/android/data/api/ParseableApiClient.ktnew file+508
@@ -0,0 +1,508 @@
1+package com.parseable.android.data.api
2+
3+import timber.log.Timber
4+import com.parseable.android.data.model.*
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.withContext
7+import kotlinx.serialization.SerializationException
8+import kotlinx.serialization.json.*
9+import okhttp3.*
10+import okhttp3.MediaType.Companion.toMediaType
11+import okhttp3.RequestBody.Companion.toRequestBody
12+import java.io.IOException
13+import java.net.ConnectException
14+import java.net.SocketTimeoutException
15+import java.net.URLEncoder
16+import java.net.UnknownHostException
17+import java.security.SecureRandom
18+import java.security.cert.X509Certificate
19+import java.util.concurrent.TimeUnit
20+import javax.inject.Inject
21+import javax.inject.Singleton
22+import javax.net.ssl.SSLContext
23+import javax.net.ssl.SSLException
24+import javax.net.ssl.TrustManager
25+import javax.net.ssl.X509TrustManager
26+
27+private data class ClientConfig(
28+ val baseUrl: String,
29+ val authHeader: String,
30+ val allowInsecure: Boolean,
31+)
32+
33+@Singleton
34+class ParseableApiClient @Inject constructor() {
35+
36+ private val json = Json {
37+ ignoreUnknownKeys = true
38+ isLenient = true
39+ coerceInputValues = true
40+ }
41+
42+ private val sharedPool = okhttp3.ConnectionPool(16, 5, TimeUnit.MINUTES)
43+ private val sharedDispatcher = okhttp3.Dispatcher()
44+
45+ private val secureClient: OkHttpClient = buildClient(allowInsecure = false)
46+ private val insecureClient: OkHttpClient by lazy { buildClient(allowInsecure = true) }
47+
48+ @Volatile
49+ private var config: ClientConfig = ClientConfig("", "", false)
50+
51+ private val client: OkHttpClient
52+ get() = if (config.allowInsecure) insecureClient else secureClient
53+
54+ private fun buildClient(allowInsecure: Boolean): OkHttpClient {
55+ val builder = OkHttpClient.Builder()
56+ .connectionPool(sharedPool)
57+ .dispatcher(sharedDispatcher)
58+ .connectTimeout(30, TimeUnit.SECONDS)
59+ .readTimeout(60, TimeUnit.SECONDS)
60+ .writeTimeout(30, TimeUnit.SECONDS)
61+
62+ if (allowInsecure) {
63+ val trustManager = object : X509TrustManager {
64+ override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
65+ override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
66+ override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
67+ }
68+ val sslContext = SSLContext.getInstance("TLS")
69+ sslContext.init(null, arrayOf<TrustManager>(trustManager), SecureRandom())
70+ builder
71+ .sslSocketFactory(sslContext.socketFactory, trustManager)
72+ .hostnameVerifier { _, _ -> true }
73+ }
74+
75+ return builder.build()
76+ }
77+
78+ fun configure(serverConfig: ServerConfig) {
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}"
86+ "Basic " + android.util.Base64.encodeToString(
87+ // Explicit UTF-8 per RFC 7617 rather than the platform default charset.
88+ credentials.toByteArray(Charsets.UTF_8),
89+ android.util.Base64.NO_WRAP
90+ )
91+ }
92+ config = ClientConfig(
93+ baseUrl = url,
94+ authHeader = auth,
95+ allowInsecure = !serverConfig.useTls,
96+ )
97+ }
98+
99+ val isConfigured: Boolean get() = config.baseUrl.isNotEmpty() && config.authHeader.isNotEmpty()
100+
101+ fun clearConfig() {
102+ config = ClientConfig("", "", false)
103+ }
104+
105+ fun shutdown() {
106+ // Only evict idle connections. The dispatcher's executor is SHARED across the
107+ // secure/insecure clients of this @Singleton; shutting it down is irreversible and
108+ // would make every later request throw RejectedExecutionException after a
109+ // clearConfig()/configure() cycle. Eviction is enough to release pooled sockets.
110+ sharedPool.evictAll()
111+ }
112+
113+ private fun encodePathSegment(segment: String): String =
114+ URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
115+
116+ private fun buildRequest(path: String): Request.Builder {
117+ // Snapshot the volatile config ONCE and derive both the URL/auth header and the
118+ // TLS client (secure vs. insecure) from it. Selecting the client lazily in
119+ // executeRequest would re-read `config`, so a concurrent server switch could
120+ // dispatch server A's URL/auth through server B's TLS client. Pinning the client
121+ // onto the request as a tag keeps the whole request internally consistent.
122+ val snapshot = config
123+ val selectedClient = if (snapshot.allowInsecure) insecureClient else secureClient
124+ val builder = Request.Builder()
125+ .url("${snapshot.baseUrl}$path")
126+ .tag(OkHttpClient::class.java, selectedClient)
127+ // Don't send a literal empty "Authorization:" header for unconfigured calls
128+ // (e.g. pre-login connectivity probes) — strict servers/proxies may reject it.
129+ if (snapshot.authHeader.isNotEmpty()) {
130+ builder.header("Authorization", snapshot.authHeader)
131+ }
132+ return builder
133+ }
134+
135+ private suspend fun executeRequest(request: Request): ApiResult<String> =
136+ withContext(Dispatchers.IO) {
137+ try {
138+ val callClient = request.tag(OkHttpClient::class.java) ?: client
139+ callClient.newCall(request).execute().use { response ->
140+ val body = response.body?.string() ?: ""
141+ if (response.isSuccessful) {
142+ ApiResult.Success(body)
143+ } else {
144+ ApiResult.Error(
145+ // ifBlank, not ifEmpty: a whitespace-only error body would otherwise
146+ // be shown verbatim as a blank message instead of the status reason.
147+ message = body.ifBlank { response.message },
148+ code = response.code
149+ )
150+ }
151+ }
152+ } catch (e: SocketTimeoutException) {
153+ ApiResult.Error(message = "Connection timed out")
154+ } catch (e: UnknownHostException) {
155+ ApiResult.Error(message = "Unable to resolve host")
156+ } catch (e: ConnectException) {
157+ ApiResult.Error(message = "Connection refused. Is the server running?")
158+ } catch (e: SSLException) {
159+ ApiResult.Error(message = "SSL error: ${e.message}. Check server certificate.")
160+ } catch (e: IOException) {
161+ ApiResult.Error(message = e.message ?: "Network error")
162+ } catch (e: Exception) {
163+ Timber.e(e, "Unexpected error")
164+ ApiResult.Error(message = "Unexpected error: ${e.javaClass.simpleName}")
165+ }
166+ }
167+
168+ private fun <T> parseResponse(description: String, parse: () -> T): ApiResult<T> {
169+ return try {
170+ ApiResult.Success(parse())
171+ } catch (e: SerializationException) {
172+ ApiResult.Error("Invalid response format from server", code = ApiResult.PARSE_ERROR_CODE)
173+ } catch (e: IllegalArgumentException) {
174+ ApiResult.Error("Unexpected response format for $description", code = ApiResult.PARSE_ERROR_CODE)
175+ } catch (e: Exception) {
176+ // kotlinx-serialization's JsonElement accessors (jsonArray/jsonObject/
177+ // jsonPrimitive) throw IllegalStateException — not IllegalArgumentException —
178+ // when an element has the wrong type. Catch any remaining parse-time failure
179+ // so a malformed payload surfaces as an error instead of an uncaught crash.
180+ // parse() is non-suspending, so there's no CancellationException to preserve.
181+ Timber.w(e, "Failed to parse response for $description")
182+ ApiResult.Error("Unexpected response format for $description", code = ApiResult.PARSE_ERROR_CODE)
183+ }
184+ }
185+
186+ /**
187+ * GET /api/v1/about
188+ */
189+ suspend fun getAbout(): ApiResult<AboutInfo> {
190+ val request = buildRequest("/api/v1/about").get().build()
191+ return when (val result = executeRequest(request)) {
192+ is ApiResult.Success -> parseResponse("about info") {
193+ json.decodeFromString<AboutInfo>(result.data)
194+ }
195+ is ApiResult.Error -> result
196+ }
197+ }
198+
199+ /**
200+ * GET /api/v1/liveness - used to test connectivity
201+ */
202+ suspend fun checkLiveness(): ApiResult<String> {
203+ val request = buildRequest("/api/v1/liveness").get().build()
204+ return executeRequest(request)
205+ }
206+
207+ /**
208+ * GET /api/v1/logstream - list all log streams
209+ */
210+ suspend fun listStreams(): ApiResult<List<LogStream>> {
211+ val request = buildRequest("/api/v1/logstream").get().build()
212+ return when (val result = executeRequest(request)) {
213+ is ApiResult.Success -> {
214+ try {
215+ val streams = json.decodeFromString<List<LogStream>>(result.data)
216+ ApiResult.Success(streams)
217+ } catch (e: Exception) {
218+ // Try alternate format: list of objects with "name" key
219+ try {
220+ val parsed = json.parseToJsonElement(result.data)
221+ val arr = (parsed as? JsonArray) ?: throw IllegalArgumentException("Expected JSON array")
222+ val streams = arr.map { element ->
223+ when (element) {
224+ is JsonPrimitive -> LogStream(name = element.content)
225+ is JsonObject -> LogStream(
226+ name = element["name"]?.jsonPrimitive?.content ?: ""
227+ )
228+ else -> LogStream(name = element.toString())
229+ }
230+ }
231+ ApiResult.Success(streams)
232+ } catch (e2: Exception) {
233+ ApiResult.Error("Failed to parse streams: ${e2.message}", code = ApiResult.PARSE_ERROR_CODE)
234+ }
235+ }
236+ }
237+ is ApiResult.Error -> result
238+ }
239+ }
240+
241+ /**
242+ * GET /api/v1/logstream/{stream}/schema
243+ */
244+ suspend fun getStreamSchema(stream: String): ApiResult<StreamSchema> {
245+ val encoded = encodePathSegment(stream)
246+ val request = buildRequest("/api/v1/logstream/$encoded/schema").get().build()
247+ return when (val result = executeRequest(request)) {
248+ is ApiResult.Success -> parseResponse("schema") {
249+ json.decodeFromString<StreamSchema>(result.data)
250+ }
251+ is ApiResult.Error -> result
252+ }
253+ }
254+
255+ /**
256+ * GET /api/v1/logstream/{stream}/stats
257+ */
258+ suspend fun getStreamStats(stream: String): ApiResult<StreamStats> {
259+ val encoded = encodePathSegment(stream)
260+ val request = buildRequest("/api/v1/logstream/$encoded/stats").get().build()
261+ return when (val result = executeRequest(request)) {
262+ is ApiResult.Success -> parseResponse("stats") {
263+ json.decodeFromString<StreamStats>(result.data)
264+ }
265+ is ApiResult.Error -> result
266+ }
267+ }
268+
269+ /**
270+ * GET /api/v1/logstream/{stream}/info
271+ */
272+ suspend fun getStreamInfo(stream: String): ApiResult<JsonObject> {
273+ val encoded = encodePathSegment(stream)
274+ val request = buildRequest("/api/v1/logstream/$encoded/info").get().build()
275+ return when (val result = executeRequest(request)) {
276+ is ApiResult.Success -> parseResponse("stream info") {
277+ val element = json.parseToJsonElement(result.data)
278+ (element as? JsonObject)
279+ ?: throw IllegalArgumentException("Expected JSON object, got ${element::class.simpleName}")
280+ }
281+ is ApiResult.Error -> result
282+ }
283+ }
284+
285+ /**
286+ * GET /api/v1/logstream/{stream}/retention
287+ */
288+ suspend fun getStreamRetention(stream: String): ApiResult<List<RetentionConfig>> {
289+ val encoded = encodePathSegment(stream)
290+ val request = buildRequest("/api/v1/logstream/$encoded/retention").get().build()
291+ return when (val result = executeRequest(request)) {
292+ is ApiResult.Success -> {
293+ try {
294+ ApiResult.Success(json.decodeFromString<List<RetentionConfig>>(result.data))
295+ } catch (_: Exception) {
296+ parseResponse("retention config") {
297+ val single = json.decodeFromString<RetentionConfig>(result.data)
298+ // An empty object ({}) decodes into an all-null config — that means
299+ // "no retention configured" (zero rules), not one empty rule.
300+ if (single.description == null && single.duration == null && single.action == null) {
301+ emptyList()
302+ } else {
303+ listOf(single)
304+ }
305+ }
306+ }
307+ }
308+ is ApiResult.Error -> result
309+ }
310+ }
311+
312+ /**
313+ * POST /api/v1/query - query logs with SQL
314+ */
315+ suspend fun queryLogs(
316+ query: String,
317+ startTime: String,
318+ endTime: String,
319+ ): ApiResult<List<JsonObject>> {
320+ val requestBody = json.encodeToString(
321+ JsonObject.serializer(),
322+ buildJsonObject {
323+ put("query", query)
324+ put("startTime", startTime)
325+ put("endTime", endTime)
326+ }
327+ )
328+
329+ val request = buildRequest("/api/v1/query")
330+ .post(requestBody.toRequestBody("application/json".toMediaType()))
331+ .build()
332+
333+ return when (val result = executeRequest(request)) {
334+ is ApiResult.Success -> parseResponse("query results") {
335+ val element = json.parseToJsonElement(result.data)
336+ val elements = (element as? JsonArray)
337+ ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
338+ elements.mapNotNull { it as? JsonObject }
339+ }
340+ is ApiResult.Error -> result
341+ }
342+ }
343+
344+ /**
345+ * DELETE /api/v1/logstream/{stream}
346+ */
347+ suspend fun deleteStream(stream: String): ApiResult<String> {
348+ val encoded = encodePathSegment(stream)
349+ val request = buildRequest("/api/v1/logstream/$encoded").delete().build()
350+ return executeRequest(request)
351+ }
352+
353+ /**
354+ * GET /api/v1/alerts
355+ */
356+ suspend fun listAlerts(): ApiResult<List<Alert>> {
357+ val request = buildRequest("/api/v1/alerts").get().build()
358+ return when (val result = executeRequest(request)) {
359+ is ApiResult.Success -> parseResponse("alerts") {
360+ val element = json.parseToJsonElement(result.data)
361+ val alertsArray = when (element) {
362+ is JsonArray -> element
363+ // Use a safe cast, not the `.jsonArray` accessor: a present-but-non-array
364+ // value (e.g. {"alerts": null} or an object) makes `.jsonArray` throw, which
365+ // would surface as a parse error instead of degrading to an empty list.
366+ is JsonObject -> (element["alerts"] as? JsonArray) ?: JsonArray(emptyList())
367+ else -> JsonArray(emptyList())
368+ }
369+ alertsArray.mapNotNull { alertElement ->
370+ try {
371+ json.decodeFromJsonElement<Alert>(alertElement)
372+ } catch (e: Exception) {
373+ Timber.w(e, "Skipping malformed alert entry")
374+ null
375+ }
376+ }
377+ }
378+ is ApiResult.Error -> result
379+ }
380+ }
381+
382+ /**
383+ * DELETE /api/v1/alerts/{alertId}
384+ */
385+ suspend fun deleteAlert(alertId: String): ApiResult<String> {
386+ val encoded = encodePathSegment(alertId)
387+ val request = buildRequest("/api/v1/alerts/$encoded").delete().build()
388+ return executeRequest(request)
389+ }
390+
391+ /**
392+ * POST /api/v1/alerts
393+ */
394+ suspend fun createAlert(alert: Alert): ApiResult<Alert> {
395+ val requestBody = json.encodeToString(Alert.serializer(), alert)
396+ .toRequestBody("application/json".toMediaType())
397+ val request = buildRequest("/api/v1/alerts")
398+ .post(requestBody)
399+ .build()
400+ return when (val result = executeRequest(request)) {
401+ is ApiResult.Success -> parseResponse("alert") {
402+ json.decodeFromString<Alert>(result.data)
403+ }
404+ is ApiResult.Error -> result
405+ }
406+ }
407+
408+ /**
409+ * GET /api/v1/user - list users
410+ */
411+ suspend fun listUsers(): ApiResult<List<JsonObject>> {
412+ val request = buildRequest("/api/v1/user").get().build()
413+ return when (val result = executeRequest(request)) {
414+ is ApiResult.Success -> parseResponse("users") {
415+ val element = json.parseToJsonElement(result.data)
416+ val elements = (element as? JsonArray)
417+ ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
418+ elements.mapNotNull { it as? JsonObject }
419+ }
420+ is ApiResult.Error -> result
421+ }
422+ }
423+
424+ /**
425+ * GET /api/v1/filters - list saved filters for the authenticated user
426+ */
427+ suspend fun listFilters(): ApiResult<List<SavedFilter>> {
428+ val request = buildRequest("/api/v1/filters").get().build()
429+ return when (val result = executeRequest(request)) {
430+ is ApiResult.Success -> parseResponse("filters") {
431+ val element = json.parseToJsonElement(result.data)
432+ val array = (element as? JsonArray)
433+ ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
434+ array.mapNotNull { filterElement ->
435+ try {
436+ val filter = json.decodeFromJsonElement<SavedFilter>(filterElement)
437+ // coerceInputValues turns a server-null identity field into "". Skip such
438+ // entries rather than surface a nameless/stream-less ghost filter (and so
439+ // one malformed entry doesn't fail the whole list).
440+ if (filter.filterName.isBlank() || filter.streamName.isBlank()) null else filter
441+ } catch (e: Exception) {
442+ Timber.w(e, "Skipping malformed filter entry")
443+ null
444+ }
445+ }
446+ }
447+ is ApiResult.Error -> result
448+ }
449+ }
450+
451+ /**
452+ * GET /api/v1/filters/{filterId} - get a specific saved filter
453+ */
454+ suspend fun getFilter(filterId: String): ApiResult<SavedFilter> {
455+ val encoded = encodePathSegment(filterId)
456+ val request = buildRequest("/api/v1/filters/$encoded").get().build()
457+ return when (val result = executeRequest(request)) {
458+ is ApiResult.Success -> parseResponse("filter") {
459+ json.decodeFromString<SavedFilter>(result.data)
460+ }
461+ is ApiResult.Error -> result
462+ }
463+ }
464+
465+ /**
466+ * POST /api/v1/filters - create a new saved filter
467+ */
468+ suspend fun createFilter(filter: SavedFilter): ApiResult<SavedFilter> {
469+ val requestBody = json.encodeToString(SavedFilter.serializer(), filter)
470+ .toRequestBody("application/json".toMediaType())
471+ val request = buildRequest("/api/v1/filters")
472+ .post(requestBody)
473+ .build()
474+ return when (val result = executeRequest(request)) {
475+ is ApiResult.Success -> parseResponse("filter") {
476+ json.decodeFromString<SavedFilter>(result.data)
477+ }
478+ is ApiResult.Error -> result
479+ }
480+ }
481+
482+ /**
483+ * PUT /api/v1/filters/{filterId} - update an existing saved filter
484+ */
485+ suspend fun updateFilter(filterId: String, filter: SavedFilter): ApiResult<SavedFilter> {
486+ val encoded = encodePathSegment(filterId)
487+ val requestBody = json.encodeToString(SavedFilter.serializer(), filter)
488+ .toRequestBody("application/json".toMediaType())
489+ val request = buildRequest("/api/v1/filters/$encoded")
490+ .put(requestBody)
491+ .build()
492+ return when (val result = executeRequest(request)) {
493+ is ApiResult.Success -> parseResponse("filter") {
494+ json.decodeFromString<SavedFilter>(result.data)
495+ }
496+ is ApiResult.Error -> result
497+ }
498+ }
499+
500+ /**
501+ * DELETE /api/v1/filters/{filterId} - delete a saved filter
502+ */
503+ suspend fun deleteFilter(filterId: String): ApiResult<String> {
504+ val encoded = encodePathSegment(filterId)
505+ val request = buildRequest("/api/v1/filters/$encoded").delete().build()
506+ return executeRequest(request)
507+ }
508+}
app/src/main/java/com/parseable/android/data/local/FavoriteStream.ktnew file+11
@@ -0,0 +1,11 @@
1+package com.parseable.android.data.local
2+
3+import androidx.room.Entity
4+import androidx.room.PrimaryKey
5+
6+@Entity(tableName = "favorite_streams")
7+data class FavoriteStream(
8+ @PrimaryKey
9+ val streamName: String,
10+ val addedAt: Long = System.currentTimeMillis(),
11+)
app/src/main/java/com/parseable/android/data/local/FavoriteStreamDao.ktnew file+39
@@ -0,0 +1,39 @@
1+package com.parseable.android.data.local
2+
3+import androidx.room.Dao
4+import androidx.room.Delete
5+import androidx.room.Insert
6+import androidx.room.OnConflictStrategy
7+import androidx.room.Query
8+import kotlinx.coroutines.flow.Flow
9+
10+@Dao
11+interface FavoriteStreamDao {
12+ @Query("SELECT * FROM favorite_streams ORDER BY addedAt DESC")
13+ fun getAll(): Flow<List<FavoriteStream>>
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")
18+ fun getAllNames(): Flow<List<String>>
19+
20+ // IGNORE (not REPLACE): re-favoriting an existing stream must keep its original
21+ // addedAt so the getAll() "ORDER BY addedAt DESC" ordering stays stable.
22+ @Insert(onConflict = OnConflictStrategy.IGNORE)
23+ suspend fun insert(favorite: FavoriteStream)
24+
25+ @Delete
26+ suspend fun delete(favorite: FavoriteStream)
27+
28+ @Query("DELETE FROM favorite_streams WHERE streamName = :name")
29+ suspend fun deleteByName(name: String)
30+
31+ @Query("SELECT EXISTS(SELECT 1 FROM favorite_streams WHERE streamName = :name)")
32+ fun isFavorite(name: String): Flow<Boolean>
33+
34+ // One-shot suspend variant. toggleFavorite() must decide insert-vs-delete from the
35+ // committed DB state rather than the async-mirrored UI state, so rapid double-taps
36+ // can't both observe a stale value and take the same branch.
37+ @Query("SELECT EXISTS(SELECT 1 FROM favorite_streams WHERE streamName = :name)")
38+ suspend fun isFavoriteNow(name: String): Boolean
39+}
app/src/main/java/com/parseable/android/data/local/ParseableDatabase.ktnew file+14
@@ -0,0 +1,14 @@
1+package com.parseable.android.data.local
2+
3+import androidx.room.Database
4+import androidx.room.RoomDatabase
5+
6+@Database(
7+ entities = [FavoriteStream::class, SavedServer::class],
8+ version = 3,
9+ exportSchema = true,
10+)
11+abstract class ParseableDatabase : RoomDatabase() {
12+ abstract fun favoriteStreamDao(): FavoriteStreamDao
13+ abstract fun savedServerDao(): SavedServerDao
14+}
app/src/main/java/com/parseable/android/data/local/SavedServer.ktnew file+23
@@ -0,0 +1,23 @@
1+package com.parseable.android.data.local
2+
3+import androidx.room.Entity
4+import androidx.room.Index
5+import androidx.room.PrimaryKey
6+
7+@Entity(
8+ tableName = "saved_servers",
9+ // One logical server per (url, username). The dedupe in SettingsRepository.saveServer
10+ // relies on this invariant; enforcing it in the schema means REPLACE actually upserts on
11+ // the natural key instead of silently inserting a duplicate row if the read-back misses.
12+ indices = [Index(value = ["serverUrl", "username"], unique = true)],
13+)
14+data class SavedServer(
15+ @PrimaryKey(autoGenerate = true)
16+ val id: Long = 0,
17+ val serverUrl: String,
18+ val username: String,
19+ val useTls: Boolean = true,
20+ /** Key into EncryptedSharedPreferences for this server's password. */
21+ val passwordKey: String,
22+ val addedAt: Long = System.currentTimeMillis(),
23+)
app/src/main/java/com/parseable/android/data/local/SavedServerDao.ktnew file+25
@@ -0,0 +1,25 @@
1+package com.parseable.android.data.local
2+
3+import androidx.room.Dao
4+import androidx.room.Insert
5+import androidx.room.OnConflictStrategy
6+import androidx.room.Query
7+import kotlinx.coroutines.flow.Flow
8+
9+@Dao
10+interface SavedServerDao {
11+ @Query("SELECT * FROM saved_servers ORDER BY addedAt DESC")
12+ fun getAll(): Flow<List<SavedServer>>
13+
14+ @Query("SELECT * FROM saved_servers WHERE id = :id")
15+ suspend fun getById(id: Long): SavedServer?
16+
17+ @Query("SELECT * FROM saved_servers WHERE serverUrl = :url AND username = :username LIMIT 1")
18+ suspend fun findByUrlAndUsername(url: String, username: String): SavedServer?
19+
20+ @Insert(onConflict = OnConflictStrategy.REPLACE)
21+ suspend fun insert(server: SavedServer): Long
22+
23+ @Query("DELETE FROM saved_servers WHERE id = :id")
24+ suspend fun deleteById(id: Long)
25+}
app/src/main/java/com/parseable/android/data/model/Models.ktnew file+253
@@ -0,0 +1,253 @@
1+package com.parseable.android.data.model
2+
3+import kotlinx.serialization.KSerializer
4+import kotlinx.serialization.SerialName
5+import 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
12+import kotlinx.serialization.json.JsonElement
13+import kotlinx.serialization.json.JsonNull
14+import 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+
42+/**
43+ * Server connection configuration stored in DataStore.
44+ */
45+data class ServerConfig(
46+ val serverUrl: String,
47+ val username: String,
48+ val password: String,
49+ val useTls: Boolean = true,
50+)
51+
52+/**
53+ * Represents a log stream entry returned by GET /api/v1/logstream
54+ */
55+@Serializable
56+data class LogStream(
57+ val name: String,
58+)
59+
60+/**
61+ * Query request body sent to POST /api/v1/query
62+ */
63+@Serializable
64+data class QueryRequest(
65+ val query: String,
66+ val startTime: String,
67+ val endTime: String,
68+)
69+
70+/**
71+ * Stream stats from GET /api/v1/logstream/{name}/stats
72+ */
73+@Serializable
74+data class StreamStats(
75+ val ingestion: IngestionStats? = null,
76+ val storage: StorageStats? = null,
77+ val stream: String? = null,
78+ val time: String? = null,
79+)
80+
81+@Serializable
82+data class IngestionStats(
83+ val count: Long? = null,
84+ @Serializable(with = FlexibleStringSerializer::class)
85+ val size: String? = null,
86+ val format: String? = null,
87+ @SerialName("lifetime_count")
88+ val lifetimeCount: Long? = null,
89+ @SerialName("lifetime_size")
90+ @Serializable(with = FlexibleStringSerializer::class)
91+ val lifetimeSize: String? = null,
92+ @SerialName("deleted_count")
93+ val deletedCount: Long? = null,
94+ @SerialName("deleted_size")
95+ @Serializable(with = FlexibleStringSerializer::class)
96+ val deletedSize: String? = null,
97+)
98+
99+@Serializable
100+data class StorageStats(
101+ @Serializable(with = FlexibleStringSerializer::class)
102+ val size: String? = null,
103+ val format: String? = null,
104+ @SerialName("lifetime_size")
105+ @Serializable(with = FlexibleStringSerializer::class)
106+ val lifetimeSize: String? = null,
107+ @SerialName("deleted_size")
108+ @Serializable(with = FlexibleStringSerializer::class)
109+ val deletedSize: String? = null,
110+)
111+
112+/**
113+ * Schema field info from GET /api/v1/logstream/{name}/schema
114+ */
115+@Serializable
116+data class StreamSchema(
117+ val fields: List<SchemaField> = emptyList(),
118+)
119+
120+@Serializable
121+data class SchemaField(
122+ val name: String,
123+ @SerialName("data_type")
124+ val dataType: JsonElement? = null,
125+ val nullable: Boolean? = null,
126+ val dict_id: Long? = null,
127+ val dict_is_ordered: Boolean? = null,
128+ val metadata: JsonObject? = null,
129+)
130+
131+/**
132+ * Retention config from GET /api/v1/logstream/{name}/retention
133+ */
134+@Serializable
135+data class RetentionConfig(
136+ val description: String? = null,
137+ val duration: String? = null,
138+ val action: String? = null,
139+)
140+
141+/**
142+ * About info from GET /api/v1/about
143+ */
144+@Serializable
145+data class AboutInfo(
146+ val version: String? = null,
147+ val commit: String? = null,
148+ @SerialName("deploymentId")
149+ val deploymentId: String? = null,
150+ val license: JsonObject? = null,
151+ val mode: String? = null,
152+ val staging: String? = null,
153+ val store: JsonObject? = null,
154+ @SerialName("grpcPort")
155+ val grpcPort: Int? = null,
156+ val analytics: JsonObject? = null,
157+ @SerialName("queryEngine")
158+ val queryEngine: String? = null,
159+)
160+
161+/**
162+ * Alert summary returned by GET /api/v1/alerts.
163+ *
164+ * The global alerts endpoint returns summary objects with fields like `title`, `state`,
165+ * `severity`, and `datasets`. The per-stream endpoint uses `name` and `alerts[]` nesting.
166+ * Both shapes are supported via optional fields.
167+ */
168+@Serializable
169+data class Alert(
170+ val id: String? = null,
171+ val version: String? = null,
172+ // Per-stream alert format uses "name"; global summary uses "title"
173+ val name: String? = null,
174+ val title: String? = null,
175+ val message: String? = null,
176+ val rule: JsonObject? = null,
177+ val targets: List<JsonObject> = emptyList(),
178+ // Per-stream alert format
179+ val stream: String? = null,
180+ val enabled: Boolean? = null,
181+ // Global summary fields
182+ val state: String? = null,
183+ val severity: String? = null,
184+ @SerialName("alertType")
185+ val alertType: String? = null,
186+ @SerialName("notificationState")
187+ val notificationState: String? = null,
188+ val created: String? = null,
189+ @SerialName("lastTriggeredAt")
190+ val lastTriggeredAt: String? = null,
191+ val tags: List<String> = emptyList(),
192+ val datasets: List<String> = emptyList(),
193+) {
194+ /** Best-effort display name from either response format. */
195+ val displayName: String
196+ get() = title?.takeIf { it.isNotBlank() }
197+ ?: name?.takeIf { it.isNotBlank() }
198+ ?: "Unnamed Alert"
199+
200+ /** Whether the alert is active, derived from either response format. */
201+ val isEnabled: Boolean get() = when {
202+ state != null -> !state.equals("Disabled", ignoreCase = true)
203+ enabled != null -> enabled
204+ else -> true
205+ }
206+
207+ /** Stream(s) this alert is configured for. */
208+ val streamDisplay: String? get() = when {
209+ datasets.isNotEmpty() -> datasets.joinToString(", ")
210+ stream != null -> stream
211+ else -> null
212+ }
213+}
214+
215+/**
216+ * Result wrapper for API calls
217+ */
218+sealed class ApiResult<out T> {
219+ data class Success<T>(val data: T) : ApiResult<T>()
220+ data class Error(val message: String, val code: Int = 0) : ApiResult<Nothing>() {
221+ val isUnauthorized: Boolean get() = code == 401
222+ val isNotFound: Boolean get() = code == 404
223+ val isServerError: Boolean get() = code in 500..599
224+ // Only genuine transport failures use code 0. A successful HTTP response whose body
225+ // failed to parse carries PARSE_ERROR_CODE so it isn't mislabeled as a network error.
226+ val isNetworkError: Boolean get() = code == 0
227+
228+ val userMessage: String get() = when {
229+ code == 401 -> "Session expired. Please log in again."
230+ code == 403 -> "Permission denied."
231+ code == 404 -> "Resource not found. It may have been deleted."
232+ code == 429 -> "Too many requests. Please wait and try again."
233+ code in 500..599 -> "Server error ($code). Please try again later."
234+ code == 0 -> when {
235+ message.contains("timeout", ignoreCase = true) -> "Connection timed out. Check your network."
236+ message.contains("Unable to resolve host", ignoreCase = true) -> "No internet connection."
237+ message.contains("Connection refused", ignoreCase = true) -> "Server is unreachable."
238+ else -> "Network error: $message"
239+ }
240+ else -> message
241+ }
242+ }
243+
244+ companion object {
245+ /**
246+ * Code used for client-side response-parsing failures: the HTTP request succeeded
247+ * but the body was malformed/unexpected. Distinct from 0 (network/transport failure)
248+ * so [Error.isNetworkError] and [Error.userMessage] don't show a misleading
249+ * "Network error" for what is actually a server-data problem.
250+ */
251+ const val PARSE_ERROR_CODE = -1
252+ }
253+}
app/src/main/java/com/parseable/android/data/model/SavedFilter.ktnew file+61
@@ -0,0 +1,61 @@
1+package com.parseable.android.data.model
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+
6+/**
7+ * Saved filter synced with Parseable server via /api/v1/filters.
8+ */
9+@Serializable
10+data class SavedFilter(
11+ @SerialName("filter_id")
12+ val filterId: String? = null,
13+ @SerialName("filter_name")
14+ val filterName: String = "",
15+ @SerialName("stream_name")
16+ val streamName: String = "",
17+ @SerialName("user_id")
18+ val userId: String? = null,
19+ val version: String? = null,
20+ val query: SavedFilterQuery = SavedFilterQuery(),
21+ @SerialName("time_filter")
22+ val timeFilter: SavedFilterTimeFilter? = null,
23+)
24+
25+@Serializable
26+data class SavedFilterQuery(
27+ @SerialName("filter_type")
28+ val filterType: String = "filter",
29+ @SerialName("filter_query")
30+ val filterQuery: String? = null,
31+ @SerialName("filter_builder")
32+ val filterBuilder: FilterBuilder? = null,
33+)
34+
35+@Serializable
36+data class FilterBuilder(
37+ val id: String = "root",
38+ val combinator: String = "and",
39+ val rules: List<FilterRuleGroup> = emptyList(),
40+)
41+
42+@Serializable
43+data class FilterRuleGroup(
44+ val id: String = "",
45+ val combinator: String = "and",
46+ val rules: List<FilterRule> = emptyList(),
47+)
48+
49+@Serializable
50+data class FilterRule(
51+ val id: String = "",
52+ val field: String = "",
53+ val value: String = "",
54+ val operator: String = "=",
55+)
56+
57+@Serializable
58+data class SavedFilterTimeFilter(
59+ val from: String? = null,
60+ val to: String? = null,
61+)
app/src/main/java/com/parseable/android/data/repository/ParseableRepository.ktnew file+190
@@ -0,0 +1,190 @@
1+package com.parseable.android.data.repository
2+
3+import com.parseable.android.data.api.ParseableApiClient
4+import com.parseable.android.data.escapeIdentifier
5+import com.parseable.android.data.model.*
6+import kotlinx.coroutines.channels.Channel
7+import kotlinx.coroutines.flow.receiveAsFlow
8+import kotlinx.serialization.json.JsonObject
9+import java.util.concurrent.ConcurrentHashMap
10+import javax.inject.Inject
11+import javax.inject.Singleton
12+
13+private class CacheEntry<T>(
14+ val data: T,
15+ val timestamp: Long = System.currentTimeMillis(),
16+) {
17+ fun isValid(ttlMs: Long): Boolean =
18+ System.currentTimeMillis() - timestamp < ttlMs
19+}
20+
21+@Singleton
22+class ParseableRepository @Inject constructor(
23+ private val apiClient: ParseableApiClient,
24+) {
25+ companion object {
26+ private const val STREAMS_TTL_MS = 30_000L // 30s
27+ private const val SCHEMA_TTL_MS = 120_000L // 2min
28+ private const val STATS_TTL_MS = 30_000L // 30s
29+ private const val ABOUT_TTL_MS = 300_000L // 5min
30+ }
31+
32+ private val _authErrorChannel = Channel<Unit>(Channel.CONFLATED)
33+ val authErrors = _authErrorChannel.receiveAsFlow()
34+
35+ private fun <T> checkAuth(result: ApiResult<T>): ApiResult<T> {
36+ if (result is ApiResult.Error && result.isUnauthorized) {
37+ clearCredentials()
38+ _authErrorChannel.trySend(Unit)
39+ }
40+ return result
41+ }
42+
43+ @Volatile private var streamsCache: CacheEntry<List<LogStream>>? = null
44+ @Volatile private var aboutCache: CacheEntry<AboutInfo>? = null
45+ private val schemaCache = ConcurrentHashMap<String, CacheEntry<StreamSchema>>()
46+ private val statsCache = ConcurrentHashMap<String, CacheEntry<StreamStats>>()
47+
48+ fun configure(config: ServerConfig) {
49+ apiClient.configure(config)
50+ invalidateAll()
51+ }
52+
53+ val isConfigured: Boolean get() = apiClient.isConfigured
54+
55+ fun invalidateAll() {
56+ streamsCache = null
57+ aboutCache = null
58+ schemaCache.clear()
59+ statsCache.clear()
60+ }
61+
62+ fun clearCredentials() {
63+ invalidateAll()
64+ apiClient.clearConfig()
65+ }
66+
67+ suspend fun testConnection(): ApiResult<String> = apiClient.checkLiveness()
68+
69+ /**
70+ * Verify this is a Parseable server and credentials are valid.
71+ * Does NOT trigger [checkAuth] so the global auth-error handler won't fire during login.
72+ */
73+ suspend fun verifyServer(): ApiResult<AboutInfo> {
74+ return when (val aboutResult = apiClient.getAbout()) {
75+ is ApiResult.Success -> {
76+ // GET /about is not auth-gated on Parseable, so a success here only proves
77+ // the server is reachable and is Parseable — NOT that the credentials are
78+ // valid. Probe an auth-required endpoint (logstream listing, which the app
79+ // needs anyway) so wrong credentials are rejected at login instead of being
80+ // persisted and only failing on the first real request. Call apiClient
81+ // directly to avoid checkAuth firing the global auth-error handler mid-login.
82+ when (val authProbe = apiClient.listStreams()) {
83+ is ApiResult.Success -> {
84+ aboutCache = CacheEntry(aboutResult.data)
85+ aboutResult
86+ }
87+ is ApiResult.Error -> authProbe
88+ }
89+ }
90+ is ApiResult.Error -> aboutResult
91+ }
92+ }
93+
94+ suspend fun getAbout(): ApiResult<AboutInfo> {
95+ aboutCache?.let { if (it.isValid(ABOUT_TTL_MS)) return ApiResult.Success(it.data) }
96+ return checkAuth(apiClient.getAbout()).also { result ->
97+ if (result is ApiResult.Success) {
98+ aboutCache = CacheEntry(result.data)
99+ }
100+ }
101+ }
102+
103+ suspend fun listStreams(forceRefresh: Boolean = false): ApiResult<List<LogStream>> {
104+ if (!forceRefresh) {
105+ streamsCache?.let { if (it.isValid(STREAMS_TTL_MS)) return ApiResult.Success(it.data) }
106+ }
107+ return checkAuth(apiClient.listStreams()).also { result ->
108+ if (result is ApiResult.Success) {
109+ streamsCache = CacheEntry(result.data)
110+ }
111+ }
112+ }
113+
114+ suspend fun getStreamSchema(stream: String, forceRefresh: Boolean = false): ApiResult<StreamSchema> {
115+ if (!forceRefresh) {
116+ schemaCache[stream]?.let { if (it.isValid(SCHEMA_TTL_MS)) return ApiResult.Success(it.data) }
117+ }
118+ return checkAuth(apiClient.getStreamSchema(stream)).also { result ->
119+ if (result is ApiResult.Success) {
120+ schemaCache[stream] = CacheEntry(result.data)
121+ }
122+ }
123+ }
124+
125+ suspend fun getStreamStats(stream: String, forceRefresh: Boolean = false): ApiResult<StreamStats> {
126+ if (!forceRefresh) {
127+ statsCache[stream]?.let { if (it.isValid(STATS_TTL_MS)) return ApiResult.Success(it.data) }
128+ }
129+ return checkAuth(apiClient.getStreamStats(stream)).also { result ->
130+ if (result is ApiResult.Success) {
131+ statsCache[stream] = CacheEntry(result.data)
132+ }
133+ }
134+ }
135+
136+ suspend fun getStreamInfo(stream: String): ApiResult<JsonObject> =
137+ checkAuth(apiClient.getStreamInfo(stream))
138+
139+ suspend fun getStreamRetention(stream: String): ApiResult<List<RetentionConfig>> =
140+ checkAuth(apiClient.getStreamRetention(stream))
141+
142+ suspend fun queryLogs(
143+ stream: String,
144+ startTime: String,
145+ endTime: String,
146+ filterSql: String = "",
147+ limit: Int = 1000,
148+ ): ApiResult<List<JsonObject>> {
149+ val whereClause = if (filterSql.isNotBlank()) " WHERE $filterSql" else ""
150+ val safeName = escapeIdentifier(stream)
151+ val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT $limit"
152+ return checkAuth(apiClient.queryLogs(sql, startTime, endTime))
153+ }
154+
155+ suspend fun queryLogsRaw(
156+ sql: String,
157+ startTime: String,
158+ endTime: String,
159+ ): ApiResult<List<JsonObject>> = checkAuth(apiClient.queryLogs(sql, startTime, endTime))
160+
161+ suspend fun deleteStream(stream: String): ApiResult<String> {
162+ return checkAuth(apiClient.deleteStream(stream)).also { result ->
163+ if (result is ApiResult.Success) invalidateAll()
164+ }
165+ }
166+
167+ suspend fun listAlerts(): ApiResult<List<Alert>> = checkAuth(apiClient.listAlerts())
168+
169+ suspend fun deleteAlert(alertId: String): ApiResult<String> =
170+ checkAuth(apiClient.deleteAlert(alertId))
171+
172+ suspend fun createAlert(alert: Alert): ApiResult<Alert> =
173+ checkAuth(apiClient.createAlert(alert))
174+
175+ suspend fun listUsers(): ApiResult<List<JsonObject>> = checkAuth(apiClient.listUsers())
176+
177+ suspend fun listFilters(): ApiResult<List<SavedFilter>> = checkAuth(apiClient.listFilters())
178+
179+ suspend fun getFilter(filterId: String): ApiResult<SavedFilter> =
180+ checkAuth(apiClient.getFilter(filterId))
181+
182+ suspend fun createFilter(filter: SavedFilter): ApiResult<SavedFilter> =
183+ checkAuth(apiClient.createFilter(filter))
184+
185+ suspend fun updateFilter(filterId: String, filter: SavedFilter): ApiResult<SavedFilter> =
186+ checkAuth(apiClient.updateFilter(filterId, filter))
187+
188+ suspend fun deleteFilter(filterId: String): ApiResult<String> =
189+ checkAuth(apiClient.deleteFilter(filterId))
190+}
app/src/main/java/com/parseable/android/data/repository/SettingsRepository.ktnew file+351
@@ -0,0 +1,351 @@
1+package com.parseable.android.data.repository
2+
3+import android.content.Context
4+import android.content.SharedPreferences
5+import timber.log.Timber
6+import androidx.datastore.core.DataStore
7+import androidx.datastore.preferences.core.*
8+import androidx.datastore.preferences.preferencesDataStore
9+import androidx.security.crypto.EncryptedSharedPreferences
10+import androidx.security.crypto.MasterKey
11+import com.parseable.android.data.local.SavedServer
12+import com.parseable.android.data.local.SavedServerDao
13+import com.parseable.android.data.model.ServerConfig
14+import dagger.hilt.android.qualifiers.ApplicationContext
15+import kotlinx.coroutines.Dispatchers
16+import kotlinx.coroutines.flow.Flow
17+import kotlinx.coroutines.flow.catch
18+import kotlinx.coroutines.flow.first
19+import kotlinx.coroutines.flow.flowOn
20+import kotlinx.coroutines.flow.map
21+import kotlinx.coroutines.sync.Mutex
22+import kotlinx.coroutines.sync.withLock
23+import kotlinx.coroutines.withContext
24+import javax.inject.Inject
25+import javax.inject.Singleton
26+
27+private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "parseable_settings")
28+
29+@Singleton
30+class SettingsRepository @Inject constructor(
31+ @ApplicationContext private val context: Context,
32+ private val savedServerDao: SavedServerDao,
33+) {
34+ private val serverUrlKey = stringPreferencesKey("server_url")
35+ private val usernameKey = stringPreferencesKey("username")
36+ private val useTlsKey = booleanPreferencesKey("use_tls")
37+ private val activeServerIdKey = longPreferencesKey("active_server_id")
38+
39+ /** Guards concurrent reads/writes across DataStore and EncryptedSharedPreferences. */
40+ private val configMutex = Mutex()
41+
42+ private val encryptedPrefs: SharedPreferences by lazy {
43+ try {
44+ createEncryptedPrefs()
45+ } catch (e: Exception) {
46+ // Keystore corruption (common after backup/restore). Delete and recreate.
47+ Timber.e(e, "EncryptedSharedPreferences corrupted, resetting")
48+ try {
49+ val prefsFile = java.io.File(context.filesDir.parent, "shared_prefs/parseable_secure_prefs.xml")
50+ if (prefsFile.exists()) prefsFile.delete()
51+ } catch (_: Exception) { /* best-effort cleanup */ }
52+ createEncryptedPrefs()
53+ }
54+ }
55+
56+ private fun createEncryptedPrefs(): SharedPreferences {
57+ val masterKey = MasterKey.Builder(context)
58+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
59+ .build()
60+ return EncryptedSharedPreferences.create(
61+ context,
62+ "parseable_secure_prefs",
63+ masterKey,
64+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
65+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
66+ )
67+ }
68+
69+ // ── Active server config (current connection) ──────────────────────
70+
71+ val serverConfig: Flow<ServerConfig?> = context.dataStore.data
72+ .catch { e ->
73+ Timber.e(e, "Failed to read DataStore")
74+ emit(emptyPreferences())
75+ }
76+ .map {
77+ // Read url/username/tls AND the encrypted password together under configMutex,
78+ // re-reading a fresh DataStore snapshot inside the lock. Writers (switch/save/clear)
79+ // hold this mutex across their whole multi-store edit, so acquiring it here guarantees
80+ // the url/username returned always belong to the same server as the password. A
81+ // lock-free read could otherwise pair one server's username with another server's
82+ // password if a concurrent switch's writes landed between the DataStore read and the
83+ // encrypted-password read. All callers consume this via .first(), so serializing the
84+ // read behind the (brief) write critical sections costs at most one extra snapshot read.
85+ configMutex.withLock {
86+ val prefs = context.dataStore.data.first()
87+ val url = prefs[serverUrlKey] ?: return@withLock null
88+ val user = prefs[usernameKey] ?: return@withLock null
89+ val pass = try {
90+ encryptedPrefs.getString("password", null)
91+ } catch (e: Exception) {
92+ Timber.e(e, "Failed to read encrypted password")
93+ null
94+ } ?: return@withLock null
95+ ServerConfig(
96+ serverUrl = url,
97+ username = user,
98+ password = pass,
99+ useTls = prefs[useTlsKey] ?: true,
100+ )
101+ }
102+ }
103+ .flowOn(Dispatchers.IO)
104+
105+ val activeServerId: Flow<Long?> = context.dataStore.data
106+ .catch { e ->
107+ Timber.e(e, "Failed to read DataStore")
108+ emit(emptyPreferences())
109+ }
110+ .map { prefs -> prefs[activeServerIdKey] }
111+ .flowOn(Dispatchers.IO)
112+
113+ suspend fun getSavedPassword(): ServerConfig? = serverConfig.first()
114+
115+ /**
116+ * Persist the server config. Returns true only if the credentials were durably saved.
117+ * Callers (e.g. login) must not report success when this returns false — otherwise the
118+ * session would appear logged in but vanish on the next launch.
119+ */
120+ suspend fun saveServerConfig(config: ServerConfig): Boolean {
121+ return configMutex.withLock {
122+ try {
123+ // Persist the password (encrypted) first and confirm it committed. If the
124+ // Keystore-backed write fails we abort before touching DataStore, so we never
125+ // leave a new URL/username paired with a stale password.
126+ val passwordSaved = withContext(Dispatchers.IO) {
127+ encryptedPrefs.edit().putString("password", config.password).commit()
128+ }
129+ if (!passwordSaved) {
130+ Timber.e("Failed to persist credentials securely; aborting config save")
131+ return@withLock false
132+ }
133+ context.dataStore.edit { prefs ->
134+ prefs[serverUrlKey] = config.serverUrl
135+ prefs[usernameKey] = config.username
136+ prefs[useTlsKey] = config.useTls
137+ }
138+ true
139+ } catch (e: Exception) {
140+ Timber.e(e, "Failed to save server config")
141+ false
142+ }
143+ }
144+ }
145+
146+ suspend fun clearConfig() {
147+ configMutex.withLock {
148+ try {
149+ context.dataStore.edit { prefs ->
150+ prefs.remove(serverUrlKey)
151+ prefs.remove(usernameKey)
152+ prefs.remove(useTlsKey)
153+ prefs.remove(activeServerIdKey)
154+ }
155+ withContext(Dispatchers.IO) {
156+ // commit() (not apply()) so the cleared password is durably gone before we
157+ // return — a queued async clear could survive into the next session.
158+ encryptedPrefs.edit().remove("password").commit()
159+ }
160+ } catch (e: Exception) {
161+ Timber.e(e, "Failed to clear config")
162+ }
163+ }
164+ }
165+
166+ // ── Multi-server management ────────────────────────────────────────
167+
168+ val savedServers: Flow<List<SavedServer>> = savedServerDao.getAll()
169+
170+ /**
171+ * Persist the current server connection as a saved server entry.
172+ * If a server with the same URL and username already exists, updates it.
173+ * Returns the server ID, or null if the credentials could not be persisted.
174+ */
175+ suspend fun saveServer(config: ServerConfig): Long? {
176+ return configMutex.withLock {
177+ val existing = savedServerDao.findByUrlAndUsername(config.serverUrl, config.username)
178+ // Use a UUID, not System.currentTimeMillis(): two distinct new servers saved
179+ // within the same millisecond would otherwise share a key and the second
180+ // would overwrite the first's encrypted password.
181+ val passwordKey = existing?.passwordKey ?: "server_pwd_${java.util.UUID.randomUUID()}"
182+ val server = SavedServer(
183+ id = existing?.id ?: 0,
184+ serverUrl = config.serverUrl,
185+ username = config.username,
186+ useTls = config.useTls,
187+ passwordKey = passwordKey,
188+ addedAt = existing?.addedAt ?: System.currentTimeMillis(),
189+ )
190+ // Persist the password durably (commit + check) BEFORE inserting the row and
191+ // marking it active. With apply() a crash could leave a saved-server row and an
192+ // active-server-id pointing at a passwordKey that was never flushed, making the
193+ // server unusable (switchToServer would return null).
194+ val passwordSaved = withContext(Dispatchers.IO) {
195+ encryptedPrefs.edit().putString(passwordKey, config.password).commit()
196+ }
197+ if (!passwordSaved) {
198+ // Return null rather than existing?.id: reusing the existing id would look like a
199+ // successful update to the caller, while the new password was never written and a
200+ // later switchToServer would silently load the stale credentials.
201+ Timber.e("Failed to persist saved-server credentials securely; aborting saveServer")
202+ return@withLock null
203+ }
204+ val id = savedServerDao.insert(server)
205+
206+ // Update active server ID
207+ context.dataStore.edit { prefs ->
208+ prefs[activeServerIdKey] = id
209+ }
210+ id
211+ }
212+ }
213+
214+ /**
215+ * Switch to a previously saved server. Loads its credentials into the
216+ * active config (DataStore + encrypted password).
217+ */
218+ suspend fun switchToServer(serverId: Long): ServerConfig? {
219+ return configMutex.withLock {
220+ val server = savedServerDao.getById(serverId) ?: return@withLock null
221+ val password = withContext(Dispatchers.IO) {
222+ try {
223+ encryptedPrefs.getString(server.passwordKey, null)
224+ } catch (e: Exception) {
225+ Timber.e(e, "Failed to read server password")
226+ null
227+ }
228+ } ?: return@withLock null
229+
230+ val config = ServerConfig(
231+ serverUrl = server.serverUrl,
232+ username = server.username,
233+ password = password,
234+ useTls = server.useTls,
235+ )
236+
237+ // Persist the active password durably (commit + check) BEFORE updating the active
238+ // DataStore config. With apply() a crash could leave the active URL/username pointing
239+ // at a stale/missing password — the exact inconsistency saveServerConfig avoids.
240+ val passwordSaved = withContext(Dispatchers.IO) {
241+ encryptedPrefs.edit().putString("password", config.password).commit()
242+ }
243+ if (!passwordSaved) {
244+ Timber.e("Failed to persist credentials securely; aborting server switch")
245+ return@withLock null
246+ }
247+ // Write to active config
248+ context.dataStore.edit { prefs ->
249+ prefs[serverUrlKey] = config.serverUrl
250+ prefs[usernameKey] = config.username
251+ prefs[useTlsKey] = config.useTls
252+ prefs[activeServerIdKey] = serverId
253+ }
254+ config
255+ }
256+ }
257+
258+ /**
259+ * Remove a saved server and its stored password.
260+ * If the deleted server was the active one, the active server ID is cleared.
261+ * Returns true if the deleted server was the active connection (so callers can clear any
262+ * now-stale displayed server info), false otherwise.
263+ */
264+ suspend fun deleteServer(serverId: Long): Boolean {
265+ return configMutex.withLock {
266+ val server = savedServerDao.getById(serverId) ?: return@withLock false
267+ withContext(Dispatchers.IO) {
268+ // commit() so the password is durably removed before we drop the row that
269+ // references its key — otherwise a crash could orphan the encrypted entry.
270+ encryptedPrefs.edit().remove(server.passwordKey).commit()
271+ }
272+ savedServerDao.deleteById(serverId)
273+
274+ // Clear active server reference if it pointed at the deleted server. The active
275+ // connection keys (url/username/tls) and the active encrypted password live
276+ // separately from the per-server row, so they must be torn down too — otherwise
277+ // serverConfig still emits a valid config and the app stays logged in to the
278+ // server we just deleted (and restores that session on next launch).
279+ val currentActiveId = context.dataStore.data.first()[activeServerIdKey]
280+ val wasActive = currentActiveId == serverId
281+ if (wasActive) {
282+ context.dataStore.edit { prefs ->
283+ prefs.remove(activeServerIdKey)
284+ prefs.remove(serverUrlKey)
285+ prefs.remove(usernameKey)
286+ prefs.remove(useTlsKey)
287+ }
288+ withContext(Dispatchers.IO) {
289+ encryptedPrefs.edit().remove("password").commit()
290+ }
291+ }
292+ wasActive
293+ }
294+ }
295+
296+ /**
297+ * Repair a dangling active_server_id left by a row-collapsing migration. MIGRATION_2_3 deletes
298+ * duplicate saved_servers rows as raw SQL, but the active-server pointer lives in DataStore and
299+ * can't be touched there — so after that upgrade it may reference one of the deleted duplicates.
300+ * Re-link it to the surviving row for the active (url, username) when one exists; otherwise just
301+ * clear the stale pointer. The active connection (url/username/password) is left intact, so a
302+ * working session is never logged out — only the saved-servers radio selection is corrected.
303+ * Best-effort, safe to run at startup.
304+ */
305+ suspend fun reconcileActiveServerId() {
306+ configMutex.withLock {
307+ val prefs = context.dataStore.data.first()
308+ val activeId = prefs[activeServerIdKey] ?: return@withLock
309+ if (savedServerDao.getById(activeId) != null) return@withLock
310+ val url = prefs[serverUrlKey]
311+ val user = prefs[usernameKey]
312+ val surviving = if (url != null && user != null) {
313+ savedServerDao.findByUrlAndUsername(url, user)
314+ } else {
315+ null
316+ }
317+ context.dataStore.edit { p ->
318+ if (surviving != null) {
319+ p[activeServerIdKey] = surviving.id
320+ } else {
321+ p.remove(activeServerIdKey)
322+ }
323+ }
324+ }
325+ }
326+
327+ /**
328+ * Remove EncryptedSharedPreferences password entries that no longer correspond to a
329+ * saved_servers row. Row-collapsing schema migrations (e.g. MIGRATION_2_3, which deletes
330+ * duplicate rows) run as raw SQL and can't reach EncryptedSharedPreferences, so the
331+ * removed rows' "server_pwd_*" keys would otherwise linger encrypted on disk forever.
332+ * Best-effort, safe to run at startup. Only touches per-server keys ("server_pwd_*") so
333+ * the active-connection "password" key is never disturbed.
334+ */
335+ suspend fun cleanupOrphanedServerPasswords() {
336+ configMutex.withLock {
337+ val referenced = savedServerDao.getAll().first().map { it.passwordKey }.toSet()
338+ withContext(Dispatchers.IO) {
339+ val orphans = encryptedPrefs.all.keys.filter {
340+ it.startsWith("server_pwd_") && it !in referenced
341+ }
342+ if (orphans.isNotEmpty()) {
343+ encryptedPrefs.edit().apply {
344+ orphans.forEach { remove(it) }
345+ }.commit()
346+ Timber.i("Removed ${orphans.size} orphaned server password entries")
347+ }
348+ }
349+ }
350+ }
351+}
app/src/main/java/com/parseable/android/di/AppModule.ktnew file+88
@@ -0,0 +1,88 @@
1+package com.parseable.android.di
2+
3+import android.content.Context
4+import androidx.room.Room
5+import androidx.room.migration.Migration
6+import androidx.sqlite.db.SupportSQLiteDatabase
7+import com.parseable.android.data.local.FavoriteStreamDao
8+import com.parseable.android.data.local.ParseableDatabase
9+import com.parseable.android.data.local.SavedServerDao
10+import dagger.Module
11+import dagger.Provides
12+import dagger.hilt.InstallIn
13+import dagger.hilt.android.qualifiers.ApplicationContext
14+import dagger.hilt.components.SingletonComponent
15+import javax.inject.Singleton
16+
17+@Module
18+@InstallIn(SingletonComponent::class)
19+object AppModule {
20+
21+ /**
22+ * v1 → v2 added the `saved_servers` table (multi-server management). This is NOT a
23+ * rebuildable cache like favorites: it holds the user's saved server list and the keys
24+ * into EncryptedSharedPreferences for each server's password. Losing it strands users'
25+ * credentials, so the upgrade must be a real additive migration, not a destructive wipe.
26+ *
27+ * The CREATE TABLE statement must match the schema Room generates for the SavedServer
28+ * entity exactly (column names, affinities, NOT NULL, the AUTOINCREMENT primary key) or
29+ * Room's post-migration schema validation will throw on first open.
30+ */
31+ private val MIGRATION_1_2 = object : Migration(1, 2) {
32+ override fun migrate(db: SupportSQLiteDatabase) {
33+ db.execSQL(
34+ "CREATE TABLE IF NOT EXISTS `saved_servers` (" +
35+ "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
36+ "`serverUrl` TEXT NOT NULL, " +
37+ "`username` TEXT NOT NULL, " +
38+ "`useTls` INTEGER NOT NULL, " +
39+ "`passwordKey` TEXT NOT NULL, " +
40+ "`addedAt` INTEGER NOT NULL)"
41+ )
42+ }
43+ }
44+
45+ /**
46+ * v2 → v3 adds a UNIQUE index on (serverUrl, username) so the one-row-per-server invariant
47+ * is enforced by the schema rather than only by app logic. Any pre-existing duplicate rows
48+ * (possible because v2 had no constraint) must be collapsed first or the index creation
49+ * fails — keep the lowest id for each (url, username). The index name and SQL must match
50+ * what Room generates for the entity's @Index or post-migration validation will throw.
51+ */
52+ private val MIGRATION_2_3 = object : Migration(2, 3) {
53+ override fun migrate(db: SupportSQLiteDatabase) {
54+ db.execSQL(
55+ "DELETE FROM `saved_servers` WHERE `id` NOT IN " +
56+ "(SELECT MIN(`id`) FROM `saved_servers` GROUP BY `serverUrl`, `username`)"
57+ )
58+ db.execSQL(
59+ "CREATE UNIQUE INDEX IF NOT EXISTS " +
60+ "`index_saved_servers_serverUrl_username` " +
61+ "ON `saved_servers` (`serverUrl`, `username`)"
62+ )
63+ }
64+ }
65+
66+ @Provides
67+ @Singleton
68+ fun provideDatabase(@ApplicationContext context: Context): ParseableDatabase =
69+ Room.databaseBuilder(context, ParseableDatabase::class.java, "parseable.db")
70+ // Register real migrations for any schema bump that must preserve user data.
71+ // IMPORTANT: every future version bump needs a Migration added here — the
72+ // destructive fallback below is only a last resort for unmigrated paths and will
73+ // silently drop ALL tables (including saved_servers) if it ever runs on upgrade.
74+ // Exported schemas (see ParseableDatabase, exportSchema = true) provide the diffs.
75+ .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
76+ .fallbackToDestructiveMigration()
77+ .build()
78+
79+ @Provides
80+ @Singleton
81+ fun provideFavoriteStreamDao(db: ParseableDatabase): FavoriteStreamDao =
82+ db.favoriteStreamDao()
83+
84+ @Provides
85+ @Singleton
86+ fun provideSavedServerDao(db: ParseableDatabase): SavedServerDao =
87+ db.savedServerDao()
88+}
app/src/main/java/com/parseable/android/ui/ErrorHandler.ktnew file+84
@@ -0,0 +1,84 @@
1+package com.parseable.android.ui
2+
3+import androidx.compose.foundation.layout.Box
4+import androidx.compose.foundation.layout.WindowInsets
5+import androidx.compose.foundation.layout.fillMaxSize
6+import androidx.compose.material3.Scaffold
7+import androidx.compose.material3.Snackbar
8+import androidx.compose.material3.SnackbarDuration
9+import androidx.compose.material3.SnackbarHost
10+import androidx.compose.material3.SnackbarHostState
11+import androidx.compose.runtime.Composable
12+import androidx.compose.runtime.LaunchedEffect
13+import androidx.compose.runtime.compositionLocalOf
14+import androidx.compose.runtime.remember
15+import androidx.compose.ui.Modifier
16+import kotlinx.coroutines.channels.Channel
17+import kotlinx.coroutines.flow.receiveAsFlow
18+
19+/**
20+ * Central error handler that any screen can use to display error messages
21+ * via a global Snackbar. Access it through [LocalErrorHandler].
22+ *
23+ * Uses a [Channel] so that rapid-fire calls to [showError] queue messages
24+ * rather than overwriting each other (which happened with StateFlow).
25+ */
26+class ErrorHandler {
27+ private val _errors = Channel<String>(Channel.BUFFERED)
28+ val errors = _errors.receiveAsFlow()
29+
30+ fun showError(message: String) {
31+ _errors.trySend(message)
32+ }
33+}
34+
35+/**
36+ * CompositionLocal providing access to the global [ErrorHandler].
37+ * Any composable in the tree can call `LocalErrorHandler.current.showError(...)`.
38+ */
39+val LocalErrorHandler = compositionLocalOf<ErrorHandler> {
40+ error("No ErrorHandler provided. Wrap your content in GlobalErrorBoundary.")
41+}
42+
43+/**
44+ * Wraps [content] in a Scaffold with a SnackbarHost that reacts to errors
45+ * posted via the provided [ErrorHandler]. The handler is exposed to the
46+ * entire subtree through [LocalErrorHandler].
47+ */
48+@Suppress("UnusedMaterial3ScaffoldPaddingParameter")
49+@Composable
50+fun GlobalErrorBoundary(
51+ errorHandler: ErrorHandler = remember { ErrorHandler() },
52+ content: @Composable () -> Unit,
53+) {
54+ val snackbarHostState = remember { SnackbarHostState() }
55+
56+ // Collect errors and show them as Snackbars
57+ LaunchedEffect(errorHandler) {
58+ errorHandler.errors.collect { message ->
59+ snackbarHostState.showSnackbar(
60+ message = message,
61+ duration = SnackbarDuration.Short,
62+ )
63+ }
64+ }
65+
66+ androidx.compose.runtime.CompositionLocalProvider(
67+ LocalErrorHandler provides errorHandler,
68+ ) {
69+ Scaffold(
70+ snackbarHost = {
71+ SnackbarHost(hostState = snackbarHostState) { data ->
72+ Snackbar(snackbarData = data)
73+ }
74+ },
75+ contentWindowInsets = WindowInsets(0, 0, 0, 0),
76+ ) {
77+ Box(
78+ modifier = Modifier.fillMaxSize(),
79+ ) {
80+ content()
81+ }
82+ }
83+ }
84+}
app/src/main/java/com/parseable/android/ui/navigation/NavGraph.ktnew file+220
@@ -0,0 +1,220 @@
1+package com.parseable.android.ui.navigation
2+
3+import androidx.compose.runtime.Composable
4+import androidx.navigation.NavHostController
5+import androidx.navigation.NavType
6+import androidx.navigation.compose.NavHost
7+import androidx.navigation.compose.composable
8+import androidx.navigation.navArgument
9+import androidx.navigation.navDeepLink
10+import androidx.compose.runtime.LaunchedEffect
11+import com.parseable.android.data.repository.ParseableRepository
12+import com.parseable.android.ui.screens.alerts.AlertsScreen
13+import com.parseable.android.ui.screens.login.LoginScreen
14+import com.parseable.android.ui.screens.logviewer.LogViewerScreen
15+import com.parseable.android.ui.screens.logviewer.StreamInfoScreen
16+import com.parseable.android.ui.screens.about.AboutScreen
17+import com.parseable.android.ui.screens.settings.SettingsScreen
18+import com.parseable.android.ui.screens.streams.StreamsScreen
19+import java.net.URLEncoder
20+
21+object Routes {
22+ const val LOGIN = "login"
23+ const val STREAMS = "streams"
24+ const val LOG_VIEWER = "log_viewer/{streamName}"
25+ const val STREAM_INFO = "stream_info/{streamName}"
26+ const val ALERTS = "alerts"
27+ const val SETTINGS = "settings"
28+ const val ABOUT = "about"
29+
30+ fun login(sessionExpired: Boolean = false) =
31+ if (sessionExpired) "login?sessionExpired=true" else LOGIN
32+ fun logViewer(streamName: String) =
33+ "log_viewer/${encodeArg(streamName)}"
34+ fun streamInfo(streamName: String) =
35+ "stream_info/${encodeArg(streamName)}"
36+
37+ // URLEncoder maps space to "+", but Navigation decodes captured path args with Uri.decode,
38+ // which only understands "%20" and leaves "+" untouched (it would survive as a literal
39+ // "+"). Normalize to "%20" so the name Navigation hands back matches the original. Do NOT
40+ // decode again at the call site — Navigation already does that one decode for us.
41+ private fun encodeArg(value: String) =
42+ URLEncoder.encode(value, "UTF-8").replace("+", "%20")
43+}
44+
45+@Composable
46+fun ParseableNavGraph(
47+ navController: NavHostController,
48+ startDestination: String,
49+ repository: ParseableRepository,
50+) {
51+ NavHost(
52+ navController = navController,
53+ startDestination = startDestination,
54+ ) {
55+ composable(
56+ route = "${Routes.LOGIN}?sessionExpired={sessionExpired}",
57+ arguments = listOf(navArgument("sessionExpired") {
58+ type = NavType.BoolType
59+ defaultValue = false
60+ }),
61+ ) { backStackEntry ->
62+ val sessionExpired = backStackEntry.arguments?.getBoolean("sessionExpired") ?: false
63+ LoginScreen(
64+ onLoginSuccess = {
65+ navController.navigate(Routes.STREAMS) {
66+ popUpTo(Routes.LOGIN) { inclusive = true }
67+ }
68+ },
69+ sessionExpired = sessionExpired,
70+ )
71+ }
72+
73+ composable(
74+ route = Routes.STREAMS,
75+ deepLinks = listOf(navDeepLink { uriPattern = "parseable://streams" }),
76+ ) {
77+ if (!repository.isConfigured) {
78+ LaunchedEffect(Unit) {
79+ navController.navigate(Routes.LOGIN) {
80+ popUpTo(0) { inclusive = true }
81+ }
82+ }
83+ return@composable
84+ }
85+ StreamsScreen(
86+ onStreamClick = { streamName ->
87+ navController.navigate(Routes.logViewer(streamName)) {
88+ launchSingleTop = true
89+ }
90+ },
91+ onAlertsClick = {
92+ navController.navigate(Routes.ALERTS) {
93+ launchSingleTop = true
94+ }
95+ },
96+ onSettingsClick = {
97+ navController.navigate(Routes.SETTINGS) {
98+ launchSingleTop = true
99+ }
100+ },
101+ onLogout = {
102+ navController.navigate(Routes.LOGIN) {
103+ popUpTo(0) { inclusive = true }
104+ }
105+ },
106+ )
107+ }
108+
109+ composable(
110+ route = Routes.LOG_VIEWER,
111+ arguments = listOf(navArgument("streamName") { type = NavType.StringType }),
112+ deepLinks = listOf(navDeepLink { uriPattern = "parseable://stream/{streamName}" }),
113+ ) { backStackEntry ->
114+ // Guard: redirect to login if the API client isn't configured
115+ if (!repository.isConfigured) {
116+ LaunchedEffect(Unit) {
117+ navController.navigate(Routes.LOGIN) {
118+ popUpTo(0) { inclusive = true }
119+ }
120+ }
121+ return@composable
122+ }
123+ // Navigation already URI-decodes captured path args, so use the value as-is.
124+ val streamName = backStackEntry.arguments?.getString("streamName") ?: ""
125+ LogViewerScreen(
126+ streamName = streamName,
127+ onBack = { navController.popBackStack() },
128+ onStreamInfo = { name ->
129+ navController.navigate(Routes.streamInfo(name)) {
130+ launchSingleTop = true
131+ }
132+ },
133+ )
134+ }
135+
136+ composable(
137+ route = Routes.STREAM_INFO,
138+ arguments = listOf(navArgument("streamName") { type = NavType.StringType }),
139+ ) { backStackEntry ->
140+ // Navigation already URI-decodes captured path args, so use the value as-is.
141+ val streamName = backStackEntry.arguments?.getString("streamName") ?: ""
142+ StreamInfoScreen(
143+ streamName = streamName,
144+ onBack = { navController.popBackStack() },
145+ onStreamDeleted = {
146+ // popBackStack no-ops (returns false) when there's no streams entry on
147+ // the back stack — e.g. when the user arrived via the deep link. In that
148+ // case navigate to streams fresh so we don't strand them on the info
149+ // screen for a now-deleted stream.
150+ val popped = navController.popBackStack(Routes.STREAMS, inclusive = false)
151+ if (!popped) {
152+ navController.navigate(Routes.STREAMS) {
153+ popUpTo(0) { inclusive = true }
154+ }
155+ }
156+ },
157+ )
158+ }
159+
160+ composable(
161+ route = Routes.ALERTS,
162+ deepLinks = listOf(navDeepLink { uriPattern = "parseable://alerts" }),
163+ ) {
164+ if (!repository.isConfigured) {
165+ LaunchedEffect(Unit) {
166+ navController.navigate(Routes.LOGIN) {
167+ popUpTo(0) { inclusive = true }
168+ }
169+ }
170+ return@composable
171+ }
172+ AlertsScreen(
173+ onBack = { navController.popBackStack() },
174+ )
175+ }
176+
177+ composable(Routes.SETTINGS) {
178+ if (!repository.isConfigured) {
179+ LaunchedEffect(Unit) {
180+ navController.navigate(Routes.LOGIN) {
181+ popUpTo(0) { inclusive = true }
182+ }
183+ }
184+ return@composable
185+ }
186+ SettingsScreen(
187+ onBack = { navController.popBackStack() },
188+ onAboutClick = {
189+ navController.navigate(Routes.ABOUT) {
190+ launchSingleTop = true
191+ }
192+ },
193+ onServerSwitched = {
194+ navController.navigate(Routes.STREAMS) {
195+ popUpTo(0) { inclusive = true }
196+ }
197+ },
198+ onLoggedOut = {
199+ navController.navigate(Routes.LOGIN) {
200+ popUpTo(0) { inclusive = true }
201+ }
202+ },
203+ )
204+ }
205+
206+ composable(Routes.ABOUT) {
207+ if (!repository.isConfigured) {
208+ LaunchedEffect(Unit) {
209+ navController.navigate(Routes.LOGIN) {
210+ popUpTo(0) { inclusive = true }
211+ }
212+ }
213+ return@composable
214+ }
215+ AboutScreen(
216+ onBack = { navController.popBackStack() },
217+ )
218+ }
219+ }
220+}
app/src/main/java/com/parseable/android/ui/screens/about/AboutScreen.ktnew file+178
@@ -0,0 +1,178 @@
1+package com.parseable.android.ui.screens.about
2+
3+import android.content.Intent
4+import android.net.Uri
5+import androidx.compose.foundation.Image
6+import androidx.compose.foundation.layout.*
7+import androidx.compose.foundation.rememberScrollState
8+import androidx.compose.foundation.verticalScroll
9+import androidx.compose.material.icons.Icons
10+import androidx.compose.material.icons.automirrored.filled.ArrowBack
11+import androidx.compose.material.icons.filled.*
12+import androidx.compose.material3.*
13+import androidx.compose.runtime.Composable
14+import androidx.compose.ui.Alignment
15+import androidx.compose.ui.Modifier
16+import androidx.compose.ui.platform.LocalContext
17+import androidx.compose.ui.res.painterResource
18+import androidx.compose.ui.text.font.FontWeight
19+import androidx.compose.ui.unit.dp
20+import com.parseable.android.BuildConfig
21+import com.parseable.android.R
22+
23+private const val GITHUB_REPO_URL = "https://github.com/parseablehq/parseable-android"
24+private const val PARSEABLE_WEBSITE_URL = "https://www.parseable.com"
25+
26+@OptIn(ExperimentalMaterial3Api::class)
27+@Composable
28+fun AboutScreen(
29+ onBack: () -> Unit,
30+) {
31+ val context = LocalContext.current
32+
33+ Scaffold(
34+ topBar = {
35+ TopAppBar(
36+ title = { Text("About") },
37+ navigationIcon = {
38+ IconButton(onClick = onBack) {
39+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
40+ }
41+ },
42+ )
43+ },
44+ ) { padding ->
45+ Column(
46+ modifier = Modifier
47+ .fillMaxSize()
48+ .padding(padding)
49+ .verticalScroll(rememberScrollState())
50+ .padding(16.dp),
51+ verticalArrangement = Arrangement.spacedBy(16.dp),
52+ ) {
53+ // App header
54+ Card(
55+ modifier = Modifier.fillMaxWidth(),
56+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
57+ ) {
58+ Column(
59+ modifier = Modifier
60+ .fillMaxWidth()
61+ .padding(24.dp),
62+ horizontalAlignment = Alignment.CenterHorizontally,
63+ ) {
64+ Image(
65+ painter = painterResource(id = R.mipmap.ic_launcher),
66+ contentDescription = "Parseable logo",
67+ modifier = Modifier.size(80.dp),
68+ )
69+ Spacer(modifier = Modifier.height(12.dp))
70+ Text(
71+ text = "Parseable",
72+ style = MaterialTheme.typography.headlineSmall,
73+ fontWeight = FontWeight.Bold,
74+ )
75+ Text(
76+ text = "Android Client",
77+ style = MaterialTheme.typography.titleMedium,
78+ color = MaterialTheme.colorScheme.onSurfaceVariant,
79+ )
80+ Spacer(modifier = Modifier.height(8.dp))
81+ Text(
82+ text = "Version ${BuildConfig.VERSION_NAME}",
83+ style = MaterialTheme.typography.bodyMedium,
84+ color = MaterialTheme.colorScheme.onSurfaceVariant,
85+ )
86+ }
87+ }
88+
89+ // Description
90+ Card(
91+ modifier = Modifier.fillMaxWidth(),
92+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
93+ ) {
94+ Column(modifier = Modifier.padding(16.dp)) {
95+ Row(verticalAlignment = Alignment.CenterVertically) {
96+ Icon(
97+ Icons.Filled.Info,
98+ contentDescription = null,
99+ tint = MaterialTheme.colorScheme.primary,
100+ )
101+ Spacer(modifier = Modifier.width(8.dp))
102+ Text(
103+ text = "About",
104+ style = MaterialTheme.typography.titleMedium,
105+ fontWeight = FontWeight.Bold,
106+ )
107+ }
108+ Spacer(modifier = Modifier.height(12.dp))
109+ Text(
110+ text = "Parseable is an open-source log analytics platform. " +
111+ "This app connects to a Parseable server instance and lets you " +
112+ "browse, search, filter, and live-tail log data.",
113+ style = MaterialTheme.typography.bodyMedium,
114+ )
115+ }
116+ }
117+
118+ // Links
119+ Card(
120+ modifier = Modifier.fillMaxWidth(),
121+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
122+ ) {
123+ Column(modifier = Modifier.padding(16.dp)) {
124+ Row(verticalAlignment = Alignment.CenterVertically) {
125+ Icon(
126+ Icons.Filled.Link,
127+ contentDescription = null,
128+ tint = MaterialTheme.colorScheme.primary,
129+ )
130+ Spacer(modifier = Modifier.width(8.dp))
131+ Text(
132+ text = "Links",
133+ style = MaterialTheme.typography.titleMedium,
134+ fontWeight = FontWeight.Bold,
135+ )
136+ }
137+ Spacer(modifier = Modifier.height(12.dp))
138+
139+ OutlinedButton(
140+ onClick = {
141+ context.startActivity(
142+ Intent(Intent.ACTION_VIEW, Uri.parse(GITHUB_REPO_URL))
143+ )
144+ },
145+ modifier = Modifier.fillMaxWidth(),
146+ ) {
147+ Icon(
148+ Icons.Filled.Code,
149+ contentDescription = null,
150+ modifier = Modifier.size(18.dp),
151+ )
152+ Spacer(modifier = Modifier.width(8.dp))
153+ Text("GitHub Repository")
154+ }
155+
156+ Spacer(modifier = Modifier.height(8.dp))
157+
158+ OutlinedButton(
159+ onClick = {
160+ context.startActivity(
161+ Intent(Intent.ACTION_VIEW, Uri.parse(PARSEABLE_WEBSITE_URL))
162+ )
163+ },
164+ modifier = Modifier.fillMaxWidth(),
165+ ) {
166+ Icon(
167+ Icons.Filled.Language,
168+ contentDescription = null,
169+ modifier = Modifier.size(18.dp),
170+ )
171+ Spacer(modifier = Modifier.width(8.dp))
172+ Text("Parseable Website")
173+ }
174+ }
175+ }
176+ }
177+ }
178+}
app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsScreen.ktnew file

Binary file not shown.

app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsViewModel.ktnew file+97
@@ -0,0 +1,97 @@
1+package com.parseable.android.ui.screens.alerts
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import com.parseable.android.data.model.Alert
6+import com.parseable.android.data.model.ApiResult
7+import com.parseable.android.data.repository.ParseableRepository
8+import dagger.hilt.android.lifecycle.HiltViewModel
9+import kotlinx.coroutines.Job
10+import kotlinx.coroutines.flow.MutableStateFlow
11+import kotlinx.coroutines.flow.StateFlow
12+import kotlinx.coroutines.flow.asStateFlow
13+import kotlinx.coroutines.flow.update
14+import kotlinx.coroutines.launch
15+import javax.inject.Inject
16+
17+data class AlertsState(
18+ val alerts: List<Alert> = emptyList(),
19+ val isLoading: Boolean = false,
20+ val error: String? = null,
21+ val alertToDelete: Alert? = null,
22+)
23+
24+@HiltViewModel
25+class AlertsViewModel @Inject constructor(
26+ private val repository: ParseableRepository,
27+) : ViewModel() {
28+
29+ private val _state = MutableStateFlow(AlertsState())
30+ val state: StateFlow<AlertsState> = _state.asStateFlow()
31+
32+ private var refreshJob: Job? = null
33+
34+ init {
35+ // Load once on creation; the screen no longer refreshes on every RESUME.
36+ refresh()
37+ }
38+
39+ fun refresh() {
40+ // Cancel any in-flight load so out-of-order completions can't overwrite a newer alert
41+ // list with a stale one (init load, pull-to-refresh and deleteAlert->refresh can overlap).
42+ refreshJob?.cancel()
43+ refreshJob = viewModelScope.launch {
44+ _state.update { it.copy(isLoading = true, error = null) }
45+ loadAlerts()
46+ }
47+ }
48+
49+ /**
50+ * Fetches the alert list and writes a terminal state update (clearing isLoading). Callers set
51+ * isLoading = true before invoking. Extracted so deleteAlert can reload within its own
52+ * (never-cancelled) job rather than delegating to refresh(), whose refreshJob can be cancelled
53+ * by a concurrent refresh before it clears the spinner.
54+ */
55+ private suspend fun loadAlerts() {
56+ when (val result = repository.listAlerts()) {
57+ is ApiResult.Success -> {
58+ // Sort by the same field the UI shows (displayName = title ?: name), otherwise
59+ // alerts that carry both fields, or a mix of per-stream and global formats,
60+ // render in an order that doesn't match their visible labels.
61+ val sorted = result.data.sortedBy { it.displayName.lowercase() }
62+ _state.update { it.copy(alerts = sorted, isLoading = false) }
63+ }
64+ is ApiResult.Error -> {
65+ _state.update { it.copy(isLoading = false, error = result.userMessage) }
66+ }
67+ }
68+ }
69+
70+ fun requestDelete(alert: Alert) {
71+ _state.update { it.copy(alertToDelete = alert) }
72+ }
73+
74+ fun cancelDelete() {
75+ _state.update { it.copy(alertToDelete = null) }
76+ }
77+
78+ private var deleteJob: Job? = null
79+
80+ fun deleteAlert(alertId: String) {
81+ // Guard against a double-tap on the confirm button firing two concurrent DELETEs.
82+ if (deleteJob?.isActive == true) return
83+ deleteJob = viewModelScope.launch {
84+ _state.update { it.copy(alertToDelete = null, isLoading = true, error = null) }
85+ when (val result = repository.deleteAlert(alertId)) {
86+ // Reload inline rather than via refresh(): refresh() runs on the cancellable
87+ // refreshJob, so a pull-to-refresh landing right after could cancel it before it
88+ // clears isLoading, leaving the spinner stuck. This job is never cancelled by a
89+ // refresh, so its terminal state update always runs.
90+ is ApiResult.Success -> loadAlerts()
91+ is ApiResult.Error -> {
92+ _state.update { it.copy(isLoading = false, error = result.userMessage) }
93+ }
94+ }
95+ }
96+ }
97+}
app/src/main/java/com/parseable/android/ui/screens/login/LoginScreen.ktnew file+304
@@ -0,0 +1,304 @@
1+package com.parseable.android.ui.screens.login
2+
3+import androidx.compose.foundation.Image
4+import androidx.compose.foundation.layout.*
5+import androidx.compose.foundation.rememberScrollState
6+import androidx.compose.foundation.text.KeyboardActions
7+import androidx.compose.foundation.text.KeyboardOptions
8+import androidx.compose.foundation.verticalScroll
9+import androidx.compose.material.icons.Icons
10+import androidx.compose.material.icons.filled.Visibility
11+import androidx.compose.material.icons.filled.VisibilityOff
12+import androidx.compose.material3.*
13+import androidx.compose.runtime.*
14+import androidx.compose.runtime.saveable.rememberSaveable
15+import androidx.compose.ui.Alignment
16+import androidx.compose.ui.Modifier
17+import androidx.compose.ui.focus.FocusDirection
18+import androidx.compose.ui.platform.LocalFocusManager
19+import androidx.compose.ui.res.painterResource
20+import androidx.compose.ui.text.font.FontWeight
21+import com.parseable.android.R
22+import com.parseable.android.ui.LocalErrorHandler
23+import androidx.compose.ui.text.input.ImeAction
24+import androidx.compose.ui.text.input.KeyboardType
25+import androidx.compose.ui.text.input.PasswordVisualTransformation
26+import androidx.compose.ui.text.input.VisualTransformation
27+import androidx.compose.ui.unit.dp
28+import androidx.hilt.navigation.compose.hiltViewModel
29+import androidx.lifecycle.compose.collectAsStateWithLifecycle
30+
31+@OptIn(ExperimentalMaterial3Api::class)
32+@Composable
33+fun LoginScreen(
34+ onLoginSuccess: () -> Unit,
35+ sessionExpired: Boolean = false,
36+ viewModel: LoginViewModel = hiltViewModel(),
37+) {
38+ val state by viewModel.state.collectAsStateWithLifecycle()
39+ val focusManager = LocalFocusManager.current
40+ var passwordVisible by rememberSaveable { mutableStateOf(false) }
41+ val errorHandler = LocalErrorHandler.current
42+
43+ LaunchedEffect(Unit) {
44+ viewModel.loginSuccessEvent.collect {
45+ onLoginSuccess()
46+ }
47+ }
48+
49+ LaunchedEffect(sessionExpired) {
50+ if (sessionExpired) {
51+ errorHandler.showError("Session expired. Please log in again.")
52+ }
53+ }
54+
55+ Scaffold { padding ->
56+ Column(
57+ modifier = Modifier
58+ .fillMaxSize()
59+ .padding(padding)
60+ .verticalScroll(rememberScrollState())
61+ .padding(16.dp),
62+ horizontalAlignment = Alignment.CenterHorizontally,
63+ verticalArrangement = Arrangement.Center,
64+ ) {
65+ Image(
66+ painter = painterResource(id = R.mipmap.ic_launcher),
67+ contentDescription = "Parseable logo",
68+ modifier = Modifier.size(120.dp),
69+ )
70+
71+ Text(
72+ text = "Parseable",
73+ style = MaterialTheme.typography.headlineLarge,
74+ fontWeight = FontWeight.Bold,
75+ color = MaterialTheme.colorScheme.primary,
76+ )
77+
78+ Spacer(modifier = Modifier.height(8.dp))
79+
80+ Text(
81+ text = "Log Viewer",
82+ style = MaterialTheme.typography.titleMedium,
83+ color = MaterialTheme.colorScheme.onSurfaceVariant,
84+ )
85+
86+ Spacer(modifier = Modifier.height(48.dp))
87+
88+ OutlinedTextField(
89+ value = state.serverUrl,
90+ onValueChange = viewModel::onServerUrlChange,
91+ label = { Text("Server URL") },
92+ placeholder = { Text("https://demo.parseable.com") },
93+ singleLine = true,
94+ modifier = Modifier.fillMaxWidth(),
95+ keyboardOptions = KeyboardOptions(
96+ keyboardType = KeyboardType.Uri,
97+ imeAction = ImeAction.Next,
98+ ),
99+ keyboardActions = KeyboardActions(
100+ onNext = { focusManager.moveFocus(FocusDirection.Down) },
101+ ),
102+ isError = state.serverUrlError != null,
103+ supportingText = state.serverUrlError?.let { { Text(it) } },
104+ )
105+
106+ Spacer(modifier = Modifier.height(12.dp))
107+
108+ OutlinedTextField(
109+ value = state.username,
110+ onValueChange = viewModel::onUsernameChange,
111+ label = { Text("Username") },
112+ singleLine = true,
113+ modifier = Modifier.fillMaxWidth(),
114+ keyboardOptions = KeyboardOptions(
115+ imeAction = ImeAction.Next,
116+ ),
117+ keyboardActions = KeyboardActions(
118+ onNext = { focusManager.moveFocus(FocusDirection.Down) },
119+ ),
120+ isError = state.usernameError != null,
121+ supportingText = state.usernameError?.let { { Text(it) } },
122+ )
123+
124+ Spacer(modifier = Modifier.height(12.dp))
125+
126+ OutlinedTextField(
127+ value = state.password,
128+ onValueChange = viewModel::onPasswordChange,
129+ label = { Text("Password") },
130+ singleLine = true,
131+ modifier = Modifier.fillMaxWidth(),
132+ visualTransformation = if (passwordVisible) {
133+ VisualTransformation.None
134+ } else {
135+ PasswordVisualTransformation()
136+ },
137+ trailingIcon = {
138+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
139+ Icon(
140+ imageVector = if (passwordVisible) {
141+ Icons.Filled.VisibilityOff
142+ } else {
143+ Icons.Filled.Visibility
144+ },
145+ contentDescription = if (passwordVisible) "Hide password" else "Show password",
146+ )
147+ }
148+ },
149+ keyboardOptions = KeyboardOptions(
150+ keyboardType = KeyboardType.Password,
151+ imeAction = ImeAction.Done,
152+ ),
153+ keyboardActions = KeyboardActions(
154+ onDone = {
155+ focusManager.clearFocus()
156+ viewModel.onLogin()
157+ },
158+ ),
159+ isError = state.passwordError != null,
160+ supportingText = state.passwordError?.let { { Text(it) } },
161+ )
162+
163+ Spacer(modifier = Modifier.height(8.dp))
164+
165+ Row(
166+ modifier = Modifier.fillMaxWidth(),
167+ verticalAlignment = Alignment.CenterVertically,
168+ ) {
169+ Checkbox(
170+ checked = state.allowInsecure,
171+ onCheckedChange = viewModel::onAllowInsecureChange,
172+ )
173+ Text(
174+ text = "Allow insecure (HTTP) connections",
175+ style = MaterialTheme.typography.bodyMedium,
176+ )
177+ }
178+
179+ if (state.allowInsecure) {
180+ val isPrivateNetwork = remember(state.serverUrl) {
181+ isPrivateHost(extractHost(state.serverUrl))
182+ }
183+ if (!isPrivateNetwork) {
184+ Card(
185+ colors = CardDefaults.cardColors(
186+ containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f),
187+ ),
188+ modifier = Modifier.fillMaxWidth(),
189+ ) {
190+ Text(
191+ text = "Warning: HTTP sends credentials and data in plaintext. " +
192+ "Only use this for trusted private networks.",
193+ modifier = Modifier.padding(12.dp),
194+ color = MaterialTheme.colorScheme.onErrorContainer,
195+ style = MaterialTheme.typography.bodySmall,
196+ )
197+ }
198+ }
199+ }
200+
201+ Spacer(modifier = Modifier.height(24.dp))
202+
203+ Button(
204+ onClick = viewModel::onLogin,
205+ modifier = Modifier
206+ .fillMaxWidth()
207+ .defaultMinSize(minHeight = 50.dp),
208+ enabled = !state.isLoading,
209+ ) {
210+ if (state.isLoading) {
211+ CircularProgressIndicator(
212+ modifier = Modifier.size(20.dp),
213+ strokeWidth = 2.dp,
214+ color = MaterialTheme.colorScheme.onPrimary,
215+ )
216+ Spacer(modifier = Modifier.width(8.dp))
217+ }
218+ Text("Connect")
219+ }
220+
221+ if (state.hasSavedCredentials && state.password.isEmpty()) {
222+ Spacer(modifier = Modifier.height(8.dp))
223+ OutlinedButton(
224+ onClick = viewModel::loginWithSavedCredentials,
225+ modifier = Modifier
226+ .fillMaxWidth()
227+ .defaultMinSize(minHeight = 50.dp),
228+ enabled = !state.isLoading,
229+ ) {
230+ Text("Use saved credentials")
231+ }
232+ }
233+
234+ state.error?.let { errorText ->
235+ Spacer(modifier = Modifier.height(16.dp))
236+ Card(
237+ colors = CardDefaults.cardColors(
238+ containerColor = MaterialTheme.colorScheme.errorContainer,
239+ ),
240+ modifier = Modifier.fillMaxWidth(),
241+ ) {
242+ Text(
243+ text = errorText,
244+ modifier = Modifier.padding(16.dp),
245+ color = MaterialTheme.colorScheme.onErrorContainer,
246+ style = MaterialTheme.typography.bodyMedium,
247+ )
248+ }
249+ }
250+ }
251+ }
252+}
253+
254+/**
255+ * Extract the bare host from a possibly-schemeless, possibly-ported URL, handling
256+ * IPv6 literals (`[::1]:8000`) and bare IPv6 addresses correctly.
257+ */
258+internal fun extractHost(url: String): String {
259+ // Drop the scheme if present, then the path.
260+ var hostPort = url.substringAfter("://").substringBefore("/")
261+ return if (hostPort.startsWith("[")) {
262+ // Bracketed IPv6 literal, e.g. "[::1]:8000" -> "::1"
263+ hostPort.substringAfter("[").substringBefore("]")
264+ } else {
265+ // Strip ":port" only when there's a single colon (host:port). A bare IPv6
266+ // address has multiple colons and no brackets, so leave it intact.
267+ if (hostPort.count { it == ':' } == 1) hostPort.substringBefore(":") else hostPort
268+ }
269+}
270+
271+/**
272+ * Whether [host] is a loopback/private-range address. Used to decide whether the
273+ * plaintext-HTTP warning is needed. Matches actual IPv4 octet ranges (not string
274+ * prefixes, so "10.example.com" is correctly treated as public) and IPv6
275+ * loopback/link-local/unique-local addresses.
276+ */
277+internal fun isPrivateHost(host: String): Boolean {
278+ if (host.isBlank()) return true
279+ val h = host.lowercase()
280+ if (h == "localhost" || h.endsWith(".localhost")) return true
281+
282+ // IPv6 ranges (only meaningful for actual IPv6 literals, which contain ':').
283+ if (h.contains(":")) {
284+ if (h == "::1") return true // loopback
285+ if (h.startsWith("fe80:")) return true // link-local fe80::/10
286+ if (h.startsWith("fc") || h.startsWith("fd")) return true // unique-local fc00::/7
287+ return false
288+ }
289+
290+ // IPv4: require four numeric octets in 0..255, then match private ranges.
291+ val octets = h.split(".")
292+ if (octets.size == 4 && octets.all { val n = it.toIntOrNull(); n != null && n in 0..255 }) {
293+ val a = octets[0].toInt()
294+ val b = octets[1].toInt()
295+ return when {
296+ a == 10 -> true // 10.0.0.0/8
297+ a == 127 -> true // loopback 127.0.0.0/8
298+ a == 192 && b == 168 -> true // 192.168.0.0/16
299+ a == 172 && b in 16..31 -> true // 172.16.0.0/12
300+ else -> false
301+ }
302+ }
303+ return false
304+}
app/src/main/java/com/parseable/android/ui/screens/login/LoginViewModel.ktnew file+261
@@ -0,0 +1,261 @@
1+package com.parseable.android.ui.screens.login
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import com.parseable.android.data.model.ApiResult
6+import com.parseable.android.data.model.ServerConfig
7+import com.parseable.android.data.repository.ParseableRepository
8+import com.parseable.android.data.repository.SettingsRepository
9+import dagger.hilt.android.lifecycle.HiltViewModel
10+import kotlinx.coroutines.channels.Channel
11+import kotlinx.coroutines.flow.MutableStateFlow
12+import kotlinx.coroutines.flow.StateFlow
13+import kotlinx.coroutines.flow.asStateFlow
14+import kotlinx.coroutines.flow.first
15+import kotlinx.coroutines.flow.receiveAsFlow
16+import kotlinx.coroutines.flow.update
17+import kotlinx.coroutines.launch
18+import javax.inject.Inject
19+
20+data class LoginState(
21+ val serverUrl: String = "",
22+ val username: String = "",
23+ val password: String = "",
24+ val allowInsecure: Boolean = false,
25+ val isLoading: Boolean = false,
26+ val error: String? = null,
27+ val serverUrlError: String? = null,
28+ val usernameError: String? = null,
29+ val passwordError: String? = null,
30+ val hasSavedCredentials: Boolean = false,
31+)
32+
33+@HiltViewModel
34+class LoginViewModel @Inject constructor(
35+ private val repository: ParseableRepository,
36+ private val settingsRepository: SettingsRepository,
37+) : ViewModel() {
38+
39+ private val _state = MutableStateFlow(LoginState())
40+ val state: StateFlow<LoginState> = _state.asStateFlow()
41+
42+ /**
43+ * Emitted exactly once when login succeeds so the screen navigates away. A one-shot [Channel]
44+ * (like the codebase's other nav events) rather than a retained StateFlow flag: a retained
45+ * `loginSuccess = true` would re-fire navigation on any later recomposition that re-collects
46+ * the state if this ViewModel instance outlived the first navigation.
47+ */
48+ private val _loginSuccessEvent = Channel<Unit>(Channel.BUFFERED)
49+ val loginSuccessEvent = _loginSuccessEvent.receiveAsFlow()
50+
51+ init {
52+ viewModelScope.launch {
53+ val config = settingsRepository.serverConfig.first()
54+ if (config != null) {
55+ _state.update {
56+ it.copy(
57+ serverUrl = config.serverUrl,
58+ username = config.username,
59+ allowInsecure = !config.useTls,
60+ hasSavedCredentials = true,
61+ )
62+ }
63+ }
64+ }
65+ }
66+
67+ fun loginWithSavedCredentials() {
68+ // Guard against a second tap (or a concurrent onLogin()) launching a duplicate
69+ // login. Set isLoading synchronously so the guard is effective before the
70+ // coroutine starts.
71+ if (_state.value.isLoading) return
72+ _state.update { it.copy(isLoading = true, error = null) }
73+ viewModelScope.launch {
74+ val config = settingsRepository.getSavedPassword()
75+ if (config == null) {
76+ // URL/username are present but the saved password can't be read (e.g. the
77+ // Keystore entry was lost). Surface this instead of silently stopping the
78+ // spinner, and hide the now-useless "use saved credentials" button.
79+ _state.update {
80+ it.copy(
81+ isLoading = false,
82+ hasSavedCredentials = false,
83+ error = "Saved credentials could not be read. Please log in again.",
84+ )
85+ }
86+ return@launch
87+ }
88+ // Restore the saved URL/username so the saved password is paired with the
89+ // server/user it was saved for, even if the user edited those fields.
90+ _state.update {
91+ it.copy(
92+ serverUrl = config.serverUrl,
93+ username = config.username,
94+ allowInsecure = !config.useTls,
95+ serverUrlError = null,
96+ usernameError = null,
97+ passwordError = null,
98+ )
99+ }
100+ performLogin(config.serverUrl, config.username, config.password, !config.useTls)
101+ }
102+ }
103+
104+ fun onServerUrlChange(value: String) {
105+ _state.update { it.copy(serverUrl = value, serverUrlError = null, error = null) }
106+ }
107+
108+ fun onUsernameChange(value: String) {
109+ _state.update { it.copy(username = value, usernameError = null, error = null) }
110+ }
111+
112+ fun onPasswordChange(value: String) {
113+ _state.update { it.copy(password = value, passwordError = null, error = null) }
114+ }
115+
116+ fun onAllowInsecureChange(value: Boolean) {
117+ _state.update { it.copy(allowInsecure = value) }
118+ }
119+
120+ fun onLogin() {
121+ val current = _state.value
122+
123+ // With saved credentials the password field is intentionally left empty (the user is
124+ // expected to tap "Use saved credentials"). Tapping the primary Connect button in that
125+ // state should do the obvious thing — log in with the saved password — rather than
126+ // reject with "Password is required".
127+ if (current.password.isBlank() && current.hasSavedCredentials) {
128+ loginWithSavedCredentials()
129+ return
130+ }
131+
132+ // Validate
133+ var hasError = false
134+ var urlError: String? = null
135+ var userError: String? = null
136+ var passError: String? = null
137+
138+ if (current.serverUrl.isBlank()) {
139+ urlError = "Server URL is required"
140+ hasError = true
141+ }
142+ if (current.username.isBlank()) {
143+ userError = "Username is required"
144+ hasError = true
145+ }
146+ if (current.password.isBlank()) {
147+ passError = "Password is required"
148+ hasError = true
149+ }
150+
151+ if (hasError) {
152+ _state.update {
153+ it.copy(
154+ serverUrlError = urlError,
155+ usernameError = userError,
156+ passwordError = passError,
157+ )
158+ }
159+ return
160+ }
161+
162+ // Guard against a duplicate in-flight login and set isLoading synchronously so a
163+ // fast second tap can't launch a concurrent performLogin that races on _state.
164+ if (current.isLoading) return
165+ _state.update { it.copy(isLoading = true, error = null) }
166+ // Pass every field from the same snapshot so the validated password can't be
167+ // paired with a URL/username the user edited after tapping.
168+ viewModelScope.launch {
169+ performLogin(current.serverUrl, current.username, current.password, current.allowInsecure)
170+ }
171+ }
172+
173+ private suspend fun performLogin(
174+ serverUrl: String,
175+ username: String,
176+ password: String,
177+ allowInsecure: Boolean,
178+ ) {
179+ _state.update { it.copy(isLoading = true, error = null) }
180+
181+ // Strip trailing slashes so the persisted URL matches the canonical form the API
182+ // client uses (ParseableApiClient.configure also does trimEnd('/')). Without this,
183+ // "http://host:8000" and "http://host:8000/" persist as distinct strings, defeating
184+ // saveServer's (serverUrl, username) dedup and the UNIQUE index — the same server
185+ // ends up as two saved-server rows.
186+ var url = serverUrl.trim().trimEnd('/')
187+ val urlLower = url.lowercase()
188+ if (!urlLower.startsWith("http://") && !urlLower.startsWith("https://")) {
189+ url = if (allowInsecure) "http://$url" else "https://$url"
190+ }
191+
192+ val isValidUrl = try {
193+ val uri = java.net.URI(url)
194+ uri.scheme != null && !uri.host.isNullOrBlank() && !uri.host!!.contains(" ")
195+ } catch (_: Exception) {
196+ false
197+ }
198+ if (!isValidUrl) {
199+ _state.update {
200+ it.copy(
201+ isLoading = false,
202+ serverUrlError = "Invalid URL format",
203+ )
204+ }
205+ return
206+ }
207+
208+ val config = ServerConfig(
209+ serverUrl = url,
210+ username = username.trim(),
211+ password = password,
212+ useTls = url.lowercase().startsWith("https"),
213+ )
214+
215+ repository.configure(config)
216+
217+ when (val result = repository.testConnection()) {
218+ is ApiResult.Success -> {
219+ // Verify this is actually a Parseable server and credentials are valid.
220+ // Uses verifyServer() to avoid triggering the global auth-error handler.
221+ when (val aboutResult = repository.verifyServer()) {
222+ is ApiResult.Success -> {
223+ // Only report success if the credentials were durably persisted.
224+ // Otherwise the app would navigate in and clear the typed password,
225+ // yet bounce back to login on next launch with nothing to restore.
226+ if (settingsRepository.saveServerConfig(config)) {
227+ settingsRepository.saveServer(config)
228+ _state.update { it.copy(isLoading = false, password = "") }
229+ _loginSuccessEvent.send(Unit)
230+ } else {
231+ _state.update {
232+ it.copy(
233+ isLoading = false,
234+ error = "Connected, but your credentials couldn't be saved securely on this device. Please try again.",
235+ )
236+ }
237+ }
238+ }
239+ is ApiResult.Error -> {
240+ val errorMsg = if (aboutResult.isUnauthorized) {
241+ "Invalid credentials. Check your username and password."
242+ } else {
243+ "Server responded but doesn't appear to be Parseable: ${aboutResult.userMessage}"
244+ }
245+ _state.update {
246+ it.copy(isLoading = false, error = errorMsg)
247+ }
248+ }
249+ }
250+ }
251+ is ApiResult.Error -> {
252+ _state.update {
253+ it.copy(
254+ isLoading = false,
255+ error = "Connection failed: ${result.userMessage}",
256+ )
257+ }
258+ }
259+ }
260+ }
261+}
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.ktnew file+1423
@@ -0,0 +1,1423 @@
1+package com.parseable.android.ui.screens.logviewer
2+
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.animation.animateContentSize
5+import androidx.compose.animation.core.*
6+import androidx.compose.foundation.horizontalScroll
7+import androidx.compose.foundation.layout.*
8+import androidx.compose.foundation.lazy.LazyColumn
9+import androidx.compose.foundation.lazy.itemsIndexed
10+import androidx.compose.foundation.lazy.rememberLazyListState
11+import androidx.compose.foundation.rememberScrollState
12+import androidx.compose.foundation.text.KeyboardActions
13+import androidx.compose.foundation.text.KeyboardOptions
14+import androidx.compose.material.icons.Icons
15+import androidx.compose.material.icons.automirrored.filled.ArrowBack
16+import androidx.compose.material.icons.filled.*
17+import androidx.compose.material3.*
18+import androidx.compose.material3.MenuAnchorType
19+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
20+import androidx.compose.runtime.*
21+import androidx.compose.ui.Alignment
22+import androidx.compose.ui.Modifier
23+import androidx.compose.ui.draw.alpha
24+import androidx.compose.ui.platform.LocalContext
25+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
26+import androidx.compose.ui.text.font.FontFamily
27+import androidx.compose.ui.text.font.FontWeight
28+import androidx.compose.ui.text.input.ImeAction
29+import androidx.compose.ui.text.style.TextOverflow
30+import androidx.compose.ui.unit.dp
31+import androidx.compose.ui.unit.sp
32+import android.content.Intent
33+import androidx.core.content.FileProvider
34+import com.parseable.android.data.escapeIdentifier
35+import com.parseable.android.data.formatTimestamp
36+import androidx.hilt.navigation.compose.hiltViewModel
37+import androidx.lifecycle.compose.collectAsStateWithLifecycle
38+import kotlinx.coroutines.launch
39+import kotlinx.coroutines.withContext
40+import kotlinx.serialization.json.*
41+
42+private val prettyJson = Json { prettyPrint = true }
43+
44+@OptIn(ExperimentalMaterial3Api::class)
45+@Composable
46+fun LogViewerScreen(
47+ streamName: String,
48+ onBack: () -> Unit,
49+ onStreamInfo: (String) -> Unit,
50+ viewModel: LogViewerViewModel = hiltViewModel(),
51+) {
52+ val state by viewModel.state.collectAsStateWithLifecycle()
53+ val context = LocalContext.current
54+ val scope = rememberCoroutineScope()
55+ val snackbarHostState = remember { SnackbarHostState() }
56+ val keyboardController = LocalSoftwareKeyboardController.current
57+ var showFilterSheet by remember { mutableStateOf(false) }
58+ var showSqlSheet by remember { mutableStateOf(false) }
59+ var showSavedFiltersSheet by remember { mutableStateOf(false) }
60+ var showSaveFilterDialog by remember { mutableStateOf(false) }
61+ var expandedLogKey by remember { mutableStateOf<String?>(null) }
62+ var showDateRangePicker by remember { mutableStateOf(false) }
63+ var showShareWarning by remember { mutableStateOf(false) }
64+
65+ LaunchedEffect(streamName) {
66+ viewModel.initialize(streamName)
67+ }
68+
69+ Scaffold(
70+ snackbarHost = { SnackbarHost(snackbarHostState) },
71+ topBar = {
72+ TopAppBar(
73+ title = {
74+ Column {
75+ Text(
76+ text = streamName,
77+ fontWeight = FontWeight.Bold,
78+ maxLines = 1,
79+ overflow = TextOverflow.Ellipsis,
80+ )
81+ Text(
82+ text = if (state.isStreaming) {
83+ "Live - ${state.logs.size} logs (+${state.streamingNewCount} new)"
84+ } else {
85+ "${state.logs.size} results"
86+ },
87+ style = MaterialTheme.typography.bodySmall,
88+ color = if (state.isStreaming) {
89+ MaterialTheme.colorScheme.primary
90+ } else {
91+ MaterialTheme.colorScheme.onSurfaceVariant
92+ },
93+ )
94+ }
95+ },
96+ navigationIcon = {
97+ IconButton(onClick = onBack) {
98+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
99+ }
100+ },
101+ actions = {
102+ StreamingToggleButton(
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,
109+ )
110+ IconButton(onClick = { onStreamInfo(streamName) }) {
111+ Icon(Icons.Filled.Info, contentDescription = "Stream Info")
112+ }
113+ IconButton(onClick = { showFilterSheet = true }) {
114+ Icon(Icons.Filled.FilterList, contentDescription = "Filters")
115+ }
116+ IconButton(onClick = { showSqlSheet = true }) {
117+ Icon(Icons.Filled.Code, contentDescription = "SQL Query")
118+ }
119+ IconButton(onClick = {
120+ viewModel.loadSavedFilters()
121+ showSavedFiltersSheet = true
122+ }) {
123+ Icon(Icons.Filled.BookmarkBorder, contentDescription = "Saved Filters")
124+ }
125+ IconButton(
126+ onClick = { showShareWarning = true },
127+ enabled = state.logs.isNotEmpty(),
128+ ) {
129+ Icon(Icons.Filled.Share, contentDescription = "Share logs")
130+ }
131+ },
132+ )
133+ },
134+ ) { padding ->
135+ Column(
136+ modifier = Modifier
137+ .fillMaxSize()
138+ .padding(padding),
139+ ) {
140+ // Time range chips
141+ TimeRangeBar(
142+ selectedRange = state.selectedTimeRange,
143+ onRangeSelected = { range ->
144+ if (range == TimeRange.CUSTOM) {
145+ showDateRangePicker = true
146+ } else {
147+ viewModel.onTimeRangeChange(range)
148+ }
149+ },
150+ )
151+
152+ // Quick search bar
153+ OutlinedTextField(
154+ value = state.searchQuery,
155+ onValueChange = viewModel::onSearchQueryChange,
156+ placeholder = { Text("Search logs...") },
157+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") },
158+ trailingIcon = {
159+ if (state.isSearching) {
160+ CircularProgressIndicator(
161+ modifier = Modifier.size(20.dp),
162+ strokeWidth = 2.dp,
163+ )
164+ } else if (state.searchQuery.isNotEmpty()) {
165+ IconButton(onClick = { viewModel.onSearchQueryChange("") }) {
166+ Icon(Icons.Filled.Clear, contentDescription = "Clear")
167+ }
168+ }
169+ },
170+ singleLine = true,
171+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
172+ keyboardActions = KeyboardActions(onSearch = {
173+ keyboardController?.hide()
174+ // Runs the search now and cancels the pending debounce (which stops live-tail
175+ // and refreshes) so we don't fire a second, redundant refresh ~300ms later.
176+ viewModel.submitSearch()
177+ }),
178+ modifier = Modifier
179+ .fillMaxWidth()
180+ .padding(horizontal = 16.dp, vertical = 4.dp),
181+ textStyle = MaterialTheme.typography.bodyMedium,
182+ )
183+
184+ // Active filters indicator
185+ if (state.activeFilters.isNotEmpty()) {
186+ Row(
187+ modifier = Modifier
188+ .horizontalScroll(rememberScrollState())
189+ .padding(horizontal = 16.dp, vertical = 4.dp)
190+ .animateContentSize(),
191+ horizontalArrangement = Arrangement.spacedBy(8.dp),
192+ ) {
193+ state.activeFilters.forEach { filter ->
194+ FilterChip(
195+ selected = true,
196+ onClick = { viewModel.removeFilter(filter) },
197+ label = { Text(filter, maxLines = 1) },
198+ trailingIcon = {
199+ Icon(
200+ Icons.Filled.Close,
201+ contentDescription = "Remove filter",
202+ modifier = Modifier.size(16.dp),
203+ )
204+ },
205+ )
206+ }
207+ TextButton(onClick = viewModel::clearFilters) {
208+ Text("Clear all")
209+ }
210+ }
211+ }
212+
213+ // Streaming indicator banner
214+ AnimatedVisibility(visible = state.isStreaming) {
215+ Surface(
216+ color = MaterialTheme.colorScheme.primaryContainer,
217+ modifier = Modifier.fillMaxWidth(),
218+ ) {
219+ Row(
220+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
221+ verticalAlignment = Alignment.CenterVertically,
222+ ) {
223+ val infiniteTransition = rememberInfiniteTransition(label = "pulse")
224+ val alpha by infiniteTransition.animateFloat(
225+ initialValue = 1f,
226+ targetValue = 0.3f,
227+ animationSpec = infiniteRepeatable(
228+ animation = tween(800, easing = EaseInOut),
229+ repeatMode = RepeatMode.Reverse,
230+ ),
231+ label = "pulse_alpha",
232+ )
233+ Icon(
234+ Icons.Filled.FiberManualRecord,
235+ contentDescription = "Live streaming active",
236+ tint = MaterialTheme.colorScheme.error,
237+ modifier = Modifier
238+ .size(12.dp)
239+ .alpha(alpha),
240+ )
241+ Spacer(modifier = Modifier.width(8.dp))
242+ Text(
243+ text = "Live tail active - polling every ${state.currentIntervalMs / 1000}s",
244+ style = MaterialTheme.typography.labelMedium,
245+ color = MaterialTheme.colorScheme.onPrimaryContainer,
246+ modifier = Modifier.weight(1f),
247+ )
248+ TextButton(onClick = viewModel::stopStreaming) {
249+ Text("Stop")
250+ }
251+ }
252+ }
253+ }
254+
255+ // Streaming error banner
256+ AnimatedVisibility(visible = state.streamingError != null) {
257+ Surface(
258+ color = MaterialTheme.colorScheme.errorContainer,
259+ modifier = Modifier.fillMaxWidth(),
260+ ) {
261+ Row(
262+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
263+ verticalAlignment = Alignment.CenterVertically,
264+ ) {
265+ Icon(
266+ Icons.Filled.ErrorOutline,
267+ contentDescription = "Streaming error",
268+ tint = MaterialTheme.colorScheme.onErrorContainer,
269+ modifier = Modifier.size(16.dp),
270+ )
271+ Spacer(modifier = Modifier.width(8.dp))
272+ Text(
273+ text = state.streamingError ?: "",
274+ style = MaterialTheme.typography.labelMedium,
275+ color = MaterialTheme.colorScheme.onErrorContainer,
276+ modifier = Modifier.weight(1f),
277+ maxLines = 1,
278+ overflow = TextOverflow.Ellipsis,
279+ )
280+ TextButton(onClick = viewModel::dismissStreamingError) {
281+ Text("Dismiss")
282+ }
283+ }
284+ }
285+ }
286+
287+ // Log entries
288+ PullToRefreshBox(
289+ isRefreshing = state.isLoading,
290+ onRefresh = viewModel::pullRefresh,
291+ modifier = Modifier.fillMaxSize(),
292+ ) {
293+ // Hoisted above the error/loading/empty/list branches so the scroll position,
294+ // the clipboard handle, and the viewing-top / load-more effects survive
295+ // transitions through those states. If they lived inside the list `else` branch
296+ // they would be disposed and recreated on every empty/loading/error transition,
297+ // resetting scroll to the top and re-arming loadMore mid-load.
298+ val listState = rememberLazyListState()
299+ val clipboard = remember {
300+ context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
301+ }
302+
303+ // Tell the ViewModel when the list is at the top so it can clear the live-tail
304+ // "+N new" badge — new rows prepend at the top and are seen immediately there.
305+ val atTop by remember {
306+ derivedStateOf {
307+ listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
308+ }
309+ }
310+ LaunchedEffect(atTop) { viewModel.setViewingTop(atTop) }
311+
312+ // Auto-load more when scrolling near the bottom.
313+ val shouldLoadMore by remember {
314+ derivedStateOf {
315+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
316+ lastVisible >= listState.layoutInfo.totalItemsCount - 5
317+ }
318+ }
319+ LaunchedEffect(shouldLoadMore, state.hasMore, state.isLoading) {
320+ if (shouldLoadMore && state.hasMore && !state.isLoading) {
321+ viewModel.loadMore()
322+ }
323+ }
324+
325+ if (state.error != null && state.logs.isEmpty()) {
326+ Box(
327+ modifier = Modifier.fillMaxSize(),
328+ contentAlignment = Alignment.Center,
329+ ) {
330+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
331+ Icon(
332+ Icons.Filled.ErrorOutline,
333+ contentDescription = "Error loading logs",
334+ modifier = Modifier.size(48.dp),
335+ tint = MaterialTheme.colorScheme.error,
336+ )
337+ Spacer(modifier = Modifier.height(16.dp))
338+ Text(
339+ text = state.error.orEmpty(),
340+ style = MaterialTheme.typography.bodyMedium,
341+ color = MaterialTheme.colorScheme.error,
342+ modifier = Modifier.padding(horizontal = 32.dp),
343+ )
344+ Spacer(modifier = Modifier.height(16.dp))
345+ OutlinedButton(onClick = viewModel::refresh) {
346+ Text("Retry")
347+ }
348+ }
349+ }
350+ } else if (state.logs.isEmpty() && state.isLoading) {
351+ Box(
352+ modifier = Modifier.fillMaxSize(),
353+ contentAlignment = Alignment.Center,
354+ ) {
355+ CircularProgressIndicator()
356+ }
357+ } else if (state.logs.isEmpty() && !state.isLoading) {
358+ val emptyMessage = when {
359+ state.isStreaming ->
360+ "Waiting for new logs\u2026"
361+ state.searchQuery.isNotBlank() && state.activeFilters.isNotEmpty() ->
362+ "No logs match \"${state.searchQuery}\" with the active filters"
363+ state.searchQuery.isNotBlank() ->
364+ "No logs match \"${state.searchQuery}\""
365+ state.activeFilters.isNotEmpty() ->
366+ "No logs match the active filters"
367+ else ->
368+ "No logs found for the selected time range"
369+ }
370+ Box(
371+ modifier = Modifier.fillMaxSize(),
372+ contentAlignment = Alignment.Center,
373+ ) {
374+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
375+ Icon(
376+ Icons.Filled.SearchOff,
377+ contentDescription = "No logs found",
378+ modifier = Modifier.size(48.dp),
379+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
380+ )
381+ Spacer(modifier = Modifier.height(16.dp))
382+ Text(
383+ text = emptyMessage,
384+ style = MaterialTheme.typography.bodyLarge,
385+ color = MaterialTheme.colorScheme.onSurfaceVariant,
386+ modifier = Modifier.padding(horizontal = 32.dp),
387+ )
388+ }
389+ }
390+ } else {
391+ LazyColumn(
392+ state = listState,
393+ contentPadding = PaddingValues(8.dp),
394+ verticalArrangement = Arrangement.spacedBy(4.dp),
395+ ) {
396+ itemsIndexed(
397+ state.logs,
398+ key = { index, _ -> state.logKeys.getOrElse(index) { "log_$index" } },
399+ ) { index, logEntry ->
400+ val key = state.logKeys.getOrElse(index) { "log_$index" }
401+ LogEntryCard(
402+ logEntry = logEntry,
403+ stableKey = key,
404+ isExpanded = expandedLogKey == key,
405+ onClick = {
406+ if (expandedLogKey == key) {
407+ // Collapsing: if the top of the entry is scrolled
408+ // above the viewport, jump to it so the user sees
409+ // the collapsed card instead of a random position.
410+ // Resolve the row by its stable key, not the captured
411+ // index — live-tail prepends shift every row's index,
412+ // so the closed-over index can point at the wrong row.
413+ val itemInfo = listState.layoutInfo.visibleItemsInfo
414+ .firstOrNull { it.key == key }
415+ val needsScroll = itemInfo == null || itemInfo.offset < 0
416+ expandedLogKey = null
417+ if (needsScroll) {
418+ val target = state.logKeys.indexOf(key).takeIf { it >= 0 } ?: index
419+ scope.launch {
420+ listState.animateScrollToItem(target)
421+ }
422+ }
423+ } else {
424+ expandedLogKey = key
425+ }
426+ },
427+ clipboardManager = clipboard,
428+ onCopied = {
429+ scope.launch {
430+ snackbarHostState.showSnackbar(
431+ message = "Log entry copied to clipboard",
432+ duration = SnackbarDuration.Short,
433+ )
434+ }
435+ },
436+ onFilterInclude = { column, value ->
437+ viewModel.addFilter(column, "=", value)
438+ },
439+ onFilterExclude = { column, value ->
440+ viewModel.addFilter(column, "!=", value)
441+ },
442+ )
443+ }
444+
445+ if (state.hasMore) {
446+ item {
447+ Box(
448+ modifier = Modifier
449+ .fillMaxWidth()
450+ .padding(16.dp),
451+ contentAlignment = Alignment.Center,
452+ ) {
453+ CircularProgressIndicator(
454+ modifier = Modifier.size(24.dp),
455+ strokeWidth = 2.dp,
456+ )
457+ }
458+ }
459+ } else if (state.logs.isNotEmpty()) {
460+ item {
461+ Box(
462+ modifier = Modifier
463+ .fillMaxWidth()
464+ .padding(16.dp),
465+ contentAlignment = Alignment.Center,
466+ ) {
467+ val atCap = state.currentLimit >= 5000 && state.logs.size >= 5000
468+ Text(
469+ text = if (atCap) {
470+ "Showing max 5,000 results. Refine your query to see more."
471+ } else {
472+ "End of results"
473+ },
474+ style = MaterialTheme.typography.labelMedium,
475+ color = if (atCap) {
476+ MaterialTheme.colorScheme.primary
477+ } else {
478+ MaterialTheme.colorScheme.onSurfaceVariant
479+ },
480+ )
481+ }
482+ }
483+ }
484+ }
485+ }
486+ }
487+ }
488+ }
489+
490+ // Filter bottom sheet
491+ if (showFilterSheet) {
492+ FilterBottomSheet(
493+ columns = state.columns,
494+ logs = state.logs,
495+ onDismiss = { showFilterSheet = false },
496+ onApplyFilter = { column, operator, value ->
497+ viewModel.addFilter(column, operator, value)
498+ showFilterSheet = false
499+ },
500+ )
501+ }
502+
503+ // SQL query bottom sheet
504+ if (showSqlSheet) {
505+ SqlQueryBottomSheet(
506+ streamName = streamName,
507+ currentSql = state.customSql,
508+ onDismiss = { showSqlSheet = false },
509+ onExecute = { sql ->
510+ viewModel.executeCustomSql(sql)
511+ showSqlSheet = false
512+ },
513+ )
514+ }
515+
516+ // Share confirmation dialog (logs may contain sensitive data)
517+ if (showShareWarning) {
518+ AlertDialog(
519+ onDismissRequest = { showShareWarning = false },
520+ title = { Text("Share Logs") },
521+ text = {
522+ Text("Logs may contain sensitive information (PII, secrets, tokens). " +
523+ "They will be exported as unencrypted JSON. Continue?")
524+ },
525+ confirmButton = {
526+ TextButton(onClick = {
527+ showShareWarning = false
528+ val logs = state.logs
529+ if (logs.isNotEmpty()) {
530+ scope.launch {
531+ try {
532+ val file = withContext(kotlinx.coroutines.Dispatchers.IO) {
533+ val dir = java.io.File(context.cacheDir, "shared_logs")
534+ if (!dir.mkdirs() && !dir.isDirectory) {
535+ throw java.io.IOException("Failed to create shared_logs directory")
536+ }
537+ // Clean up STALE exports only. A share chooser opened moments
538+ // ago may still be reading a recent file via its FileProvider
539+ // URI; deleting every file (as before) could pull one out from
540+ // under an in-flight share. Keep anything written in the last
541+ // hour and give each export a unique name so concurrent shares
542+ // never collide.
543+ val cutoff = System.currentTimeMillis() - 60 * 60 * 1000L
544+ dir.listFiles()?.forEach { old ->
545+ if (old.lastModified() < cutoff) old.delete()
546+ }
547+ val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
548+ val file = java.io.File(dir, "logs_${safeFileName}_${System.currentTimeMillis()}.json")
549+ file.bufferedWriter().use { writer ->
550+ // Use compact JSON for large exports to reduce memory/file size
551+ val encoder = if (logs.size > 1000) Json else prettyJson
552+ writer.write("[")
553+ logs.forEachIndexed { index, log ->
554+ if (index > 0) writer.write(",")
555+ writer.newLine()
556+ writer.write(encoder.encodeToString(JsonObject.serializer(), log))
557+ }
558+ writer.newLine()
559+ writer.write("]")
560+ }
561+ file
562+ }
563+ val uri = FileProvider.getUriForFile(
564+ context,
565+ "${context.packageName}.fileprovider",
566+ file,
567+ )
568+ val sendIntent = Intent().apply {
569+ action = Intent.ACTION_SEND
570+ putExtra(Intent.EXTRA_STREAM, uri)
571+ putExtra(Intent.EXTRA_SUBJECT, "Logs: $streamName")
572+ type = "application/json"
573+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
574+ }
575+ context.startActivity(
576+ Intent.createChooser(sendIntent, "Share logs")
577+ )
578+ } catch (e: Exception) {
579+ snackbarHostState.showSnackbar(
580+ message = "Failed to share logs: ${e.message}",
581+ duration = SnackbarDuration.Short,
582+ )
583+ }
584+ }
585+ }
586+ }) {
587+ Text("Share")
588+ }
589+ },
590+ dismissButton = {
591+ TextButton(onClick = { showShareWarning = false }) {
592+ Text("Cancel")
593+ }
594+ },
595+ )
596+ }
597+
598+ // Custom date range picker – state is created inside the block so it
599+ // resets every time the dialog reopens (no stale selection carried over).
600+ if (showDateRangePicker) {
601+ val dateRangePickerState = rememberDateRangePickerState()
602+ DatePickerDialog(
603+ onDismissRequest = { showDateRangePicker = false },
604+ confirmButton = {
605+ TextButton(
606+ onClick = {
607+ val start = dateRangePickerState.selectedStartDateMillis
608+ val end = dateRangePickerState.selectedEndDateMillis
609+ if (start != null && end != null) {
610+ // DateRangePicker returns midnight UTC for the selected date, so the
611+ // calendar date must be read back in UTC — reading it in the local zone
612+ // would roll west-of-UTC users to the previous day. Once we have the
613+ // tapped date, build the boundaries to cover the full day locally.
614+ val localZone = java.time.ZoneId.systemDefault()
615+ val endLocalDate = java.time.Instant.ofEpochMilli(end)
616+ .atZone(java.time.ZoneOffset.UTC).toLocalDate()
617+ val endOfDayUtc = endLocalDate.plusDays(1)
618+ .atStartOfDay(localZone)
619+ .toInstant().toEpochMilli() - 1
620+ val startLocalDate = java.time.Instant.ofEpochMilli(start)
621+ .atZone(java.time.ZoneOffset.UTC).toLocalDate()
622+ val startUtc = startLocalDate
623+ .atStartOfDay(localZone)
624+ .toInstant().toEpochMilli()
625+ viewModel.setCustomTimeRange(startUtc, endOfDayUtc)
626+ }
627+ showDateRangePicker = false
628+ },
629+ enabled = dateRangePickerState.selectedStartDateMillis != null &&
630+ dateRangePickerState.selectedEndDateMillis != null,
631+ ) {
632+ Text("Apply")
633+ }
634+ },
635+ dismissButton = {
636+ TextButton(onClick = { showDateRangePicker = false }) {
637+ Text("Cancel")
638+ }
639+ },
640+ ) {
641+ DateRangePicker(
642+ state = dateRangePickerState,
643+ modifier = Modifier.weight(1f),
644+ )
645+ }
646+ }
647+
648+ // Saved filters bottom sheet
649+ if (showSavedFiltersSheet) {
650+ SavedFiltersBottomSheet(
651+ savedFiltersState = state.savedFilters,
652+ hasActiveFilters = state.activeFilters.isNotEmpty() ||
653+ state.searchQuery.isNotBlank() ||
654+ state.customSql.isNotBlank(),
655+ onDismiss = { showSavedFiltersSheet = false },
656+ onSaveCurrentFilter = { showSaveFilterDialog = true },
657+ onApplyFilter = { filter ->
658+ viewModel.applySavedFilter(filter)
659+ showSavedFiltersSheet = false
660+ },
661+ onDeleteFilter = { filterId -> viewModel.deleteSavedFilter(filterId) },
662+ onRetry = { viewModel.loadSavedFilters() },
663+ )
664+ }
665+
666+ // Save filter name dialog
667+ if (showSaveFilterDialog) {
668+ SaveFilterDialog(
669+ isSaving = state.savedFilters.isSaving,
670+ onDismiss = { showSaveFilterDialog = false },
671+ onSave = { name ->
672+ viewModel.saveCurrentFilter(name)
673+ showSaveFilterDialog = false
674+ showSavedFiltersSheet = false
675+ },
676+ )
677+ }
678+}
679+
680+@Composable
681+private fun TimeRangeBar(
682+ selectedRange: TimeRange,
683+ onRangeSelected: (TimeRange) -> Unit,
684+) {
685+ Row(
686+ modifier = Modifier
687+ .horizontalScroll(rememberScrollState())
688+ .padding(horizontal = 16.dp, vertical = 4.dp),
689+ horizontalArrangement = Arrangement.spacedBy(8.dp),
690+ ) {
691+ TimeRange.entries.forEach { range ->
692+ FilterChip(
693+ selected = selectedRange == range,
694+ onClick = { onRangeSelected(range) },
695+ label = { Text(range.label) },
696+ )
697+ }
698+ }
699+}
700+
701+@Composable
702+private fun StreamingToggleButton(
703+ isStreaming: Boolean,
704+ enabled: Boolean,
705+ onClick: () -> Unit,
706+) {
707+ IconButton(onClick = onClick, enabled = enabled || isStreaming) {
708+ if (isStreaming) {
709+ val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
710+ val alpha by infiniteTransition.animateFloat(
711+ initialValue = 1f,
712+ targetValue = 0.3f,
713+ animationSpec = infiniteRepeatable(
714+ animation = tween(600, easing = EaseInOut),
715+ repeatMode = RepeatMode.Reverse,
716+ ),
717+ label = "stream_btn_alpha",
718+ )
719+ Icon(
720+ Icons.Filled.Stop,
721+ contentDescription = "Stop streaming",
722+ tint = MaterialTheme.colorScheme.error,
723+ modifier = Modifier.alpha(alpha),
724+ )
725+ } else {
726+ Icon(
727+ Icons.Filled.PlayArrow,
728+ contentDescription = "Start live tail",
729+ tint = MaterialTheme.colorScheme.primary,
730+ )
731+ }
732+ }
733+}
734+
735+@Composable
736+fun LogEntryCard(
737+ logEntry: JsonObject,
738+ stableKey: String,
739+ isExpanded: Boolean,
740+ onClick: () -> Unit,
741+ onCopied: () -> Unit = {},
742+ onFilterInclude: (column: String, value: String) -> Unit = { _, _ -> },
743+ onFilterExclude: (column: String, value: String) -> Unit = { _, _ -> },
744+ clipboardManager: android.content.ClipboardManager,
745+) {
746+ val severity = remember(logEntry) { detectSeverity(logEntry) }
747+ val severityTint = severityBackgroundColor(severity)
748+ val containerColor = severityTint ?: MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
749+ val cardColors = CardDefaults.cardColors(containerColor = containerColor)
750+ Card(
751+ onClick = onClick,
752+ modifier = Modifier.fillMaxWidth(),
753+ colors = cardColors,
754+ ) {
755+ Column(modifier = Modifier.padding(12.dp)) {
756+ // Timestamp row
757+ // Key the derived values on the content-stable log key (not the JsonObject
758+ // instance), so live-tail re-emissions of unchanged rows don't re-parse them.
759+ val rawTimestamp = remember(stableKey) {
760+ try {
761+ logEntry["p_timestamp"]?.jsonPrimitive?.content
762+ ?: logEntry["datetime"]?.jsonPrimitive?.content
763+ ?: ""
764+ } catch (_: Exception) {
765+ ""
766+ }
767+ }
768+ val displayTimestamp = remember(rawTimestamp) {
769+ if (rawTimestamp.isNotEmpty()) formatTimestamp(rawTimestamp) else ""
770+ }
771+
772+ if (displayTimestamp.isNotEmpty()) {
773+ Text(
774+ text = displayTimestamp,
775+ style = MaterialTheme.typography.labelSmall,
776+ color = MaterialTheme.colorScheme.primary,
777+ fontFamily = FontFamily.Monospace,
778+ )
779+ Spacer(modifier = Modifier.height(4.dp))
780+ }
781+
782+ if (!isExpanded) {
783+ // Compact view: show first meaningful fields
784+ val preview = remember(stableKey) {
785+ logEntry.entries
786+ .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
787+ .take(3)
788+ .joinToString(" | ") { "${it.key}: ${formatJsonValue(it.value)}" }
789+ }
790+ Text(
791+ text = preview.ifEmpty { logEntry.toString() },
792+ style = MaterialTheme.typography.bodySmall,
793+ fontFamily = FontFamily.Monospace,
794+ maxLines = 2,
795+ overflow = TextOverflow.Ellipsis,
796+ fontSize = 12.sp,
797+ )
798+ } else {
799+ // Expanded view: show all fields + copy button
800+ Row(
801+ modifier = Modifier.fillMaxWidth(),
802+ horizontalArrangement = Arrangement.End,
803+ ) {
804+ IconButton(
805+ onClick = {
806+ val text = prettyJson.encodeToString(JsonObject.serializer(), logEntry)
807+ try {
808+ clipboardManager.setPrimaryClip(
809+ android.content.ClipData.newPlainText("Log entry", text)
810+ )
811+ onCopied()
812+ } catch (_: SecurityException) {
813+ // Some devices/custom ROMs restrict clipboard access
814+ }
815+ },
816+ ) {
817+ Icon(
818+ Icons.Filled.ContentCopy,
819+ contentDescription = "Copy log entry to clipboard",
820+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
821+ )
822+ }
823+ }
824+ HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
825+ logEntry.entries.forEach { (key, value) ->
826+ val displayValue = formatJsonValue(value)
827+ Row(
828+ modifier = Modifier
829+ .fillMaxWidth()
830+ .padding(vertical = 2.dp),
831+ verticalAlignment = Alignment.CenterVertically,
832+ ) {
833+ Column(modifier = Modifier.weight(1f)) {
834+ Row {
835+ Text(
836+ text = "$key: ",
837+ style = MaterialTheme.typography.bodySmall,
838+ fontWeight = FontWeight.Bold,
839+ fontFamily = FontFamily.Monospace,
840+ color = MaterialTheme.colorScheme.primary,
841+ fontSize = 12.sp,
842+ )
843+ Text(
844+ text = displayValue,
845+ style = MaterialTheme.typography.bodySmall,
846+ fontFamily = FontFamily.Monospace,
847+ fontSize = 12.sp,
848+ )
849+ }
850+ }
851+ // Filter include (=) button
852+ IconButton(
853+ onClick = { onFilterInclude(key, displayValue) },
854+ modifier = Modifier.size(28.dp),
855+ ) {
856+ Icon(
857+ Icons.Filled.AddCircle,
858+ contentDescription = "Filter for $key = $displayValue",
859+ modifier = Modifier.size(16.dp),
860+ tint = MaterialTheme.colorScheme.primary,
861+ )
862+ }
863+ // Filter exclude (!=) button
864+ IconButton(
865+ onClick = { onFilterExclude(key, displayValue) },
866+ modifier = Modifier.size(28.dp),
867+ ) {
868+ Icon(
869+ Icons.Filled.RemoveCircle,
870+ contentDescription = "Filter out $key = $displayValue",
871+ modifier = Modifier.size(16.dp),
872+ tint = MaterialTheme.colorScheme.error,
873+ )
874+ }
875+ }
876+ }
877+ }
878+ }
879+ }
880+}
881+
882+private fun formatJsonValue(element: JsonElement): String {
883+ return when (element) {
884+ is JsonPrimitive -> element.content
885+ is JsonNull -> "null"
886+ else -> element.toString()
887+ }
888+}
889+
890+@OptIn(ExperimentalMaterial3Api::class)
891+@Composable
892+fun FilterBottomSheet(
893+ columns: List<String>,
894+ logs: List<JsonObject> = emptyList(),
895+ onDismiss: () -> Unit,
896+ onApplyFilter: (column: String, operator: String, value: String) -> Unit,
897+) {
898+ var selectedColumn by remember { mutableStateOf(columns.firstOrNull() ?: "") }
899+ // The filter icon is always enabled, so the sheet can open before the schema (and thus
900+ // `columns`) has loaded. In that case `selectedColumn` initializes to "" and — because the
901+ // remember above isn't keyed on `columns` — would stay blank even after columns arrive,
902+ // leaving the Column field empty and the Apply button permanently disabled. Backfill the
903+ // default once columns are available, but only while the selection is still blank so a
904+ // user's manual pick isn't clobbered by a late schema refresh.
905+ LaunchedEffect(columns) {
906+ if (selectedColumn.isBlank()) {
907+ selectedColumn = columns.firstOrNull() ?: ""
908+ }
909+ }
910+ var selectedOperator by remember { mutableStateOf("=") }
911+ var filterValue by remember { mutableStateOf("") }
912+ var columnDropdownExpanded by remember { mutableStateOf(false) }
913+ var operatorDropdownExpanded by remember { mutableStateOf(false) }
914+
915+ val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
916+
917+ // Extract distinct values for the selected column from current logs
918+ val columnValues = remember(selectedColumn, logs) {
919+ if (selectedColumn.isBlank() || logs.isEmpty()) {
920+ emptyList()
921+ } else {
922+ logs.asSequence()
923+ .mapNotNull { entry ->
924+ try {
925+ entry[selectedColumn]?.let { element ->
926+ formatJsonValue(element).takeIf { it != "null" && it.isNotBlank() }
927+ }
928+ } catch (_: Exception) {
929+ null
930+ }
931+ }
932+ .distinct()
933+ .take(50)
934+ .sorted()
935+ .toList()
936+ }
937+ }
938+
939+ ModalBottomSheet(onDismissRequest = onDismiss) {
940+ Column(
941+ modifier = Modifier.padding(16.dp),
942+ verticalArrangement = Arrangement.spacedBy(12.dp),
943+ ) {
944+ Text(
945+ text = "Add Filter",
946+ style = MaterialTheme.typography.titleLarge,
947+ fontWeight = FontWeight.Bold,
948+ )
949+
950+ // Column selector
951+ ExposedDropdownMenuBox(
952+ expanded = columnDropdownExpanded,
953+ onExpandedChange = { columnDropdownExpanded = it },
954+ ) {
955+ OutlinedTextField(
956+ value = selectedColumn,
957+ onValueChange = {},
958+ readOnly = true,
959+ label = { Text("Column") },
960+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = columnDropdownExpanded) },
961+ modifier = Modifier
962+ .fillMaxWidth()
963+ .menuAnchor(MenuAnchorType.PrimaryNotEditable),
964+ )
965+ ExposedDropdownMenu(
966+ expanded = columnDropdownExpanded,
967+ onDismissRequest = { columnDropdownExpanded = false },
968+ ) {
969+ columns.forEach { column ->
970+ DropdownMenuItem(
971+ text = { Text(column) },
972+ onClick = {
973+ selectedColumn = column
974+ filterValue = ""
975+ columnDropdownExpanded = false
976+ },
977+ )
978+ }
979+ }
980+ }
981+
982+ // Operator selector
983+ ExposedDropdownMenuBox(
984+ expanded = operatorDropdownExpanded,
985+ onExpandedChange = { operatorDropdownExpanded = it },
986+ ) {
987+ OutlinedTextField(
988+ value = selectedOperator,
989+ onValueChange = {},
990+ readOnly = true,
991+ label = { Text("Operator") },
992+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = operatorDropdownExpanded) },
993+ modifier = Modifier
994+ .fillMaxWidth()
995+ .menuAnchor(MenuAnchorType.PrimaryNotEditable),
996+ )
997+ ExposedDropdownMenu(
998+ expanded = operatorDropdownExpanded,
999+ onDismissRequest = { operatorDropdownExpanded = false },
1000+ ) {
1001+ operators.forEach { op ->
1002+ DropdownMenuItem(
1003+ text = { Text(op) },
1004+ onClick = {
1005+ selectedOperator = op
1006+ operatorDropdownExpanded = false
1007+ },
1008+ )
1009+ }
1010+ }
1011+ }
1012+
1013+ // Value input (hidden for IS NULL / IS NOT NULL)
1014+ if (selectedOperator !in listOf("IS NULL", "IS NOT NULL")) {
1015+ val valueHint = when (selectedOperator) {
1016+ "LIKE", "ILIKE" -> "Pattern (% added automatically)"
1017+ ">", "<", ">=", "<=" -> "Numeric or date value"
1018+ else -> "Exact value"
1019+ }
1020+ val valueError = when {
1021+ filterValue.isNotEmpty() && selectedOperator in listOf(">", "<", ">=", "<=") &&
1022+ filterValue.toDoubleOrNull() == null &&
1023+ !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*")) ->
1024+ "Expected a number or date"
1025+ else -> null
1026+ }
1027+ OutlinedTextField(
1028+ value = filterValue,
1029+ onValueChange = { filterValue = it },
1030+ label = { Text("Value") },
1031+ placeholder = { Text(valueHint) },
1032+ modifier = Modifier.fillMaxWidth(),
1033+ singleLine = true,
1034+ isError = valueError != null,
1035+ supportingText = valueError?.let { { Text(it) } },
1036+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
1037+ keyboardActions = KeyboardActions(onDone = {
1038+ if (selectedColumn.isNotBlank() && filterValue.isNotEmpty() && valueError == null) {
1039+ onApplyFilter(selectedColumn, selectedOperator, filterValue)
1040+ }
1041+ }),
1042+ )
1043+
1044+ // Show existing values as selectable suggestions
1045+ if (columnValues.isNotEmpty() && selectedOperator in listOf("=", "!=")) {
1046+ Text(
1047+ text = "Existing values:",
1048+ style = MaterialTheme.typography.labelMedium,
1049+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1050+ )
1051+ // Filter suggestions by typed text
1052+ val filteredValues = remember(columnValues, filterValue) {
1053+ if (filterValue.isBlank()) columnValues
1054+ else columnValues.filter {
1055+ it.contains(filterValue, ignoreCase = true)
1056+ }
1057+ }
1058+ Row(
1059+ modifier = Modifier
1060+ .fillMaxWidth()
1061+ .horizontalScroll(rememberScrollState()),
1062+ horizontalArrangement = Arrangement.spacedBy(6.dp),
1063+ ) {
1064+ filteredValues.forEach { value ->
1065+ SuggestionChip(
1066+ onClick = {
1067+ onApplyFilter(selectedColumn, selectedOperator, value)
1068+ },
1069+ label = {
1070+ Text(
1071+ text = if (value.length > 40) value.take(40) + "\u2026" else value,
1072+ maxLines = 1,
1073+ overflow = TextOverflow.Ellipsis,
1074+ fontSize = 12.sp,
1075+ )
1076+ },
1077+ )
1078+ }
1079+ }
1080+ }
1081+ }
1082+
1083+ val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
1084+ filterValue.isNotEmpty() &&
1085+ filterValue.toDoubleOrNull() == null &&
1086+ !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*"))
1087+
1088+ Row(
1089+ modifier = Modifier.fillMaxWidth(),
1090+ horizontalArrangement = Arrangement.End,
1091+ verticalAlignment = Alignment.CenterVertically,
1092+ ) {
1093+ TextButton(onClick = onDismiss) {
1094+ Text("Cancel")
1095+ }
1096+ Spacer(modifier = Modifier.width(8.dp))
1097+ Button(
1098+ onClick = {
1099+ if (selectedColumn.isNotBlank()) {
1100+ onApplyFilter(selectedColumn, selectedOperator, filterValue)
1101+ }
1102+ },
1103+ enabled = selectedColumn.isNotBlank() &&
1104+ !hasValueError &&
1105+ (selectedOperator in listOf("IS NULL", "IS NOT NULL") || filterValue.isNotEmpty()),
1106+ ) {
1107+ Text("Apply")
1108+ }
1109+ }
1110+
1111+ Spacer(modifier = Modifier.height(32.dp))
1112+ }
1113+ }
1114+}
1115+
1116+@OptIn(ExperimentalMaterial3Api::class)
1117+@Composable
1118+fun SqlQueryBottomSheet(
1119+ streamName: String,
1120+ currentSql: String,
1121+ onDismiss: () -> Unit,
1122+ onExecute: (String) -> Unit,
1123+) {
1124+ val safeName = remember(streamName) { escapeIdentifier(streamName) }
1125+ var sql by remember {
1126+ mutableStateOf(
1127+ currentSql.ifEmpty {
1128+ "SELECT * FROM \"$safeName\" ORDER BY p_timestamp DESC LIMIT 100"
1129+ }
1130+ )
1131+ }
1132+
1133+ ModalBottomSheet(onDismissRequest = onDismiss) {
1134+ Column(
1135+ modifier = Modifier.padding(16.dp),
1136+ verticalArrangement = Arrangement.spacedBy(12.dp),
1137+ ) {
1138+ Text(
1139+ text = "SQL Query",
1140+ style = MaterialTheme.typography.titleLarge,
1141+ fontWeight = FontWeight.Bold,
1142+ )
1143+
1144+ Text(
1145+ text = "Use PostgreSQL-compatible SQL to query your logs.",
1146+ style = MaterialTheme.typography.bodySmall,
1147+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1148+ )
1149+
1150+ OutlinedTextField(
1151+ value = sql,
1152+ onValueChange = { sql = it },
1153+ label = { Text("SQL") },
1154+ modifier = Modifier
1155+ .fillMaxWidth()
1156+ .heightIn(min = 120.dp),
1157+ textStyle = MaterialTheme.typography.bodyMedium.copy(
1158+ fontFamily = FontFamily.Monospace,
1159+ ),
1160+ maxLines = 8,
1161+ )
1162+
1163+ Row(
1164+ modifier = Modifier.fillMaxWidth(),
1165+ horizontalArrangement = Arrangement.End,
1166+ ) {
1167+ TextButton(onClick = onDismiss) {
1168+ Text("Cancel")
1169+ }
1170+ Spacer(modifier = Modifier.width(8.dp))
1171+ Button(
1172+ onClick = { onExecute(sql) },
1173+ enabled = sql.isNotBlank(),
1174+ ) {
1175+ Icon(Icons.Filled.PlayArrow, contentDescription = "Execute query")
1176+ Spacer(modifier = Modifier.width(4.dp))
1177+ Text("Execute")
1178+ }
1179+ }
1180+
1181+ Spacer(modifier = Modifier.height(32.dp))
1182+ }
1183+ }
1184+}
1185+
1186+@OptIn(ExperimentalMaterial3Api::class)
1187+@Composable
1188+fun SavedFiltersBottomSheet(
1189+ savedFiltersState: SavedFiltersState,
1190+ hasActiveFilters: Boolean,
1191+ onDismiss: () -> Unit,
1192+ onSaveCurrentFilter: () -> Unit,
1193+ onApplyFilter: (com.parseable.android.data.model.SavedFilter) -> Unit,
1194+ onDeleteFilter: (String) -> Unit,
1195+ onRetry: () -> Unit,
1196+) {
1197+ var filterToDelete by remember { mutableStateOf<String?>(null) }
1198+
1199+ ModalBottomSheet(onDismissRequest = onDismiss) {
1200+ Column(
1201+ modifier = Modifier.padding(16.dp),
1202+ verticalArrangement = Arrangement.spacedBy(12.dp),
1203+ ) {
1204+ Row(
1205+ modifier = Modifier.fillMaxWidth(),
1206+ horizontalArrangement = Arrangement.SpaceBetween,
1207+ verticalAlignment = Alignment.CenterVertically,
1208+ ) {
1209+ Text(
1210+ text = "Saved Filters",
1211+ style = MaterialTheme.typography.titleLarge,
1212+ fontWeight = FontWeight.Bold,
1213+ )
1214+ if (hasActiveFilters) {
1215+ FilledTonalButton(onClick = onSaveCurrentFilter) {
1216+ Icon(
1217+ Icons.Filled.Add,
1218+ contentDescription = null,
1219+ modifier = Modifier.size(18.dp),
1220+ )
1221+ Spacer(modifier = Modifier.width(4.dp))
1222+ Text("Save Current")
1223+ }
1224+ }
1225+ }
1226+
1227+ when {
1228+ savedFiltersState.isLoading -> {
1229+ Box(
1230+ modifier = Modifier
1231+ .fillMaxWidth()
1232+ .padding(32.dp),
1233+ contentAlignment = Alignment.Center,
1234+ ) {
1235+ CircularProgressIndicator()
1236+ }
1237+ }
1238+ savedFiltersState.error != null -> {
1239+ Column(
1240+ modifier = Modifier
1241+ .fillMaxWidth()
1242+ .padding(16.dp),
1243+ horizontalAlignment = Alignment.CenterHorizontally,
1244+ ) {
1245+ Text(
1246+ text = savedFiltersState.error,
1247+ style = MaterialTheme.typography.bodyMedium,
1248+ color = MaterialTheme.colorScheme.error,
1249+ )
1250+ Spacer(modifier = Modifier.height(8.dp))
1251+ OutlinedButton(onClick = onRetry) {
1252+ Text("Retry")
1253+ }
1254+ }
1255+ }
1256+ savedFiltersState.filters.isEmpty() -> {
1257+ Box(
1258+ modifier = Modifier
1259+ .fillMaxWidth()
1260+ .padding(32.dp),
1261+ contentAlignment = Alignment.Center,
1262+ ) {
1263+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
1264+ Icon(
1265+ Icons.Filled.BookmarkBorder,
1266+ contentDescription = null,
1267+ modifier = Modifier.size(40.dp),
1268+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1269+ )
1270+ Spacer(modifier = Modifier.height(8.dp))
1271+ Text(
1272+ text = "No saved filters for this stream",
1273+ style = MaterialTheme.typography.bodyMedium,
1274+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1275+ )
1276+ if (hasActiveFilters) {
1277+ Spacer(modifier = Modifier.height(4.dp))
1278+ Text(
1279+ text = "Use \"Save Current\" to save your active filters",
1280+ style = MaterialTheme.typography.bodySmall,
1281+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1282+ )
1283+ }
1284+ }
1285+ }
1286+ }
1287+ else -> {
1288+ savedFiltersState.filters.forEach { filter ->
1289+ Card(
1290+ onClick = { onApplyFilter(filter) },
1291+ modifier = Modifier.fillMaxWidth(),
1292+ ) {
1293+ Row(
1294+ modifier = Modifier
1295+ .fillMaxWidth()
1296+ .padding(12.dp),
1297+ verticalAlignment = Alignment.CenterVertically,
1298+ ) {
1299+ Icon(
1300+ Icons.Filled.Bookmark,
1301+ contentDescription = null,
1302+ tint = MaterialTheme.colorScheme.primary,
1303+ modifier = Modifier.size(20.dp),
1304+ )
1305+ Spacer(modifier = Modifier.width(12.dp))
1306+ Column(modifier = Modifier.weight(1f)) {
1307+ Text(
1308+ text = filter.filterName,
1309+ style = MaterialTheme.typography.titleSmall,
1310+ fontWeight = FontWeight.Medium,
1311+ )
1312+ val description = when (filter.query.filterType) {
1313+ "sql" -> filter.query.filterQuery?.take(60) ?: "SQL query"
1314+ "search" -> "Search: ${filter.query.filterQuery ?: ""}"
1315+ else -> {
1316+ val ruleCount = filter.query.filterBuilder?.rules
1317+ ?.sumOf { it.rules.size } ?: 0
1318+ if (ruleCount > 0) "$ruleCount filter rule(s)" else "No rules"
1319+ }
1320+ }
1321+ Text(
1322+ text = description,
1323+ style = MaterialTheme.typography.bodySmall,
1324+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1325+ maxLines = 1,
1326+ overflow = TextOverflow.Ellipsis,
1327+ )
1328+ }
1329+ IconButton(onClick = {
1330+ filterToDelete = filter.filterId
1331+ }) {
1332+ Icon(
1333+ Icons.Filled.Delete,
1334+ contentDescription = "Delete filter",
1335+ tint = MaterialTheme.colorScheme.error,
1336+ )
1337+ }
1338+ }
1339+ }
1340+ }
1341+ }
1342+ }
1343+
1344+ Spacer(modifier = Modifier.height(32.dp))
1345+ }
1346+ }
1347+
1348+ // Delete confirmation dialog
1349+ if (filterToDelete != null) {
1350+ AlertDialog(
1351+ onDismissRequest = { filterToDelete = null },
1352+ title = { Text("Delete Filter") },
1353+ text = { Text("Are you sure you want to delete this saved filter? This will remove it from the server.") },
1354+ confirmButton = {
1355+ TextButton(onClick = {
1356+ filterToDelete?.let { onDeleteFilter(it) }
1357+ filterToDelete = null
1358+ }) {
1359+ Text("Delete", color = MaterialTheme.colorScheme.error)
1360+ }
1361+ },
1362+ dismissButton = {
1363+ TextButton(onClick = { filterToDelete = null }) {
1364+ Text("Cancel")
1365+ }
1366+ },
1367+ )
1368+ }
1369+}
1370+
1371+@Composable
1372+fun SaveFilterDialog(
1373+ isSaving: Boolean,
1374+ onDismiss: () -> Unit,
1375+ onSave: (String) -> Unit,
1376+) {
1377+ var filterName by remember { mutableStateOf("") }
1378+
1379+ AlertDialog(
1380+ onDismissRequest = onDismiss,
1381+ title = { Text("Save Filter") },
1382+ text = {
1383+ Column {
1384+ Text(
1385+ text = "Save your current filters to the Parseable server. You can load them later on any device.",
1386+ style = MaterialTheme.typography.bodySmall,
1387+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1388+ )
1389+ Spacer(modifier = Modifier.height(12.dp))
1390+ OutlinedTextField(
1391+ value = filterName,
1392+ onValueChange = { filterName = it },
1393+ label = { Text("Filter name") },
1394+ singleLine = true,
1395+ modifier = Modifier.fillMaxWidth(),
1396+ enabled = !isSaving,
1397+ )
1398+ }
1399+ },
1400+ confirmButton = {
1401+ if (isSaving) {
1402+ CircularProgressIndicator(
1403+ modifier = Modifier
1404+ .size(24.dp)
1405+ .padding(end = 8.dp),
1406+ strokeWidth = 2.dp,
1407+ )
1408+ } else {
1409+ TextButton(
1410+ onClick = { onSave(filterName.trim()) },
1411+ enabled = filterName.isNotBlank(),
1412+ ) {
1413+ Text("Save")
1414+ }
1415+ }
1416+ },
1417+ dismissButton = {
1418+ TextButton(onClick = onDismiss, enabled = !isSaving) {
1419+ Text("Cancel")
1420+ }
1421+ },
1422+ )
1423+}
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerViewModel.ktnew file+964
@@ -0,0 +1,964 @@
1+package com.parseable.android.ui.screens.logviewer
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import com.parseable.android.data.escapeIdentifier
6+import com.parseable.android.data.escapeLikePattern
7+import com.parseable.android.data.escapeSql
8+import com.parseable.android.data.model.*
9+import com.parseable.android.data.repository.ParseableRepository
10+import dagger.hilt.android.lifecycle.HiltViewModel
11+import kotlinx.coroutines.Job
12+import kotlinx.coroutines.delay
13+import kotlinx.coroutines.flow.MutableStateFlow
14+import kotlinx.coroutines.flow.StateFlow
15+import kotlinx.coroutines.flow.asStateFlow
16+import kotlinx.coroutines.flow.update
17+import kotlinx.coroutines.isActive
18+import kotlinx.coroutines.launch
19+import kotlinx.coroutines.sync.Mutex
20+import kotlinx.coroutines.sync.withLock
21+import kotlinx.serialization.json.JsonObject
22+import kotlinx.serialization.json.jsonPrimitive
23+import java.time.Instant
24+import java.time.LocalDateTime
25+import java.time.OffsetDateTime
26+import java.time.ZoneOffset
27+import java.time.ZonedDateTime
28+import java.time.format.DateTimeFormatter
29+import javax.inject.Inject
30+
31+enum class TimeRange(val label: String, val minutes: Long) {
32+ LAST_5M("5m", 5),
33+ LAST_15M("15m", 15),
34+ LAST_30M("30m", 30),
35+ LAST_1H("1h", 60),
36+ LAST_6H("6h", 360),
37+ LAST_24H("24h", 1440),
38+ LAST_7D("7d", 10080),
39+ LAST_30D("30d", 43200),
40+ CUSTOM("Custom", 0),
41+}
42+
43+/** The original (column, operator, value) a filter chip was built from. */
44+data class FilterCondition(
45+ val column: String,
46+ val operator: String,
47+ val value: String,
48+)
49+
50+data class FilterState(
51+ val searchQuery: String = "",
52+ val activeFilters: List<String> = emptyList(),
53+ val filterClauses: List<String> = emptyList(),
54+ // Structured form of each chip, index-aligned with activeFilters/filterClauses.
55+ // Kept so saved filters reconstruct from real values instead of re-parsing the
56+ // human-readable display string (which is ambiguous when a value contains an operator).
57+ val filterConditions: List<FilterCondition> = emptyList(),
58+ val customSql: String = "",
59+ val isSearching: Boolean = false,
60+)
61+
62+data class StreamingState(
63+ val isStreaming: Boolean = false,
64+ val streamingNewCount: Int = 0,
65+ val streamingError: String? = null,
66+ val currentIntervalMs: Long = 3000L,
67+)
68+
69+data class SavedFiltersState(
70+ val filters: List<SavedFilter> = emptyList(),
71+ val isLoading: Boolean = false,
72+ val isSaving: Boolean = false,
73+ val error: String? = null,
74+)
75+
76+data class LogViewerState(
77+ val streamName: String = "",
78+ val logs: List<JsonObject> = emptyList(),
79+ val logKeys: List<String> = emptyList(),
80+ val columns: List<String> = emptyList(),
81+ val searchableColumns: List<String> = emptyList(),
82+ val isLoading: Boolean = false,
83+ val error: String? = null,
84+ val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
85+ val filters: FilterState = FilterState(),
86+ val streaming: StreamingState = StreamingState(),
87+ val savedFilters: SavedFiltersState = SavedFiltersState(),
88+ val currentLimit: Int = 500,
89+ val hasMore: Boolean = false,
90+ val customStartTime: Long? = null,
91+ val customEndTime: Long? = null,
92+ // Upper time bound (epoch millis) fixed at the first page of a paging session, so loadMore()
93+ // doesn't slide a relative ("last N minutes") window forward as it re-queries. Null until the
94+ // first refresh of a session; re-anchored whenever currentLimit resets to the initial page.
95+ val pageAnchorTime: Long? = null,
96+) {
97+ // Convenience accessors for backward compatibility with Screen
98+ val searchQuery: String get() = filters.searchQuery
99+ val activeFilters: List<String> get() = filters.activeFilters
100+ val filterClauses: List<String> get() = filters.filterClauses
101+ val customSql: String get() = filters.customSql
102+ val isStreaming: Boolean get() = streaming.isStreaming
103+ val streamingNewCount: Int get() = streaming.streamingNewCount
104+ val streamingError: String? get() = streaming.streamingError
105+ val currentIntervalMs: Long get() = streaming.currentIntervalMs
106+ val isSearching: Boolean get() = filters.isSearching
107+}
108+
109+@HiltViewModel
110+class LogViewerViewModel @Inject constructor(
111+ private val repository: ParseableRepository,
112+) : ViewModel() {
113+
114+ private val _state = MutableStateFlow(LogViewerState())
115+ val state: StateFlow<LogViewerState> = _state.asStateFlow()
116+
117+ private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'+00:00'")
118+
119+ private var streamingJob: Job? = null
120+ @Volatile private var streamingGeneration: Int = 0
121+ @Volatile private var lastSeenTimestamp: String? = null
122+ @Volatile private var consecutiveStreamingErrors: Int = 0
123+ // True while the log list is scrolled to the top. New rows prepend at the top, so when the
124+ // user is already there they see them live and the "+N new" badge must stay at 0. The badge
125+ // only accumulates while the user is scrolled away, and resets when they return to the top.
126+ @Volatile private var viewingTop: Boolean = true
127+
128+ // Identities of rows already surfaced during the current streaming session. Used to dedupe
129+ // re-fetched boundary rows. Kept INDEPENDENT of the displayed `logs` list — which is capped
130+ // at STREAMING_MAX_LOGS — so that under sustained high-volume streaming, boundary rows
131+ // evicted from the view are still recognized as duplicates and not prepended twice.
132+ // Insertion-ordered so the oldest keys are trimmed first. Touched only from the single
133+ // streaming coroutine and from startStreaming (which first cancels that coroutine).
134+ private val seenStreamingKeys = LinkedHashSet<String>()
135+ private var searchJob: Job? = null
136+ private var schemaJob: Job? = null
137+ private var refreshJob: Job? = null
138+
139+ companion object {
140+ private const val STREAMING_BASE_INTERVAL_MS = 3000L
141+ private const val STREAMING_MAX_INTERVAL_MS = 30000L
142+ private const val STREAMING_MAX_LOGS = 1000
143+ // Remember more identities than the display cap so a row evicted from the view is still
144+ // deduped on a later re-fetch. 2x the display cap covers the worst realistic overlap.
145+ private const val SEEN_STREAMING_KEYS_MAX = STREAMING_MAX_LOGS * 2
146+ private const val MAX_LOAD_LIMIT = 5000
147+ private const val LOAD_MORE_INCREMENT = 500
148+ private const val MAX_STREAMING_ERRORS = 5
149+ private val ALLOWED_OPERATORS = setOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
150+
151+ /**
152+ * Produces deterministic keys for a list of log entries so that expanded state
153+ * survives list mutations (e.g., new logs prepended during streaming).
154+ */
155+ internal fun computeLogKeys(logs: List<JsonObject>): List<String> {
156+ val seen = mutableMapOf<String, Int>()
157+ return logs.map { log ->
158+ // Key on the full row content (same identity used for streaming dedup, see
159+ // logIdentity). Keying on only p_timestamp/p_metadata/p_tags collided for rows
160+ // sharing those fields but differing in body, so the positional tiebreaker made
161+ // keys shift when streaming prepended rows — corrupting expanded state and
162+ // letting remember(stableKey) blocks render a stale body. The trailing counter
163+ // only disambiguates genuinely identical rows, whose rendering is identical too.
164+ val base = log.toString()
165+ val count = seen.getOrDefault(base, 0)
166+ seen[base] = count + 1
167+ if (count == 0) base else "$base|$count"
168+ }
169+ }
170+ }
171+
172+ fun initialize(streamName: String) {
173+ if (_state.value.streamName == streamName) return
174+ stopStreaming()
175+ _state.update { it.copy(streamName = streamName) }
176+ loadSchema(streamName)
177+ refresh()
178+ }
179+
180+ private fun loadSchema(streamName: String) {
181+ schemaJob?.cancel()
182+ schemaJob = viewModelScope.launch {
183+ when (val result = repository.getStreamSchema(streamName)) {
184+ is ApiResult.Success -> {
185+ val columns = result.data.fields.map { it.name }.sorted()
186+ val searchable = columns.filter { !it.startsWith("p_") }
187+ _state.update { it.copy(columns = columns, searchableColumns = searchable) }
188+ }
189+ is ApiResult.Error -> { /* Schema loading failure is non-fatal */ }
190+ }
191+ }
192+ }
193+
194+ fun onTimeRangeChange(range: TimeRange) {
195+ stopStreaming()
196+ _state.update {
197+ it.copy(
198+ selectedTimeRange = range,
199+ currentLimit = 500,
200+ customStartTime = if (range != TimeRange.CUSTOM) null else it.customStartTime,
201+ customEndTime = if (range != TimeRange.CUSTOM) null else it.customEndTime,
202+ )
203+ }
204+ if (range != TimeRange.CUSTOM) refresh()
205+ }
206+
207+ fun setCustomTimeRange(startMillis: Long, endMillis: Long) {
208+ stopStreaming()
209+ _state.update {
210+ it.copy(
211+ selectedTimeRange = TimeRange.CUSTOM,
212+ customStartTime = startMillis,
213+ customEndTime = endMillis,
214+ currentLimit = 500,
215+ )
216+ }
217+ refresh()
218+ }
219+
220+ fun onSearchQueryChange(query: String) {
221+ // A new query invalidates the live-tail baseline and the paged-up limit, matching
222+ // the time-range / custom-SQL handlers.
223+ stopStreaming()
224+ // Searching returns to the filter-builder query path, so drop any active custom SQL.
225+ _state.update { it.copy(currentLimit = 500, filters = it.filters.copy(searchQuery = query, customSql = "", isSearching = query.isNotBlank())) }
226+ searchJob?.cancel()
227+ searchJob = viewModelScope.launch {
228+ delay(300)
229+ _state.update { it.copy(filters = it.filters.copy(isSearching = false)) }
230+ refresh()
231+ }
232+ }
233+
234+ /**
235+ * Run the current search immediately (e.g. the IME "Search" action). Cancels the pending
236+ * debounce so it can't fire a second, redundant refresh ~300ms later.
237+ */
238+ fun submitSearch() {
239+ searchJob?.cancel()
240+ _state.update { it.copy(filters = it.filters.copy(isSearching = false)) }
241+ stopStreaming()
242+ refresh()
243+ }
244+
245+ fun addFilter(column: String, operator: String, value: String) {
246+ addFilterInternal(column, operator, value)
247+ refresh()
248+ }
249+
250+ private fun addFilterInternal(column: String, operator: String, value: String) {
251+ if (operator !in ALLOWED_OPERATORS) return
252+ val safeColumn = escapeIdentifier(column)
253+ val clause = when (operator) {
254+ "IS NULL", "IS NOT NULL" -> "\"$safeColumn\" $operator"
255+ // %/_ in the user's value must match literally, so escape the LIKE wildcards
256+ // and declare the escape character. Exact comparisons keep %/_ verbatim.
257+ "LIKE", "ILIKE" -> "\"$safeColumn\" $operator '%${escapeLikePattern(value)}%' ESCAPE '\\'"
258+ else -> "\"$safeColumn\" $operator '${escapeSql(value)}'"
259+ }
260+ // IS NULL / IS NOT NULL ignore the value in both the clause and the display, so store
261+ // an empty value in the structured condition too (keeps saved filters clean).
262+ val conditionValue = when (operator) {
263+ "IS NULL", "IS NOT NULL" -> ""
264+ else -> value
265+ }
266+ val display = when (operator) {
267+ "IS NULL", "IS NOT NULL" -> "$column $operator"
268+ else -> "$column $operator $value"
269+ }
270+ // A new filter invalidates the live-tail baseline and the paged-up limit.
271+ stopStreaming()
272+ _state.update {
273+ it.copy(
274+ currentLimit = 500,
275+ filters = it.filters.copy(
276+ filterClauses = it.filters.filterClauses + clause,
277+ activeFilters = it.filters.activeFilters + display,
278+ filterConditions = it.filters.filterConditions + FilterCondition(column, operator, conditionValue),
279+ customSql = "",
280+ ),
281+ )
282+ }
283+ }
284+
285+ fun removeFilter(display: String) {
286+ var removed = false
287+ _state.update {
288+ val index = it.filters.activeFilters.indexOf(display)
289+ if (index >= 0 && index < it.filters.filterClauses.size) {
290+ removed = true
291+ it.copy(
292+ currentLimit = 500,
293+ filters = it.filters.copy(
294+ activeFilters = it.filters.activeFilters.filterIndexed { i, _ -> i != index },
295+ filterClauses = it.filters.filterClauses.filterIndexed { i, _ -> i != index },
296+ filterConditions = it.filters.filterConditions.filterIndexed { i, _ -> i != index },
297+ customSql = "",
298+ ),
299+ )
300+ } else {
301+ it
302+ }
303+ }
304+ if (removed) {
305+ // A changed filter set invalidates the live-tail baseline.
306+ stopStreaming()
307+ refresh()
308+ }
309+ }
310+
311+ fun clearFilters() {
312+ stopStreaming()
313+ _state.update {
314+ it.copy(currentLimit = 500, filters = it.filters.copy(activeFilters = emptyList(), filterClauses = emptyList(), filterConditions = emptyList(), customSql = "", searchQuery = "", isSearching = false))
315+ }
316+ refresh()
317+ }
318+
319+ fun executeCustomSql(sql: String) {
320+ // Custom SQL changes the query basis, so cancel any pending search debounce that would
321+ // otherwise fire a redundant refresh() ~300ms later. (Unlike the filter/time-range
322+ // handlers, this path doesn't route through stopStreaming().)
323+ searchJob?.cancel()
324+ // Drop a trailing statement terminator before we append anything — otherwise
325+ // "SELECT * FROM x;" would become "SELECT * FROM x; LIMIT 5000", a syntax error.
326+ val trimmed = sql.trim().trimEnd(';').trim()
327+ if (!trimmed.uppercase().startsWith("SELECT")) {
328+ _state.update { it.copy(error = "Only SELECT queries are allowed") }
329+ return
330+ }
331+ // Enforce a LIMIT to prevent OOM from unbounded queries. Only honor a *top-level*
332+ // `LIMIT <n>` (optionally with OFFSET) anchored at the end of the statement — a
333+ // LIMIT inside a subquery doesn't bound the outer result set, so matching it
334+ // anywhere would let an unbounded outer query slip past the safety limit. Anchoring
335+ // at the end also means a string literal like 'LIMIT EXCEEDED' can't disable it.
336+ val hasTopLevelLimit =
337+ Regex("(?i)\\bLIMIT\\s+\\d+(\\s+OFFSET\\s+\\d+)?\\s*$").containsMatchIn(trimmed)
338+ val safeSql = if (!hasTopLevelLimit) {
339+ "$trimmed LIMIT $MAX_LOAD_LIMIT"
340+ } else {
341+ trimmed
342+ }
343+ // Store the custom SQL and let refresh() run it. Keeping it in state means
344+ // pull-to-refresh / time-range changes re-run the custom query instead of
345+ // silently replacing it with the default filter query.
346+ _state.update { it.copy(filters = it.filters.copy(customSql = safeSql), currentLimit = 500) }
347+ refresh()
348+ }
349+
350+ private fun buildSearchClause(searchableColumns: List<String>, searchQuery: String): String? {
351+ if (searchQuery.isBlank()) return null
352+ if (searchableColumns.isEmpty()) return null
353+ val safeSearch = escapeLikePattern(searchQuery)
354+ return searchableColumns.joinToString(" OR ") {
355+ "CAST(\"${escapeIdentifier(it)}\" AS VARCHAR) ILIKE '%$safeSearch%' ESCAPE '\\'"
356+ }
357+ }
358+
359+ fun refresh() {
360+ if (_state.value.streamName.isEmpty()) return
361+
362+ // Cancel any in-flight query so out-of-order completions can't overwrite
363+ // fresh results with stale ones (filter/search/time-range/loadMore all call refresh()).
364+ refreshJob?.cancel()
365+ refreshJob = viewModelScope.launch {
366+ // Anchor the upper time bound on the first page of a paging session (currentLimit at
367+ // its initial value) and reuse it for subsequent loadMore() pages. Otherwise a relative
368+ // range would recompute "now" on every page in getTimeRange(), sliding the window and
369+ // duplicating/dropping rows on an actively-ingesting stream.
370+ val anchor = _state.value.let { s ->
371+ if (s.currentLimit <= 500) System.currentTimeMillis() else s.pageAnchorTime
372+ }
373+ _state.update {
374+ it.copy(
375+ isLoading = true,
376+ error = null,
377+ streaming = it.streaming.copy(streamingError = null),
378+ pageAnchorTime = anchor,
379+ )
380+ }
381+
382+ // Snapshot state inside the coroutine so we always see the latest filters/columns
383+ val current = _state.value
384+ val (startTime, endTime) = getTimeRange()
385+
386+ // A custom SQL query owns the whole statement (its own LIMIT, columns, ordering),
387+ // so re-run it verbatim. It is not paginated via loadMore(), hence hasMore = false.
388+ if (current.filters.customSql.isNotBlank()) {
389+ when (val result = repository.queryLogsRaw(current.filters.customSql, startTime, endTime)) {
390+ is ApiResult.Success -> {
391+ _state.update { it.copy(logs = result.data, logKeys = computeLogKeys(result.data), isLoading = false, hasMore = false) }
392+ }
393+ is ApiResult.Error -> {
394+ _state.update { it.copy(isLoading = false, error = result.userMessage) }
395+ }
396+ }
397+ return@launch
398+ }
399+
400+ // Build WHERE clause from filters + search
401+ val clauses = current.filters.filterClauses.toMutableList()
402+ if (current.filters.searchQuery.isNotBlank()) {
403+ val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
404+ if (searchClause != null) {
405+ clauses.add("($searchClause)")
406+ } else {
407+ _state.update {
408+ it.copy(isLoading = false, error = "Search unavailable: stream schema not loaded")
409+ }
410+ return@launch
411+ }
412+ }
413+
414+ val filterSql = clauses.joinToString(" AND ")
415+
416+ // Over-fetch by one row so we can distinguish "exactly currentLimit rows exist"
417+ // (no more pages) from "the page is full and more may exist" without firing a
418+ // wasted extra query the next time the user scrolls to the bottom.
419+ val requestLimit = current.currentLimit + 1
420+ when (val result = repository.queryLogs(
421+ stream = current.streamName,
422+ startTime = startTime,
423+ endTime = endTime,
424+ filterSql = filterSql,
425+ limit = requestLimit,
426+ )) {
427+ is ApiResult.Success -> {
428+ val hasMore = result.data.size > current.currentLimit && current.currentLimit < MAX_LOAD_LIMIT
429+ val logs = if (result.data.size > current.currentLimit) {
430+ result.data.take(current.currentLimit)
431+ } else {
432+ result.data
433+ }
434+ _state.update {
435+ it.copy(logs = logs, logKeys = computeLogKeys(logs), isLoading = false, hasMore = hasMore)
436+ }
437+ }
438+ is ApiResult.Error -> {
439+ _state.update {
440+ it.copy(isLoading = false, error = result.userMessage)
441+ }
442+ }
443+ }
444+ }
445+ }
446+
447+ /**
448+ * Pull-to-refresh: restart the paging session from the first page. Resetting currentLimit
449+ * makes refresh() re-anchor pageAnchorTime to "now", so a relative time range advances and
450+ * newly-ingested rows appear. Calling refresh() alone keeps the frozen anchor once the user
451+ * has paged past the first page, making pull-to-refresh look like it does nothing.
452+ */
453+ fun pullRefresh() {
454+ if (_state.value.streamName.isEmpty()) return
455+ // While live-tailing, the poller continuously prepends fresh rows; a concurrent refresh()
456+ // would replace the list with a full page at the same time, racing the poller on logs/
457+ // logKeys/streamingError and producing duplicated or dropped rows plus a stale "+N" badge.
458+ // The displayed logs are already current under streaming, so the pull is a no-op here —
459+ // mirror loadMore()'s guard rather than the stopStreaming() path the other mutators take.
460+ if (_state.value.streaming.isStreaming) return
461+ _state.update { it.copy(currentLimit = 500) }
462+ refresh()
463+ }
464+
465+ fun loadMore() {
466+ val current = _state.value
467+ // While live-tailing, logs are prepended in memory; a refresh() here would replace
468+ // them with a fresh page and drop the streamed rows. Pagination resumes once stopped.
469+ if (current.streaming.isStreaming) return
470+ if (current.currentLimit >= MAX_LOAD_LIMIT) {
471+ _state.update { it.copy(hasMore = false) }
472+ return
473+ }
474+ val newLimit = (current.currentLimit + LOAD_MORE_INCREMENT).coerceAtMost(MAX_LOAD_LIMIT)
475+ _state.update { it.copy(currentLimit = newLimit) }
476+ refresh()
477+ }
478+
479+ private fun getTimeRange(): Pair<String, String> {
480+ val current = _state.value
481+ if (current.selectedTimeRange == TimeRange.CUSTOM &&
482+ current.customStartTime != null && current.customEndTime != null
483+ ) {
484+ val start = ZonedDateTime.ofInstant(
485+ java.time.Instant.ofEpochMilli(current.customStartTime), ZoneOffset.UTC
486+ )
487+ val end = ZonedDateTime.ofInstant(
488+ java.time.Instant.ofEpochMilli(current.customEndTime), ZoneOffset.UTC
489+ )
490+ return Pair(start.format(dateFormatter), end.format(dateFormatter))
491+ }
492+ val now = current.pageAnchorTime
493+ ?.let { ZonedDateTime.ofInstant(java.time.Instant.ofEpochMilli(it), ZoneOffset.UTC) }
494+ ?: ZonedDateTime.now(ZoneOffset.UTC)
495+ val start = now.minusMinutes(current.selectedTimeRange.minutes)
496+ return Pair(
497+ start.format(dateFormatter),
498+ now.format(dateFormatter),
499+ )
500+ }
501+
502+ /**
503+ * Report whether the log list is scrolled to the top. Clears the "+N new" streaming badge
504+ * when the user returns to the top, since prepended rows there are seen immediately.
505+ */
506+ fun setViewingTop(atTop: Boolean) {
507+ viewingTop = atTop
508+ if (atTop && _state.value.streaming.streamingNewCount != 0) {
509+ _state.update { it.copy(streaming = it.streaming.copy(streamingNewCount = 0)) }
510+ }
511+ }
512+
513+ fun toggleStreaming() {
514+ if (_state.value.streaming.isStreaming) {
515+ stopStreaming()
516+ } else {
517+ startStreaming()
518+ }
519+ }
520+
521+ private fun startStreaming() {
522+ // The poller builds its own default SELECT * query and cannot honor a custom SQL
523+ // projection/filter, so streaming custom-query results would prepend mismatched rows.
524+ // The UI disables the toggle in this state; guard here too in case it's reached anyway.
525+ if (_state.value.filters.customSql.isNotBlank()) return
526+ stopStreaming()
527+ consecutiveStreamingErrors = 0
528+ val generation = ++streamingGeneration
529+ _state.update {
530+ it.copy(streaming = StreamingState(isStreaming = true, streamingNewCount = 0))
531+ }
532+
533+ // Seed the dedup set with the rows already on screen so an initial row sitting exactly
534+ // on the streaming baseline timestamp can't be re-fetched and duplicated.
535+ seenStreamingKeys.clear()
536+ _state.value.logs.forEach { seenStreamingKeys.add(logIdentity(it)) }
537+ trimSeenStreamingKeys()
538+
539+ // Set the baseline timestamp to "now" so we only poll for new logs
540+ lastSeenTimestamp = ZonedDateTime.now(ZoneOffset.UTC).format(dateFormatter)
541+
542+ streamingJob = viewModelScope.launch {
543+ while (isActive && streamingGeneration == generation) {
544+ pollNewLogs()
545+ // Exponential backoff on errors, normal interval on success
546+ val intervalMs = if (consecutiveStreamingErrors > 0) {
547+ (STREAMING_BASE_INTERVAL_MS * (1L shl consecutiveStreamingErrors.coerceAtMost(4)))
548+ .coerceAtMost(STREAMING_MAX_INTERVAL_MS)
549+ } else {
550+ STREAMING_BASE_INTERVAL_MS
551+ }
552+ _state.update {
553+ it.copy(streaming = it.streaming.copy(currentIntervalMs = intervalMs))
554+ }
555+ delay(intervalMs)
556+ }
557+ }
558+ }
559+
560+ fun stopStreaming() {
561+ // Cancel any pending search debounce too. Every query-basis change (time range, filters,
562+ // saved filter, clear, new search) routes through stopStreaming(); without this, a
563+ // searchJob scheduled moments before such a change would still fire refresh() ~300ms
564+ // later and redundantly re-query over the just-applied state. onSearchQueryChange()
565+ // re-assigns searchJob immediately after calling stopStreaming(), so its own debounce
566+ // survives.
567+ searchJob?.cancel()
568+ streamingGeneration++
569+ streamingJob?.cancel()
570+ streamingJob = null
571+ _state.update {
572+ it.copy(streaming = it.streaming.copy(isStreaming = false))
573+ }
574+ }
575+
576+ fun dismissStreamingError() {
577+ _state.update {
578+ it.copy(streaming = it.streaming.copy(streamingError = null))
579+ }
580+ }
581+
582+ /**
583+ * Content-based identity for a log row, used to dedupe re-fetched boundary logs.
584+ * Uses the full row: two rows are duplicates only when every field matches, so
585+ * distinct logs that happen to share a timestamp/metadata/tags are not dropped.
586+ */
587+ private fun logIdentity(log: JsonObject): String = log.toString()
588+
589+ /**
590+ * Parse a server-rendered p_timestamp (which may be ISO with 'Z', offset-carrying,
591+ * space-separated, or offset-less UTC) and reformat it to the canonical query format
592+ * (`dateFormatter`) so it's a valid inclusive startTime for the next poll. Returns null
593+ * if the value can't be parsed, so the caller can keep the previous boundary.
594+ */
595+ private fun normalizeBoundaryTimestamp(raw: String): String? {
596+ val isoLike = if (' ' in raw && 'T' !in raw) raw.replaceFirst(' ', 'T') else raw
597+ val instant = try {
598+ Instant.parse(isoLike)
599+ } catch (_: Exception) {
600+ try {
601+ OffsetDateTime.parse(isoLike).toInstant()
602+ } catch (_: Exception) {
603+ try {
604+ LocalDateTime.parse(isoLike).toInstant(ZoneOffset.UTC)
605+ } catch (_: Exception) {
606+ return null
607+ }
608+ }
609+ }
610+ return instant.atOffset(ZoneOffset.UTC).format(dateFormatter)
611+ }
612+
613+ /** Bound the dedup set, evicting the oldest identities first (insertion order). */
614+ private fun trimSeenStreamingKeys() {
615+ if (seenStreamingKeys.size <= SEEN_STREAMING_KEYS_MAX) return
616+ val iterator = seenStreamingKeys.iterator()
617+ var toRemove = seenStreamingKeys.size - SEEN_STREAMING_KEYS_MAX
618+ while (toRemove > 0 && iterator.hasNext()) {
619+ iterator.next()
620+ iterator.remove()
621+ toRemove--
622+ }
623+ }
624+
625+ private suspend fun pollNewLogs() {
626+ val current = _state.value
627+ if (current.streamName.isEmpty()) return
628+
629+ val startTime = lastSeenTimestamp ?: return
630+ val endTime = ZonedDateTime.now(ZoneOffset.UTC).format(dateFormatter)
631+
632+ // Build WHERE clause from active filters + search
633+ val clauses = current.filters.filterClauses.toMutableList()
634+ if (current.filters.searchQuery.isNotBlank()) {
635+ val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
636+ if (searchClause != null) {
637+ // Parenthesize: the search clause is OR-joined and must not break the
638+ // precedence of the AND-joined filter clauses around it.
639+ clauses.add("($searchClause)")
640+ } else {
641+ // Schema not loaded yet, so we can't build the search filter. Skip this
642+ // poll rather than querying without it — otherwise we'd prepend rows that
643+ // don't match the active search into a filtered view. Mirrors refresh(),
644+ // which errors out instead of running an unfiltered query.
645+ _state.update {
646+ it.copy(
647+ streaming = it.streaming.copy(
648+ streamingError = "Search unavailable: stream schema not loaded",
649+ ),
650+ )
651+ }
652+ return
653+ }
654+ }
655+
656+ val whereClause = if (clauses.isNotEmpty()) " WHERE ${clauses.joinToString(" AND ")}" else ""
657+ val safeName = escapeIdentifier(current.streamName)
658+ // Fetch up to the in-memory display cap. With a small limit (e.g. 200), a burst of
659+ // more than that many logs in one interval would advance lastSeenTimestamp past the
660+ // un-fetched rows and drop them permanently. Capping at STREAMING_MAX_LOGS means we
661+ // never skip rows we'd actually keep (anything older is evicted by the cap anyway).
662+ val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT $STREAMING_MAX_LOGS"
663+
664+ when (val result = repository.queryLogsRaw(sql, startTime, endTime)) {
665+ is ApiResult.Success -> {
666+ val newLogs = result.data
667+ if (newLogs.isNotEmpty()) {
668+ // Advance the last-seen boundary. Rows are ordered newest-first, so
669+ // normally we jump to the newest. But a full page means the LIMIT may
670+ // have capped off matching rows OLDER than the newest in this batch
671+ // (possible once filters/search narrow a high-volume stream) — advancing
672+ // to the newest would skip them forever. In that case advance only to the
673+ // oldest row we did fetch, so the next poll re-queries forward and closes
674+ // the gap (re-fetched rows are deduped below).
675+ val fullPage = newLogs.size >= STREAMING_MAX_LOGS
676+ val boundaryLog = if (fullPage) newLogs.lastOrNull() else newLogs.firstOrNull()
677+ val boundaryTimestamp = try {
678+ boundaryLog?.get("p_timestamp")?.jsonPrimitive?.content
679+ } catch (_: Exception) {
680+ null
681+ }
682+ // Re-canonicalize the server's p_timestamp before reusing it as the next
683+ // query's startTime. The raw value can be space-separated or offset-less,
684+ // which the query API may reject or misparse — that would stall live-tail
685+ // after the first batch. If it can't be parsed, leave lastSeenTimestamp as-is
686+ // (the inclusive startTime simply re-fetches the window; rows are deduped).
687+ var normalized = boundaryTimestamp?.let { normalizeBoundaryTimestamp(it) }
688+ // On a saturated (full) page the boundary is the OLDEST fetched row so the
689+ // next poll re-queries forward and closes the LIMIT gap. But if that oldest
690+ // row carries the SAME timestamp we're already at — e.g. a burst of more than
691+ // STREAMING_MAX_LOGS rows sharing one millisecond, or the parse failing — the
692+ // boundary wouldn't move and we'd re-query the identical window forever. Fall
693+ // back to the NEWEST row's timestamp to guarantee forward progress; the
694+ // skipped backlog would be evicted by the display cap anyway.
695+ if (fullPage && (normalized == null || normalized == lastSeenTimestamp)) {
696+ val newestTimestamp = try {
697+ newLogs.firstOrNull()?.get("p_timestamp")?.jsonPrimitive?.content
698+ } catch (_: Exception) {
699+ null
700+ }
701+ val newestNormalized = newestTimestamp?.let { normalizeBoundaryTimestamp(it) }
702+ if (newestNormalized != null && newestNormalized != lastSeenTimestamp) {
703+ normalized = newestNormalized
704+ }
705+ }
706+ if (normalized != null) {
707+ lastSeenTimestamp = normalized
708+ }
709+
710+ // The query's startTime is inclusive, so rows sitting on the previous
711+ // boundary timestamp get re-fetched. Dedup against everything seen this
712+ // session (independent of the display cap) to avoid duplicate rows and an
713+ // inflated count. Computed BEFORE the state update — and the seen-set
714+ // mutated here, not inside the update lambda, which may re-run.
715+ val freshLogs = newLogs.filterNot { logIdentity(it) in seenStreamingKeys }
716+ newLogs.forEach { seenStreamingKeys.add(logIdentity(it)) }
717+ trimSeenStreamingKeys()
718+
719+ if (freshLogs.isEmpty()) {
720+ _state.update { it.copy(streaming = it.streaming.copy(streamingError = null)) }
721+ } else {
722+ _state.update { state ->
723+ // Prepend new logs, cap total — avoid full intermediate list allocation
724+ val remaining = (STREAMING_MAX_LOGS - freshLogs.size).coerceAtLeast(0)
725+ val capped = ArrayList<JsonObject>(freshLogs.size + remaining).apply {
726+ addAll(freshLogs)
727+ val oldLogs = state.logs
728+ addAll(oldLogs.subList(0, remaining.coerceAtMost(oldLogs.size)))
729+ }
730+ state.copy(
731+ logs = capped,
732+ logKeys = computeLogKeys(capped),
733+ streaming = state.streaming.copy(
734+ // Accumulate new rows while the user is scrolled away; reset
735+ // to 0 when they're at the top (where prepended rows are seen
736+ // live). The previous cap was against total list size, so the
737+ // badge saturated at the display cap and never cleared.
738+ streamingNewCount = if (viewingTop) {
739+ 0
740+ } else {
741+ state.streaming.streamingNewCount + freshLogs.size
742+ },
743+ streamingError = null,
744+ ),
745+ )
746+ }
747+ }
748+ }
749+ consecutiveStreamingErrors = 0
750+ }
751+ is ApiResult.Error -> {
752+ consecutiveStreamingErrors++
753+ if (consecutiveStreamingErrors >= MAX_STREAMING_ERRORS) {
754+ stopStreaming()
755+ _state.update {
756+ it.copy(error = "Streaming stopped after repeated errors: ${result.userMessage}")
757+ }
758+ } else if (consecutiveStreamingErrors >= 3) {
759+ _state.update {
760+ it.copy(
761+ streaming = it.streaming.copy(
762+ streamingError = "Streaming interrupted: ${result.userMessage}. Retrying..."
763+ ),
764+ )
765+ }
766+ }
767+ }
768+ }
769+ }
770+
771+ // --- Saved Filters (synced with Parseable server) ---
772+
773+ // Serializes loadSavedFilters / saveCurrentFilter / deleteSavedFilter so they never
774+ // interleave. Without this, a wholesale-overwrite load completing after a concurrent
775+ // save (append) or delete (remove) would silently drop the local add/remove — a
776+ // read-modify-write race across coroutines.
777+ private val savedFiltersMutex = Mutex()
778+
779+ fun loadSavedFilters() {
780+ // Capture the stream this load is for; if the user navigates to a different stream
781+ // before it completes, bind the result to the right stream instead of clobbering the
782+ // new stream's filter list with another stream's results.
783+ val stream = _state.value.streamName
784+ viewModelScope.launch {
785+ savedFiltersMutex.withLock {
786+ _state.update { it.copy(savedFilters = it.savedFilters.copy(isLoading = true, error = null)) }
787+ when (val result = repository.listFilters()) {
788+ is ApiResult.Success -> {
789+ if (_state.value.streamName != stream) {
790+ _state.update { it.copy(savedFilters = it.savedFilters.copy(isLoading = false)) }
791+ return@withLock
792+ }
793+ val streamFilters = result.data
794+ .filter { it.streamName == stream }
795+ .sortedBy { it.filterName.lowercase() }
796+ _state.update {
797+ it.copy(savedFilters = it.savedFilters.copy(filters = streamFilters, isLoading = false))
798+ }
799+ }
800+ is ApiResult.Error -> {
801+ _state.update {
802+ it.copy(savedFilters = it.savedFilters.copy(isLoading = false, error = result.userMessage))
803+ }
804+ }
805+ }
806+ }
807+ }
808+ }
809+
810+ fun saveCurrentFilter(name: String) {
811+ val current = _state.value
812+ if (current.streamName.isEmpty()) return
813+
814+ viewModelScope.launch {
815+ savedFiltersMutex.withLock {
816+ _state.update { it.copy(savedFilters = it.savedFilters.copy(isSaving = true, error = null)) }
817+
818+ val query = if (current.filters.customSql.isNotBlank()) {
819+ SavedFilterQuery(
820+ filterType = "sql",
821+ filterQuery = current.filters.customSql,
822+ )
823+ } else {
824+ val ruleGroups = current.filters.filterConditions
825+ .mapIndexed { index, condition ->
826+ FilterRule(
827+ id = "rule_$index",
828+ field = condition.column,
829+ value = condition.value,
830+ operator = condition.operator,
831+ )
832+ }
833+ val builder = if (ruleGroups.isNotEmpty()) {
834+ FilterBuilder(
835+ id = "root",
836+ combinator = "and",
837+ rules = listOf(
838+ FilterRuleGroup(
839+ id = "group_0",
840+ combinator = "and",
841+ rules = ruleGroups,
842+ ),
843+ ),
844+ )
845+ } else null
846+
847+ SavedFilterQuery(
848+ filterType = if (current.filters.searchQuery.isNotBlank()) "search" else "filter",
849+ filterQuery = current.filters.searchQuery.ifBlank { null },
850+ filterBuilder = builder,
851+ )
852+ }
853+
854+ val filter = SavedFilter(
855+ filterName = name,
856+ streamName = current.streamName,
857+ query = query,
858+ )
859+
860+ when (val result = repository.createFilter(filter)) {
861+ is ApiResult.Success -> {
862+ _state.update {
863+ it.copy(
864+ savedFilters = it.savedFilters.copy(
865+ isSaving = false,
866+ filters = it.savedFilters.filters + result.data,
867+ ),
868+ )
869+ }
870+ }
871+ is ApiResult.Error -> {
872+ _state.update {
873+ it.copy(savedFilters = it.savedFilters.copy(isSaving = false, error = result.userMessage))
874+ }
875+ }
876+ }
877+ }
878+ }
879+ }
880+
881+ fun applySavedFilter(filter: SavedFilter) {
882+ stopStreaming()
883+
884+ when (filter.query.filterType) {
885+ "sql" -> {
886+ val sql = filter.query.filterQuery ?: return
887+ // Reset to a clean filter state first, then let executeCustomSql validate
888+ // the SQL and own the customSql state. Pre-seeding filters.customSql with
889+ // the raw, unvalidated query meant that if executeCustomSql rejected it
890+ // (e.g. a legacy non-SELECT saved filter, which returns early without
891+ // touching state) we were left wedged in custom-SQL mode with broken SQL
892+ // that every later refresh() would keep re-running.
893+ _state.update {
894+ it.copy(
895+ filters = FilterState(),
896+ currentLimit = 500,
897+ )
898+ }
899+ executeCustomSql(sql)
900+ }
901+ "search" -> {
902+ val searchQuery = filter.query.filterQuery ?: ""
903+ _state.update {
904+ it.copy(
905+ filters = FilterState(searchQuery = searchQuery),
906+ currentLimit = 500,
907+ )
908+ }
909+ // Also apply builder rules if present
910+ applyBuilderRules(filter.query.filterBuilder)
911+ refresh()
912+ }
913+ else -> {
914+ _state.update {
915+ it.copy(
916+ filters = FilterState(),
917+ currentLimit = 500,
918+ )
919+ }
920+ applyBuilderRules(filter.query.filterBuilder)
921+ refresh()
922+ }
923+ }
924+ }
925+
926+ private fun applyBuilderRules(builder: FilterBuilder?) {
927+ if (builder == null) return
928+ for (group in builder.rules) {
929+ for (rule in group.rules) {
930+ if (rule.field.isNotBlank()) {
931+ addFilterInternal(rule.field, rule.operator, rule.value)
932+ }
933+ }
934+ }
935+ }
936+
937+ fun deleteSavedFilter(filterId: String) {
938+ viewModelScope.launch {
939+ savedFiltersMutex.withLock {
940+ when (repository.deleteFilter(filterId)) {
941+ is ApiResult.Success -> {
942+ _state.update {
943+ it.copy(
944+ savedFilters = it.savedFilters.copy(
945+ filters = it.savedFilters.filters.filter { f -> f.filterId != filterId },
946+ ),
947+ )
948+ }
949+ }
950+ is ApiResult.Error -> { /* Silently ignore — will show on next reload */ }
951+ }
952+ }
953+ }
954+ }
955+
956+ override fun onCleared() {
957+ super.onCleared()
958+ schemaJob?.cancel()
959+ schemaJob = null
960+ searchJob?.cancel()
961+ searchJob = null
962+ stopStreaming()
963+ }
964+}
app/src/main/java/com/parseable/android/ui/screens/logviewer/SeverityColor.ktnew file+169
@@ -0,0 +1,169 @@
1+package com.parseable.android.ui.screens.logviewer
2+
3+import androidx.compose.foundation.isSystemInDarkTheme
4+import androidx.compose.runtime.Composable
5+import androidx.compose.ui.graphics.Color
6+import kotlinx.serialization.json.JsonObject
7+import kotlinx.serialization.json.JsonPrimitive
8+
9+/**
10+ * Severity levels for log entries, ordered from most to least severe.
11+ */
12+enum class LogSeverity {
13+ FATAL,
14+ ERROR,
15+ WARNING,
16+ INFO,
17+ DEBUG,
18+ TRACE,
19+ UNKNOWN,
20+}
21+
22+/**
23+ * Field names commonly used for severity/level across logging frameworks.
24+ * Checked in order of priority (case-insensitive).
25+ */
26+private val SEVERITY_FIELD_NAMES = listOf(
27+ "level",
28+ "severity",
29+ "loglevel",
30+ "log_level",
31+ "log.level",
32+ "priority",
33+ "verbosity",
34+ "p_level",
35+ "status",
36+ "syslog_severity",
37+ "log_severity",
38+ "msg_severity",
39+ "event_severity",
40+)
41+
42+/**
43+ * Attempts to detect the severity level from a log entry by checking common field names.
44+ * Returns [LogSeverity.UNKNOWN] if no recognizable severity field or value is found.
45+ */
46+fun detectSeverity(logEntry: JsonObject): LogSeverity {
47+ for (fieldName in SEVERITY_FIELD_NAMES) {
48+ val entry = logEntry.entries.firstOrNull { it.key.equals(fieldName, ignoreCase = true) }
49+ if (entry != null) {
50+ val value = entry.value
51+ if (value is JsonPrimitive) {
52+ val parsed = parseSeverityValue(value.content, entry.key)
53+ if (parsed != LogSeverity.UNKNOWN) return parsed
54+ }
55+ }
56+ }
57+ return LogSeverity.UNKNOWN
58+}
59+
60+private fun parseSeverityValue(raw: String, fieldName: String): LogSeverity {
61+ val trimmed = raw.trim()
62+ if (trimmed.isEmpty()) return LogSeverity.UNKNOWN
63+
64+ // Try numeric parsing first
65+ trimmed.toIntOrNull()?.let { num ->
66+ return when {
67+ // Syslog severity (0 = emergency .. 7 = debug)
68+ fieldName.equals("syslog_severity", ignoreCase = true) -> when (num) {
69+ 0, 1, 2 -> LogSeverity.FATAL // emergency, alert, critical
70+ 3 -> LogSeverity.ERROR
71+ 4 -> LogSeverity.WARNING
72+ 5 -> LogSeverity.INFO // notice
73+ 6 -> LogSeverity.INFO // informational
74+ 7 -> LogSeverity.DEBUG
75+ else -> LogSeverity.UNKNOWN
76+ }
77+ // Syslog "priority" (PRI) is the encoded value facility*8 + severity (0..191), so the
78+ // severity is the low 3 bits. For a bare 0..7 this is identity; a real PRI like 134
79+ // decodes to severity 6 (info) instead of falling through to UNKNOWN.
80+ fieldName.equals("priority", ignoreCase = true) -> when {
81+ num < 0 || num > 191 -> LogSeverity.UNKNOWN
82+ else -> when (num % 8) {
83+ 0, 1, 2 -> LogSeverity.FATAL // emergency, alert, critical
84+ 3 -> LogSeverity.ERROR
85+ 4 -> LogSeverity.WARNING
86+ 5, 6 -> LogSeverity.INFO // notice, informational
87+ else -> LogSeverity.DEBUG // 7
88+ }
89+ }
90+ // HTTP status codes. Bound every branch to the valid HTTP range (100..599):
91+ // `status` is a very generic field name, so an unbounded `>= 500`/`>= 400`
92+ // would mis-color non-HTTP values (e.g. status: 50000) as ERROR/WARNING.
93+ fieldName.equals("status", ignoreCase = true) -> when (num) {
94+ in 500..599 -> LogSeverity.ERROR
95+ in 400..499 -> LogSeverity.WARNING
96+ // 1xx (informational) through 3xx are non-error HTTP statuses; 1xx previously
97+ // fell through to UNKNOWN.
98+ in 100..399 -> LogSeverity.INFO
99+ else -> LogSeverity.UNKNOWN
100+ }
101+ // Generic numeric (higher = more severe). Two scales coexist under a bare numeric
102+ // "level"/"severity" field, so handle both:
103+ // • Large-magnitude java.util.logging (FINEST=300 … SEVERE=1000).
104+ // • Small-integer structured JSON loggers (e.g. Bunyan: trace=10 … fatal=60).
105+ // Without the small-integer arm, Bunyan-style levels (10..60) would all fall into the
106+ // `num > 0 -> TRACE` catch-all and every such log would render as TRACE. The >= 100
107+ // boundary keeps every existing java.util.logging mapping unchanged (100..399 stayed
108+ // TRACE before and still does).
109+ else -> when {
110+ num >= 1000 -> LogSeverity.FATAL
111+ num >= 900 -> LogSeverity.ERROR
112+ num >= 800 -> LogSeverity.WARNING
113+ num >= 700 -> LogSeverity.INFO
114+ num >= 400 -> LogSeverity.DEBUG
115+ num >= 100 -> LogSeverity.TRACE
116+ num >= 60 -> LogSeverity.FATAL
117+ num >= 50 -> LogSeverity.ERROR
118+ num >= 40 -> LogSeverity.WARNING
119+ num >= 30 -> LogSeverity.INFO
120+ num >= 20 -> LogSeverity.DEBUG
121+ num > 0 -> LogSeverity.TRACE
122+ else -> LogSeverity.UNKNOWN
123+ }
124+ }
125+ }
126+
127+ // String-based matching (case-insensitive)
128+ return when (trimmed.uppercase()) {
129+ "FATAL", "F", "CRIT", "CRITICAL", "EMERG", "EMERGENCY",
130+ "ALERT", "A", "PANIC",
131+ -> LogSeverity.FATAL
132+
133+ "ERROR", "ERR", "E", "SEVERE", "FAILURE", "FAIL",
134+ -> LogSeverity.ERROR
135+
136+ "WARN", "WARNING", "W",
137+ -> LogSeverity.WARNING
138+
139+ "INFO", "I", "INFORMATION", "INFORMATIONAL", "NOTICE", "N",
140+ "OK", "SUCCESS",
141+ -> LogSeverity.INFO
142+
143+ "DEBUG", "D", "FINE", "CONFIG", "FINER",
144+ -> LogSeverity.DEBUG
145+
146+ "TRACE", "T", "V", "VERBOSE", "FINEST", "ALL",
147+ -> LogSeverity.TRACE
148+
149+ else -> LogSeverity.UNKNOWN
150+ }
151+}
152+
153+/**
154+ * Returns a subtle background tint color for the given severity level,
155+ * or `null` to use the default card color.
156+ */
157+@Composable
158+fun severityBackgroundColor(severity: LogSeverity): Color? {
159+ val isDark = isSystemInDarkTheme()
160+ return when (severity) {
161+ LogSeverity.FATAL -> if (isDark) Color(0xFF3A1820) else Color(0xFFFCE4EC)
162+ LogSeverity.ERROR -> if (isDark) Color(0xFF2D1A1E) else Color(0xFFFDECEC)
163+ LogSeverity.WARNING -> if (isDark) Color(0xFF2D2715) else Color(0xFFFFF8E1)
164+ LogSeverity.INFO -> if (isDark) Color(0xFF152535) else Color(0xFFE8F4FD)
165+ LogSeverity.DEBUG -> null
166+ LogSeverity.TRACE -> if (isDark) Color(0xFF1E1F25) else Color(0xFFF5F5F8)
167+ LogSeverity.UNKNOWN -> null
168+ }
169+}
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoScreen.ktnew file+269
@@ -0,0 +1,269 @@
1+package com.parseable.android.ui.screens.logviewer
2+
3+import androidx.compose.foundation.layout.*
4+import androidx.compose.foundation.rememberScrollState
5+import androidx.compose.foundation.verticalScroll
6+import androidx.compose.material.icons.Icons
7+import androidx.compose.material.icons.automirrored.filled.ArrowBack
8+import androidx.compose.material.icons.filled.*
9+import androidx.compose.material3.*
10+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
11+import androidx.compose.runtime.*
12+import androidx.compose.ui.Alignment
13+import androidx.compose.ui.Modifier
14+import androidx.compose.ui.text.font.FontFamily
15+import androidx.compose.ui.text.font.FontWeight
16+import androidx.compose.ui.unit.dp
17+import androidx.hilt.navigation.compose.hiltViewModel
18+import androidx.lifecycle.compose.collectAsStateWithLifecycle
19+import com.parseable.android.data.formatBytes
20+import kotlinx.serialization.json.Json
21+import kotlinx.serialization.json.JsonObject
22+
23+private val prettyJson = Json { prettyPrint = true }
24+
25+@OptIn(ExperimentalMaterial3Api::class)
26+@Composable
27+fun StreamInfoScreen(
28+ streamName: String,
29+ onBack: () -> Unit,
30+ onStreamDeleted: () -> Unit,
31+ viewModel: StreamInfoViewModel = hiltViewModel(),
32+) {
33+ val state by viewModel.state.collectAsStateWithLifecycle()
34+ var showDeleteConfirmation by remember { mutableStateOf(false) }
35+
36+ // Load once per stream. Reloading on every RESUME would refetch all four endpoints on
37+ // each return and could re-query a stream that was just deleted (spurious error card).
38+ LaunchedEffect(streamName) {
39+ viewModel.load(streamName)
40+ }
41+
42+ LaunchedEffect(state.deleteSuccess) {
43+ if (state.deleteSuccess) {
44+ viewModel.consumeDeleteSuccess()
45+ onStreamDeleted()
46+ }
47+ }
48+
49+ Scaffold(
50+ topBar = {
51+ TopAppBar(
52+ title = { Text("Stream: $streamName") },
53+ navigationIcon = {
54+ IconButton(onClick = onBack) {
55+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
56+ }
57+ },
58+ actions = {
59+ IconButton(
60+ onClick = { showDeleteConfirmation = true },
61+ enabled = !state.isDeleting && !state.isLoading,
62+ ) {
63+ Icon(
64+ Icons.Filled.Delete,
65+ contentDescription = "Delete stream",
66+ tint = MaterialTheme.colorScheme.error,
67+ )
68+ }
69+ },
70+ )
71+ },
72+ ) { padding ->
73+ // Has any section loaded yet? Drives whether a load shows the centered first-load
74+ // spinner or keeps existing content visible (so pull-to-refresh stays usable on retry).
75+ val hasContent = state.stats != null || state.schema.isNotEmpty() ||
76+ state.rawInfo != null || state.retention.isNotEmpty() || state.schemaFailed
77+ PullToRefreshBox(
78+ // Only show the pull indicator when refreshing over existing content; the very
79+ // first load uses the centered spinner below, so don't show both at once.
80+ isRefreshing = state.isLoading && hasContent,
81+ onRefresh = viewModel::refresh,
82+ modifier = Modifier
83+ .fillMaxSize()
84+ .padding(padding),
85+ ) {
86+ if (state.isLoading && !hasContent) {
87+ Box(
88+ modifier = Modifier.fillMaxSize(),
89+ contentAlignment = Alignment.Center,
90+ ) {
91+ CircularProgressIndicator()
92+ }
93+ } else {
94+ Column(
95+ modifier = Modifier
96+ .fillMaxSize()
97+ .verticalScroll(rememberScrollState())
98+ .padding(16.dp),
99+ verticalArrangement = Arrangement.spacedBy(16.dp),
100+ ) {
101+ // Stats section
102+ state.stats?.let { stats ->
103+ InfoSection(title = "Statistics") {
104+ // Show the current-window count when available. Fall back to "N/A"
105+ // only when there's no lifetime count either — otherwise the lifetime
106+ // figure is shown in its own row below, so don't duplicate it here.
107+ when {
108+ stats.ingestion?.count != null -> InfoRow("Event Count", stats.ingestion.count.toString())
109+ stats.ingestion?.lifetimeCount == null -> InfoRow("Event Count", "N/A")
110+ }
111+ InfoRow("Ingestion Size", formatBytes(stats.ingestion?.size) ?: formatBytes(stats.ingestion?.lifetimeSize) ?: "N/A")
112+ InfoRow("Storage Size", formatBytes(stats.storage?.size) ?: formatBytes(stats.storage?.lifetimeSize) ?: "N/A")
113+ if (stats.ingestion?.lifetimeCount != null) {
114+ InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
115+ }
116+ if (stats.ingestion?.deletedCount != null) {
117+ InfoRow("Deleted Events", stats.ingestion.deletedCount.toString())
118+ }
119+ }
120+ }
121+
122+ // Schema section
123+ if (state.schema.isNotEmpty()) {
124+ InfoSection(title = "Schema (${state.schema.size} fields)") {
125+ state.schema.forEach { field ->
126+ InfoRow(
127+ field.name,
128+ field.dataType?.toString()?.trim('"') ?: "Unknown",
129+ )
130+ }
131+ }
132+ } else if (state.schemaFailed) {
133+ InfoSection(title = "Schema") {
134+ Text(
135+ text = "Schema unavailable",
136+ style = MaterialTheme.typography.bodyMedium,
137+ color = MaterialTheme.colorScheme.onSurfaceVariant,
138+ )
139+ }
140+ }
141+
142+ // Retention section
143+ if (state.retention.isNotEmpty()) {
144+ InfoSection(title = "Retention") {
145+ state.retention.forEach { r ->
146+ InfoRow("Duration", r.duration ?: "N/A")
147+ InfoRow("Action", r.action ?: "N/A")
148+ if (r.description != null) {
149+ InfoRow("Description", r.description)
150+ }
151+ }
152+ }
153+ }
154+
155+ // Raw info section
156+ state.rawInfo?.let { rawInfo ->
157+ val prettyInfo = remember(rawInfo) {
158+ prettyJson.encodeToString(JsonObject.serializer(), rawInfo)
159+ }
160+ InfoSection(title = "Stream Info") {
161+ Text(
162+ text = prettyInfo,
163+ style = MaterialTheme.typography.bodySmall,
164+ fontFamily = FontFamily.Monospace,
165+ )
166+ }
167+ }
168+
169+ state.error?.let { errorText ->
170+ Card(
171+ colors = CardDefaults.cardColors(
172+ containerColor = MaterialTheme.colorScheme.errorContainer,
173+ ),
174+ ) {
175+ Text(
176+ text = errorText,
177+ modifier = Modifier.padding(16.dp),
178+ color = MaterialTheme.colorScheme.onErrorContainer,
179+ )
180+ }
181+ }
182+ }
183+ }
184+ }
185+ }
186+
187+ if (showDeleteConfirmation) {
188+ AlertDialog(
189+ onDismissRequest = { showDeleteConfirmation = false },
190+ icon = {
191+ Icon(
192+ Icons.Filled.Warning,
193+ contentDescription = "Warning",
194+ tint = MaterialTheme.colorScheme.error,
195+ )
196+ },
197+ title = { Text("Delete Stream") },
198+ text = {
199+ Text(
200+ "Are you sure you want to delete \"$streamName\"? " +
201+ "This will permanently remove all log data and cannot be undone."
202+ )
203+ },
204+ confirmButton = {
205+ Button(
206+ onClick = {
207+ showDeleteConfirmation = false
208+ viewModel.deleteStream()
209+ },
210+ colors = ButtonDefaults.buttonColors(
211+ containerColor = MaterialTheme.colorScheme.error,
212+ ),
213+ ) {
214+ Text("Delete")
215+ }
216+ },
217+ dismissButton = {
218+ TextButton(onClick = { showDeleteConfirmation = false }) {
219+ Text("Cancel")
220+ }
221+ },
222+ )
223+ }
224+}
225+
226+@Composable
227+fun InfoSection(
228+ title: String,
229+ content: @Composable ColumnScope.() -> Unit,
230+) {
231+ Card(
232+ modifier = Modifier.fillMaxWidth(),
233+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
234+ ) {
235+ Column(modifier = Modifier.padding(16.dp)) {
236+ Text(
237+ text = title,
238+ style = MaterialTheme.typography.titleMedium,
239+ fontWeight = FontWeight.Bold,
240+ )
241+ Spacer(modifier = Modifier.height(12.dp))
242+ content()
243+ }
244+ }
245+}
246+
247+@Composable
248+fun InfoRow(label: String, value: String) {
249+ Row(
250+ modifier = Modifier
251+ .fillMaxWidth()
252+ .padding(vertical = 4.dp),
253+ horizontalArrangement = Arrangement.SpaceBetween,
254+ ) {
255+ Text(
256+ text = label,
257+ style = MaterialTheme.typography.bodyMedium,
258+ color = MaterialTheme.colorScheme.onSurfaceVariant,
259+ modifier = Modifier.weight(1f),
260+ )
261+ Text(
262+ text = value,
263+ style = MaterialTheme.typography.bodyMedium,
264+ fontWeight = FontWeight.Medium,
265+ modifier = Modifier.weight(1f),
266+ fontFamily = FontFamily.Monospace,
267+ )
268+ }
269+}
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoViewModel.ktnew file+153
@@ -0,0 +1,153 @@
1+package com.parseable.android.ui.screens.logviewer
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import com.parseable.android.data.model.*
6+import com.parseable.android.data.repository.ParseableRepository
7+import dagger.hilt.android.lifecycle.HiltViewModel
8+import kotlinx.coroutines.CancellationException
9+import kotlinx.coroutines.Job
10+import kotlinx.coroutines.async
11+import kotlinx.coroutines.coroutineScope
12+import kotlinx.coroutines.flow.MutableStateFlow
13+import kotlinx.coroutines.flow.StateFlow
14+import kotlinx.coroutines.flow.asStateFlow
15+import kotlinx.coroutines.flow.update
16+import kotlinx.coroutines.launch
17+import kotlinx.serialization.json.JsonObject
18+import javax.inject.Inject
19+
20+data class StreamInfoState(
21+ val streamName: String = "",
22+ val stats: StreamStats? = null,
23+ val schema: List<SchemaField> = emptyList(),
24+ val schemaFailed: Boolean = false,
25+ val retention: List<RetentionConfig> = emptyList(),
26+ val rawInfo: JsonObject? = null,
27+ val isLoading: Boolean = false,
28+ val error: String? = null,
29+ val isDeleting: Boolean = false,
30+ val deleteSuccess: Boolean = false,
31+)
32+
33+@HiltViewModel
34+class StreamInfoViewModel @Inject constructor(
35+ private val repository: ParseableRepository,
36+) : ViewModel() {
37+
38+ private val _state = MutableStateFlow(StreamInfoState())
39+ val state: StateFlow<StreamInfoState> = _state.asStateFlow()
40+ private var loadJob: Job? = null
41+
42+ /** Holds the four parallel load results so coroutineScope can return them with their types intact. */
43+ private data class LoadResults(
44+ val stats: ApiResult<StreamStats>,
45+ val schema: ApiResult<StreamSchema>,
46+ val retention: ApiResult<List<RetentionConfig>>,
47+ val info: ApiResult<JsonObject>,
48+ )
49+
50+ fun load(streamName: String) {
51+ // Already showing (or loading) this stream — don't cancel and refetch all four
52+ // endpoints on recomposition / config change. Matches LogViewerViewModel.initialize's
53+ // guard; the screen loads once per stream (see StreamInfoScreen's LaunchedEffect).
54+ if (_state.value.streamName == streamName) return
55+ fetch(streamName)
56+ }
57+
58+ /**
59+ * Re-fetch the currently-shown stream, bypassing load()'s identity guard. Wired to
60+ * pull-to-refresh so the user can retry after a transient endpoint failure (e.g. a failed
61+ * schema fetch or a delete that errored) without leaving and re-entering the screen.
62+ */
63+ fun refresh() {
64+ val name = _state.value.streamName
65+ if (name.isEmpty()) return
66+ fetch(name)
67+ }
68+
69+ private fun fetch(streamName: String) {
70+ loadJob?.cancel()
71+ _state.update { it.copy(streamName = streamName, isLoading = true, error = null) }
72+
73+ loadJob = viewModelScope.launch {
74+ try {
75+ // Wrap the parallel fetches in coroutineScope so the four async children
76+ // form their own structured-concurrency scope (matching StreamsViewModel /
77+ // SettingsViewModel). The repository returns ApiResult.Error rather than
78+ // throwing, but if any call did throw unexpectedly this keeps the failure
79+ // contained to this load instead of it propagating oddly through the launch.
80+ val results = coroutineScope {
81+ val statsDeferred = async { repository.getStreamStats(streamName) }
82+ val schemaDeferred = async { repository.getStreamSchema(streamName) }
83+ val retentionDeferred = async { repository.getStreamRetention(streamName) }
84+ val infoDeferred = async { repository.getStreamInfo(streamName) }
85+
86+ LoadResults(
87+ stats = statsDeferred.await(),
88+ schema = schemaDeferred.await(),
89+ retention = retentionDeferred.await(),
90+ info = infoDeferred.await(),
91+ )
92+ }
93+ val statsResult = results.stats
94+ val schemaResult = results.schema
95+ val retentionResult = results.retention
96+ val infoResult = results.info
97+
98+ _state.update {
99+ it.copy(
100+ stats = (statsResult as? ApiResult.Success)?.data,
101+ schema = (schemaResult as? ApiResult.Success)?.data?.fields ?: emptyList(),
102+ schemaFailed = schemaResult is ApiResult.Error,
103+ retention = (retentionResult as? ApiResult.Success)?.data ?: emptyList(),
104+ rawInfo = (infoResult as? ApiResult.Success)?.data,
105+ isLoading = false,
106+ error = listOfNotNull(
107+ (statsResult as? ApiResult.Error)?.userMessage,
108+ (schemaResult as? ApiResult.Error)?.userMessage,
109+ (retentionResult as? ApiResult.Error)?.userMessage,
110+ (infoResult as? ApiResult.Error)?.userMessage,
111+ ).distinct().joinToString("\n").ifEmpty { null },
112+ )
113+ }
114+ } catch (e: CancellationException) {
115+ throw e
116+ } catch (e: Exception) {
117+ _state.update {
118+ it.copy(isLoading = false, error = e.message ?: "Failed to load stream info")
119+ }
120+ }
121+ }
122+ }
123+
124+ fun consumeDeleteSuccess() {
125+ _state.update { it.copy(deleteSuccess = false) }
126+ }
127+
128+ fun deleteStream() {
129+ val name = _state.value.streamName
130+ // Guard against a no-op or a second tap firing a duplicate DELETE while one is in flight.
131+ if (name.isEmpty() || _state.value.isDeleting) return
132+ // Cancel any in-flight load so its terminal update can't re-populate stats/schema for the
133+ // stream we're about to delete after the DELETE completes.
134+ loadJob?.cancel()
135+ viewModelScope.launch {
136+ // Clear isLoading here too: cancelling loadJob above kills any in-flight fetch before
137+ // its terminal update can flip isLoading back to false, so without this a load that was
138+ // running when delete started would leave the pull-to-refresh spinner stuck on the
139+ // error path (delete only manages isDeleting).
140+ _state.update { it.copy(isDeleting = true, isLoading = false, error = null) }
141+ when (val result = repository.deleteStream(name)) {
142+ is ApiResult.Success -> {
143+ _state.update { it.copy(isDeleting = false, deleteSuccess = true) }
144+ }
145+ is ApiResult.Error -> {
146+ _state.update {
147+ it.copy(isDeleting = false, error = "Delete failed: ${result.userMessage}")
148+ }
149+ }
150+ }
151+ }
152+ }
153+}
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsScreen.ktnew file+436
@@ -0,0 +1,436 @@
1+package com.parseable.android.ui.screens.settings
2+
3+import androidx.compose.foundation.layout.*
4+import androidx.compose.foundation.rememberScrollState
5+import androidx.compose.foundation.verticalScroll
6+import androidx.compose.material.icons.Icons
7+import androidx.compose.material.icons.automirrored.filled.ArrowBack
8+import androidx.compose.material.icons.filled.*
9+import androidx.compose.material3.*
10+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
11+import androidx.compose.runtime.*
12+import androidx.compose.ui.Alignment
13+import androidx.compose.ui.Modifier
14+import androidx.compose.ui.text.font.FontWeight
15+import androidx.compose.ui.text.style.TextOverflow
16+import androidx.compose.ui.unit.dp
17+import androidx.hilt.navigation.compose.hiltViewModel
18+import androidx.lifecycle.compose.collectAsStateWithLifecycle
19+import com.parseable.android.BuildConfig
20+import com.parseable.android.data.local.SavedServer
21+import kotlinx.serialization.json.contentOrNull
22+import kotlinx.serialization.json.jsonPrimitive
23+
24+@OptIn(ExperimentalMaterial3Api::class)
25+@Composable
26+fun SettingsScreen(
27+ onBack: () -> Unit,
28+ onAboutClick: () -> Unit,
29+ onServerSwitched: () -> Unit,
30+ onLoggedOut: () -> Unit,
31+ viewModel: SettingsViewModel = hiltViewModel(),
32+) {
33+ val state by viewModel.state.collectAsStateWithLifecycle()
34+
35+ LaunchedEffect(Unit) {
36+ viewModel.switchEvent.collect {
37+ onServerSwitched()
38+ }
39+ }
40+
41+ LaunchedEffect(Unit) {
42+ viewModel.loggedOutEvent.collect {
43+ onLoggedOut()
44+ }
45+ }
46+
47+ Scaffold(
48+ topBar = {
49+ TopAppBar(
50+ title = { Text("Settings") },
51+ navigationIcon = {
52+ IconButton(onClick = onBack) {
53+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
54+ }
55+ },
56+ )
57+ },
58+ ) { padding ->
59+ PullToRefreshBox(
60+ isRefreshing = state.isLoading,
61+ onRefresh = viewModel::load,
62+ modifier = Modifier
63+ .fillMaxSize()
64+ .padding(padding),
65+ ) {
66+ Column(
67+ modifier = Modifier
68+ .fillMaxSize()
69+ .verticalScroll(rememberScrollState())
70+ .padding(16.dp),
71+ verticalArrangement = Arrangement.spacedBy(16.dp),
72+ ) {
73+ // Server info
74+ Card(
75+ modifier = Modifier.fillMaxWidth(),
76+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
77+ ) {
78+ Column(modifier = Modifier.padding(16.dp)) {
79+ Row(verticalAlignment = Alignment.CenterVertically) {
80+ Icon(
81+ Icons.Filled.Dns,
82+ contentDescription = "Server connection",
83+ tint = MaterialTheme.colorScheme.primary,
84+ )
85+ Spacer(modifier = Modifier.width(8.dp))
86+ Text(
87+ text = "Server Connection",
88+ style = MaterialTheme.typography.titleMedium,
89+ fontWeight = FontWeight.Bold,
90+ )
91+ }
92+ Spacer(modifier = Modifier.height(12.dp))
93+ SettingsRow("URL", state.serverUrl)
94+ SettingsRow("Username", state.username)
95+ SettingsRow("TLS", if (state.useTls) "Enabled" else "Disabled")
96+ }
97+ }
98+
99+ // Saved servers
100+ if (state.savedServers.size > 1) {
101+ SavedServersCard(
102+ servers = state.savedServers,
103+ activeServerId = state.activeServerId,
104+ isSwitching = state.isSwitching,
105+ onSwitch = viewModel::switchToServer,
106+ onDelete = viewModel::deleteServer,
107+ )
108+ }
109+
110+ // Loading indicator for server data — only on the initial load. During a
111+ // pull-to-refresh the PullToRefreshBox already shows its own spinner and
112+ // aboutInfo is still populated, so gating on isLoading alone double-spins.
113+ if (state.isLoading && state.aboutInfo == null) {
114+ Box(
115+ modifier = Modifier
116+ .fillMaxWidth()
117+ .padding(vertical = 8.dp),
118+ contentAlignment = Alignment.Center,
119+ ) {
120+ Row(
121+ verticalAlignment = Alignment.CenterVertically,
122+ horizontalArrangement = Arrangement.spacedBy(8.dp),
123+ ) {
124+ CircularProgressIndicator(
125+ modifier = Modifier.size(16.dp),
126+ strokeWidth = 2.dp,
127+ )
128+ Text(
129+ text = "Loading server info...",
130+ style = MaterialTheme.typography.bodySmall,
131+ color = MaterialTheme.colorScheme.onSurfaceVariant,
132+ )
133+ }
134+ }
135+ }
136+
137+ // Error from loading server data
138+ state.error?.let { errorText ->
139+ Card(
140+ colors = CardDefaults.cardColors(
141+ containerColor = MaterialTheme.colorScheme.errorContainer,
142+ ),
143+ modifier = Modifier.fillMaxWidth(),
144+ ) {
145+ Row(
146+ modifier = Modifier.padding(16.dp),
147+ verticalAlignment = Alignment.CenterVertically,
148+ ) {
149+ Icon(
150+ Icons.Filled.ErrorOutline,
151+ contentDescription = "Error",
152+ tint = MaterialTheme.colorScheme.onErrorContainer,
153+ )
154+ Spacer(modifier = Modifier.width(8.dp))
155+ Text(
156+ text = errorText,
157+ color = MaterialTheme.colorScheme.onErrorContainer,
158+ style = MaterialTheme.typography.bodyMedium,
159+ )
160+ }
161+ }
162+ }
163+
164+ // About info
165+ state.aboutInfo?.let { about ->
166+ Card(
167+ modifier = Modifier.fillMaxWidth(),
168+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
169+ ) {
170+ Column(modifier = Modifier.padding(16.dp)) {
171+ Row(verticalAlignment = Alignment.CenterVertically) {
172+ Icon(
173+ Icons.Filled.Info,
174+ contentDescription = "Server info",
175+ tint = MaterialTheme.colorScheme.primary,
176+ )
177+ Spacer(modifier = Modifier.width(8.dp))
178+ Text(
179+ text = "Server Info",
180+ style = MaterialTheme.typography.titleMedium,
181+ fontWeight = FontWeight.Bold,
182+ )
183+ }
184+ Spacer(modifier = Modifier.height(12.dp))
185+ about.version?.let { SettingsRow("Version", it) }
186+ about.commit?.let { SettingsRow("Commit", it.take(12)) }
187+ about.mode?.let { SettingsRow("Mode", it) }
188+ about.license?.get("name")?.jsonPrimitive?.contentOrNull?.let { SettingsRow("License", it) }
189+ about.deploymentId?.let { SettingsRow("Deployment ID", it.take(12)) }
190+ about.queryEngine?.let { SettingsRow("Query Engine", it) }
191+ }
192+ }
193+ }
194+
195+ // Users section
196+ if (state.users.isNotEmpty()) {
197+ Card(
198+ modifier = Modifier.fillMaxWidth(),
199+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
200+ ) {
201+ Column(modifier = Modifier.padding(16.dp)) {
202+ Row(verticalAlignment = Alignment.CenterVertically) {
203+ Icon(
204+ Icons.Filled.People,
205+ contentDescription = "Users",
206+ tint = MaterialTheme.colorScheme.primary,
207+ )
208+ Spacer(modifier = Modifier.width(8.dp))
209+ Text(
210+ text = "Users (${state.users.size})",
211+ style = MaterialTheme.typography.titleMedium,
212+ fontWeight = FontWeight.Bold,
213+ )
214+ }
215+ Spacer(modifier = Modifier.height(12.dp))
216+ state.users.forEach { user ->
217+ Text(
218+ text = user,
219+ style = MaterialTheme.typography.bodyMedium,
220+ modifier = Modifier.padding(vertical = 4.dp),
221+ )
222+ }
223+ }
224+ }
225+ }
226+
227+ // App info
228+ Card(
229+ modifier = Modifier.fillMaxWidth(),
230+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
231+ ) {
232+ Column(modifier = Modifier.padding(16.dp)) {
233+ Row(verticalAlignment = Alignment.CenterVertically) {
234+ Icon(
235+ Icons.Filled.PhoneAndroid,
236+ contentDescription = "App info",
237+ tint = MaterialTheme.colorScheme.primary,
238+ )
239+ Spacer(modifier = Modifier.width(8.dp))
240+ Text(
241+ text = "App",
242+ style = MaterialTheme.typography.titleMedium,
243+ fontWeight = FontWeight.Bold,
244+ )
245+ }
246+ Spacer(modifier = Modifier.height(12.dp))
247+ SettingsRow("App Version", BuildConfig.VERSION_NAME)
248+ SettingsRow("Platform", "Android")
249+ }
250+ }
251+
252+ // About
253+ Card(
254+ onClick = onAboutClick,
255+ modifier = Modifier.fillMaxWidth(),
256+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
257+ ) {
258+ Row(
259+ modifier = Modifier
260+ .fillMaxWidth()
261+ .padding(16.dp),
262+ verticalAlignment = Alignment.CenterVertically,
263+ ) {
264+ Icon(
265+ Icons.Filled.Info,
266+ contentDescription = "About",
267+ tint = MaterialTheme.colorScheme.primary,
268+ )
269+ Spacer(modifier = Modifier.width(8.dp))
270+ Text(
271+ text = "About",
272+ style = MaterialTheme.typography.titleMedium,
273+ fontWeight = FontWeight.Bold,
274+ modifier = Modifier.weight(1f),
275+ )
276+ Icon(
277+ Icons.Filled.ChevronRight,
278+ contentDescription = null,
279+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
280+ )
281+ }
282+ }
283+ }
284+ }
285+ }
286+}
287+
288+@Composable
289+private fun SavedServersCard(
290+ servers: List<SavedServer>,
291+ activeServerId: Long?,
292+ isSwitching: Boolean,
293+ onSwitch: (Long) -> Unit,
294+ onDelete: (Long) -> Unit,
295+) {
296+ var serverToDelete by remember { mutableStateOf<SavedServer?>(null) }
297+
298+ Card(
299+ modifier = Modifier.fillMaxWidth(),
300+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
301+ ) {
302+ Column(modifier = Modifier.padding(16.dp)) {
303+ Row(verticalAlignment = Alignment.CenterVertically) {
304+ Icon(
305+ Icons.Filled.SwapHoriz,
306+ contentDescription = "Saved servers",
307+ tint = MaterialTheme.colorScheme.primary,
308+ )
309+ Spacer(modifier = Modifier.width(8.dp))
310+ Text(
311+ text = "Saved Servers (${servers.size})",
312+ style = MaterialTheme.typography.titleMedium,
313+ fontWeight = FontWeight.Bold,
314+ )
315+ if (isSwitching) {
316+ Spacer(modifier = Modifier.width(8.dp))
317+ CircularProgressIndicator(
318+ modifier = Modifier.size(16.dp),
319+ strokeWidth = 2.dp,
320+ )
321+ }
322+ }
323+ Spacer(modifier = Modifier.height(12.dp))
324+ servers.forEach { server ->
325+ val isActive = server.id == activeServerId
326+ SavedServerRow(
327+ server = server,
328+ isActive = isActive,
329+ enabled = !isSwitching,
330+ onSwitch = { onSwitch(server.id) },
331+ onDelete = { serverToDelete = server },
332+ )
333+ }
334+ }
335+ }
336+
337+ serverToDelete?.let { server ->
338+ AlertDialog(
339+ onDismissRequest = { serverToDelete = null },
340+ title = { Text("Remove Server") },
341+ text = {
342+ Text("Remove ${server.username}@${server.serverUrl} from saved servers?")
343+ },
344+ confirmButton = {
345+ Button(
346+ onClick = {
347+ onDelete(server.id)
348+ serverToDelete = null
349+ },
350+ colors = ButtonDefaults.buttonColors(
351+ containerColor = MaterialTheme.colorScheme.error,
352+ ),
353+ ) {
354+ Text("Remove")
355+ }
356+ },
357+ dismissButton = {
358+ TextButton(onClick = { serverToDelete = null }) {
359+ Text("Cancel")
360+ }
361+ },
362+ )
363+ }
364+}
365+
366+@Composable
367+private fun SavedServerRow(
368+ server: SavedServer,
369+ isActive: Boolean,
370+ enabled: Boolean,
371+ onSwitch: () -> Unit,
372+ onDelete: () -> Unit,
373+) {
374+ Row(
375+ modifier = Modifier
376+ .fillMaxWidth()
377+ .padding(vertical = 4.dp),
378+ verticalAlignment = Alignment.CenterVertically,
379+ ) {
380+ Icon(
381+ if (isActive) Icons.Filled.RadioButtonChecked else Icons.Filled.RadioButtonUnchecked,
382+ contentDescription = if (isActive) "Active" else "Inactive",
383+ tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
384+ modifier = Modifier.size(20.dp),
385+ )
386+ Spacer(modifier = Modifier.width(8.dp))
387+ Column(modifier = Modifier.weight(1f)) {
388+ Text(
389+ text = server.serverUrl,
390+ style = MaterialTheme.typography.bodyMedium,
391+ fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
392+ maxLines = 1,
393+ overflow = TextOverflow.Ellipsis,
394+ )
395+ Text(
396+ text = server.username,
397+ style = MaterialTheme.typography.bodySmall,
398+ color = MaterialTheme.colorScheme.onSurfaceVariant,
399+ )
400+ }
401+ if (!isActive) {
402+ TextButton(onClick = onSwitch, enabled = enabled) {
403+ Text("Switch")
404+ }
405+ IconButton(onClick = onDelete, enabled = enabled) {
406+ Icon(
407+ Icons.Filled.Delete,
408+ contentDescription = "Remove server",
409+ tint = MaterialTheme.colorScheme.error,
410+ modifier = Modifier.size(20.dp),
411+ )
412+ }
413+ }
414+ }
415+}
416+
417+@Composable
418+private fun SettingsRow(label: String, value: String) {
419+ Row(
420+ modifier = Modifier
421+ .fillMaxWidth()
422+ .padding(vertical = 4.dp),
423+ horizontalArrangement = Arrangement.SpaceBetween,
424+ ) {
425+ Text(
426+ text = label,
427+ style = MaterialTheme.typography.bodyMedium,
428+ color = MaterialTheme.colorScheme.onSurfaceVariant,
429+ )
430+ Text(
431+ text = value,
432+ style = MaterialTheme.typography.bodyMedium,
433+ fontWeight = FontWeight.Medium,
434+ )
435+ }
436+}
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsViewModel.ktnew file+217
@@ -0,0 +1,217 @@
1+package com.parseable.android.ui.screens.settings
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import com.parseable.android.data.local.SavedServer
6+import com.parseable.android.data.model.AboutInfo
7+import com.parseable.android.data.model.ApiResult
8+import com.parseable.android.data.repository.ParseableRepository
9+import com.parseable.android.data.repository.SettingsRepository
10+import dagger.hilt.android.lifecycle.HiltViewModel
11+import kotlinx.coroutines.CancellationException
12+import kotlinx.coroutines.Job
13+import kotlinx.coroutines.async
14+import kotlinx.coroutines.channels.Channel
15+import kotlinx.coroutines.coroutineScope
16+import kotlinx.coroutines.flow.MutableStateFlow
17+import kotlinx.coroutines.flow.StateFlow
18+import kotlinx.coroutines.flow.asStateFlow
19+import kotlinx.coroutines.flow.first
20+import kotlinx.coroutines.flow.receiveAsFlow
21+import kotlinx.coroutines.flow.update
22+import kotlinx.coroutines.launch
23+import kotlinx.serialization.json.jsonPrimitive
24+import javax.inject.Inject
25+
26+data class SettingsState(
27+ val serverUrl: String = "",
28+ val username: String = "",
29+ val useTls: Boolean = true,
30+ val aboutInfo: AboutInfo? = null,
31+ val users: List<String> = emptyList(),
32+ val isLoading: Boolean = false,
33+ val error: String? = null,
34+ val savedServers: List<SavedServer> = emptyList(),
35+ val activeServerId: Long? = null,
36+ val isSwitching: Boolean = false,
37+ val isDeleting: Boolean = false,
38+)
39+
40+@HiltViewModel
41+class SettingsViewModel @Inject constructor(
42+ private val settingsRepository: SettingsRepository,
43+ private val repository: ParseableRepository,
44+) : ViewModel() {
45+
46+ private val _state = MutableStateFlow(SettingsState())
47+ val state: StateFlow<SettingsState> = _state.asStateFlow()
48+
49+ /**
50+ * Emits the server ID after a successful switch so the screen can navigate. A [Channel]
51+ * (matching the codebase's other one-shot events) so the navigation fires exactly once and
52+ * is not re-delivered on rotation — a StateFlow retains its last value and would re-navigate
53+ * when the screen re-collects after a config change.
54+ */
55+ private val _switchEvent = Channel<Long>(Channel.BUFFERED)
56+ val switchEvent = _switchEvent.receiveAsFlow()
57+
58+ /**
59+ * Emitted when the currently-active server is deleted. The active connection has been torn
60+ * down, so there is no valid session left — the screen must navigate back to login rather
61+ * than leave the user on a Settings screen pointed at a deleted server. A one-shot [Channel]
62+ * for the same reason as [switchEvent].
63+ */
64+ private val _loggedOutEvent = Channel<Unit>(Channel.BUFFERED)
65+ val loggedOutEvent = _loggedOutEvent.receiveAsFlow()
66+
67+ private var loadJob: Job? = null
68+
69+ init {
70+ load()
71+ viewModelScope.launch {
72+ settingsRepository.savedServers.collect { servers ->
73+ _state.update { it.copy(savedServers = servers) }
74+ }
75+ }
76+ viewModelScope.launch {
77+ settingsRepository.activeServerId.collect { id ->
78+ _state.update { it.copy(activeServerId = id) }
79+ }
80+ }
81+ }
82+
83+ 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 {
87+ _state.update { it.copy(isLoading = true, error = null) }
88+
89+ val config = settingsRepository.serverConfig.first()
90+
91+ // Show server info immediately
92+ _state.update {
93+ it.copy(
94+ serverUrl = config?.serverUrl ?: it.serverUrl,
95+ username = config?.username ?: it.username,
96+ useTls = config?.useTls ?: it.useTls,
97+ )
98+ }
99+
100+ try {
101+ val (aboutResult, usersResult) = coroutineScope {
102+ val aboutDeferred = async { repository.getAbout() }
103+ val usersDeferred = async { repository.listUsers() }
104+ aboutDeferred.await() to usersDeferred.await()
105+ }
106+
107+ val userNames = (usersResult as? ApiResult.Success)?.data?.mapNotNull { obj ->
108+ try {
109+ obj["username"]?.jsonPrimitive?.content
110+ ?: obj["id"]?.jsonPrimitive?.content
111+ } catch (_: IllegalStateException) {
112+ null
113+ }
114+ } ?: emptyList()
115+
116+ // Only treat a failure of the primary server-info call (/about) as a blocking
117+ // error. The user list (/user) is secondary and is commonly forbidden for
118+ // restricted accounts — failing it shouldn't hide otherwise-valid server info.
119+ val error = (aboutResult as? ApiResult.Error)?.userMessage
120+
121+ _state.update {
122+ it.copy(
123+ aboutInfo = (aboutResult as? ApiResult.Success)?.data,
124+ users = userNames,
125+ isLoading = false,
126+ error = error,
127+ )
128+ }
129+ } catch (e: CancellationException) {
130+ _state.update { it.copy(isLoading = false) }
131+ throw e
132+ } catch (e: Exception) {
133+ _state.update {
134+ it.copy(isLoading = false, error = e.message ?: "Failed to load settings")
135+ }
136+ }
137+ }
138+ }
139+
140+ fun switchToServer(serverId: Long) {
141+ // Guard against a second tap launching a concurrent switch that would send a
142+ // duplicate _switchEvent and navigate twice, and against racing an in-flight delete
143+ // (both mutate the active connection and fire conflicting one-shot nav events).
144+ if (_state.value.isSwitching || _state.value.isDeleting) return
145+ viewModelScope.launch {
146+ _state.update { it.copy(isSwitching = true, error = null) }
147+ try {
148+ val config = settingsRepository.switchToServer(serverId)
149+ if (config != null) {
150+ repository.configure(config)
151+ _switchEvent.send(serverId)
152+ } else {
153+ // Surface the failure instead of silently dropping the spinner — a null
154+ // result means the saved credentials couldn't be loaded for this server.
155+ _state.update {
156+ it.copy(
157+ error = "Couldn't switch servers — the saved credentials for this server are missing or couldn't be read.",
158+ )
159+ }
160+ }
161+ } catch (e: CancellationException) {
162+ throw e
163+ } catch (e: Exception) {
164+ _state.update {
165+ it.copy(error = e.message ?: "Couldn't switch servers.")
166+ }
167+ } finally {
168+ // Always clear the flag. Without finally, an exception or cancellation would
169+ // leave isSwitching = true, and the early-return guard above would then make
170+ // server-switching permanently impossible for this ViewModel instance.
171+ _state.update { it.copy(isSwitching = false) }
172+ }
173+ }
174+ }
175+
176+ fun deleteServer(serverId: Long) {
177+ // Don't delete while a switch or another delete is in flight: all mutate the active
178+ // connection and can fire conflicting one-shot navigation events (_loggedOutEvent vs
179+ // _switchEvent), or send _loggedOutEvent twice on a double-tap of the active server.
180+ // The UI also disables the buttons during a switch, but guard here too. isDeleting is
181+ // set inside the launch (viewModelScope is Main.immediate, so it runs synchronously
182+ // before this call returns) and cleared in finally, mirroring switchToServer.
183+ if (_state.value.isSwitching || _state.value.isDeleting) return
184+ viewModelScope.launch {
185+ _state.update { it.copy(isDeleting = true) }
186+ try {
187+ val wasActive = settingsRepository.deleteServer(serverId)
188+ if (wasActive) {
189+ // The active connection was torn down, so the Server Connection / Server Info
190+ // cards are now showing a deleted server. Clear them for the transition frame,
191+ // then navigate back to login — there is no valid session to keep using and the
192+ // in-memory api client still points at the deleted server, so any fetch from
193+ // here would hit it.
194+ _state.update {
195+ it.copy(
196+ serverUrl = "",
197+ username = "",
198+ aboutInfo = null,
199+ users = emptyList(),
200+ )
201+ }
202+ _loggedOutEvent.send(Unit)
203+ }
204+ } catch (e: CancellationException) {
205+ throw e
206+ } catch (e: Exception) {
207+ // Surface the failure instead of silently swallowing it, so the user knows the
208+ // server wasn't removed and can retry.
209+ _state.update { it.copy(error = e.message ?: "Couldn't remove server.") }
210+ } finally {
211+ // Always clear the flag, even on exception/cancellation, or the guard above would
212+ // make deletion permanently impossible for this ViewModel instance.
213+ _state.update { it.copy(isDeleting = false) }
214+ }
215+ }
216+ }
217+}
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsScreen.ktnew file+374
@@ -0,0 +1,374 @@
1+package com.parseable.android.ui.screens.streams
2+
3+import androidx.compose.foundation.layout.*
4+import androidx.compose.foundation.lazy.LazyColumn
5+import androidx.compose.foundation.lazy.items
6+import androidx.compose.foundation.lazy.rememberLazyListState
7+import androidx.compose.material.icons.Icons
8+import androidx.compose.material.icons.automirrored.filled.Logout
9+import androidx.compose.material.icons.filled.*
10+import androidx.compose.material3.*
11+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
12+import androidx.compose.runtime.*
13+import androidx.compose.runtime.saveable.rememberSaveable
14+import androidx.compose.ui.Alignment
15+import androidx.compose.ui.Modifier
16+import androidx.compose.ui.text.font.FontWeight
17+import androidx.compose.ui.unit.dp
18+import androidx.hilt.navigation.compose.hiltViewModel
19+import androidx.lifecycle.compose.collectAsStateWithLifecycle
20+import kotlinx.coroutines.flow.collectLatest
21+import kotlinx.coroutines.launch
22+
23+@OptIn(ExperimentalMaterial3Api::class)
24+@Composable
25+fun StreamsScreen(
26+ onStreamClick: (String) -> Unit,
27+ onAlertsClick: () -> Unit,
28+ onSettingsClick: () -> Unit,
29+ onLogout: () -> Unit,
30+ viewModel: StreamsViewModel = hiltViewModel(),
31+) {
32+ val state by viewModel.state.collectAsStateWithLifecycle()
33+ val scope = rememberCoroutineScope()
34+ var showLogoutConfirmation by remember { mutableStateOf(false) }
35+ var searchQuery by rememberSaveable { mutableStateOf("") }
36+ val snackbarHostState = remember { SnackbarHostState() }
37+
38+ val listState = rememberLazyListState()
39+
40+ // Scroll to top only when a refresh actually completes (isLoading transitions true -> false
41+ // while composed). Keying solely on isLoading also fired on return-to-screen and on the
42+ // initial composition, yanking the list to the top and discarding the user's restored
43+ // scroll position. Tracking the previous value limits the jump to genuine refresh events.
44+ var wasLoading by remember { mutableStateOf(false) }
45+ LaunchedEffect(state.isLoading) {
46+ if (wasLoading && !state.isLoading) {
47+ listState.scrollToItem(0)
48+ }
49+ wasLoading = state.isLoading
50+ }
51+
52+ val filteredStreams = remember(state.streams, searchQuery, state.favoriteNames) {
53+ val favorites = state.favoriteNames
54+ val filtered = if (searchQuery.isBlank()) {
55+ state.streams
56+ } else {
57+ state.streams.filter { it.name.contains(searchQuery, ignoreCase = true) }
58+ }
59+ // 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 }
61+ // `favorites` iterates most-recently-favorited first (DAO ORDER BY addedAt DESC
62+ // preserved through LinkedHashSet); order favs to match so recency is reflected.
63+ val favRank = favorites.withIndex().associate { (i, name) -> name to i }
64+ val sortedFavs = favs.sortedBy { favRank[it.name] ?: Int.MAX_VALUE }
65+ sortedFavs + rest
66+ }
67+
68+ LaunchedEffect(Unit) {
69+ viewModel.snackbarEvent.collectLatest { message ->
70+ snackbarHostState.showSnackbar(
71+ message = message,
72+ duration = SnackbarDuration.Short,
73+ )
74+ }
75+ }
76+
77+ Scaffold(
78+ snackbarHost = { SnackbarHost(snackbarHostState) },
79+ topBar = {
80+ TopAppBar(
81+ title = {
82+ Column {
83+ Text("Parseable", fontWeight = FontWeight.Bold)
84+ if (state.aboutInfo != null) {
85+ Text(
86+ text = "${state.aboutInfo?.version ?: "?"} - ${state.aboutInfo?.mode ?: ""}",
87+ style = MaterialTheme.typography.bodySmall,
88+ color = MaterialTheme.colorScheme.onSurfaceVariant,
89+ )
90+ }
91+ }
92+ },
93+ actions = {
94+ IconButton(onClick = onAlertsClick) {
95+ Icon(Icons.Filled.Notifications, contentDescription = "Alerts")
96+ }
97+ IconButton(onClick = onSettingsClick) {
98+ Icon(Icons.Filled.Settings, contentDescription = "Settings")
99+ }
100+ IconButton(onClick = { showLogoutConfirmation = true }) {
101+ Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Logout")
102+ }
103+ },
104+ )
105+ },
106+ ) { padding ->
107+ PullToRefreshBox(
108+ isRefreshing = state.isLoading,
109+ onRefresh = viewModel::refresh,
110+ modifier = Modifier
111+ .fillMaxSize()
112+ .padding(padding),
113+ ) {
114+ if (state.error != null && state.streams.isEmpty()) {
115+ Box(
116+ modifier = Modifier.fillMaxSize(),
117+ contentAlignment = Alignment.Center,
118+ ) {
119+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
120+ Icon(
121+ Icons.Filled.ErrorOutline,
122+ contentDescription = "Error loading streams",
123+ modifier = Modifier.size(48.dp),
124+ tint = MaterialTheme.colorScheme.error,
125+ )
126+ Spacer(modifier = Modifier.height(16.dp))
127+ Text(
128+ text = state.error.orEmpty(),
129+ style = MaterialTheme.typography.bodyLarge,
130+ color = MaterialTheme.colorScheme.error,
131+ )
132+ Spacer(modifier = Modifier.height(16.dp))
133+ OutlinedButton(onClick = viewModel::refresh) {
134+ Text("Retry")
135+ }
136+ }
137+ }
138+ } else if (state.streams.isEmpty() && !state.isLoading) {
139+ Box(
140+ modifier = Modifier.fillMaxSize(),
141+ contentAlignment = Alignment.Center,
142+ ) {
143+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
144+ Icon(
145+ Icons.Filled.Inbox,
146+ contentDescription = "No log streams found",
147+ modifier = Modifier.size(48.dp),
148+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
149+ )
150+ Spacer(modifier = Modifier.height(16.dp))
151+ Text(
152+ text = "No log streams found",
153+ style = MaterialTheme.typography.bodyLarge,
154+ color = MaterialTheme.colorScheme.onSurfaceVariant,
155+ )
156+ }
157+ }
158+ } else {
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()) {
166+ OutlinedTextField(
167+ value = searchQuery,
168+ onValueChange = { searchQuery = it },
169+ placeholder = { Text("Search streams...") },
170+ leadingIcon = {
171+ Icon(Icons.Filled.Search, contentDescription = "Search")
172+ },
173+ trailingIcon = {
174+ if (searchQuery.isNotEmpty()) {
175+ IconButton(onClick = { searchQuery = "" }) {
176+ Icon(Icons.Filled.Clear, contentDescription = "Clear search")
177+ }
178+ }
179+ },
180+ singleLine = true,
181+ modifier = Modifier
182+ .fillMaxWidth()
183+ .padding(horizontal = 16.dp, vertical = 8.dp),
184+ )
185+ }
186+ LazyColumn(
187+ state = listState,
188+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
189+ verticalArrangement = Arrangement.spacedBy(8.dp),
190+ modifier = Modifier.weight(1f),
191+ ) {
192+ items(filteredStreams, key = { it.name }) { stream ->
193+ val stats = state.streamStats[stream.name]
194+ val isFavorite = stream.name in state.favoriteNames
195+ StreamCard(
196+ streamName = stream.name,
197+ stats = stats,
198+ statsFailed = stream.name in state.failedStats,
199+ isFavorite = isFavorite,
200+ onToggleFavorite = { viewModel.toggleFavorite(stream.name) },
201+ onRetryStats = { viewModel.retryStats(stream.name) },
202+ onClick = { onStreamClick(stream.name) },
203+ )
204+ }
205+ if (filteredStreams.isEmpty() && searchQuery.isNotBlank()) {
206+ item {
207+ Box(
208+ modifier = Modifier
209+ .fillMaxWidth()
210+ .padding(32.dp),
211+ contentAlignment = Alignment.Center,
212+ ) {
213+ Text(
214+ text = "No streams matching \"$searchQuery\"",
215+ style = MaterialTheme.typography.bodyMedium,
216+ color = MaterialTheme.colorScheme.onSurfaceVariant,
217+ )
218+ }
219+ }
220+ }
221+ }
222+ }
223+ }
224+ }
225+ }
226+
227+ if (showLogoutConfirmation) {
228+ AlertDialog(
229+ onDismissRequest = { showLogoutConfirmation = false },
230+ title = { Text("Logout") },
231+ text = { Text("Are you sure you want to disconnect from this server?") },
232+ confirmButton = {
233+ Button(
234+ onClick = {
235+ showLogoutConfirmation = false
236+ scope.launch {
237+ viewModel.logout()
238+ onLogout()
239+ }
240+ },
241+ ) {
242+ Text("Logout")
243+ }
244+ },
245+ dismissButton = {
246+ TextButton(onClick = { showLogoutConfirmation = false }) {
247+ Text("Cancel")
248+ }
249+ },
250+ )
251+ }
252+}
253+
254+@Composable
255+private fun StreamCard(
256+ streamName: String,
257+ stats: StreamsViewModel.StreamStatsUi?,
258+ statsFailed: Boolean = false,
259+ isFavorite: Boolean = false,
260+ onToggleFavorite: () -> Unit = {},
261+ onRetryStats: () -> Unit = {},
262+ onClick: () -> Unit,
263+) {
264+ Card(
265+ onClick = onClick,
266+ modifier = Modifier.fillMaxWidth(),
267+ colors = CardDefaults.cardColors(
268+ containerColor = MaterialTheme.colorScheme.surface,
269+ ),
270+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
271+ ) {
272+ Column(
273+ modifier = Modifier.padding(16.dp),
274+ ) {
275+ Row(
276+ modifier = Modifier.fillMaxWidth(),
277+ horizontalArrangement = Arrangement.SpaceBetween,
278+ verticalAlignment = Alignment.CenterVertically,
279+ ) {
280+ Row(
281+ verticalAlignment = Alignment.CenterVertically,
282+ modifier = Modifier.weight(1f),
283+ ) {
284+ Icon(
285+ Icons.Filled.Storage,
286+ contentDescription = "Log stream",
287+ tint = MaterialTheme.colorScheme.primary,
288+ modifier = Modifier.size(20.dp),
289+ )
290+ Spacer(modifier = Modifier.width(8.dp))
291+ Text(
292+ text = streamName,
293+ style = MaterialTheme.typography.titleMedium,
294+ fontWeight = FontWeight.SemiBold,
295+ )
296+ }
297+ IconButton(onClick = onToggleFavorite) {
298+ Icon(
299+ if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
300+ contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
301+ tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
302+ )
303+ }
304+ Icon(
305+ Icons.Filled.ChevronRight,
306+ contentDescription = null, // Decorative; parent Card handles click
307+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
308+ )
309+ }
310+
311+ if (stats != null) {
312+ Spacer(modifier = Modifier.height(8.dp))
313+ Row(
314+ modifier = Modifier.fillMaxWidth(),
315+ horizontalArrangement = Arrangement.spacedBy(16.dp),
316+ ) {
317+ if (stats.eventCount != null) {
318+ StatChip(label = "Events", value = formatCount(stats.eventCount))
319+ }
320+ if (stats.ingestionSize != null) {
321+ StatChip(label = "Ingested", value = stats.ingestionSize)
322+ }
323+ if (stats.storageSize != null) {
324+ StatChip(label = "Storage", value = stats.storageSize)
325+ }
326+ }
327+ } else if (statsFailed) {
328+ Spacer(modifier = Modifier.height(8.dp))
329+ Row(
330+ verticalAlignment = Alignment.CenterVertically,
331+ horizontalArrangement = Arrangement.spacedBy(4.dp),
332+ ) {
333+ Text(
334+ text = "Stats unavailable",
335+ style = MaterialTheme.typography.labelSmall,
336+ color = MaterialTheme.colorScheme.onSurfaceVariant,
337+ )
338+ TextButton(
339+ onClick = onRetryStats,
340+ contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
341+ modifier = Modifier.defaultMinSize(minHeight = 48.dp),
342+ ) {
343+ Text("Retry", style = MaterialTheme.typography.labelSmall)
344+ }
345+ }
346+ }
347+ }
348+ }
349+}
350+
351+@Composable
352+private fun StatChip(label: String, value: String) {
353+ Column {
354+ Text(
355+ text = label,
356+ style = MaterialTheme.typography.labelSmall,
357+ color = MaterialTheme.colorScheme.onSurfaceVariant,
358+ )
359+ Text(
360+ text = value,
361+ style = MaterialTheme.typography.bodySmall,
362+ fontWeight = FontWeight.Medium,
363+ )
364+ }
365+}
366+
367+private fun formatCount(count: Long): String {
368+ return when {
369+ count >= 1_000_000_000 -> String.format(java.util.Locale.US, "%.1fB", count / 1_000_000_000.0)
370+ count >= 1_000_000 -> String.format(java.util.Locale.US, "%.1fM", count / 1_000_000.0)
371+ count >= 1_000 -> String.format(java.util.Locale.US, "%.1fK", count / 1_000.0)
372+ else -> count.toString()
373+ }
374+}
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsViewModel.ktnew file+223
@@ -0,0 +1,223 @@
1+package com.parseable.android.ui.screens.streams
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import com.parseable.android.data.local.FavoriteStream
6+import com.parseable.android.data.local.FavoriteStreamDao
7+import com.parseable.android.data.formatBytes
8+import com.parseable.android.data.model.AboutInfo
9+import com.parseable.android.data.model.ApiResult
10+import com.parseable.android.data.model.LogStream
11+import com.parseable.android.data.repository.ParseableRepository
12+import com.parseable.android.data.repository.SettingsRepository
13+import dagger.hilt.android.lifecycle.HiltViewModel
14+import kotlinx.coroutines.CancellationException
15+import kotlinx.coroutines.Job
16+import kotlinx.coroutines.async
17+import kotlinx.coroutines.coroutineScope
18+import kotlinx.coroutines.channels.Channel
19+import kotlinx.coroutines.flow.MutableStateFlow
20+import kotlinx.coroutines.flow.StateFlow
21+import kotlinx.coroutines.flow.asStateFlow
22+import kotlinx.coroutines.flow.receiveAsFlow
23+import kotlinx.coroutines.flow.update
24+import kotlinx.coroutines.launch
25+import kotlinx.coroutines.sync.Mutex
26+import kotlinx.coroutines.sync.Semaphore
27+import kotlinx.coroutines.sync.withLock
28+import kotlinx.coroutines.sync.withPermit
29+import javax.inject.Inject
30+
31+data class StreamsState(
32+ val streams: List<LogStream> = emptyList(),
33+ val streamStats: Map<String, StreamsViewModel.StreamStatsUi> = emptyMap(),
34+ val failedStats: Set<String> = emptySet(),
35+ val aboutInfo: AboutInfo? = null,
36+ val isLoading: Boolean = false,
37+ val error: String? = null,
38+ val favoriteNames: Set<String> = emptySet(),
39+)
40+
41+@HiltViewModel
42+class StreamsViewModel @Inject constructor(
43+ private val repository: ParseableRepository,
44+ private val settingsRepository: SettingsRepository,
45+ private val favoriteDao: FavoriteStreamDao,
46+) : ViewModel() {
47+
48+ data class StreamStatsUi(
49+ val eventCount: Long? = null,
50+ val ingestionSize: String? = null,
51+ val storageSize: String? = null,
52+ )
53+
54+ private val _state = MutableStateFlow(StreamsState())
55+ val state: StateFlow<StreamsState> = _state.asStateFlow()
56+
57+ private val _snackbarEvent = Channel<String>(Channel.BUFFERED)
58+ val snackbarEvent = _snackbarEvent.receiveAsFlow()
59+
60+ init {
61+ viewModelScope.launch {
62+ favoriteDao.getAllNames().collect { names ->
63+ _state.update { it.copy(favoriteNames = names.toSet()) }
64+ }
65+ }
66+ // Load once when the ViewModel is created. The screen no longer refreshes on every
67+ // RESUME, so returning via back-navigation reuses the already-loaded data.
68+ refresh()
69+ }
70+
71+ // Serializes toggles so two rapid taps can't both read the same pre-update state and
72+ // take the same branch (which, with idempotent DAO ops, would leave the favorite in the
73+ // wrong final state — e.g. tap-twice nets to "favorited" instead of cancelling out).
74+ private val favoriteMutex = Mutex()
75+
76+ fun toggleFavorite(streamName: String) {
77+ viewModelScope.launch {
78+ favoriteMutex.withLock {
79+ // Decide from the committed DB state, not the async-mirrored favoriteNames.
80+ if (favoriteDao.isFavoriteNow(streamName)) {
81+ favoriteDao.deleteByName(streamName)
82+ _snackbarEvent.send("Removed from favorites")
83+ } else {
84+ favoriteDao.insert(FavoriteStream(streamName = streamName))
85+ _snackbarEvent.send("Added to favorites")
86+ }
87+ }
88+ }
89+ }
90+
91+ private var refreshJob: Job? = null
92+
93+ fun refresh() {
94+ // Cancel any in-flight refresh so out-of-order completions can't overwrite a newer
95+ // result with a stale one (the init load and a pull-to-refresh, or two quick pulls,
96+ // can otherwise race and leave the older stream list showing). Mirrors the refreshJob
97+ // guard in LogViewerViewModel.
98+ refreshJob?.cancel()
99+ refreshJob = viewModelScope.launch {
100+ _state.update { it.copy(isLoading = true, error = null) }
101+
102+ try {
103+ // Fetch about info and streams in parallel
104+ val (aboutResult, streamsResult) = coroutineScope {
105+ val aboutDeferred = async { repository.getAbout() }
106+ val streamsDeferred = async { repository.listStreams(forceRefresh = true) }
107+ aboutDeferred.await() to streamsDeferred.await()
108+ }
109+
110+ if (aboutResult is ApiResult.Success) {
111+ _state.update { it.copy(aboutInfo = aboutResult.data) }
112+ }
113+
114+ when (streamsResult) {
115+ is ApiResult.Success -> {
116+ // Prune cached stats/failures for streams that no longer exist so
117+ // deleted or recreated streams don't show stale numbers.
118+ val names = streamsResult.data.mapTo(HashSet()) { it.name }
119+ _state.update {
120+ it.copy(
121+ streams = streamsResult.data.sortedBy { s -> s.name.lowercase() },
122+ streamStats = it.streamStats.filterKeys { name -> name in names },
123+ failedStats = it.failedStats.filterTo(mutableSetOf()) { name -> name in names },
124+ isLoading = false,
125+ )
126+ }
127+ // Load stats for each stream in parallel
128+ loadStreamStats(streamsResult.data)
129+ }
130+ is ApiResult.Error -> {
131+ _state.update {
132+ it.copy(
133+ isLoading = false,
134+ error = streamsResult.userMessage,
135+ )
136+ }
137+ }
138+ }
139+ } catch (e: CancellationException) {
140+ _state.update { it.copy(isLoading = false) }
141+ throw e
142+ } catch (e: Exception) {
143+ _state.update {
144+ it.copy(isLoading = false, error = e.message ?: "Failed to load streams")
145+ }
146+ }
147+ }
148+ }
149+
150+ private val statsSemaphore = Semaphore(8)
151+ private var statsJob: Job? = null
152+ // Per-stream retry jobs, tracked so a new bulk refresh can cancel any in-flight
153+ // retry — otherwise a late retry result could overwrite freshly-loaded stats.
154+ private val retryJobs = mutableMapOf<String, Job>()
155+
156+ private fun loadStreamStats(streams: List<LogStream>) {
157+ statsJob?.cancel()
158+ retryJobs.values.forEach { it.cancel() }
159+ retryJobs.clear()
160+ statsJob = viewModelScope.launch {
161+ streams.forEach { stream ->
162+ launch {
163+ statsSemaphore.withPermit {
164+ loadSingleStreamStats(stream.name)
165+ }
166+ }
167+ }
168+ }
169+ }
170+
171+ fun retryStats(streamName: String) {
172+ retryJobs[streamName]?.cancel()
173+ val job = viewModelScope.launch {
174+ _state.update { it.copy(failedStats = it.failedStats - streamName) }
175+ statsSemaphore.withPermit {
176+ loadSingleStreamStats(streamName)
177+ }
178+ }
179+ retryJobs[streamName] = job
180+ // Drop the entry once the retry finishes so the map doesn't accumulate completed
181+ // jobs for the ViewModel's lifetime. Guard with an identity check so we never remove
182+ // a newer retry that has since replaced this one for the same stream.
183+ job.invokeOnCompletion {
184+ if (retryJobs[streamName] === job) retryJobs.remove(streamName)
185+ }
186+ }
187+
188+ private suspend fun loadSingleStreamStats(streamName: String) {
189+ when (val result = repository.getStreamStats(streamName)) {
190+ is ApiResult.Success -> {
191+ val stats = result.data
192+ val ui = StreamStatsUi(
193+ eventCount = stats.ingestion?.count
194+ ?: stats.ingestion?.lifetimeCount,
195+ ingestionSize = formatBytes(stats.ingestion?.size)
196+ ?: formatBytes(stats.ingestion?.lifetimeSize),
197+ storageSize = formatBytes(stats.storage?.size)
198+ ?: formatBytes(stats.storage?.lifetimeSize),
199+ )
200+ _state.update {
201+ // Drop late results for streams a concurrent refresh has already pruned,
202+ // so an in-flight retry can't resurrect stats for a deleted stream.
203+ if (it.streams.none { s -> s.name == streamName }) it
204+ else it.copy(
205+ streamStats = it.streamStats + (streamName to ui),
206+ failedStats = it.failedStats - streamName,
207+ )
208+ }
209+ }
210+ is ApiResult.Error -> {
211+ _state.update {
212+ if (it.streams.none { s -> s.name == streamName }) it
213+ else it.copy(failedStats = it.failedStats + streamName)
214+ }
215+ }
216+ }
217+ }
218+
219+ suspend fun logout() {
220+ settingsRepository.clearConfig()
221+ repository.clearCredentials()
222+ }
223+}
app/src/main/java/com/parseable/android/ui/theme/Theme.ktnew file+67
@@ -0,0 +1,67 @@
1+package com.parseable.android.ui.theme
2+
3+import android.os.Build
4+import androidx.compose.foundation.isSystemInDarkTheme
5+import androidx.compose.material3.*
6+import androidx.compose.runtime.Composable
7+import androidx.compose.ui.graphics.Color
8+import androidx.compose.ui.platform.LocalContext
9+
10+private val ParseablePrimary = Color(0xFF545BEB)
11+private val ParseableOnPrimary = Color(0xFFFFFFFF)
12+private val ParseableSecondary = Color(0xFF3D43B0)
13+private val ParseableTertiary = Color(0xFF7C82FF)
14+
15+private val DarkColorScheme = darkColorScheme(
16+ primary = ParseableTertiary, // Lighter variant for dark background readability
17+ onPrimary = Color.White,
18+ secondary = ParseablePrimary,
19+ tertiary = ParseableSecondary,
20+ background = Color(0xFF0F1117),
21+ surface = Color(0xFF1A1B23),
22+ surfaceVariant = Color(0xFF25262F),
23+ onBackground = Color(0xFFE3E3E8),
24+ onSurface = Color(0xFFE3E3E8),
25+ onSurfaceVariant = Color(0xFFA0A1A8),
26+ outline = Color(0xFF3A3B44),
27+ error = Color(0xFFFF6B6B),
28+ onError = Color.White,
29+)
30+
31+private val LightColorScheme = lightColorScheme(
32+ primary = ParseablePrimary,
33+ onPrimary = Color.White,
34+ secondary = ParseableSecondary,
35+ tertiary = ParseableTertiary,
36+ background = Color(0xFFF8F8FC),
37+ surface = Color.White,
38+ surfaceVariant = Color(0xFFF0F0F6),
39+ onBackground = Color(0xFF1A1B23),
40+ onSurface = Color(0xFF1A1B23),
41+ onSurfaceVariant = Color(0xFF5A5B64),
42+ outline = Color(0xFFD0D0D8),
43+ error = Color(0xFFD32F2F),
44+ onError = Color.White,
45+)
46+
47+@Composable
48+fun ParseableTheme(
49+ darkTheme: Boolean = isSystemInDarkTheme(),
50+ dynamicColor: Boolean = false,
51+ content: @Composable () -> Unit,
52+) {
53+ val colorScheme = when {
54+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
55+ val context = LocalContext.current
56+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
57+ }
58+ darkTheme -> DarkColorScheme
59+ else -> LightColorScheme
60+ }
61+
62+ MaterialTheme(
63+ colorScheme = colorScheme,
64+ typography = Typography(),
65+ content = content,
66+ )
67+}
app/src/main/res/mipmap-hdpi/ic_launcher.webpnew file

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

app/src/main/res/values/strings.xmlnew file+78
@@ -0,0 +1,78 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <string name="app_name">Parseable</string>
4+
5+ <!-- Navigation / Screen titles -->
6+ <string name="title_login">Login</string>
7+ <string name="title_streams">Parseable</string>
8+ <string name="title_alerts">Alerts</string>
9+ <string name="title_settings">Settings</string>
10+ <string name="title_stream_info">Stream: %s</string>
11+ <string name="title_add_filter">Add Filter</string>
12+ <string name="title_sql_query">SQL Query</string>
13+
14+ <!-- Common actions -->
15+ <string name="action_back">Back</string>
16+ <string name="action_retry">Retry</string>
17+ <string name="action_cancel">Cancel</string>
18+ <string name="action_apply">Apply</string>
19+ <string name="action_delete">Delete</string>
20+ <string name="action_login">Login</string>
21+ <string name="action_logout">Logout</string>
22+ <string name="action_share">Share</string>
23+ <string name="action_stop">Stop</string>
24+ <string name="action_clear_all">Clear all</string>
25+ <string name="action_search">Search</string>
26+ <string name="action_clear">Clear</string>
27+ <string name="action_execute">Execute</string>
28+
29+ <!-- Search -->
30+ <string name="search_logs_placeholder">Search logs\u2026</string>
31+ <string name="search_streams_placeholder">Search streams\u2026</string>
32+
33+ <!-- Log viewer -->
34+ <string name="live_tail_banner">Live tail active - polling every %ds</string>
35+ <string name="end_of_results">End of results</string>
36+ <string name="log_entry_copied">Log entry copied to clipboard</string>
37+ <string name="no_logs_time_range">No logs found for the selected time range</string>
38+ <string name="no_logs_search">No logs match \"%s\"</string>
39+ <string name="no_logs_filters">No logs match the active filters</string>
40+ <string name="no_logs_search_filters">No logs match \"%s\" with the active filters</string>
41+
42+ <!-- Streams -->
43+ <string name="no_streams_found">No log streams found</string>
44+ <string name="no_streams_matching">No streams matching \"%s\"</string>
45+ <string name="stats_unavailable">Stats unavailable</string>
46+ <string name="added_to_favorites">Added to favorites</string>
47+ <string name="removed_from_favorites">Removed from favorites</string>
48+
49+ <!-- Alerts -->
50+ <string name="no_alerts">No alerts configured</string>
51+ <string name="delete_alert_title">Delete Alert</string>
52+ <string name="delete_alert_message">Are you sure you want to delete \"%s\"? This action cannot be undone.</string>
53+
54+ <!-- Login -->
55+ <string name="login_use_saved">Use saved credentials</string>
56+ <string name="login_server_url">Server URL</string>
57+ <string name="login_username">Username</string>
58+ <string name="login_password">Password</string>
59+ <string name="login_allow_insecure">Allow insecure (HTTP)</string>
60+ <string name="login_http_warning">Warning: HTTP sends credentials and data in plaintext. Only use this for trusted private networks.</string>
61+
62+ <!-- Settings sections -->
63+ <string name="settings_server_connection">Server Connection</string>
64+ <string name="settings_server_info">Server Info</string>
65+ <string name="settings_app">App</string>
66+
67+ <!-- Share warning -->
68+ <string name="share_warning_title">Share Logs</string>
69+ <string name="share_warning_message">Logs may contain sensitive information (PII, secrets, tokens). They will be exported as unencrypted JSON. Continue?</string>
70+
71+ <!-- Logout -->
72+ <string name="logout_confirmation_title">Logout</string>
73+ <string name="logout_confirmation_message">Are you sure you want to disconnect from this server?</string>
74+
75+ <!-- Stream delete -->
76+ <string name="delete_stream_title">Delete Stream</string>
77+ <string name="delete_stream_message">Are you sure you want to delete \"%s\"? This will permanently remove all log data and cannot be undone.</string>
78+</resources>
app/src/main/res/values/themes.xmlnew file+4
@@ -0,0 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <style name="Theme.ParseableAndroid" parent="android:Theme.Material.Light.NoActionBar" />
4+</resources>
app/src/main/res/xml/file_paths.xmlnew file+4
@@ -0,0 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<paths>
3+ <cache-path name="shared_logs" path="shared_logs/" />
4+</paths>
app/src/main/res/xml/network_security_config.xmlnew file+19
@@ -0,0 +1,19 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<network-security-config>
3+ <!--
4+ Cleartext (HTTP) is permitted because many Parseable instances are
5+ self-hosted on private networks without TLS. The app defaults to HTTPS
6+ and shows a security warning in the login screen when the user enables
7+ the "Allow insecure" toggle.
8+
9+ Note: Android's domain-config with includeSubdomains does NOT support
10+ IP subnet matching (e.g. 192.168.x.x), so whitelisting private IP
11+ ranges is not possible via this config. Instead we allow cleartext
12+ globally and rely on the app-level UI warning.
13+ -->
14+ <base-config cleartextTrafficPermitted="true">
15+ <trust-anchors>
16+ <certificates src="system" />
17+ </trust-anchors>
18+ </base-config>
19+</network-security-config>
app/src/test/java/com/parseable/android/AlertsViewModelTest.ktnew file+127
@@ -0,0 +1,127 @@
1+package com.parseable.android
2+
3+import com.parseable.android.data.model.Alert
4+import com.parseable.android.data.model.ApiResult
5+import com.parseable.android.data.repository.ParseableRepository
6+import com.parseable.android.ui.screens.alerts.AlertsViewModel
7+import io.mockk.*
8+import kotlinx.coroutines.Dispatchers
9+import kotlinx.coroutines.ExperimentalCoroutinesApi
10+import kotlinx.coroutines.test.*
11+import org.junit.After
12+import org.junit.Assert.*
13+import org.junit.Before
14+import org.junit.Test
15+
16+@OptIn(ExperimentalCoroutinesApi::class)
17+class AlertsViewModelTest {
18+
19+ private lateinit var repository: ParseableRepository
20+ private lateinit var viewModel: AlertsViewModel
21+ private val testDispatcher = UnconfinedTestDispatcher()
22+
23+ @Before
24+ fun setup() {
25+ Dispatchers.setMain(testDispatcher)
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)
32+ }
33+
34+ @After
35+ fun tearDown() {
36+ Dispatchers.resetMain()
37+ }
38+
39+ @Test
40+ fun `initial state has empty alerts and no loading`() {
41+ val state = viewModel.state.value
42+ assertTrue(state.alerts.isEmpty())
43+ assertFalse(state.isLoading)
44+ assertNull(state.error)
45+ assertNull(state.alertToDelete)
46+ }
47+
48+ @Test
49+ fun `refresh loads alerts on success`() = runTest {
50+ val alerts = listOf(
51+ Alert(id = "1", title = "Alert 1", datasets = listOf("stream1"), state = "NotTriggered"),
52+ Alert(id = "2", title = "Alert 2", datasets = listOf("stream2"), state = "Triggered"),
53+ )
54+ coEvery { repository.listAlerts() } returns ApiResult.Success(alerts)
55+
56+ viewModel.refresh()
57+
58+ val state = viewModel.state.value
59+ assertEquals(alerts, state.alerts)
60+ assertFalse(state.isLoading)
61+ assertNull(state.error)
62+ }
63+
64+ @Test
65+ fun `refresh shows error on failure`() = runTest {
66+ coEvery { repository.listAlerts() } returns ApiResult.Error("Server error", 500)
67+
68+ viewModel.refresh()
69+
70+ val state = viewModel.state.value
71+ assertTrue(state.alerts.isEmpty())
72+ assertFalse(state.isLoading)
73+ assertNotNull(state.error)
74+ }
75+
76+ @Test
77+ fun `requestDelete sets alertToDelete`() {
78+ val alert = Alert(id = "1", title = "Alert 1", datasets = listOf("stream1"))
79+
80+ viewModel.requestDelete(alert)
81+
82+ val state = viewModel.state.value
83+ assertEquals(alert, state.alertToDelete)
84+ }
85+
86+ @Test
87+ fun `cancelDelete clears alertToDelete`() {
88+ val alert = Alert(id = "1", title = "Alert 1", datasets = listOf("stream1"))
89+ viewModel.requestDelete(alert)
90+ assertEquals(alert, viewModel.state.value.alertToDelete)
91+
92+ viewModel.cancelDelete()
93+
94+ assertNull(viewModel.state.value.alertToDelete)
95+ }
96+
97+ @Test
98+ fun `deleteAlert on success refreshes list`() = runTest {
99+ val alerts = listOf(
100+ Alert(id = "2", title = "Alert 2", datasets = listOf("stream2")),
101+ )
102+ coEvery { repository.deleteAlert("1") } returns ApiResult.Success("deleted")
103+ coEvery { repository.listAlerts() } returns ApiResult.Success(alerts)
104+
105+ viewModel.deleteAlert("1")
106+
107+ val state = viewModel.state.value
108+ assertEquals(alerts, state.alerts)
109+ assertFalse(state.isLoading)
110+ assertNull(state.error)
111+ assertNull(state.alertToDelete)
112+ coVerify { repository.deleteAlert("1") }
113+ coVerify { repository.listAlerts() }
114+ }
115+
116+ @Test
117+ fun `deleteAlert on error shows error message`() = runTest {
118+ coEvery { repository.deleteAlert("1") } returns ApiResult.Error("Not found", 404)
119+
120+ viewModel.deleteAlert("1")
121+
122+ val state = viewModel.state.value
123+ assertFalse(state.isLoading)
124+ assertNotNull(state.error)
125+ assertNull(state.alertToDelete)
126+ }
127+}
app/src/test/java/com/parseable/android/ApiResultTest.ktnew file+89
@@ -0,0 +1,89 @@
1+package com.parseable.android
2+
3+import com.parseable.android.data.model.ApiResult
4+import org.junit.Assert.*
5+import org.junit.Test
6+
7+class ApiResultTest {
8+
9+ @Test
10+ fun `401 error produces session expired message`() {
11+ val error = ApiResult.Error("Unauthorized", code = 401)
12+ assertTrue(error.isUnauthorized)
13+ assertEquals("Session expired. Please log in again.", error.userMessage)
14+ }
15+
16+ @Test
17+ fun `403 error produces permission denied message`() {
18+ val error = ApiResult.Error("Forbidden", code = 403)
19+ assertEquals("Permission denied.", error.userMessage)
20+ }
21+
22+ @Test
23+ fun `404 error produces not found message`() {
24+ val error = ApiResult.Error("Not Found", code = 404)
25+ assertTrue(error.isNotFound)
26+ assertEquals("Resource not found. It may have been deleted.", error.userMessage)
27+ }
28+
29+ @Test
30+ fun `429 error produces rate limit message`() {
31+ val error = ApiResult.Error("Too Many Requests", code = 429)
32+ assertEquals("Too many requests. Please wait and try again.", error.userMessage)
33+ }
34+
35+ @Test
36+ fun `500 error produces server error message`() {
37+ val error = ApiResult.Error("Internal Server Error", code = 500)
38+ assertTrue(error.isServerError)
39+ assertEquals("Server error (500). Please try again later.", error.userMessage)
40+ }
41+
42+ @Test
43+ fun `502 error produces server error message`() {
44+ val error = ApiResult.Error("Bad Gateway", code = 502)
45+ assertTrue(error.isServerError)
46+ assertEquals("Server error (502). Please try again later.", error.userMessage)
47+ }
48+
49+ @Test
50+ fun `network timeout produces timeout message`() {
51+ val error = ApiResult.Error("connect timeout", code = 0)
52+ assertTrue(error.isNetworkError)
53+ assertEquals("Connection timed out. Check your network.", error.userMessage)
54+ }
55+
56+ @Test
57+ fun `DNS resolution failure produces no internet message`() {
58+ val error = ApiResult.Error("Unable to resolve host \"example.com\"", code = 0)
59+ assertTrue(error.isNetworkError)
60+ assertEquals("No internet connection.", error.userMessage)
61+ }
62+
63+ @Test
64+ fun `connection refused produces unreachable message`() {
65+ val error = ApiResult.Error("Connection refused", code = 0)
66+ assertTrue(error.isNetworkError)
67+ assertEquals("Server is unreachable.", error.userMessage)
68+ }
69+
70+ @Test
71+ fun `generic network error includes original message`() {
72+ val error = ApiResult.Error("SSL handshake failed", code = 0)
73+ assertTrue(error.isNetworkError)
74+ assertEquals("Network error: SSL handshake failed", error.userMessage)
75+ }
76+
77+ @Test
78+ fun `non-standard HTTP code returns raw message`() {
79+ val error = ApiResult.Error("Custom error from proxy", code = 418)
80+ assertEquals("Custom error from proxy", error.userMessage)
81+ }
82+
83+ @Test
84+ fun `success result carries data`() {
85+ val result: ApiResult<String> = ApiResult.Success("hello")
86+ assertTrue(result is ApiResult.Success)
87+ assertEquals("hello", (result as ApiResult.Success).data)
88+ }
89+}
app/src/test/java/com/parseable/android/EscapeSqlTest.ktnew file+101
@@ -0,0 +1,101 @@
1+package com.parseable.android
2+
3+import com.parseable.android.data.escapeIdentifier
4+import com.parseable.android.data.escapeLikePattern
5+import com.parseable.android.data.escapeSql
6+import org.junit.Assert.assertEquals
7+import org.junit.Test
8+
9+class EscapeSqlTest {
10+
11+ @Test
12+ fun `escapeSql replaces single quotes`() {
13+ assertEquals("it''s", escapeSql("it's"))
14+ }
15+
16+ @Test
17+ fun `escapeSql handles multiple single quotes`() {
18+ assertEquals("it''s a ''test''", escapeSql("it's a 'test'"))
19+ }
20+
21+ @Test
22+ fun `escapeSql handles empty string`() {
23+ assertEquals("", escapeSql(""))
24+ }
25+
26+ @Test
27+ fun `escapeSql handles string without quotes`() {
28+ assertEquals("hello world", escapeSql("hello world"))
29+ }
30+
31+ @Test
32+ fun `escapeSql handles consecutive quotes`() {
33+ assertEquals("''''", escapeSql("''"))
34+ }
35+
36+ @Test
37+ fun `escapeSql does not alter double quotes`() {
38+ assertEquals("say \"hello\"", escapeSql("say \"hello\""))
39+ }
40+
41+ @Test
42+ fun `escapeSql handles backslash`() {
43+ assertEquals("path\\to\\file", escapeSql("path\\to\\file"))
44+ }
45+
46+ @Test
47+ fun `escapeSql handles semicolons`() {
48+ assertEquals("; DROP TABLE users --", escapeSql("; DROP TABLE users --"))
49+ }
50+
51+ @Test
52+ fun `escapeSql handles quote injection attempt`() {
53+ assertEquals("''; DROP TABLE users --", escapeSql("'; DROP TABLE users --"))
54+ }
55+
56+ @Test
57+ fun `escapeLikePattern escapes percent wildcard`() {
58+ assertEquals("100\\%", escapeLikePattern("100%"))
59+ }
60+
61+ @Test
62+ fun `escapeLikePattern escapes underscore wildcard`() {
63+ assertEquals("a\\_b", escapeLikePattern("a_b"))
64+ }
65+
66+ @Test
67+ fun `escapeLikePattern escapes backslash before wildcards`() {
68+ // Input backslash becomes a doubled backslash; %/_ each gain one escape.
69+ assertEquals("\\\\\\%\\_", escapeLikePattern("\\%_"))
70+ }
71+
72+ @Test
73+ fun `escapeLikePattern also escapes single quotes`() {
74+ assertEquals("it''s 50\\%", escapeLikePattern("it's 50%"))
75+ }
76+
77+ @Test
78+ fun `escapeLikePattern leaves plain text untouched`() {
79+ assertEquals("hello world", escapeLikePattern("hello world"))
80+ }
81+
82+ @Test
83+ fun `escapeIdentifier doubles double quotes`() {
84+ assertEquals("foo\"\"bar", escapeIdentifier("foo\"bar"))
85+ }
86+
87+ @Test
88+ fun `escapeIdentifier handles no quotes`() {
89+ assertEquals("my_stream", escapeIdentifier("my_stream"))
90+ }
91+
92+ @Test
93+ fun `escapeIdentifier handles empty string`() {
94+ assertEquals("", escapeIdentifier(""))
95+ }
96+
97+ @Test
98+ fun `escapeIdentifier handles consecutive double quotes`() {
99+ assertEquals("\"\"\"\"", escapeIdentifier("\"\""))
100+ }
101+}
app/src/test/java/com/parseable/android/FormatBytesTest.ktnew file+80
@@ -0,0 +1,80 @@
1+package com.parseable.android
2+
3+import com.parseable.android.data.formatBytes
4+import org.junit.Assert.*
5+import org.junit.Test
6+
7+class FormatBytesTest {
8+
9+ @Test
10+ fun `null input returns null`() {
11+ assertNull(formatBytes(null))
12+ }
13+
14+ @Test
15+ fun `zero bytes`() {
16+ assertEquals("0 B", formatBytes("0"))
17+ }
18+
19+ @Test
20+ fun `small byte values`() {
21+ assertEquals("512 B", formatBytes("512"))
22+ assertEquals("1 B", formatBytes("1"))
23+ }
24+
25+ @Test
26+ fun `kilobytes`() {
27+ assertEquals("1.00 KB", formatBytes("1024"))
28+ assertEquals("1.50 KB", formatBytes("1536"))
29+ }
30+
31+ @Test
32+ fun `megabytes`() {
33+ assertEquals("1.00 MB", formatBytes("1048576"))
34+ assertEquals("5.25 MB", formatBytes("5505024"))
35+ }
36+
37+ @Test
38+ fun `gigabytes`() {
39+ assertEquals("1.00 GB", formatBytes("1073741824"))
40+ assertEquals("2.50 GB", formatBytes("2684354560"))
41+ }
42+
43+ @Test
44+ fun `terabytes`() {
45+ assertEquals("1.00 TB", formatBytes("1099511627776"))
46+ }
47+
48+ @Test
49+ fun `large values use no decimals`() {
50+ // 150 GB = 161061273600
51+ assertEquals("150 GB", formatBytes("161061273600"))
52+ }
53+
54+ @Test
55+ fun `mid-range values use one decimal`() {
56+ // 15.5 MB = 16252928
57+ assertEquals("15.5 MB", formatBytes("16252928"))
58+ }
59+
60+ @Test
61+ fun `non-numeric string passes through unchanged`() {
62+ assertEquals("already formatted", formatBytes("already formatted"))
63+ assertEquals("1.5 GiB", formatBytes("1.5 GiB"))
64+ }
65+
66+ @Test
67+ fun `negative value passes through unchanged`() {
68+ assertEquals("-1024", formatBytes("-1024"))
69+ }
70+
71+ @Test
72+ fun `whitespace is trimmed before parsing`() {
73+ assertEquals("1.00 KB", formatBytes(" 1024 "))
74+ }
75+
76+ @Test
77+ fun `fractional bytes from API`() {
78+ assertEquals("1.50 KB", formatBytes("1536.0"))
79+ }
80+}
app/src/test/java/com/parseable/android/FormatTimestampTest.ktnew file+73
@@ -0,0 +1,73 @@
1+package com.parseable.android
2+
3+import com.parseable.android.data.formatTimestamp
4+import org.junit.Assert.*
5+import org.junit.Test
6+import java.util.TimeZone
7+
8+class FormatTimestampTest {
9+
10+ @Test
11+ fun `formats ISO8601 UTC timestamp`() {
12+ // Force UTC for test determinism
13+ val original = TimeZone.getDefault()
14+ try {
15+ TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
16+ val result = formatTimestamp("2024-12-15T14:30:45.123Z")
17+ assertEquals("Dec 15 14:30:45.123", result)
18+ } finally {
19+ TimeZone.setDefault(original)
20+ }
21+ }
22+
23+ @Test
24+ fun `formats Parseable timestamp format with +00 00 suffix`() {
25+ val original = TimeZone.getDefault()
26+ try {
27+ TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
28+ val result = formatTimestamp("2024-12-15T14:30:45.123456+00:00")
29+ assertEquals("Dec 15 14:30:45.123", result)
30+ } finally {
31+ TimeZone.setDefault(original)
32+ }
33+ }
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
50+ fun `returns raw string for unparseable timestamp`() {
51+ val result = formatTimestamp("not-a-timestamp")
52+ assertEquals("not-a-timestamp", result)
53+ }
54+
55+ @Test
56+ fun `handles empty string`() {
57+ val result = formatTimestamp("")
58+ assertEquals("", result)
59+ }
60+
61+ @Test
62+ fun `converts to local timezone`() {
63+ val original = TimeZone.getDefault()
64+ try {
65+ TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"))
66+ val result = formatTimestamp("2024-06-15T18:30:45.000Z")
67+ // June = EDT = UTC-4
68+ assertEquals("Jun 15 14:30:45.000", result)
69+ } finally {
70+ TimeZone.setDefault(original)
71+ }
72+ }
73+}
app/src/test/java/com/parseable/android/LogViewerViewModelTest.ktnew file+340
@@ -0,0 +1,340 @@
1+package com.parseable.android
2+
3+import com.parseable.android.data.model.ApiResult
4+import com.parseable.android.data.model.SchemaField
5+import com.parseable.android.data.model.StreamSchema
6+import com.parseable.android.data.repository.ParseableRepository
7+import com.parseable.android.ui.screens.logviewer.LogViewerViewModel
8+import com.parseable.android.ui.screens.logviewer.TimeRange
9+import io.mockk.*
10+import kotlinx.coroutines.Dispatchers
11+import kotlinx.coroutines.ExperimentalCoroutinesApi
12+import kotlinx.coroutines.test.*
13+import kotlinx.serialization.json.JsonObject
14+import kotlinx.serialization.json.JsonPrimitive
15+import org.junit.After
16+import org.junit.Assert.*
17+import org.junit.Before
18+import org.junit.Test
19+
20+@OptIn(ExperimentalCoroutinesApi::class)
21+class LogViewerViewModelTest {
22+
23+ private lateinit var repository: ParseableRepository
24+ private lateinit var viewModel: LogViewerViewModel
25+ private val testDispatcher = UnconfinedTestDispatcher()
26+
27+ @Before
28+ fun setup() {
29+ Dispatchers.setMain(testDispatcher)
30+ repository = mockk(relaxed = true)
31+ coEvery { repository.getStreamSchema(any()) } returns ApiResult.Success(
32+ StreamSchema(fields = listOf(
33+ SchemaField(name = "message"),
34+ SchemaField(name = "level"),
35+ SchemaField(name = "p_timestamp"),
36+ ))
37+ )
38+ coEvery { repository.queryLogs(any(), any(), any(), any(), any()) } returns ApiResult.Success(emptyList())
39+ viewModel = LogViewerViewModel(repository)
40+ }
41+
42+ @After
43+ fun tearDown() {
44+ Dispatchers.resetMain()
45+ }
46+
47+ @Test
48+ fun `initialize sets stream name and loads schema`() {
49+ viewModel.initialize("test_stream")
50+
51+ val state = viewModel.state.value
52+ assertEquals("test_stream", state.streamName)
53+ assertEquals(3, state.columns.size)
54+ assertTrue(state.columns.contains("message"))
55+ }
56+
57+ @Test
58+ fun `initialize is idempotent for same stream`() {
59+ viewModel.initialize("test_stream")
60+ viewModel.initialize("test_stream")
61+
62+ coVerify(exactly = 1) { repository.getStreamSchema("test_stream") }
63+ }
64+
65+ @Test
66+ fun `onTimeRangeChange updates state and refreshes`() {
67+ viewModel.initialize("test")
68+ viewModel.onTimeRangeChange(TimeRange.LAST_24H)
69+
70+ assertEquals(TimeRange.LAST_24H, viewModel.state.value.selectedTimeRange)
71+ assertEquals(500, viewModel.state.value.currentLimit)
72+ }
73+
74+ @Test
75+ fun `loadMore increments limit by 500`() {
76+ viewModel.initialize("test")
77+ viewModel.loadMore()
78+
79+ assertEquals(1000, viewModel.state.value.currentLimit)
80+ }
81+
82+ @Test
83+ fun `loadMore caps at MAX_LOAD_LIMIT of 5000`() {
84+ viewModel.initialize("test")
85+ // Load more 10 times (500 + 10*500 = 5500, but capped at 5000)
86+ repeat(10) { viewModel.loadMore() }
87+
88+ assertEquals(5000, viewModel.state.value.currentLimit)
89+ assertFalse(viewModel.state.value.hasMore) // Should be false once at cap
90+ }
91+
92+ @Test
93+ fun `addFilter adds clause and display to state`() {
94+ viewModel.initialize("test")
95+ viewModel.addFilter("level", "=", "ERROR")
96+
97+ val state = viewModel.state.value
98+ assertEquals(1, state.activeFilters.size)
99+ assertEquals("level = ERROR", state.activeFilters[0])
100+ assertEquals(1, state.filterClauses.size)
101+ }
102+
103+ @Test
104+ fun `removeFilter removes correct filter by index`() {
105+ viewModel.initialize("test")
106+ viewModel.addFilter("level", "=", "ERROR")
107+ viewModel.addFilter("message", "ILIKE", "timeout")
108+
109+ assertEquals(2, viewModel.state.value.activeFilters.size)
110+
111+ viewModel.removeFilter("level = ERROR")
112+
113+ val state = viewModel.state.value
114+ assertEquals(1, state.activeFilters.size)
115+ assertEquals("message ILIKE timeout", state.activeFilters[0])
116+ }
117+
118+ @Test
119+ fun `clearFilters removes all filters`() {
120+ viewModel.initialize("test")
121+ viewModel.addFilter("level", "=", "ERROR")
122+ viewModel.addFilter("level", "=", "WARN")
123+ viewModel.clearFilters()
124+
125+ assertTrue(viewModel.state.value.activeFilters.isEmpty())
126+ assertTrue(viewModel.state.value.filterClauses.isEmpty())
127+ }
128+
129+ @Test
130+ fun `refresh with error sets error state`() {
131+ coEvery { repository.queryLogs(any(), any(), any(), any(), any()) } returns
132+ ApiResult.Error("Server error", 500)
133+
134+ viewModel.initialize("test")
135+
136+ assertNotNull(viewModel.state.value.error)
137+ }
138+
139+ @Test
140+ fun `refresh with success clears error`() {
141+ val logs = listOf(
142+ JsonObject(mapOf("message" to JsonPrimitive("hello")))
143+ )
144+ coEvery { repository.queryLogs(any(), any(), any(), any(), any()) } returns
145+ ApiResult.Success(logs)
146+
147+ viewModel.initialize("test")
148+
149+ assertNull(viewModel.state.value.error)
150+ assertEquals(1, viewModel.state.value.logs.size)
151+ }
152+
153+ @Test
154+ fun `search builds ILIKE clause for all non-internal columns`() {
155+ viewModel.initialize("test")
156+ viewModel.onSearchQueryChange("error")
157+
158+ // Advance past debounce
159+ testDispatcher.scheduler.advanceTimeBy(400)
160+
161+ coVerify {
162+ repository.queryLogs(
163+ stream = "test",
164+ startTime = any(),
165+ endTime = any(),
166+ filterSql = match {
167+ it.contains("CAST(\"message\" AS VARCHAR) ILIKE '%error%'") &&
168+ it.contains("CAST(\"level\" AS VARCHAR) ILIKE '%error%'") &&
169+ !it.contains("p_timestamp") // Internal columns excluded
170+ },
171+ limit = any(),
172+ )
173+ }
174+ }
175+
176+ @Test
177+ fun `setCustomTimeRange updates state correctly`() {
178+ viewModel.initialize("test")
179+ viewModel.setCustomTimeRange(1000L, 2000L)
180+
181+ val state = viewModel.state.value
182+ assertEquals(TimeRange.CUSTOM, state.selectedTimeRange)
183+ assertEquals(1000L, state.customStartTime)
184+ assertEquals(2000L, state.customEndTime)
185+ assertEquals(500, state.currentLimit) // Reset
186+ }
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
207+ fun `toggleStreaming starts and stops`() {
208+ viewModel.initialize("test")
209+
210+ viewModel.toggleStreaming()
211+ assertTrue(viewModel.state.value.isStreaming)
212+
213+ viewModel.toggleStreaming()
214+ assertFalse(viewModel.state.value.isStreaming)
215+ }
216+
217+ @Test
218+ fun `stopStreaming stops but preserves streaming error`() {
219+ viewModel.initialize("test")
220+ viewModel.toggleStreaming()
221+ viewModel.stopStreaming()
222+
223+ val state = viewModel.state.value
224+ assertFalse(state.isStreaming)
225+ }
226+
227+ @Test
228+ fun `dismissStreamingError clears the error`() {
229+ viewModel.initialize("test")
230+ viewModel.toggleStreaming()
231+ viewModel.stopStreaming()
232+ viewModel.dismissStreamingError()
233+
234+ assertNull(viewModel.state.value.streamingError)
235+ }
236+
237+ @Test
238+ fun `executeCustomSql rejects non-SELECT queries`() {
239+ viewModel.initialize("test")
240+ viewModel.executeCustomSql("DELETE FROM test_stream")
241+
242+ assertEquals("Only SELECT queries are allowed", viewModel.state.value.error)
243+ }
244+
245+ @Test
246+ fun `executeCustomSql appends LIMIT when missing`() {
247+ coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
248+
249+ viewModel.initialize("test")
250+ viewModel.executeCustomSql("SELECT * FROM test_stream")
251+
252+ coVerify {
253+ repository.queryLogsRaw(
254+ match { it.contains("LIMIT 5000") },
255+ any(),
256+ any(),
257+ )
258+ }
259+ }
260+
261+ @Test
262+ fun `executeCustomSql preserves existing LIMIT`() {
263+ coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
264+
265+ viewModel.initialize("test")
266+ viewModel.executeCustomSql("SELECT * FROM test_stream LIMIT 50")
267+
268+ coVerify {
269+ repository.queryLogsRaw(
270+ match { it.trim() == "SELECT * FROM test_stream LIMIT 50" },
271+ any(),
272+ any(),
273+ )
274+ }
275+ }
276+
277+ @Test
278+ fun `executeCustomSql strips trailing semicolon before appending LIMIT`() {
279+ coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
280+
281+ viewModel.initialize("test")
282+ // Without stripping the ';', the appended LIMIT would land after the terminator
283+ // ("...test_stream; LIMIT 5000") and produce invalid SQL.
284+ viewModel.executeCustomSql("SELECT * FROM test_stream;")
285+
286+ coVerify {
287+ repository.queryLogsRaw(
288+ match { it.trim() == "SELECT * FROM test_stream LIMIT 5000" },
289+ any(),
290+ any(),
291+ )
292+ }
293+ }
294+
295+ @Test
296+ fun `executeCustomSql adds outer LIMIT when only a subquery has one`() {
297+ coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
298+
299+ viewModel.initialize("test")
300+ // The inner LIMIT doesn't bound the outer result set, so the safety LIMIT must still
301+ // be appended.
302+ viewModel.executeCustomSql("SELECT * FROM (SELECT * FROM test_stream LIMIT 10) t")
303+
304+ coVerify {
305+ repository.queryLogsRaw(
306+ match { it.contains("LIMIT 10") && it.trimEnd().endsWith("LIMIT 5000") },
307+ any(),
308+ any(),
309+ )
310+ }
311+ }
312+
313+ @Test
314+ fun `addFilter escapes column names`() {
315+ viewModel.initialize("test")
316+ viewModel.addFilter("col\"name", "=", "value")
317+
318+ val clause = viewModel.state.value.filterClauses[0]
319+ assertTrue(clause.contains("col\"\"name")) // Double-escaped
320+ }
321+
322+ @Test
323+ fun `addFilter escapes SQL values`() {
324+ viewModel.initialize("test")
325+ viewModel.addFilter("col", "=", "it's")
326+
327+ val clause = viewModel.state.value.filterClauses[0]
328+ assertTrue(clause.contains("it''s")) // Escaped quote
329+ }
330+
331+ @Test
332+ fun `addFilter handles IS NULL without value`() {
333+ viewModel.initialize("test")
334+ viewModel.addFilter("col", "IS NULL", "")
335+
336+ val clause = viewModel.state.value.filterClauses[0]
337+ assertTrue(clause.endsWith("IS NULL"))
338+ assertFalse(clause.contains("''"))
339+ }
340+}
app/src/test/java/com/parseable/android/LoginViewModelTest.ktnew file+253
@@ -0,0 +1,253 @@
1+package com.parseable.android
2+
3+import com.parseable.android.data.model.AboutInfo
4+import com.parseable.android.data.model.ApiResult
5+import com.parseable.android.data.repository.ParseableRepository
6+import com.parseable.android.data.repository.SettingsRepository
7+import com.parseable.android.ui.screens.login.LoginViewModel
8+import io.mockk.*
9+import kotlinx.coroutines.Dispatchers
10+import kotlinx.coroutines.ExperimentalCoroutinesApi
11+import kotlinx.coroutines.flow.flowOf
12+import kotlinx.coroutines.flow.toList
13+import kotlinx.coroutines.launch
14+import kotlinx.coroutines.test.*
15+import org.junit.After
16+import org.junit.Assert.*
17+import org.junit.Before
18+import org.junit.Test
19+
20+@OptIn(ExperimentalCoroutinesApi::class)
21+class LoginViewModelTest {
22+
23+ private lateinit var repository: ParseableRepository
24+ private lateinit var settingsRepository: SettingsRepository
25+ private lateinit var viewModel: LoginViewModel
26+ private val testDispatcher = UnconfinedTestDispatcher()
27+
28+ @Before
29+ fun setup() {
30+ Dispatchers.setMain(testDispatcher)
31+ repository = mockk(relaxed = true)
32+ settingsRepository = mockk(relaxed = true)
33+ coEvery { settingsRepository.serverConfig } returns flowOf(null)
34+ viewModel = LoginViewModel(repository, settingsRepository)
35+ }
36+
37+ @After
38+ fun tearDown() {
39+ Dispatchers.resetMain()
40+ }
41+
42+ /**
43+ * Start collecting the one-shot login-success events into a list (on the eager
44+ * UnconfinedTestDispatcher) before the action under test, so the success emission is observed.
45+ * The collector lives in backgroundScope, which runTest auto-cancels.
46+ */
47+ private fun TestScope.collectSuccessEvents(): MutableList<Unit> {
48+ val events = mutableListOf<Unit>()
49+ backgroundScope.launch(testDispatcher) { viewModel.loginSuccessEvent.toList(events) }
50+ return events
51+ }
52+
53+ @Test
54+ fun `onLogin validates required fields`() {
55+ viewModel.onLogin()
56+
57+ val state = viewModel.state.value
58+ assertNotNull(state.serverUrlError)
59+ assertNotNull(state.usernameError)
60+ assertNotNull(state.passwordError)
61+ assertFalse(state.isLoading)
62+ }
63+
64+ @Test
65+ fun `onLogin validates URL only when others filled`() {
66+ viewModel.onServerUrlChange("")
67+ viewModel.onUsernameChange("admin")
68+ viewModel.onPasswordChange("password")
69+ viewModel.onLogin()
70+
71+ val state = viewModel.state.value
72+ assertNotNull(state.serverUrlError)
73+ assertNull(state.usernameError)
74+ assertNull(state.passwordError)
75+ }
76+
77+ @Test
78+ fun `onLogin succeeds with valid credentials`() = runTest {
79+ coEvery { repository.testConnection() } returns ApiResult.Success("ok")
80+ coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo(version = "1.0"))
81+ // Reach the success branch deterministically: the login-success event only fires when the
82+ // credentials were durably saved.
83+ coEvery { settingsRepository.saveServerConfig(any()) } returns true
84+
85+ val events = collectSuccessEvents()
86+
87+ viewModel.onServerUrlChange("https://test.parseable.com")
88+ viewModel.onUsernameChange("admin")
89+ viewModel.onPasswordChange("password")
90+ viewModel.onLogin()
91+
92+ val state = viewModel.state.value
93+ assertEquals(1, events.size)
94+ assertFalse(state.isLoading)
95+ coVerify { settingsRepository.saveServerConfig(any()) }
96+ }
97+
98+ @Test
99+ fun `onLogin fails when connection fails`() = runTest {
100+ coEvery { repository.testConnection() } returns ApiResult.Error("Connection refused", 0)
101+
102+ val events = collectSuccessEvents()
103+
104+ viewModel.onServerUrlChange("https://bad.server")
105+ viewModel.onUsernameChange("admin")
106+ viewModel.onPasswordChange("password")
107+ viewModel.onLogin()
108+
109+ val state = viewModel.state.value
110+ assertEquals(0, events.size)
111+ assertNotNull(state.error)
112+ assertTrue(state.error!!.contains("Connection failed"))
113+ }
114+
115+ @Test
116+ fun `onLogin fails when about check fails`() = runTest {
117+ coEvery { repository.testConnection() } returns ApiResult.Success("ok")
118+ coEvery { repository.verifyServer() } returns ApiResult.Error("not parseable", 404)
119+
120+ val events = collectSuccessEvents()
121+
122+ viewModel.onServerUrlChange("https://test.com")
123+ viewModel.onUsernameChange("admin")
124+ viewModel.onPasswordChange("password")
125+ viewModel.onLogin()
126+
127+ val state = viewModel.state.value
128+ assertEquals(0, events.size)
129+ assertNotNull(state.error)
130+ assertTrue(state.error!!.contains("doesn't appear to be Parseable"))
131+ }
132+
133+ @Test
134+ fun `onLogin shows invalid credentials on 401`() = runTest {
135+ coEvery { repository.testConnection() } returns ApiResult.Success("ok")
136+ coEvery { repository.verifyServer() } returns ApiResult.Error("Unauthorized", 401)
137+
138+ val events = collectSuccessEvents()
139+
140+ viewModel.onServerUrlChange("https://test.parseable.com")
141+ viewModel.onUsernameChange("admin")
142+ viewModel.onPasswordChange("wrongpass")
143+ viewModel.onLogin()
144+
145+ val state = viewModel.state.value
146+ assertEquals(0, events.size)
147+ assertNotNull(state.error)
148+ assertTrue(state.error!!.contains("Invalid credentials"))
149+ assertFalse(state.error!!.contains("doesn't appear to be Parseable"))
150+ }
151+
152+ @Test
153+ fun `onServerUrlChange clears error`() {
154+ viewModel.onLogin() // triggers error
155+ assertNotNull(viewModel.state.value.serverUrlError)
156+
157+ viewModel.onServerUrlChange("test")
158+ assertNull(viewModel.state.value.serverUrlError)
159+ assertNull(viewModel.state.value.error)
160+ }
161+
162+ @Test
163+ fun `onAllowInsecureChange updates state`() {
164+ assertFalse(viewModel.state.value.allowInsecure)
165+ viewModel.onAllowInsecureChange(true)
166+ assertTrue(viewModel.state.value.allowInsecure)
167+ }
168+
169+ @Test
170+ fun `does not auto-populate password from saved config`() = runTest {
171+ val config = com.parseable.android.data.model.ServerConfig(
172+ serverUrl = "https://test.com",
173+ username = "admin",
174+ password = "secret",
175+ useTls = true,
176+ )
177+ coEvery { settingsRepository.serverConfig } returns flowOf(config)
178+
179+ viewModel = LoginViewModel(repository, settingsRepository)
180+
181+ val state = viewModel.state.value
182+ assertEquals("https://test.com", state.serverUrl)
183+ assertEquals("admin", state.username)
184+ assertEquals("", state.password) // Password should NOT be auto-populated
185+ assertTrue(state.hasSavedCredentials)
186+ }
187+
188+ @Test
189+ fun `onLogin prepends https when not insecure`() = runTest {
190+ coEvery { repository.testConnection() } returns ApiResult.Success("ok")
191+ coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
192+
193+ viewModel.onServerUrlChange("test.parseable.com")
194+ viewModel.onUsernameChange("admin")
195+ viewModel.onPasswordChange("pass")
196+ viewModel.onLogin()
197+
198+ coVerify {
199+ repository.configure(match { it.serverUrl == "https://test.parseable.com" })
200+ }
201+ }
202+
203+ @Test
204+ fun `onLogin prepends http when insecure`() = runTest {
205+ coEvery { repository.testConnection() } returns ApiResult.Success("ok")
206+ coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
207+
208+ viewModel.onServerUrlChange("test.parseable.com")
209+ viewModel.onAllowInsecureChange(true)
210+ viewModel.onUsernameChange("admin")
211+ viewModel.onPasswordChange("pass")
212+ viewModel.onLogin()
213+
214+ coVerify {
215+ repository.configure(match { it.serverUrl == "http://test.parseable.com" })
216+ }
217+ }
218+
219+ @Test
220+ fun `onLogin handles uppercase URL scheme`() = runTest {
221+ coEvery { repository.testConnection() } returns ApiResult.Success("ok")
222+ coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
223+
224+ viewModel.onServerUrlChange("HTTPS://test.parseable.com")
225+ viewModel.onUsernameChange("admin")
226+ viewModel.onPasswordChange("pass")
227+ viewModel.onLogin()
228+
229+ coVerify {
230+ repository.configure(match {
231+ it.serverUrl == "HTTPS://test.parseable.com" && it.useTls
232+ })
233+ }
234+ }
235+
236+ @Test
237+ fun `onLogin handles mixed case URL scheme`() = runTest {
238+ coEvery { repository.testConnection() } returns ApiResult.Success("ok")
239+ coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
240+
241+ viewModel.onServerUrlChange("Http://test.parseable.com")
242+ viewModel.onAllowInsecureChange(true)
243+ viewModel.onUsernameChange("admin")
244+ viewModel.onPasswordChange("pass")
245+ viewModel.onLogin()
246+
247+ coVerify {
248+ repository.configure(match {
249+ it.serverUrl == "Http://test.parseable.com" && !it.useTls
250+ })
251+ }
252+ }
253+}
app/src/test/java/com/parseable/android/ParseableRepositoryTest.ktnew file+173
@@ -0,0 +1,173 @@
1+package com.parseable.android
2+
3+import com.parseable.android.data.api.ParseableApiClient
4+import com.parseable.android.data.model.*
5+import com.parseable.android.data.repository.ParseableRepository
6+import io.mockk.*
7+import kotlinx.coroutines.test.runTest
8+import org.junit.Assert.*
9+import org.junit.Before
10+import org.junit.Test
11+
12+class ParseableRepositoryTest {
13+
14+ private lateinit var apiClient: ParseableApiClient
15+ private lateinit var repository: ParseableRepository
16+
17+ @Before
18+ fun setup() {
19+ apiClient = mockk(relaxed = true)
20+ repository = ParseableRepository(apiClient)
21+ }
22+
23+ @Test
24+ fun `getAbout caches result on success`() = runTest {
25+ val aboutInfo = AboutInfo(version = "1.0.0", mode = "standalone")
26+ coEvery { apiClient.getAbout() } returns ApiResult.Success(aboutInfo)
27+
28+ val first = repository.getAbout()
29+ val second = repository.getAbout()
30+
31+ assertTrue(first is ApiResult.Success)
32+ assertEquals("1.0.0", (first as ApiResult.Success).data.version)
33+ // Should only call API once due to caching
34+ coVerify(exactly = 1) { apiClient.getAbout() }
35+ }
36+
37+ @Test
38+ fun `getAbout does not cache errors`() = runTest {
39+ coEvery { apiClient.getAbout() } returns ApiResult.Error("fail", 500)
40+
41+ val first = repository.getAbout()
42+ repository.getAbout()
43+
44+ assertTrue(first is ApiResult.Error)
45+ coVerify(exactly = 2) { apiClient.getAbout() }
46+ }
47+
48+ @Test
49+ fun `listStreams forceRefresh bypasses cache`() = runTest {
50+ val streams = listOf(LogStream(name = "test"))
51+ coEvery { apiClient.listStreams() } returns ApiResult.Success(streams)
52+
53+ repository.listStreams()
54+ repository.listStreams(forceRefresh = true)
55+
56+ coVerify(exactly = 2) { apiClient.listStreams() }
57+ }
58+
59+ @Test
60+ fun `listStreams returns cached data within TTL`() = runTest {
61+ val streams = listOf(LogStream(name = "test"))
62+ coEvery { apiClient.listStreams() } returns ApiResult.Success(streams)
63+
64+ repository.listStreams()
65+ val cached = repository.listStreams(forceRefresh = false)
66+
67+ assertTrue(cached is ApiResult.Success)
68+ assertEquals(1, (cached as ApiResult.Success).data.size)
69+ coVerify(exactly = 1) { apiClient.listStreams() }
70+ }
71+
72+ @Test
73+ fun `invalidateAll clears all caches`() = runTest {
74+ val about = AboutInfo(version = "1.0.0")
75+ val streams = listOf(LogStream(name = "s1"))
76+ coEvery { apiClient.getAbout() } returns ApiResult.Success(about)
77+ coEvery { apiClient.listStreams() } returns ApiResult.Success(streams)
78+
79+ repository.getAbout()
80+ repository.listStreams()
81+ repository.invalidateAll()
82+ repository.getAbout()
83+ repository.listStreams()
84+
85+ coVerify(exactly = 2) { apiClient.getAbout() }
86+ coVerify(exactly = 2) { apiClient.listStreams() }
87+ }
88+
89+ @Test
90+ fun `configure invalidates cache and configures client`() = runTest {
91+ val config = ServerConfig("https://test.com", "user", "pass", true)
92+ repository.configure(config)
93+ verify { apiClient.configure(config) }
94+ }
95+
96+ @Test
97+ fun `auth error triggers channel`() = runTest {
98+ coEvery { apiClient.listStreams() } returns ApiResult.Error("Unauthorized", 401)
99+
100+ val result = repository.listStreams()
101+
102+ assertTrue(result is ApiResult.Error)
103+ assertTrue((result as ApiResult.Error).isUnauthorized)
104+ }
105+
106+ @Test
107+ fun `queryLogs builds correct SQL with filter`() = runTest {
108+ coEvery { apiClient.queryLogs(any(), any(), any()) } returns ApiResult.Success(emptyList())
109+
110+ repository.queryLogs(
111+ stream = "my_stream",
112+ startTime = "2024-01-01T00:00:00Z",
113+ endTime = "2024-01-02T00:00:00Z",
114+ filterSql = "\"level\" = 'ERROR'",
115+ limit = 100,
116+ )
117+
118+ coVerify {
119+ apiClient.queryLogs(
120+ match { it.contains("WHERE \"level\" = 'ERROR'") && it.contains("LIMIT 100") },
121+ "2024-01-01T00:00:00Z",
122+ "2024-01-02T00:00:00Z",
123+ )
124+ }
125+ }
126+
127+ @Test
128+ fun `queryLogs without filter builds SQL without WHERE`() = runTest {
129+ coEvery { apiClient.queryLogs(any(), any(), any()) } returns ApiResult.Success(emptyList())
130+
131+ repository.queryLogs(
132+ stream = "test",
133+ startTime = "start",
134+ endTime = "end",
135+ )
136+
137+ coVerify {
138+ apiClient.queryLogs(
139+ match { !it.contains("WHERE") && it.contains("\"test\"") },
140+ "start",
141+ "end",
142+ )
143+ }
144+ }
145+
146+ @Test
147+ fun `deleteStream invalidates cache`() = runTest {
148+ val streams = listOf(LogStream(name = "s1"))
149+ coEvery { apiClient.listStreams() } returns ApiResult.Success(streams)
150+ coEvery { apiClient.deleteStream(any()) } returns ApiResult.Success("ok")
151+
152+ repository.listStreams()
153+ repository.deleteStream("s1")
154+ repository.listStreams()
155+
156+ // After delete, cache was invalidated, so listStreams called twice
157+ coVerify(exactly = 2) { apiClient.listStreams() }
158+ }
159+
160+ @Test
161+ fun `getStreamSchema caches per stream`() = runTest {
162+ val schema = StreamSchema(fields = listOf(SchemaField(name = "col1")))
163+ coEvery { apiClient.getStreamSchema("s1") } returns ApiResult.Success(schema)
164+ coEvery { apiClient.getStreamSchema("s2") } returns ApiResult.Success(schema)
165+
166+ repository.getStreamSchema("s1")
167+ repository.getStreamSchema("s1") // cached
168+ repository.getStreamSchema("s2") // different stream, not cached
169+
170+ coVerify(exactly = 1) { apiClient.getStreamSchema("s1") }
171+ coVerify(exactly = 1) { apiClient.getStreamSchema("s2") }
172+ }
173+}
app/src/test/java/com/parseable/android/PrivateHostTest.ktnew file+73
@@ -0,0 +1,73 @@
1+package com.parseable.android
2+
3+import com.parseable.android.ui.screens.login.extractHost
4+import com.parseable.android.ui.screens.login.isPrivateHost
5+import org.junit.Assert.assertEquals
6+import org.junit.Assert.assertFalse
7+import org.junit.Assert.assertTrue
8+import org.junit.Test
9+
10+class PrivateHostTest {
11+
12+ @Test
13+ fun `extractHost strips scheme port and path`() {
14+ assertEquals("192.168.1.5", extractHost("http://192.168.1.5:8000/path"))
15+ assertEquals("example.com", extractHost("https://example.com"))
16+ assertEquals("example.com", extractHost("example.com:9000"))
17+ }
18+
19+ @Test
20+ fun `extractHost handles bracketed ipv6`() {
21+ assertEquals("::1", extractHost("http://[::1]:8000"))
22+ assertEquals("fe80::1", extractHost("[fe80::1]"))
23+ }
24+
25+ @Test
26+ fun `extractHost leaves bare ipv6 intact`() {
27+ assertEquals("fe80::1", extractHost("fe80::1"))
28+ }
29+
30+ @Test
31+ fun `private ipv4 ranges are detected`() {
32+ assertTrue(isPrivateHost("10.0.0.1"))
33+ assertTrue(isPrivateHost("127.0.0.1"))
34+ assertTrue(isPrivateHost("192.168.1.10"))
35+ assertTrue(isPrivateHost("172.16.0.1"))
36+ assertTrue(isPrivateHost("172.31.255.255"))
37+ }
38+
39+ @Test
40+ fun `public ipv4 in the 172 gap is not private`() {
41+ assertFalse(isPrivateHost("172.15.0.1"))
42+ assertFalse(isPrivateHost("172.32.0.1"))
43+ assertFalse(isPrivateHost("8.8.8.8"))
44+ }
45+
46+ @Test
47+ fun `public hostnames sharing a private prefix are not private`() {
48+ // The old prefix-string check wrongly classified these as private, hiding the
49+ // plaintext-HTTP warning.
50+ assertFalse(isPrivateHost("10.example.com"))
51+ assertFalse(isPrivateHost("192.168.evil.com"))
52+ }
53+
54+ @Test
55+ fun `localhost is private`() {
56+ assertTrue(isPrivateHost("localhost"))
57+ assertTrue(isPrivateHost("api.localhost"))
58+ assertTrue(isPrivateHost(""))
59+ }
60+
61+ @Test
62+ fun `ipv6 loopback and local ranges are private`() {
63+ assertTrue(isPrivateHost("::1"))
64+ assertTrue(isPrivateHost("fe80::1"))
65+ assertTrue(isPrivateHost("fd00::1"))
66+ assertTrue(isPrivateHost("fc00::1"))
67+ }
68+
69+ @Test
70+ fun `public ipv6 is not private`() {
71+ assertFalse(isPrivateHost("2001:4860:4860::8888"))
72+ }
73+}
app/src/test/java/com/parseable/android/SettingsViewModelTest.ktnew file+172
@@ -0,0 +1,172 @@
1+package com.parseable.android
2+
3+import com.parseable.android.data.model.AboutInfo
4+import com.parseable.android.data.model.ApiResult
5+import com.parseable.android.data.model.ServerConfig
6+import kotlinx.serialization.json.buildJsonObject
7+import kotlinx.serialization.json.put
8+import com.parseable.android.data.repository.ParseableRepository
9+import com.parseable.android.data.repository.SettingsRepository
10+import com.parseable.android.ui.screens.settings.SettingsViewModel
11+import io.mockk.*
12+import kotlinx.coroutines.Dispatchers
13+import kotlinx.coroutines.ExperimentalCoroutinesApi
14+import kotlinx.coroutines.flow.flowOf
15+import kotlinx.coroutines.test.*
16+import kotlinx.serialization.json.JsonObject
17+import kotlinx.serialization.json.JsonPrimitive
18+import org.junit.After
19+import org.junit.Assert.*
20+import org.junit.Before
21+import org.junit.Test
22+
23+@OptIn(ExperimentalCoroutinesApi::class)
24+class SettingsViewModelTest {
25+
26+ private lateinit var settingsRepository: SettingsRepository
27+ private lateinit var repository: ParseableRepository
28+ private val testDispatcher = UnconfinedTestDispatcher()
29+
30+ @Before
31+ fun setup() {
32+ Dispatchers.setMain(testDispatcher)
33+ settingsRepository = mockk(relaxed = true)
34+ repository = mockk(relaxed = true)
35+ }
36+
37+ @After
38+ fun tearDown() {
39+ Dispatchers.resetMain()
40+ }
41+
42+ @Test
43+ fun `load populates server config from settings`() = runTest {
44+ val config = ServerConfig(
45+ serverUrl = "https://logs.example.com",
46+ username = "admin",
47+ password = "secret",
48+ useTls = true,
49+ )
50+ coEvery { settingsRepository.serverConfig } returns flowOf(config)
51+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo(version = "1.2.0"))
52+ coEvery { repository.listUsers() } returns ApiResult.Success(emptyList())
53+
54+ val viewModel = SettingsViewModel(settingsRepository, repository)
55+
56+ val state = viewModel.state.value
57+ assertEquals("https://logs.example.com", state.serverUrl)
58+ assertEquals("admin", state.username)
59+ assertTrue(state.useTls)
60+ assertFalse(state.isLoading)
61+ }
62+
63+ @Test
64+ fun `load sets about info on success`() = runTest {
65+ val about = AboutInfo(version = "1.2.0", mode = "standalone", license = buildJsonObject { put("name", "AGPL") })
66+ coEvery { settingsRepository.serverConfig } returns flowOf(null)
67+ coEvery { repository.getAbout() } returns ApiResult.Success(about)
68+ coEvery { repository.listUsers() } returns ApiResult.Success(emptyList())
69+
70+ val viewModel = SettingsViewModel(settingsRepository, repository)
71+
72+ val state = viewModel.state.value
73+ assertEquals("1.2.0", state.aboutInfo?.version)
74+ assertEquals("standalone", state.aboutInfo?.mode)
75+ assertNull(state.error)
76+ }
77+
78+ @Test
79+ fun `load parses user list from json objects`() = runTest {
80+ val users = listOf(
81+ JsonObject(mapOf("id" to JsonPrimitive("user1"))),
82+ JsonObject(mapOf("username" to JsonPrimitive("user2"))),
83+ )
84+ coEvery { settingsRepository.serverConfig } returns flowOf(null)
85+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
86+ coEvery { repository.listUsers() } returns ApiResult.Success(users)
87+
88+ val viewModel = SettingsViewModel(settingsRepository, repository)
89+
90+ val state = viewModel.state.value
91+ assertEquals(listOf("user1", "user2"), state.users)
92+ assertFalse(state.isLoading)
93+ }
94+
95+ @Test
96+ fun `load sets error when about fails`() = runTest {
97+ coEvery { settingsRepository.serverConfig } returns flowOf(null)
98+ coEvery { repository.getAbout() } returns ApiResult.Error("Server error", 500)
99+ coEvery { repository.listUsers() } returns ApiResult.Success(emptyList())
100+
101+ val viewModel = SettingsViewModel(settingsRepository, repository)
102+
103+ val state = viewModel.state.value
104+ assertNotNull(state.error)
105+ assertNull(state.aboutInfo)
106+ assertFalse(state.isLoading)
107+ }
108+
109+ @Test
110+ fun `load ignores users failure when about succeeds`() = runTest {
111+ coEvery { settingsRepository.serverConfig } returns flowOf(null)
112+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo(version = "1.2.0"))
113+ coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
114+
115+ val viewModel = SettingsViewModel(settingsRepository, repository)
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
120+ assertNull(state.error)
121+ assertEquals("1.2.0", state.aboutInfo?.version)
122+ assertTrue(state.users.isEmpty())
123+ }
124+
125+ @Test
126+ fun `load surfaces only the about error, not the users error`() = runTest {
127+ coEvery { settingsRepository.serverConfig } returns flowOf(null)
128+ coEvery { repository.getAbout() } returns ApiResult.Error("Server error", 500)
129+ coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
130+
131+ val viewModel = SettingsViewModel(settingsRepository, repository)
132+
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
136+ assertNotNull(state.error)
137+ assertTrue(state.error!!.contains("Server error"))
138+ assertFalse(state.error!!.contains("Permission denied"))
139+ }
140+
141+ @Test
142+ fun `load resets isLoading on unexpected exception`() = runTest {
143+ coEvery { settingsRepository.serverConfig } returns flowOf(null)
144+ coEvery { repository.getAbout() } throws RuntimeException("unexpected")
145+ coEvery { repository.listUsers() } returns ApiResult.Success(emptyList())
146+
147+ val viewModel = SettingsViewModel(settingsRepository, repository)
148+
149+ val state = viewModel.state.value
150+ assertFalse(state.isLoading)
151+ assertNotNull(state.error)
152+ assertTrue(state.error!!.contains("unexpected"))
153+ }
154+
155+ @Test
156+ fun `load can be called again to refresh`() = runTest {
157+ coEvery { settingsRepository.serverConfig } returns flowOf(null)
158+ coEvery { repository.getAbout() } returns ApiResult.Error("fail", 500)
159+ coEvery { repository.listUsers() } returns ApiResult.Success(emptyList())
160+
161+ val viewModel = SettingsViewModel(settingsRepository, repository)
162+ assertNotNull(viewModel.state.value.error)
163+
164+ // Fix the mock and reload
165+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo(version = "2.0"))
166+ viewModel.load()
167+
168+ val state = viewModel.state.value
169+ assertNull(state.error)
170+ assertEquals("2.0", state.aboutInfo?.version)
171+ }
172+}
app/src/test/java/com/parseable/android/SeverityDetectionTest.ktnew file+180
@@ -0,0 +1,180 @@
1+package com.parseable.android
2+
3+import com.parseable.android.ui.screens.logviewer.LogSeverity
4+import com.parseable.android.ui.screens.logviewer.detectSeverity
5+import kotlinx.serialization.json.JsonObject
6+import kotlinx.serialization.json.JsonPrimitive
7+import org.junit.Assert.assertEquals
8+import org.junit.Test
9+
10+class SeverityDetectionTest {
11+
12+ private fun logWith(vararg pairs: Pair<String, String>): JsonObject {
13+ return JsonObject(pairs.associate { (k, v) -> k to JsonPrimitive(v) })
14+ }
15+
16+ // --- Field name detection ---
17+
18+ @Test
19+ fun `detects level field`() {
20+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("level" to "error")))
21+ }
22+
23+ @Test
24+ fun `detects severity field`() {
25+ assertEquals(LogSeverity.WARNING, detectSeverity(logWith("severity" to "warn")))
26+ }
27+
28+ @Test
29+ fun `detects loglevel field`() {
30+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("loglevel" to "info")))
31+ }
32+
33+ @Test
34+ fun `detects log_level field`() {
35+ assertEquals(LogSeverity.DEBUG, detectSeverity(logWith("log_level" to "debug")))
36+ }
37+
38+ @Test
39+ fun `field name matching is case-insensitive`() {
40+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("Level" to "ERROR")))
41+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("SEVERITY" to "error")))
42+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("LogLevel" to "ERR")))
43+ }
44+
45+ @Test
46+ fun `returns UNKNOWN when no severity field present`() {
47+ assertEquals(LogSeverity.UNKNOWN, detectSeverity(logWith("message" to "hello", "host" to "server1")))
48+ }
49+
50+ @Test
51+ fun `returns UNKNOWN for empty log entry`() {
52+ assertEquals(LogSeverity.UNKNOWN, detectSeverity(JsonObject(emptyMap())))
53+ }
54+
55+ // --- String value matching ---
56+
57+ @Test
58+ fun `matches fatal variants`() {
59+ for (value in listOf("fatal", "FATAL", "F", "crit", "critical", "emerg", "emergency", "alert", "panic")) {
60+ assertEquals("Expected FATAL for '$value'", LogSeverity.FATAL, detectSeverity(logWith("level" to value)))
61+ }
62+ }
63+
64+ @Test
65+ fun `matches error variants`() {
66+ for (value in listOf("error", "ERROR", "err", "E", "severe", "failure", "fail")) {
67+ assertEquals("Expected ERROR for '$value'", LogSeverity.ERROR, detectSeverity(logWith("level" to value)))
68+ }
69+ }
70+
71+ @Test
72+ fun `matches warning variants`() {
73+ for (value in listOf("warn", "WARNING", "W", "warning")) {
74+ assertEquals("Expected WARNING for '$value'", LogSeverity.WARNING, detectSeverity(logWith("level" to value)))
75+ }
76+ }
77+
78+ @Test
79+ fun `matches info variants`() {
80+ for (value in listOf("info", "INFO", "I", "information", "informational", "notice", "ok", "success")) {
81+ assertEquals("Expected INFO for '$value'", LogSeverity.INFO, detectSeverity(logWith("level" to value)))
82+ }
83+ }
84+
85+ @Test
86+ fun `matches debug variants`() {
87+ for (value in listOf("debug", "DEBUG", "D", "fine", "config", "finer")) {
88+ assertEquals("Expected DEBUG for '$value'", LogSeverity.DEBUG, detectSeverity(logWith("level" to value)))
89+ }
90+ }
91+
92+ @Test
93+ fun `matches trace variants`() {
94+ for (value in listOf("trace", "TRACE", "T", "V", "verbose", "finest", "all")) {
95+ assertEquals("Expected TRACE for '$value'", LogSeverity.TRACE, detectSeverity(logWith("level" to value)))
96+ }
97+ }
98+
99+ @Test
100+ fun `value matching is case-insensitive`() {
101+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("level" to "Error")))
102+ assertEquals(LogSeverity.WARNING, detectSeverity(logWith("level" to "WaRnInG")))
103+ }
104+
105+ @Test
106+ fun `trims whitespace from values`() {
107+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("level" to " error ")))
108+ }
109+
110+ @Test
111+ fun `returns UNKNOWN for unrecognized values`() {
112+ assertEquals(LogSeverity.UNKNOWN, detectSeverity(logWith("level" to "custom_level")))
113+ }
114+
115+ // --- Numeric values ---
116+
117+ @Test
118+ fun `syslog severity numeric values`() {
119+ assertEquals(LogSeverity.FATAL, detectSeverity(logWith("syslog_severity" to "0"))) // emergency
120+ assertEquals(LogSeverity.FATAL, detectSeverity(logWith("syslog_severity" to "1"))) // alert
121+ assertEquals(LogSeverity.FATAL, detectSeverity(logWith("syslog_severity" to "2"))) // critical
122+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("syslog_severity" to "3"))) // error
123+ assertEquals(LogSeverity.WARNING, detectSeverity(logWith("syslog_severity" to "4"))) // warning
124+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("syslog_severity" to "5"))) // notice
125+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("syslog_severity" to "6"))) // informational
126+ assertEquals(LogSeverity.DEBUG, detectSeverity(logWith("syslog_severity" to "7"))) // debug
127+ }
128+
129+ @Test
130+ fun `HTTP status code mapping`() {
131+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "100")))
132+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "200")))
133+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "301")))
134+ assertEquals(LogSeverity.WARNING, detectSeverity(logWith("status" to "404")))
135+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("status" to "500")))
136+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("status" to "503")))
137+ }
138+
139+ @Test
140+ fun `generic numeric level mapping`() {
141+ assertEquals(LogSeverity.FATAL, detectSeverity(logWith("level" to "1000")))
142+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("level" to "900")))
143+ assertEquals(LogSeverity.WARNING, detectSeverity(logWith("level" to "800")))
144+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("level" to "700")))
145+ assertEquals(LogSeverity.DEBUG, detectSeverity(logWith("level" to "500")))
146+ assertEquals(LogSeverity.TRACE, detectSeverity(logWith("level" to "100")))
147+ }
148+
149+ @Test
150+ fun `small-integer (Bunyan-style) numeric level mapping`() {
151+ // Structured JSON loggers like Bunyan emit a numeric level field (trace=10 .. fatal=60,
152+ // higher = more severe). These previously all collapsed into TRACE.
153+ assertEquals(LogSeverity.FATAL, detectSeverity(logWith("level" to "60")))
154+ assertEquals(LogSeverity.ERROR, detectSeverity(logWith("level" to "50")))
155+ assertEquals(LogSeverity.WARNING, detectSeverity(logWith("level" to "40")))
156+ assertEquals(LogSeverity.INFO, detectSeverity(logWith("level" to "30")))
157+ assertEquals(LogSeverity.DEBUG, detectSeverity(logWith("level" to "20")))
158+ assertEquals(LogSeverity.TRACE, detectSeverity(logWith("level" to "10")))
159+ }
160+
161+ // --- Priority among fields ---
162+
163+ @Test
164+ fun `level field takes priority over status`() {
165+ val log = JsonObject(mapOf(
166+ "level" to JsonPrimitive("error"),
167+ "status" to JsonPrimitive("200"),
168+ ))
169+ assertEquals(LogSeverity.ERROR, detectSeverity(log))
170+ }
171+
172+ @Test
173+ fun `first matching field with recognized value wins`() {
174+ val log = JsonObject(mapOf(
175+ "level" to JsonPrimitive("unknown_value"),
176+ "severity" to JsonPrimitive("warn"),
177+ ))
178+ assertEquals(LogSeverity.WARNING, detectSeverity(log))
179+ }
180+}
app/src/test/java/com/parseable/android/StreamInfoViewModelTest.ktnew file+204
@@ -0,0 +1,204 @@
1+package com.parseable.android
2+
3+import com.parseable.android.data.model.*
4+import com.parseable.android.data.repository.ParseableRepository
5+import com.parseable.android.ui.screens.logviewer.StreamInfoViewModel
6+import io.mockk.*
7+import kotlinx.coroutines.Dispatchers
8+import kotlinx.coroutines.ExperimentalCoroutinesApi
9+import kotlinx.coroutines.test.*
10+import kotlinx.serialization.json.JsonObject
11+import kotlinx.serialization.json.JsonPrimitive
12+import org.junit.After
13+import org.junit.Assert.*
14+import org.junit.Before
15+import org.junit.Test
16+
17+@OptIn(ExperimentalCoroutinesApi::class)
18+class StreamInfoViewModelTest {
19+
20+ private lateinit var repository: ParseableRepository
21+ private lateinit var viewModel: StreamInfoViewModel
22+ private val testDispatcher = UnconfinedTestDispatcher()
23+
24+ @Before
25+ fun setup() {
26+ Dispatchers.setMain(testDispatcher)
27+ repository = mockk(relaxed = true)
28+ viewModel = StreamInfoViewModel(repository)
29+ }
30+
31+ @After
32+ fun tearDown() {
33+ Dispatchers.resetMain()
34+ }
35+
36+ @Test
37+ fun `initial state is empty`() {
38+ val state = viewModel.state.value
39+ assertEquals("", state.streamName)
40+ assertNull(state.stats)
41+ assertTrue(state.schema.isEmpty())
42+ assertTrue(state.retention.isEmpty())
43+ assertNull(state.rawInfo)
44+ assertFalse(state.isLoading)
45+ assertNull(state.error)
46+ assertFalse(state.isDeleting)
47+ assertFalse(state.deleteSuccess)
48+ }
49+
50+ @Test
51+ fun `load fetches all stream info in parallel`() = runTest {
52+ val stats = StreamStats(
53+ ingestion = IngestionStats(count = 1000, size = "5MB"),
54+ storage = StorageStats(size = "2MB"),
55+ )
56+ val schema = StreamSchema(
57+ fields = listOf(
58+ SchemaField(name = "timestamp"),
59+ SchemaField(name = "message"),
60+ ),
61+ )
62+ val retention = listOf(RetentionConfig(duration = "30d", action = "delete"))
63+ val info = JsonObject(mapOf("created_at" to JsonPrimitive("2024-01-01")))
64+
65+ coEvery { repository.getStreamStats("test-stream") } returns ApiResult.Success(stats)
66+ coEvery { repository.getStreamSchema("test-stream") } returns ApiResult.Success(schema)
67+ coEvery { repository.getStreamRetention("test-stream") } returns ApiResult.Success(retention)
68+ coEvery { repository.getStreamInfo("test-stream") } returns ApiResult.Success(info)
69+
70+ viewModel.load("test-stream")
71+
72+ val state = viewModel.state.value
73+ assertEquals("test-stream", state.streamName)
74+ assertEquals(1000L, state.stats?.ingestion?.count)
75+ assertEquals(2, state.schema.size)
76+ assertEquals(1, state.retention.size)
77+ assertNotNull(state.rawInfo)
78+ assertFalse(state.isLoading)
79+ assertNull(state.error)
80+ }
81+
82+ @Test
83+ fun `load shows error when some requests fail`() = runTest {
84+ coEvery { repository.getStreamStats("s1") } returns ApiResult.Error("Stats error", 500)
85+ coEvery { repository.getStreamSchema("s1") } returns ApiResult.Success(StreamSchema())
86+ coEvery { repository.getStreamRetention("s1") } returns ApiResult.Success(emptyList())
87+ coEvery { repository.getStreamInfo("s1") } returns ApiResult.Error("Info error", 500)
88+
89+ viewModel.load("s1")
90+
91+ val state = viewModel.state.value
92+ assertNotNull(state.error)
93+ assertFalse(state.isLoading)
94+ // Successful results should still be populated
95+ assertTrue(state.schema.isEmpty()) // empty but loaded
96+ assertTrue(state.retention.isEmpty())
97+ }
98+
99+ @Test
100+ fun `load sets schemaFailed when schema request fails`() = runTest {
101+ coEvery { repository.getStreamStats("s1") } returns ApiResult.Success(StreamStats())
102+ coEvery { repository.getStreamSchema("s1") } returns ApiResult.Error("Schema error", 500)
103+ coEvery { repository.getStreamRetention("s1") } returns ApiResult.Success(emptyList())
104+ coEvery { repository.getStreamInfo("s1") } returns ApiResult.Success(JsonObject(emptyMap()))
105+
106+ viewModel.load("s1")
107+
108+ val state = viewModel.state.value
109+ assertTrue(state.schemaFailed)
110+ assertTrue(state.schema.isEmpty())
111+ assertFalse(state.isLoading)
112+ }
113+
114+ @Test
115+ fun `load clears schemaFailed on successful schema load`() = runTest {
116+ coEvery { repository.getStreamStats("s1") } returns ApiResult.Success(StreamStats())
117+ coEvery { repository.getStreamSchema("s1") } returns ApiResult.Success(
118+ StreamSchema(fields = listOf(SchemaField(name = "msg")))
119+ )
120+ coEvery { repository.getStreamRetention("s1") } returns ApiResult.Success(emptyList())
121+ coEvery { repository.getStreamInfo("s1") } returns ApiResult.Success(JsonObject(emptyMap()))
122+
123+ viewModel.load("s1")
124+
125+ val state = viewModel.state.value
126+ assertFalse(state.schemaFailed)
127+ assertEquals(1, state.schema.size)
128+ }
129+
130+ @Test
131+ fun `load cancels previous load when called again`() = runTest {
132+ coEvery { repository.getStreamStats(any()) } returns ApiResult.Success(StreamStats())
133+ coEvery { repository.getStreamSchema(any()) } returns ApiResult.Success(StreamSchema())
134+ coEvery { repository.getStreamRetention(any()) } returns ApiResult.Success(emptyList())
135+ coEvery { repository.getStreamInfo(any()) } returns ApiResult.Success(JsonObject(emptyMap()))
136+
137+ viewModel.load("stream-a")
138+ viewModel.load("stream-b")
139+
140+ val state = viewModel.state.value
141+ assertEquals("stream-b", state.streamName)
142+ }
143+
144+ @Test
145+ fun `deleteStream sets success on API success`() = runTest {
146+ coEvery { repository.getStreamStats(any()) } returns ApiResult.Success(StreamStats())
147+ coEvery { repository.getStreamSchema(any()) } returns ApiResult.Success(StreamSchema())
148+ coEvery { repository.getStreamRetention(any()) } returns ApiResult.Success(emptyList())
149+ coEvery { repository.getStreamInfo(any()) } returns ApiResult.Success(JsonObject(emptyMap()))
150+ coEvery { repository.deleteStream("my-stream") } returns ApiResult.Success("deleted")
151+
152+ viewModel.load("my-stream")
153+ viewModel.deleteStream()
154+
155+ val state = viewModel.state.value
156+ assertTrue(state.deleteSuccess)
157+ assertFalse(state.isDeleting)
158+ assertNull(state.error)
159+ }
160+
161+ @Test
162+ fun `deleteStream sets error on API failure`() = runTest {
163+ coEvery { repository.getStreamStats(any()) } returns ApiResult.Success(StreamStats())
164+ coEvery { repository.getStreamSchema(any()) } returns ApiResult.Success(StreamSchema())
165+ coEvery { repository.getStreamRetention(any()) } returns ApiResult.Success(emptyList())
166+ coEvery { repository.getStreamInfo(any()) } returns ApiResult.Success(JsonObject(emptyMap()))
167+ coEvery { repository.deleteStream("my-stream") } returns ApiResult.Error("Forbidden", 403)
168+
169+ viewModel.load("my-stream")
170+ viewModel.deleteStream()
171+
172+ val state = viewModel.state.value
173+ assertFalse(state.deleteSuccess)
174+ assertFalse(state.isDeleting)
175+ assertNotNull(state.error)
176+ assertTrue(state.error!!.contains("Delete failed"))
177+ }
178+
179+ @Test
180+ fun `deleteStream does nothing when streamName is empty`() = runTest {
181+ viewModel.deleteStream()
182+
183+ val state = viewModel.state.value
184+ assertFalse(state.isDeleting)
185+ assertFalse(state.deleteSuccess)
186+ coVerify(exactly = 0) { repository.deleteStream(any()) }
187+ }
188+
189+ @Test
190+ fun `consumeDeleteSuccess resets flag`() = runTest {
191+ coEvery { repository.getStreamStats(any()) } returns ApiResult.Success(StreamStats())
192+ coEvery { repository.getStreamSchema(any()) } returns ApiResult.Success(StreamSchema())
193+ coEvery { repository.getStreamRetention(any()) } returns ApiResult.Success(emptyList())
194+ coEvery { repository.getStreamInfo(any()) } returns ApiResult.Success(JsonObject(emptyMap()))
195+ coEvery { repository.deleteStream("s") } returns ApiResult.Success("ok")
196+
197+ viewModel.load("s")
198+ viewModel.deleteStream()
199+ assertTrue(viewModel.state.value.deleteSuccess)
200+
201+ viewModel.consumeDeleteSuccess()
202+ assertFalse(viewModel.state.value.deleteSuccess)
203+ }
204+}
app/src/test/java/com/parseable/android/StreamsViewModelTest.ktnew file+155
@@ -0,0 +1,155 @@
1+package com.parseable.android
2+
3+import com.parseable.android.data.local.FavoriteStreamDao
4+import com.parseable.android.data.model.*
5+import com.parseable.android.data.repository.ParseableRepository
6+import com.parseable.android.data.repository.SettingsRepository
7+import com.parseable.android.ui.screens.streams.StreamsViewModel
8+import io.mockk.*
9+import kotlinx.coroutines.Dispatchers
10+import kotlinx.coroutines.ExperimentalCoroutinesApi
11+import kotlinx.coroutines.flow.flowOf
12+import kotlinx.coroutines.test.*
13+import org.junit.After
14+import org.junit.Assert.*
15+import org.junit.Before
16+import org.junit.Test
17+
18+@OptIn(ExperimentalCoroutinesApi::class)
19+class StreamsViewModelTest {
20+
21+ private lateinit var repository: ParseableRepository
22+ private lateinit var settingsRepository: SettingsRepository
23+ private lateinit var favoriteDao: FavoriteStreamDao
24+ private lateinit var viewModel: StreamsViewModel
25+ private val testDispatcher = UnconfinedTestDispatcher()
26+
27+ @Before
28+ fun setup() {
29+ Dispatchers.setMain(testDispatcher)
30+ repository = mockk(relaxed = true)
31+ settingsRepository = mockk(relaxed = true)
32+ favoriteDao = mockk(relaxed = true)
33+ every { favoriteDao.getAllNames() } returns flowOf(emptyList())
34+ }
35+
36+ @After
37+ fun tearDown() {
38+ Dispatchers.resetMain()
39+ }
40+
41+ @Test
42+ fun `refresh loads streams and about info`() = runTest {
43+ val streams = listOf(LogStream("stream1"), LogStream("stream2"))
44+ val about = AboutInfo(version = "1.0.0")
45+ coEvery { repository.listStreams(forceRefresh = true) } returns ApiResult.Success(streams)
46+ coEvery { repository.getAbout() } returns ApiResult.Success(about)
47+ coEvery { repository.getStreamStats(any()) } returns ApiResult.Error("skip", 0)
48+
49+ viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
50+ viewModel.refresh()
51+
52+ val state = viewModel.state.value
53+ assertEquals(2, state.streams.size)
54+ assertEquals("stream1", state.streams[0].name)
55+ assertEquals("1.0.0", state.aboutInfo?.version)
56+ assertFalse(state.isLoading)
57+ assertNull(state.error)
58+ }
59+
60+ @Test
61+ fun `refresh sets error when streams fail`() = runTest {
62+ coEvery { repository.listStreams(forceRefresh = true) } returns ApiResult.Error("Network error", 0)
63+ coEvery { repository.getAbout() } returns ApiResult.Error("fail", 0)
64+
65+ viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
66+ viewModel.refresh()
67+
68+ val state = viewModel.state.value
69+ assertTrue(state.streams.isEmpty())
70+ assertNotNull(state.error)
71+ assertFalse(state.isLoading)
72+ }
73+
74+ @Test
75+ fun `refresh loads stats for each stream`() = runTest {
76+ val streams = listOf(LogStream("s1"), LogStream("s2"))
77+ coEvery { repository.listStreams(forceRefresh = true) } returns ApiResult.Success(streams)
78+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
79+ coEvery { repository.getStreamStats("s1") } returns ApiResult.Success(
80+ StreamStats(
81+ ingestion = IngestionStats(count = 100, size = "1MB"),
82+ storage = StorageStats(size = "500KB"),
83+ )
84+ )
85+ coEvery { repository.getStreamStats("s2") } returns ApiResult.Error("fail", 500)
86+
87+ viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
88+ viewModel.refresh()
89+
90+ val state = viewModel.state.value
91+ assertEquals(1, state.streamStats.size)
92+ assertNotNull(state.streamStats["s1"])
93+ assertEquals(100L, state.streamStats["s1"]?.eventCount)
94+ assertTrue(state.failedStats.contains("s2"))
95+ }
96+
97+ @Test
98+ fun `logout calls clearConfig`() = runTest {
99+ coEvery { repository.listStreams(forceRefresh = true) } returns ApiResult.Success(emptyList())
100+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
101+
102+ viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
103+ viewModel.logout()
104+
105+ coVerify { settingsRepository.clearConfig() }
106+ }
107+
108+ @Test
109+ fun `favorites are loaded from DAO on init`() = runTest {
110+ every { favoriteDao.getAllNames() } returns flowOf(listOf("stream1", "stream2"))
111+
112+ viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
113+
114+ val state = viewModel.state.value
115+ assertTrue(state.favoriteNames.contains("stream1"))
116+ assertTrue(state.favoriteNames.contains("stream2"))
117+ }
118+
119+ @Test
120+ fun `toggleFavorite adds when not favorited`() = runTest {
121+ every { favoriteDao.getAllNames() } returns flowOf(emptyList())
122+
123+ viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
124+ viewModel.toggleFavorite("new_stream")
125+
126+ coVerify { favoriteDao.insert(match { it.streamName == "new_stream" }) }
127+ }
128+
129+ @Test
130+ fun `refresh resets isLoading on unexpected exception`() = runTest {
131+ coEvery { repository.getAbout() } throws RuntimeException("boom")
132+ coEvery { repository.listStreams(forceRefresh = true) } returns ApiResult.Success(emptyList())
133+
134+ viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
135+ viewModel.refresh()
136+
137+ val state = viewModel.state.value
138+ assertFalse(state.isLoading)
139+ assertNotNull(state.error)
140+ assertTrue(state.error!!.contains("boom"))
141+ }
142+
143+ @Test
144+ fun `toggleFavorite removes when already favorited`() = runTest {
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+
150+ viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
151+ viewModel.toggleFavorite("existing")
152+
153+ coVerify { favoriteDao.deleteByName("existing") }
154+ }
155+}
build.gradle.ktsnew file+8
@@ -0,0 +1,8 @@
1+plugins {
2+ alias(libs.plugins.android.application) apply false
3+ alias(libs.plugins.kotlin.android) apply false
4+ alias(libs.plugins.kotlin.compose) apply false
5+ alias(libs.plugins.kotlin.serialization) apply false
6+ alias(libs.plugins.hilt) apply false
7+ alias(libs.plugins.ksp) apply false
8+}
gradle.propertiesnew file+5
@@ -0,0 +1,5 @@
1+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2+android.useAndroidX=true
3+kotlin.code.style=official
4+android.nonTransitiveRClass=true
5+android.enableR8.fullMode=true
gradle/libs.versions.tomlnew file+89
@@ -0,0 +1,89 @@
1+[versions]
2+agp = "8.7.3"
3+kotlin = "2.1.0"
4+ksp = "2.1.0-1.0.29"
5+hilt = "2.53.1"
6+okhttp = "4.12.0"
7+room = "2.6.1"
8+compose-bom = "2024.12.01"
9+coroutines = "1.9.0"
10+serialization = "1.7.3"
11+lifecycle = "2.8.7"
12+navigation = "2.8.5"
13+core-ktx = "1.15.0"
14+activity-compose = "1.9.3"
15+datastore = "1.1.1"
16+security-crypto = "1.1.0-alpha06"
17+hilt-navigation = "1.2.0"
18+timber = "5.0.1"
19+
20+# Testing
21+junit = "4.13.2"
22+mockk = "1.13.13"
23+android-test-ext = "1.2.1"
24+espresso = "3.6.1"
25+
26+[libraries]
27+# Core Android
28+core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
29+lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
30+lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
31+activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" }
32+
33+# Compose
34+compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
35+compose-ui = { module = "androidx.compose.ui:ui" }
36+compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
37+compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
38+compose-material3 = { module = "androidx.compose.material3:material3" }
39+compose-material-icons = { module = "androidx.compose.material:material-icons-extended" }
40+
41+# Navigation
42+navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
43+
44+# Hilt DI
45+hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
46+hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
47+hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-navigation" }
48+
49+# Networking
50+okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
51+okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
52+
53+# Serialization
54+kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
55+
56+# DataStore
57+datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
58+
59+# Encrypted storage
60+security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" }
61+
62+# Room
63+room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
64+room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
65+room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
66+
67+# Coroutines
68+coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
69+
70+# Logging
71+timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
72+
73+# Testing
74+junit = { module = "junit:junit", version.ref = "junit" }
75+coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
76+mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
77+android-test-ext = { module = "androidx.test.ext:junit", version.ref = "android-test-ext" }
78+espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
79+compose-ui-test = { module = "androidx.compose.ui:ui-test-junit4" }
80+compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
81+compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
82+
83+[plugins]
84+android-application = { id = "com.android.application", version.ref = "agp" }
85+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
86+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
87+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
88+hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
89+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
gradle/wrapper/gradle-wrapper.propertiesnew file+7
@@ -0,0 +1,7 @@
1+distributionBase=GRADLE_USER_HOME
2+distributionPath=wrapper/dists
3+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
4+networkTimeout=10000
5+validateDistributionUrl=true
6+zipStoreBase=GRADLE_USER_HOME
7+zipStorePath=wrapper/dists
gradlewnew file+168
@@ -0,0 +1,168 @@
1+#!/bin/sh
2+
3+#
4+# Copyright © 2015-2021 the original authors.
5+#
6+# Licensed under the Apache License, Version 2.0 (the "License");
7+# you may not use this file except in compliance with the License.
8+# You may obtain a copy of the License at
9+#
10+# https://www.apache.org/licenses/LICENSE-2.0
11+#
12+# Unless required by applicable law or agreed to in writing, software
13+# distributed under the License is distributed on an "AS IS" BASIS,
14+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+# See the License for the specific language governing permissions and
16+# limitations under the License.
17+#
18+
19+##############################################################################
20+#
21+# Gradle start up script for POSIX generated by Gradle.
22+#
23+##############################################################################
24+
25+# Attempt to set APP_HOME
26+
27+# Resolve links: $0 may be a link
28+app_path=$0
29+
30+# Need this for daisy-chained symlinks.
31+while
32+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
33+ [ -h "$app_path" ]
34+do
35+ ls=$( ls -ld -- "$app_path" )
36+ link=${ls#*' -> '}
37+ case $link in #(
38+ /*) app_path=$link ;; #(
39+ *) app_path=$APP_HOME$link ;;
40+ esac
41+done
42+
43+# This is normally unused
44+# shellcheck disable=SC2034
45+APP_BASE_NAME=${0##*/}
46+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
47+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
48+
49+# Use the maximum available, or set MAX_FD != -1 to use that value.
50+MAX_FD=maximum
51+
52+warn () {
53+ echo "$*"
54+} >&2
55+
56+die () {
57+ echo
58+ echo "$*"
59+ echo
60+ exit 1
61+} >&2
62+
63+# OS specific support (must be 'true' or 'false').
64+cygwin=false
65+msys=false
66+darwin=false
67+nonstop=false
68+case "$( uname )" in #(
69+ CYGWIN* ) cygwin=true ;; #(
70+ Darwin* ) darwin=true ;; #(
71+ MSYS* | MINGW* ) msys=true ;; #(
72+ NonStop* ) nonstop=true ;;
73+esac
74+
75+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
76+
77+
78+# Determine the Java command to use to start the JVM.
79+if [ -n "$JAVA_HOME" ] ; then
80+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
81+ # IBM's JDK on AIX uses strange locations for the executables
82+ JAVACMD=$JAVA_HOME/jre/sh/java
83+ else
84+ JAVACMD=$JAVA_HOME/bin/java
85+ fi
86+ if [ ! -x "$JAVACMD" ] ; then
87+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
88+
89+Please set the JAVA_HOME variable in your environment to match the
90+location of your Java installation."
91+ fi
92+else
93+ JAVACMD=java
94+ if ! command -v java >/dev/null 2>&1 ; then
95+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
96+
97+Please set the JAVA_HOME variable in your environment to match the
98+location of your Java installation."
99+ fi
100+fi
101+
102+# Increase the maximum file descriptors if we can.
103+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
104+ case $MAX_FD in #(
105+ max*)
106+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
107+ # shellcheck disable=SC2039,SC3045
108+ MAX_FD=$( ulimit -H -n ) ||
109+ warn "Could not query maximum file descriptor limit"
110+ ;;
111+ esac
112+ case $MAX_FD in #(
113+ '' | soft) :;; #(
114+ *)
115+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
116+ # shellcheck disable=SC2039,SC3045
117+ ulimit -n "$MAX_FD" ||
118+ warn "Could not set maximum file descriptor limit to $MAX_FD"
119+ ;;
120+ esac
121+fi
122+
123+# Collect all arguments for the java command, stacking://them after any JVM arguments
124+# in DEFAULT_JVM_OPTS or JAVA_OPTS.
125+collect_all_args () {
126+ for arg do
127+ case $arg in
128+ -*=*) set -- "$@" "${arg%%=*}" "${arg#*=}" ;;
129+ *) set -- "$@" "$arg" ;;
130+ esac
131+ shift
132+ done
133+ printf '%s\n' "$@"
134+}
135+
136+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
137+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
138+
139+# Collect all arguments for the java command;
140+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
141+# shell script including quotes and variable substitutions, so put them in
142+# temporary files and parse them as such
143+# * Put all other arguments in the args array
144+
145+set -- \
146+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
147+ -classpath "$CLASSPATH" \
148+ org.gradle.wrapper.GradleWrapperMain \
149+ "$@"
150+
151+# Stop when "xargs" is not available.
152+if ! command -v xargs >/dev/null 2>&1
153+then
154+ die "xargs is not available"
155+fi
156+
157+# Use "xargs" to parse quoted args.
158+#
159+# With -n://1, we get one arg per line, with proper handling of whitespace in each arg
160+# and target strings containing spaces in a proper way.
161+eval "set -- $(
162+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
163+ xargs -n1 |
164+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
165+ tr '\n' ' '
166+ )" '"$@"'
167+
168+exec "$JAVACMD" "$@"
gradlew.batnew file+81
@@ -0,0 +1,81 @@
1+@rem
2+@rem Copyright 2015 the original author or authors.
3+@rem
4+@rem Licensed under the Apache License, Version 2.0 (the "License");
5+@rem you may not use this file except in compliance with the License.
6+@rem You may obtain a copy of the License at
7+@rem
8+@rem https://www.apache.org/licenses/LICENSE-2.0
9+@rem
10+@rem Unless required by applicable law or agreed to in writing, software
11+@rem distributed under the License is distributed on an "AS IS" BASIS,
12+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+@rem See the License for the specific language governing permissions and
14+@rem limitations under the License.
15+@rem
16+
17+@if "%DEBUG%"=="" @echo off
18+@rem ##########################################################################
19+@rem
20+@rem Gradle startup script for Windows
21+@rem
22+@rem ##########################################################################
23+
24+@rem Set local scope for the variables with windows NT shell
25+if "%OS%"=="Windows_NT" setlocal
26+
27+set DIRNAME=%~dp0
28+if "%DIRNAME%"=="" set DIRNAME=.
29+@rem This is normally unused
30+set APP_BASE_NAME=%~n0
31+set APP_HOME=%DIRNAME%
32+
33+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
34+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
35+
36+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
37+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
38+
39+@rem Find java.exe
40+if defined JAVA_HOME goto findJavaFromJavaHome
41+
42+set JAVA_EXE=java.exe
43+%JAVA_EXE% -version >NUL 2>&1
44+if %ERRORLEVEL% equ 0 goto execute
45+
46+echo. 1>&2
47+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
48+echo. 1>&2
49+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
50+echo location of your Java installation. 1>&2
51+
52+goto fail
53+
54+:findJavaFromJavaHome
55+set JAVA_HOME=%JAVA_HOME:"=%
56+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
57+
58+if exist "%JAVA_EXE%" goto execute
59+
60+echo. 1>&2
61+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
62+echo. 1>&2
63+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
64+echo location of your Java installation. 1>&2
65+
66+goto fail
67+
68+:execute
69+@rem Setup the command line
70+
71+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
72+
73+
74+@rem Execute Gradle
75+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
76+
77+:end
78+@rem End local scope for the variables with windows NT shell
79+if %OS%==Windows_NT endlocal
80+
81+:omega
settings.gradle.ktsnew file+24
@@ -0,0 +1,24 @@
1+pluginManagement {
2+ repositories {
3+ google {
4+ content {
5+ includeGroupByRegex("com\\.android.*")
6+ includeGroupByRegex("com\\.google.*")
7+ includeGroupByRegex("androidx.*")
8+ }
9+ }
10+ mavenCentral()
11+ gradlePluginPortal()
12+ }
13+}
14+
15+dependencyResolutionManagement {
16+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
17+ repositories {
18+ google()
19+ mavenCentral()
20+ }
21+}
22+
23+rootProject.name = "ParseableAndroid"
24+include(":app")