git.iko.soy/parseable-android
on a7bfb47 help sign in
.dockerignoredeleted−15
since splitsnapshots
unifiedsplit
@@ -1,15 +0,0 @@
1−# Keep the build context small, but DO keep .git — build.gradle.kts derives the
2−# app version from `git log -1`, so the repository history must be present.
3−build/
4−app/build/
5−.gradle/
6−*.iml
7−.idea/
8−.DS_Store
9−local.properties
10−captures/
11−.externalNativeBuild/
12−.cxx/
13−*.keystore
14−*.jks
15−.jj/
.github/workflows/build-release.ymldeleted−77
@@ -1,77 +0,0 @@
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.ymldeleted−141
@@ -1,141 +0,0 @@
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
.gitignoredeleted−14
@@ -1,14 +0,0 @@
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.mddeleted−82
@@ -1,82 +0,0 @@
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.
Dockerfiledeleted−72
@@ -1,72 +0,0 @@
1−# syntax=docker/dockerfile:1
2−
3−# ---- Build stage -------------------------------------------------------------
4−# Builds the Parseable Android APK from source. Mirrors the GitHub Actions CI:
5−# JDK 17, Android SDK (compileSdk 35), Gradle 8.9, with the wrapper generated
6−# on the fly (gradle-wrapper.jar is gitignored). The app's build.gradle.kts
7−# derives its version from `git log`, so git + the .git directory are required.
8−#
9−# Pinned to linux/amd64: the Android toolchain (aapt2) ships only x86-64 Linux
10−# binaries. On an arm64 host (Apple Silicon) a native arm64 image can't exec
11−# them ("rosetta error: failed to open elf .../ld-linux-x86-64.so.2"); a full
12−# amd64 image gives Rosetta/QEMU a complete x86-64 userland to emulate. On an
13−# amd64 host this is native. Override with --build-arg BUILD_PLATFORM=... .
14−ARG BUILD_PLATFORM=linux/amd64
15−FROM --platform=${BUILD_PLATFORM} eclipse-temurin:17-jdk-jammy AS build
16−
17−# Versions (override at build time with --build-arg if needed)
18−ARG GRADLE_VERSION=8.9
19−ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708
20−ARG ANDROID_PLATFORM=35
21−ARG ANDROID_BUILD_TOOLS=35.0.0
22−# Gradle task to run: assembleDebug (default) or assembleRelease
23−ARG GRADLE_TASK=assembleDebug
24−
25−ENV ANDROID_SDK_ROOT=/opt/android-sdk \
26− ANDROID_HOME=/opt/android-sdk \
27− GRADLE_USER_HOME=/root/.gradle \
28− DEBIAN_FRONTEND=noninteractive
29−
30−# Base tooling: git (version stamp), unzip/curl (fetching SDK + Gradle)
31−RUN apt-get update && \
32− apt-get install -y --no-install-recommends git unzip curl ca-certificates && \
33− rm -rf /var/lib/apt/lists/*
34−
35−# Android SDK command-line tools
36−RUN mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools" && \
37− curl -fsSL "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" -o /tmp/cmdline-tools.zip && \
38− unzip -q /tmp/cmdline-tools.zip -d "${ANDROID_SDK_ROOT}/cmdline-tools" && \
39− mv "${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools" "${ANDROID_SDK_ROOT}/cmdline-tools/latest" && \
40− rm /tmp/cmdline-tools.zip
41−
42−ENV PATH="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${PATH}"
43−
44−# Accept licenses and install required SDK packages
45−RUN yes | sdkmanager --licenses >/dev/null && \
46− sdkmanager --install \
47− "platform-tools" \
48− "platforms;android-${ANDROID_PLATFORM}" \
49− "build-tools;${ANDROID_BUILD_TOOLS}" >/dev/null
50−
51−# Gradle (used to generate the wrapper, then the wrapper drives the build)
52−RUN curl -fsSL "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" -o /tmp/gradle.zip && \
53− unzip -q /tmp/gradle.zip -d /opt && \
54− rm /tmp/gradle.zip
55−ENV PATH="/opt/gradle-${GRADLE_VERSION}/bin:${PATH}"
56−
57−WORKDIR /workspace
58−
59−# Copy the full project (including .git for the version stamp; see .dockerignore)
60−COPY . .
61−
62−# Generate the Gradle wrapper, then build the APK
63−RUN gradle wrapper --gradle-version "${GRADLE_VERSION}" && \
64− ./gradlew --no-daemon "${GRADLE_TASK}"
65−
66−# ---- Export stage ------------------------------------------------------------
67−# Minimal final image holding just the built APK(s) under /apks.
68−# Extract them to ./out with:
69−# docker build --target export --output type=local,dest=./out .
70−# (A scratch image has no shell, so use --output, not docker create/cp.)
71−FROM scratch AS export
72−COPY --from=build /workspace/app/build/outputs/apk/ /apks/
IMPROVEMENTS.mddeleted−176
@@ -1,176 +0,0 @@
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.mddeleted−71
@@ -1,71 +0,0 @@
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.ktsdeleted−142
@@ -1,142 +0,0 @@
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.prodeleted−61
@@ -1,61 +0,0 @@
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/.gitkeepdeleted

No changes to the content.

app/src/main/AndroidManifest.xmldeleted−43
@@ -1,43 +0,0 @@
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.ktdeleted−110
@@ -1,110 +0,0 @@
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.ktdeleted−29
@@ -1,29 +0,0 @@
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.ktdeleted−47
@@ -1,47 +0,0 @@
1−package com.parseable.android.data
2−
3−import java.util.Locale
4−
5−// "EB" is included so PB is never the last unit: the promotion guard below (which only fires
6−// when a higher unit exists) then handles a PB value in [1023.5, 1024) — promoting it to "1.00 EB"
7−// instead of rendering "1024 PB". The remaining unhandled top-unit case sits at ~1.2e24 bytes.
8−private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
9−
10−/**
11− * Converts a raw byte-count string (e.g. "123456789") to a human-readable
12− * size (e.g. "117.7 MB"). If the string is not a valid number, it is
13− * returned unchanged so pre-formatted values from the API pass through.
14− */
15−fun formatBytes(raw: String?): String? {
16− if (raw == null) return null
17− val bytes = raw.trim().toDoubleOrNull() ?: return raw
18− // toDoubleOrNull accepts "NaN"/"Infinity"; those slip past the range guards below and
19− // would format as "NaN B" / "Infinity PB". Pass them through unchanged instead.
20− if (!bytes.isFinite()) return raw
21− if (bytes < 0) return raw
22− // Use 1023.5, not 1024, as the cutoff: "%.0f" rounds half-up, so a byte count in
23− // [1023.5, 1024) would render as "1024 B" — a value that should promote to "1.00 KB".
24− // Such inputs fall through to the unit-scaling path, where the promotion guard below
25− // (value >= 1023.5) handles them.
26− if (bytes < 1023.5) return String.format(Locale.US, "%.0f B", bytes)
27− var value = bytes
28− var unitIndex = 0
29− while (value >= 1024 && unitIndex < BYTE_UNITS.size - 1) {
30− value /= 1024
31− unitIndex++
32− }
33− // A value just under 1024 (e.g. 1023.999 KB) is formatted with "%.0f", which rounds it to
34− // "1024 KB" — a unit that should never display. Promote to the next unit so it renders as
35− // "1.00 MB". The "%.1f"/"%.2f" branches operate far below 1024, so only this case crosses.
36− if (value >= 1023.5 && unitIndex < BYTE_UNITS.size - 1) {
37− value /= 1024
38− unitIndex++
39− }
40− return if (value >= 100) {
41− String.format(Locale.US, "%.0f %s", value, BYTE_UNITS[unitIndex])
42− } else if (value >= 10) {
43− String.format(Locale.US, "%.1f %s", value, BYTE_UNITS[unitIndex])
44− } else {
45− String.format(Locale.US, "%.2f %s", value, BYTE_UNITS[unitIndex])
46− }
47−}
app/src/main/java/com/parseable/android/data/SqlUtils.ktdeleted−23
@@ -1,23 +0,0 @@
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.ktdeleted−69
@@ -1,69 +0,0 @@
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.ktdeleted−508
@@ -1,508 +0,0 @@
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.ktdeleted−11
@@ -1,11 +0,0 @@
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.ktdeleted−39
@@ -1,39 +0,0 @@
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.ktdeleted−14
@@ -1,14 +0,0 @@
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.ktdeleted−23
@@ -1,23 +0,0 @@
1−package com.parseable.android.data.local
2−
3−import androidx.room.Entity
4−import androidx.room.Index
5−import androidx.room.PrimaryKey
6−
7−@Entity(
8− tableName = "saved_servers",
9− // One logical server per (url, username). The dedupe in SettingsRepository.saveServer
10− // relies on this invariant; enforcing it in the schema means REPLACE actually upserts on
11− // the natural key instead of silently inserting a duplicate row if the read-back misses.
12− indices = [Index(value = ["serverUrl", "username"], unique = true)],
13−)
14−data class SavedServer(
15− @PrimaryKey(autoGenerate = true)
16− val id: Long = 0,
17− val serverUrl: String,
18− val username: String,
19− val useTls: Boolean = true,
20− /** Key into EncryptedSharedPreferences for this server's password. */
21− val passwordKey: String,
22− val addedAt: Long = System.currentTimeMillis(),
23−)
app/src/main/java/com/parseable/android/data/local/SavedServerDao.ktdeleted−25
@@ -1,25 +0,0 @@
1−package com.parseable.android.data.local
2−
3−import androidx.room.Dao
4−import androidx.room.Insert
5−import androidx.room.OnConflictStrategy
6−import androidx.room.Query
7−import kotlinx.coroutines.flow.Flow
8−
9−@Dao
10−interface SavedServerDao {
11− @Query("SELECT * FROM saved_servers ORDER BY addedAt DESC")
12− fun getAll(): Flow<List<SavedServer>>
13−
14− @Query("SELECT * FROM saved_servers WHERE id = :id")
15− suspend fun getById(id: Long): SavedServer?
16−
17− @Query("SELECT * FROM saved_servers WHERE serverUrl = :url AND username = :username LIMIT 1")
18− suspend fun findByUrlAndUsername(url: String, username: String): SavedServer?
19−
20− @Insert(onConflict = OnConflictStrategy.REPLACE)
21− suspend fun insert(server: SavedServer): Long
22−
23− @Query("DELETE FROM saved_servers WHERE id = :id")
24− suspend fun deleteById(id: Long)
25−}
app/src/main/java/com/parseable/android/data/model/Models.ktdeleted−253
@@ -1,253 +0,0 @@
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.ktdeleted−61
@@ -1,61 +0,0 @@
1−package com.parseable.android.data.model
2−
3−import kotlinx.serialization.SerialName
4−import kotlinx.serialization.Serializable
5−
6−/**
7− * Saved filter synced with Parseable server via /api/v1/filters.
8− */
9−@Serializable
10−data class SavedFilter(
11− @SerialName("filter_id")
12− val filterId: String? = null,
13− @SerialName("filter_name")
14− val filterName: String = "",
15− @SerialName("stream_name")
16− val streamName: String = "",
17− @SerialName("user_id")
18− val userId: String? = null,
19− val version: String? = null,
20− val query: SavedFilterQuery = SavedFilterQuery(),
21− @SerialName("time_filter")
22− val timeFilter: SavedFilterTimeFilter? = null,
23−)
24−
25−@Serializable
26−data class SavedFilterQuery(
27− @SerialName("filter_type")
28− val filterType: String = "filter",
29− @SerialName("filter_query")
30− val filterQuery: String? = null,
31− @SerialName("filter_builder")
32− val filterBuilder: FilterBuilder? = null,
33−)
34−
35−@Serializable
36−data class FilterBuilder(
37− val id: String = "root",
38− val combinator: String = "and",
39− val rules: List<FilterRuleGroup> = emptyList(),
40−)
41−
42−@Serializable
43−data class FilterRuleGroup(
44− val id: String = "",
45− val combinator: String = "and",
46− val rules: List<FilterRule> = emptyList(),
47−)
48−
49−@Serializable
50−data class FilterRule(
51− val id: String = "",
52− val field: String = "",
53− val value: String = "",
54− val operator: String = "=",
55−)
56−
57−@Serializable
58−data class SavedFilterTimeFilter(
59− val from: String? = null,
60− val to: String? = null,
61−)
app/src/main/java/com/parseable/android/data/repository/ParseableRepository.ktdeleted−190
@@ -1,190 +0,0 @@
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.ktdeleted−351
@@ -1,351 +0,0 @@
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.ktdeleted−88
@@ -1,88 +0,0 @@
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.ktdeleted−84
@@ -1,84 +0,0 @@
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.ktdeleted−220
@@ -1,220 +0,0 @@
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.ktdeleted−178
@@ -1,178 +0,0 @@
1−package com.parseable.android.ui.screens.about
2−
3−import android.content.Intent
4−import android.net.Uri
5−import androidx.compose.foundation.Image
6−import androidx.compose.foundation.layout.*
7−import androidx.compose.foundation.rememberScrollState
8−import androidx.compose.foundation.verticalScroll
9−import androidx.compose.material.icons.Icons
10−import androidx.compose.material.icons.automirrored.filled.ArrowBack
11−import androidx.compose.material.icons.filled.*
12−import androidx.compose.material3.*
13−import androidx.compose.runtime.Composable
14−import androidx.compose.ui.Alignment
15−import androidx.compose.ui.Modifier
16−import androidx.compose.ui.platform.LocalContext
17−import androidx.compose.ui.res.painterResource
18−import androidx.compose.ui.text.font.FontWeight
19−import androidx.compose.ui.unit.dp
20−import com.parseable.android.BuildConfig
21−import com.parseable.android.R
22−
23−private const val GITHUB_REPO_URL = "https://github.com/parseablehq/parseable-android"
24−private const val PARSEABLE_WEBSITE_URL = "https://www.parseable.com"
25−
26−@OptIn(ExperimentalMaterial3Api::class)
27−@Composable
28−fun AboutScreen(
29− onBack: () -> Unit,
30−) {
31− val context = LocalContext.current
32−
33− Scaffold(
34− topBar = {
35− TopAppBar(
36− title = { Text("About") },
37− navigationIcon = {
38− IconButton(onClick = onBack) {
39− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
40− }
41− },
42− )
43− },
44− ) { padding ->
45− Column(
46− modifier = Modifier
47− .fillMaxSize()
48− .padding(padding)
49− .verticalScroll(rememberScrollState())
50− .padding(16.dp),
51− verticalArrangement = Arrangement.spacedBy(16.dp),
52− ) {
53− // App header
54− Card(
55− modifier = Modifier.fillMaxWidth(),
56− elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
57− ) {
58− Column(
59− modifier = Modifier
60− .fillMaxWidth()
61− .padding(24.dp),
62− horizontalAlignment = Alignment.CenterHorizontally,
63− ) {
64− Image(
65− painter = painterResource(id = R.mipmap.ic_launcher),
66− contentDescription = "Parseable logo",
67− modifier = Modifier.size(80.dp),
68− )
69− Spacer(modifier = Modifier.height(12.dp))
70− Text(
71− text = "Parseable",
72− style = MaterialTheme.typography.headlineSmall,
73− fontWeight = FontWeight.Bold,
74− )
75− Text(
76− text = "Android Client",
77− style = MaterialTheme.typography.titleMedium,
78− color = MaterialTheme.colorScheme.onSurfaceVariant,
79− )
80− Spacer(modifier = Modifier.height(8.dp))
81− Text(
82− text = "Version ${BuildConfig.VERSION_NAME}",
83− style = MaterialTheme.typography.bodyMedium,
84− color = MaterialTheme.colorScheme.onSurfaceVariant,
85− )
86− }
87− }
88−
89− // Description
90− Card(
91− modifier = Modifier.fillMaxWidth(),
92− elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
93− ) {
94− Column(modifier = Modifier.padding(16.dp)) {
95− Row(verticalAlignment = Alignment.CenterVertically) {
96− Icon(
97− Icons.Filled.Info,
98− contentDescription = null,
99− tint = MaterialTheme.colorScheme.primary,
100− )
101− Spacer(modifier = Modifier.width(8.dp))
102− Text(
103− text = "About",
104− style = MaterialTheme.typography.titleMedium,
105− fontWeight = FontWeight.Bold,
106− )
107− }
108− Spacer(modifier = Modifier.height(12.dp))
109− Text(
110− text = "Parseable is an open-source log analytics platform. " +
111− "This app connects to a Parseable server instance and lets you " +
112− "browse, search, filter, and live-tail log data.",
113− style = MaterialTheme.typography.bodyMedium,
114− )
115− }
116− }
117−
118− // Links
119− Card(
120− modifier = Modifier.fillMaxWidth(),
121− elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
122− ) {
123− Column(modifier = Modifier.padding(16.dp)) {
124− Row(verticalAlignment = Alignment.CenterVertically) {
125− Icon(
126− Icons.Filled.Link,
127− contentDescription = null,
128− tint = MaterialTheme.colorScheme.primary,
129− )
130− Spacer(modifier = Modifier.width(8.dp))
131− Text(
132− text = "Links",
133− style = MaterialTheme.typography.titleMedium,
134− fontWeight = FontWeight.Bold,
135− )
136− }
137− Spacer(modifier = Modifier.height(12.dp))
138−
139− OutlinedButton(
140− onClick = {
141− context.startActivity(
142− Intent(Intent.ACTION_VIEW, Uri.parse(GITHUB_REPO_URL))
143− )
144− },
145− modifier = Modifier.fillMaxWidth(),
146− ) {
147− Icon(
148− Icons.Filled.Code,
149− contentDescription = null,
150− modifier = Modifier.size(18.dp),
151− )
152− Spacer(modifier = Modifier.width(8.dp))
153− Text("GitHub Repository")
154− }
155−
156− Spacer(modifier = Modifier.height(8.dp))
157−
158− OutlinedButton(
159− onClick = {
160− context.startActivity(
161− Intent(Intent.ACTION_VIEW, Uri.parse(PARSEABLE_WEBSITE_URL))
162− )
163− },
164− modifier = Modifier.fillMaxWidth(),
165− ) {
166− Icon(
167− Icons.Filled.Language,
168− contentDescription = null,
169− modifier = Modifier.size(18.dp),
170− )
171− Spacer(modifier = Modifier.width(8.dp))
172− Text("Parseable Website")
173− }
174− }
175− }
176− }
177− }
178−}
app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsScreen.ktdeleted

Binary file not shown.

app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsViewModel.ktdeleted−97
@@ -1,97 +0,0 @@
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.ktdeleted−304
@@ -1,304 +0,0 @@
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.ktdeleted−261
@@ -1,261 +0,0 @@
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.ktdeleted−1423
@@ -1,1423 +0,0 @@
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.ktdeleted−964
@@ -1,964 +0,0 @@
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.ktdeleted−169
@@ -1,169 +0,0 @@
1−package com.parseable.android.ui.screens.logviewer
2−
3−import androidx.compose.foundation.isSystemInDarkTheme
4−import androidx.compose.runtime.Composable
5−import androidx.compose.ui.graphics.Color
6−import kotlinx.serialization.json.JsonObject
7−import kotlinx.serialization.json.JsonPrimitive
8−
9−/**
10− * Severity levels for log entries, ordered from most to least severe.
11− */
12−enum class LogSeverity {
13− FATAL,
14− ERROR,
15− WARNING,
16− INFO,
17− DEBUG,
18− TRACE,
19− UNKNOWN,
20−}
21−
22−/**
23− * Field names commonly used for severity/level across logging frameworks.
24− * Checked in order of priority (case-insensitive).
25− */
26−private val SEVERITY_FIELD_NAMES = listOf(
27− "level",
28− "severity",
29− "loglevel",
30− "log_level",
31− "log.level",
32− "priority",
33− "verbosity",
34− "p_level",
35− "status",
36− "syslog_severity",
37− "log_severity",
38− "msg_severity",
39− "event_severity",
40−)
41−
42−/**
43− * Attempts to detect the severity level from a log entry by checking common field names.
44− * Returns [LogSeverity.UNKNOWN] if no recognizable severity field or value is found.
45− */
46−fun detectSeverity(logEntry: JsonObject): LogSeverity {
47− for (fieldName in SEVERITY_FIELD_NAMES) {
48− val entry = logEntry.entries.firstOrNull { it.key.equals(fieldName, ignoreCase = true) }
49− if (entry != null) {
50− val value = entry.value
51− if (value is JsonPrimitive) {
52− val parsed = parseSeverityValue(value.content, entry.key)
53− if (parsed != LogSeverity.UNKNOWN) return parsed
54− }
55− }
56− }
57− return LogSeverity.UNKNOWN
58−}
59−
60−private fun parseSeverityValue(raw: String, fieldName: String): LogSeverity {
61− val trimmed = raw.trim()
62− if (trimmed.isEmpty()) return LogSeverity.UNKNOWN
63−
64− // Try numeric parsing first
65− trimmed.toIntOrNull()?.let { num ->
66− return when {
67− // Syslog severity (0 = emergency .. 7 = debug)
68− fieldName.equals("syslog_severity", ignoreCase = true) -> when (num) {
69− 0, 1, 2 -> LogSeverity.FATAL // emergency, alert, critical
70− 3 -> LogSeverity.ERROR
71− 4 -> LogSeverity.WARNING
72− 5 -> LogSeverity.INFO // notice
73− 6 -> LogSeverity.INFO // informational
74− 7 -> LogSeverity.DEBUG
75− else -> LogSeverity.UNKNOWN
76− }
77− // Syslog "priority" (PRI) is the encoded value facility*8 + severity (0..191), so the
78− // severity is the low 3 bits. For a bare 0..7 this is identity; a real PRI like 134
79− // decodes to severity 6 (info) instead of falling through to UNKNOWN.
80− fieldName.equals("priority", ignoreCase = true) -> when {
81− num < 0 || num > 191 -> LogSeverity.UNKNOWN
82− else -> when (num % 8) {
83− 0, 1, 2 -> LogSeverity.FATAL // emergency, alert, critical
84− 3 -> LogSeverity.ERROR
85− 4 -> LogSeverity.WARNING
86− 5, 6 -> LogSeverity.INFO // notice, informational
87− else -> LogSeverity.DEBUG // 7
88− }
89− }
90− // HTTP status codes. Bound every branch to the valid HTTP range (100..599):
91− // `status` is a very generic field name, so an unbounded `>= 500`/`>= 400`
92− // would mis-color non-HTTP values (e.g. status: 50000) as ERROR/WARNING.
93− fieldName.equals("status", ignoreCase = true) -> when (num) {
94− in 500..599 -> LogSeverity.ERROR
95− in 400..499 -> LogSeverity.WARNING
96− // 1xx (informational) through 3xx are non-error HTTP statuses; 1xx previously
97− // fell through to UNKNOWN.
98− in 100..399 -> LogSeverity.INFO
99− else -> LogSeverity.UNKNOWN
100− }
101− // Generic numeric (higher = more severe). Two scales coexist under a bare numeric
102− // "level"/"severity" field, so handle both:
103− // • Large-magnitude java.util.logging (FINEST=300 … SEVERE=1000).
104− // • Small-integer structured JSON loggers (e.g. Bunyan: trace=10 … fatal=60).
105− // Without the small-integer arm, Bunyan-style levels (10..60) would all fall into the
106− // `num > 0 -> TRACE` catch-all and every such log would render as TRACE. The >= 100
107− // boundary keeps every existing java.util.logging mapping unchanged (100..399 stayed
108− // TRACE before and still does).
109− else -> when {
110− num >= 1000 -> LogSeverity.FATAL
111− num >= 900 -> LogSeverity.ERROR
112− num >= 800 -> LogSeverity.WARNING
113− num >= 700 -> LogSeverity.INFO
114− num >= 400 -> LogSeverity.DEBUG
115− num >= 100 -> LogSeverity.TRACE
116− num >= 60 -> LogSeverity.FATAL
117− num >= 50 -> LogSeverity.ERROR
118− num >= 40 -> LogSeverity.WARNING
119− num >= 30 -> LogSeverity.INFO
120− num >= 20 -> LogSeverity.DEBUG
121− num > 0 -> LogSeverity.TRACE
122− else -> LogSeverity.UNKNOWN
123− }
124− }
125− }
126−
127− // String-based matching (case-insensitive)
128− return when (trimmed.uppercase()) {
129− "FATAL", "F", "CRIT", "CRITICAL", "EMERG", "EMERGENCY",
130− "ALERT", "A", "PANIC",
131− -> LogSeverity.FATAL
132−
133− "ERROR", "ERR", "E", "SEVERE", "FAILURE", "FAIL",
134− -> LogSeverity.ERROR
135−
136− "WARN", "WARNING", "W",
137− -> LogSeverity.WARNING
138−
139− "INFO", "I", "INFORMATION", "INFORMATIONAL", "NOTICE", "N",
140− "OK", "SUCCESS",
141− -> LogSeverity.INFO
142−
143− "DEBUG", "D", "FINE", "CONFIG", "FINER",
144− -> LogSeverity.DEBUG
145−
146− "TRACE", "T", "V", "VERBOSE", "FINEST", "ALL",
147− -> LogSeverity.TRACE
148−
149− else -> LogSeverity.UNKNOWN
150− }
151−}
152−
153−/**
154− * Returns a subtle background tint color for the given severity level,
155− * or `null` to use the default card color.
156− */
157−@Composable
158−fun severityBackgroundColor(severity: LogSeverity): Color? {
159− val isDark = isSystemInDarkTheme()
160− return when (severity) {
161− LogSeverity.FATAL -> if (isDark) Color(0xFF3A1820) else Color(0xFFFCE4EC)
162− LogSeverity.ERROR -> if (isDark) Color(0xFF2D1A1E) else Color(0xFFFDECEC)
163− LogSeverity.WARNING -> if (isDark) Color(0xFF2D2715) else Color(0xFFFFF8E1)
164− LogSeverity.INFO -> if (isDark) Color(0xFF152535) else Color(0xFFE8F4FD)
165− LogSeverity.DEBUG -> null
166− LogSeverity.TRACE -> if (isDark) Color(0xFF1E1F25) else Color(0xFFF5F5F8)
167− LogSeverity.UNKNOWN -> null
168− }
169−}
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoScreen.ktdeleted−269
@@ -1,269 +0,0 @@
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.ktdeleted−153
@@ -1,153 +0,0 @@
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.ktdeleted−436
@@ -1,436 +0,0 @@
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.ktdeleted−217
@@ -1,217 +0,0 @@
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.ktdeleted−374
@@ -1,374 +0,0 @@
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.ktdeleted−223
@@ -1,223 +0,0 @@
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.ktdeleted−67
@@ -1,67 +0,0 @@
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.webpdeleted

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

app/src/main/res/values/strings.xmldeleted−78
@@ -1,78 +0,0 @@
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.xmldeleted−4
@@ -1,4 +0,0 @@
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.xmldeleted−4
@@ -1,4 +0,0 @@
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.xmldeleted−19
@@ -1,19 +0,0 @@
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.ktdeleted−127
@@ -1,127 +0,0 @@
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.ktdeleted−89
@@ -1,89 +0,0 @@
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.ktdeleted−101
@@ -1,101 +0,0 @@
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.ktdeleted−80
@@ -1,80 +0,0 @@
1−package com.parseable.android
2−
3−import com.parseable.android.data.formatBytes
4−import org.junit.Assert.*
5−import org.junit.Test
6−
7−class FormatBytesTest {
8−
9− @Test
10− fun `null input returns null`() {
11− assertNull(formatBytes(null))
12− }
13−
14− @Test
15− fun `zero bytes`() {
16− assertEquals("0 B", formatBytes("0"))
17− }
18−
19− @Test
20− fun `small byte values`() {
21− assertEquals("512 B", formatBytes("512"))
22− assertEquals("1 B", formatBytes("1"))
23− }
24−
25− @Test
26− fun `kilobytes`() {
27− assertEquals("1.00 KB", formatBytes("1024"))
28− assertEquals("1.50 KB", formatBytes("1536"))
29− }
30−
31− @Test
32− fun `megabytes`() {
33− assertEquals("1.00 MB", formatBytes("1048576"))
34− assertEquals("5.25 MB", formatBytes("5505024"))
35− }
36−
37− @Test
38− fun `gigabytes`() {
39− assertEquals("1.00 GB", formatBytes("1073741824"))
40− assertEquals("2.50 GB", formatBytes("2684354560"))
41− }
42−
43− @Test
44− fun `terabytes`() {
45− assertEquals("1.00 TB", formatBytes("1099511627776"))
46− }
47−
48− @Test
49− fun `large values use no decimals`() {
50− // 150 GB = 161061273600
51− assertEquals("150 GB", formatBytes("161061273600"))
52− }
53−
54− @Test
55− fun `mid-range values use one decimal`() {
56− // 15.5 MB = 16252928
57− assertEquals("15.5 MB", formatBytes("16252928"))
58− }
59−
60− @Test
61− fun `non-numeric string passes through unchanged`() {
62− assertEquals("already formatted", formatBytes("already formatted"))
63− assertEquals("1.5 GiB", formatBytes("1.5 GiB"))
64− }
65−
66− @Test
67− fun `negative value passes through unchanged`() {
68− assertEquals("-1024", formatBytes("-1024"))
69− }
70−
71− @Test
72− fun `whitespace is trimmed before parsing`() {
73− assertEquals("1.00 KB", formatBytes(" 1024 "))
74− }
75−
76− @Test
77− fun `fractional bytes from API`() {
78− assertEquals("1.50 KB", formatBytes("1536.0"))
79− }
80−}
app/src/test/java/com/parseable/android/FormatTimestampTest.ktdeleted−73
@@ -1,73 +0,0 @@
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.ktdeleted−340
@@ -1,340 +0,0 @@
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.ktdeleted−253
@@ -1,253 +0,0 @@
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.ktdeleted−173
@@ -1,173 +0,0 @@
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.ktdeleted−73
@@ -1,73 +0,0 @@
1−package com.parseable.android
2−
3−import com.parseable.android.ui.screens.login.extractHost
4−import com.parseable.android.ui.screens.login.isPrivateHost
5−import org.junit.Assert.assertEquals
6−import org.junit.Assert.assertFalse
7−import org.junit.Assert.assertTrue
8−import org.junit.Test
9−
10−class PrivateHostTest {
11−
12− @Test
13− fun `extractHost strips scheme port and path`() {
14− assertEquals("192.168.1.5", extractHost("http://192.168.1.5:8000/path"))
15− assertEquals("example.com", extractHost("https://example.com"))
16− assertEquals("example.com", extractHost("example.com:9000"))
17− }
18−
19− @Test
20− fun `extractHost handles bracketed ipv6`() {
21− assertEquals("::1", extractHost("http://[::1]:8000"))
22− assertEquals("fe80::1", extractHost("[fe80::1]"))
23− }
24−
25− @Test
26− fun `extractHost leaves bare ipv6 intact`() {
27− assertEquals("fe80::1", extractHost("fe80::1"))
28− }
29−
30− @Test
31− fun `private ipv4 ranges are detected`() {
32− assertTrue(isPrivateHost("10.0.0.1"))
33− assertTrue(isPrivateHost("127.0.0.1"))
34− assertTrue(isPrivateHost("192.168.1.10"))
35− assertTrue(isPrivateHost("172.16.0.1"))
36− assertTrue(isPrivateHost("172.31.255.255"))
37− }
38−
39− @Test
40− fun `public ipv4 in the 172 gap is not private`() {
41− assertFalse(isPrivateHost("172.15.0.1"))
42− assertFalse(isPrivateHost("172.32.0.1"))
43− assertFalse(isPrivateHost("8.8.8.8"))
44− }
45−
46− @Test
47− fun `public hostnames sharing a private prefix are not private`() {
48− // The old prefix-string check wrongly classified these as private, hiding the
49− // plaintext-HTTP warning.
50− assertFalse(isPrivateHost("10.example.com"))
51− assertFalse(isPrivateHost("192.168.evil.com"))
52− }
53−
54− @Test
55− fun `localhost is private`() {
56− assertTrue(isPrivateHost("localhost"))
57− assertTrue(isPrivateHost("api.localhost"))
58− assertTrue(isPrivateHost(""))
59− }
60−
61− @Test
62− fun `ipv6 loopback and local ranges are private`() {
63− assertTrue(isPrivateHost("::1"))
64− assertTrue(isPrivateHost("fe80::1"))
65− assertTrue(isPrivateHost("fd00::1"))
66− assertTrue(isPrivateHost("fc00::1"))
67− }
68−
69− @Test
70− fun `public ipv6 is not private`() {
71− assertFalse(isPrivateHost("2001:4860:4860::8888"))
72− }
73−}
app/src/test/java/com/parseable/android/SettingsViewModelTest.ktdeleted−172
@@ -1,172 +0,0 @@
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.ktdeleted−180
@@ -1,180 +0,0 @@
1−package com.parseable.android
2−
3−import com.parseable.android.ui.screens.logviewer.LogSeverity
4−import com.parseable.android.ui.screens.logviewer.detectSeverity
5−import kotlinx.serialization.json.JsonObject
6−import kotlinx.serialization.json.JsonPrimitive
7−import org.junit.Assert.assertEquals
8−import org.junit.Test
9−
10−class SeverityDetectionTest {
11−
12− private fun logWith(vararg pairs: Pair<String, String>): JsonObject {
13− return JsonObject(pairs.associate { (k, v) -> k to JsonPrimitive(v) })
14− }
15−
16− // --- Field name detection ---
17−
18− @Test
19− fun `detects level field`() {
20− assertEquals(LogSeverity.ERROR, detectSeverity(logWith("level" to "error")))
21− }
22−
23− @Test
24− fun `detects severity field`() {
25− assertEquals(LogSeverity.WARNING, detectSeverity(logWith("severity" to "warn")))
26− }
27−
28− @Test
29− fun `detects loglevel field`() {
30− assertEquals(LogSeverity.INFO, detectSeverity(logWith("loglevel" to "info")))
31− }
32−
33− @Test
34− fun `detects log_level field`() {
35− assertEquals(LogSeverity.DEBUG, detectSeverity(logWith("log_level" to "debug")))
36− }
37−
38− @Test
39− fun `field name matching is case-insensitive`() {
40− assertEquals(LogSeverity.ERROR, detectSeverity(logWith("Level" to "ERROR")))
41− assertEquals(LogSeverity.ERROR, detectSeverity(logWith("SEVERITY" to "error")))
42− assertEquals(LogSeverity.ERROR, detectSeverity(logWith("LogLevel" to "ERR")))
43− }
44−
45− @Test
46− fun `returns UNKNOWN when no severity field present`() {
47− assertEquals(LogSeverity.UNKNOWN, detectSeverity(logWith("message" to "hello", "host" to "server1")))
48− }
49−
50− @Test
51− fun `returns UNKNOWN for empty log entry`() {
52− assertEquals(LogSeverity.UNKNOWN, detectSeverity(JsonObject(emptyMap())))
53− }
54−
55− // --- String value matching ---
56−
57− @Test
58− fun `matches fatal variants`() {
59− for (value in listOf("fatal", "FATAL", "F", "crit", "critical", "emerg", "emergency", "alert", "panic")) {
60− assertEquals("Expected FATAL for '$value'", LogSeverity.FATAL, detectSeverity(logWith("level" to value)))
61− }
62− }
63−
64− @Test
65− fun `matches error variants`() {
66− for (value in listOf("error", "ERROR", "err", "E", "severe", "failure", "fail")) {
67− assertEquals("Expected ERROR for '$value'", LogSeverity.ERROR, detectSeverity(logWith("level" to value)))
68− }
69− }
70−
71− @Test
72− fun `matches warning variants`() {
73− for (value in listOf("warn", "WARNING", "W", "warning")) {
74− assertEquals("Expected WARNING for '$value'", LogSeverity.WARNING, detectSeverity(logWith("level" to value)))
75− }
76− }
77−
78− @Test
79− fun `matches info variants`() {
80− for (value in listOf("info", "INFO", "I", "information", "informational", "notice", "ok", "success")) {
81− assertEquals("Expected INFO for '$value'", LogSeverity.INFO, detectSeverity(logWith("level" to value)))
82− }
83− }
84−
85− @Test
86− fun `matches debug variants`() {
87− for (value in listOf("debug", "DEBUG", "D", "fine", "config", "finer")) {
88− assertEquals("Expected DEBUG for '$value'", LogSeverity.DEBUG, detectSeverity(logWith("level" to value)))
89− }
90− }
91−
92− @Test
93− fun `matches trace variants`() {
94− for (value in listOf("trace", "TRACE", "T", "V", "verbose", "finest", "all")) {
95− assertEquals("Expected TRACE for '$value'", LogSeverity.TRACE, detectSeverity(logWith("level" to value)))
96− }
97− }
98−
99− @Test
100− fun `value matching is case-insensitive`() {
101− assertEquals(LogSeverity.ERROR, detectSeverity(logWith("level" to "Error")))
102− assertEquals(LogSeverity.WARNING, detectSeverity(logWith("level" to "WaRnInG")))
103− }
104−
105− @Test
106− fun `trims whitespace from values`() {
107− assertEquals(LogSeverity.ERROR, detectSeverity(logWith("level" to " error ")))
108− }
109−
110− @Test
111− fun `returns UNKNOWN for unrecognized values`() {
112− assertEquals(LogSeverity.UNKNOWN, detectSeverity(logWith("level" to "custom_level")))
113− }
114−
115− // --- Numeric values ---
116−
117− @Test
118− fun `syslog severity numeric values`() {
119− assertEquals(LogSeverity.FATAL, detectSeverity(logWith("syslog_severity" to "0"))) // emergency
120− assertEquals(LogSeverity.FATAL, detectSeverity(logWith("syslog_severity" to "1"))) // alert
121− assertEquals(LogSeverity.FATAL, detectSeverity(logWith("syslog_severity" to "2"))) // critical
122− assertEquals(LogSeverity.ERROR, detectSeverity(logWith("syslog_severity" to "3"))) // error
123− assertEquals(LogSeverity.WARNING, detectSeverity(logWith("syslog_severity" to "4"))) // warning
124− assertEquals(LogSeverity.INFO, detectSeverity(logWith("syslog_severity" to "5"))) // notice
125− assertEquals(LogSeverity.INFO, detectSeverity(logWith("syslog_severity" to "6"))) // informational
126− assertEquals(LogSeverity.DEBUG, detectSeverity(logWith("syslog_severity" to "7"))) // debug
127− }
128−
129− @Test
130− fun `HTTP status code mapping`() {
131− assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "100")))
132− assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "200")))
133− assertEquals(LogSeverity.INFO, detectSeverity(logWith("status" to "301")))
134− assertEquals(LogSeverity.WARNING, detectSeverity(logWith("status" to "404")))
135− assertEquals(LogSeverity.ERROR, detectSeverity(logWith("status" to "500")))
136− assertEquals(LogSeverity.ERROR, detectSeverity(logWith("status" to "503")))
137− }
138−
139− @Test
140− fun `generic numeric level mapping`() {
141− assertEquals(LogSeverity.FATAL, detectSeverity(logWith("level" to "1000")))
142− assertEquals(LogSeverity.ERROR, detectSeverity(logWith("level" to "900")))
143− assertEquals(LogSeverity.WARNING, detectSeverity(logWith("level" to "800")))
144− assertEquals(LogSeverity.INFO, detectSeverity(logWith("level" to "700")))
145− assertEquals(LogSeverity.DEBUG, detectSeverity(logWith("level" to "500")))
146− assertEquals(LogSeverity.TRACE, detectSeverity(logWith("level" to "100")))
147− }
148−
149− @Test
150− fun `small-integer (Bunyan-style) numeric level mapping`() {
151− // Structured JSON loggers like Bunyan emit a numeric level field (trace=10 .. fatal=60,
152− // higher = more severe). These previously all collapsed into TRACE.
153− assertEquals(LogSeverity.FATAL, detectSeverity(logWith("level" to "60")))
154− assertEquals(LogSeverity.ERROR, detectSeverity(logWith("level" to "50")))
155− assertEquals(LogSeverity.WARNING, detectSeverity(logWith("level" to "40")))
156− assertEquals(LogSeverity.INFO, detectSeverity(logWith("level" to "30")))
157− assertEquals(LogSeverity.DEBUG, detectSeverity(logWith("level" to "20")))
158− assertEquals(LogSeverity.TRACE, detectSeverity(logWith("level" to "10")))
159− }
160−
161− // --- Priority among fields ---
162−
163− @Test
164− fun `level field takes priority over status`() {
165− val log = JsonObject(mapOf(
166− "level" to JsonPrimitive("error"),
167− "status" to JsonPrimitive("200"),
168− ))
169− assertEquals(LogSeverity.ERROR, detectSeverity(log))
170− }
171−
172− @Test
173− fun `first matching field with recognized value wins`() {
174− val log = JsonObject(mapOf(
175− "level" to JsonPrimitive("unknown_value"),
176− "severity" to JsonPrimitive("warn"),
177− ))
178− assertEquals(LogSeverity.WARNING, detectSeverity(log))
179− }
180−}
app/src/test/java/com/parseable/android/StreamInfoViewModelTest.ktdeleted−204
@@ -1,204 +0,0 @@
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.ktdeleted−155
@@ -1,155 +0,0 @@
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.ktsdeleted−8
@@ -1,8 +0,0 @@
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.propertiesdeleted−5
@@ -1,5 +0,0 @@
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.tomldeleted−89
@@ -1,89 +0,0 @@
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.propertiesdeleted−7
@@ -1,7 +0,0 @@
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
gradlewdeleted−168
@@ -1,168 +0,0 @@
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.batdeleted−81
@@ -1,81 +0,0 @@
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.ktsdeleted−24
@@ -1,24 +0,0 @@
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")