git.iko.soy/parseable-android
on master help sign in
Add Parseable Android log viewer app (#1)
5e8f3d7iko
.github/workflows/build-release.ymlnew file+55
@@ -0,0 +1,55 @@
1+name: Build & Release
2+
3+on:
4+ push:
5+ tags:
6+ - 'v*'
7+ workflow_dispatch:
8+
9+permissions:
10+ contents: write
11+
12+jobs:
13+ build:
14+ name: Build APK
15+ runs-on: ubuntu-latest
16+
17+ steps:
18+ - name: Checkout code
19+ uses: actions/checkout@v4
20+
21+ - name: Set up JDK 17
22+ uses: actions/setup-java@v4
23+ with:
24+ java-version: '17'
25+ distribution: 'temurin'
26+
27+ - name: Setup Gradle
28+ uses: gradle/actions/setup-gradle@v4
29+
30+ - name: Setup Android SDK
31+ uses: android-actions/setup-android@v3
32+
33+ - name: Generate Gradle wrapper
34+ run: gradle wrapper --gradle-version 8.9
35+
36+ - name: Build release APK
37+ run: ./gradlew assembleRelease
38+
39+ - name: Run unit tests
40+ run: ./gradlew testReleaseUnitTest testDebugUnitTest
41+
42+ - name: Upload release APK artifact
43+ uses: actions/upload-artifact@v4
44+ with:
45+ name: parseable-android-release
46+ path: app/build/outputs/apk/release/*.apk
47+
48+ - name: Create Draft Release
49+ if: startsWith(github.ref, 'refs/tags/v')
50+ uses: softprops/action-gh-release@v2
51+ with:
52+ draft: true
53+ generate_release_notes: true
54+ files: |
55+ app/build/outputs/apk/release/*.apk
.github/workflows/ci.ymlnew file+60
@@ -0,0 +1,60 @@
1+name: CI
2+
3+on:
4+ pull_request:
5+ branches: [ master, main ]
6+ push:
7+ branches: [ master, main ]
8+
9+concurrency:
10+ group: ci-${{ github.ref }}
11+ cancel-in-progress: true
12+
13+jobs:
14+ build:
15+ name: Build & Lint
16+ runs-on: ubuntu-latest
17+
18+ steps:
19+ - name: Checkout code
20+ uses: actions/checkout@v4
21+
22+ - name: Set up JDK 17
23+ uses: actions/setup-java@v4
24+ with:
25+ java-version: '17'
26+ distribution: 'temurin'
27+
28+ - name: Setup Gradle
29+ uses: gradle/actions/setup-gradle@v4
30+
31+ - name: Setup Android SDK
32+ uses: android-actions/setup-android@v3
33+
34+ - name: Generate Gradle wrapper
35+ run: gradle wrapper --gradle-version 8.9
36+
37+ - name: Build release APK
38+ run: ./gradlew assembleRelease
39+
40+ - name: Run unit tests
41+ run: ./gradlew testReleaseUnitTest testDebugUnitTest
42+
43+ - name: Run Android Lint
44+ run: ./gradlew lintRelease
45+
46+ - name: Upload release APK
47+ if: github.event_name == 'pull_request'
48+ uses: actions/upload-artifact@v4
49+ with:
50+ name: parseable-android-release
51+ path: app/build/outputs/apk/release/*.apk
52+ retention-days: 14
53+
54+ - name: Upload lint results
55+ if: always()
56+ uses: actions/upload-artifact@v4
57+ with:
58+ name: lint-results
59+ path: app/build/reports/lint-results-release.html
60+ retention-days: 14
.gitignorenew file+12
@@ -0,0 +1,12 @@
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
CLAUDE.mdnew file+82
@@ -0,0 +1,82 @@
1+# CLAUDE.md
2+
3+## Project Overview
4+
5+Android client for [Parseable](https://www.parseable.com/), an open-source log analytics platform. The app connects to a Parseable server instance and lets users browse, search, filter, and live-tail log data.
6+
7+## Build & Test
8+
9+```bash
10+./gradlew assembleDebug # Build debug APK
11+./gradlew assembleRelease # Build release APK (minified)
12+./gradlew testDebugUnitTest # Run unit tests
13+./gradlew lintDebug # Run Android lint
14+```
15+
16+Requires JDK 17 and the Android SDK (compileSdk 35, minSdk 26).
17+
18+## Architecture
19+
20+Single-module Android app (`app/`) using MVVM with Jetpack Compose.
21+
22+### Layers
23+
24+- **`data/api/`** — `ParseableApiClient`: OkHttp-based REST client wrapping all Parseable API endpoints. Uses Basic Auth. Returns `ApiResult<T>` (sealed class: `Success`/`Error`).
25+- **`data/model/`** — Kotlinx Serialization data classes for API request/response types (`LogStream`, `StreamStats`, `StreamSchema`, `Alert`, `QueryRequest`, etc.).
26+- **`data/repository/`** — `ParseableRepository` (domain facade over the API client) and `SettingsRepository` (DataStore-backed credential persistence).
27+- **`di/`** — Hilt DI module. All singletons are constructor-injected.
28+- **`ui/screens/`** — One package per screen, each containing a `*Screen.kt` (Composable) and `*ViewModel.kt` (Hilt ViewModel with `StateFlow`).
29+- **`ui/navigation/`** — `NavGraph.kt` defines all routes. Navigation uses string-based routes with `{streamName}` path params.
30+- **`ui/theme/`** — Material 3 theme with Parseable brand colors (purple `#545BEB`). Supports dark/light.
31+
32+### Screen Map
33+
34+| Route | Screen | ViewModel |
35+|---|---|---|
36+| `login` | `LoginScreen` | `LoginViewModel` |
37+| `streams` | `StreamsScreen` | `StreamsViewModel` |
38+| `log_viewer/{streamName}` | `LogViewerScreen` | `LogViewerViewModel` |
39+| `stream_info/{streamName}` | `StreamInfoScreen` | `StreamInfoViewModel` |
40+| `alerts` | `AlertsScreen` | `AlertsViewModel` |
41+| `settings` | `SettingsScreen` | `SettingsViewModel` |
42+
43+### Parseable API Endpoints Used
44+
45+All endpoints are under `/api/v1/`. Auth is Basic Auth via `Authorization` header.
46+
47+- `GET /liveness` — connection test
48+- `GET /about` — server version/mode info
49+- `GET /logstream` — list all streams
50+- `GET /logstream/{name}/schema` — stream field schema
51+- `GET /logstream/{name}/stats` — ingestion/storage stats
52+- `GET /logstream/{name}/info` — stream metadata
53+- `GET /logstream/{name}/retention` — retention config
54+- `DELETE /logstream/{name}` — delete stream
55+- `POST /query` — SQL log queries (body: `{query, startTime, endTime}`)
56+- `GET /alerts` — list alerts
57+- `GET /user` — list users
58+
59+API docs: https://www.parseable.com/docs/api
60+
61+### Key Dependencies
62+
63+- Jetpack Compose with Material 3 (BOM 2024.12.01)
64+- Hilt 2.53.1 (DI via KSP)
65+- OkHttp 4.12.0
66+- Kotlinx Serialization 1.7.3
67+- Navigation Compose 2.8.5
68+- DataStore Preferences 1.1.1
69+
70+## Code Conventions
71+
72+- Kotlin with Jetpack Compose — no XML layouts
73+- State is exposed as `StateFlow` from ViewModels, collected via `collectAsStateWithLifecycle()`
74+- API responses use `ApiResult<T>` sealed class — always handle both `Success` and `Error` branches
75+- Network calls use `Dispatchers.IO` via `withContext` inside the API client
76+- Parseable queries use PostgreSQL-compatible SQL; column names are double-quoted in generated SQL
77+- Cleartext HTTP is allowed via `network_security_config.xml` (many Parseable instances are self-hosted without TLS)
78+
79+## CI/CD
80+
81+- **`.github/workflows/ci.yml`** — Runs on PRs and pushes to main/master. Builds debug APK, runs tests and lint.
82+- **`.github/workflows/build-release.yml`** — Runs on `v*` tags. Builds debug+release APKs and creates a draft GitHub release.
IMPROVEMENTS.mdnew file+176
@@ -0,0 +1,176 @@
1+# Parseable Android — Improvements
2+
3+Summary of all improvements made to the codebase, organized by category.
4+
5+---
6+
7+## Security
8+
9+### SQL Injection Prevention
10+- **LogViewerViewModel**: All user-supplied column names are escaped with `escapeIdentifier()` (double-quote escaping) and filter values use single-quote escaping (`'` → `''`) to prevent SQL injection in generated WHERE clauses.
11+- **Custom SQL Queries**: Only `SELECT` statements are allowed; `DELETE`, `DROP`, etc. are rejected. Queries without a `LIMIT` clause automatically get `LIMIT 5000` appended to prevent OOM from unbounded result sets.
12+- **ParseableRepository**: Stream names in generated SQL are escaped with `escapeIdentifier()`.
13+
14+### Credential Storage
15+- **SettingsRepository**: Passwords are stored in `EncryptedSharedPreferences` (AES-256-GCM) backed by Android Keystore, separate from other preferences in DataStore.
16+- **Corruption Recovery**: If `EncryptedSharedPreferences` becomes corrupted (common after backup/restore), the file is deleted and recreated automatically with a Timber log.
17+- **Mutex Protection**: A `configMutex` guards concurrent reads/writes across DataStore and EncryptedSharedPreferences.
18+
19+### Network Security
20+- **network_security_config.xml**: Cleartext HTTP is explicitly allowed (required for self-hosted Parseable instances without TLS) with a proper XML config rather than a blanket manifest flag.
21+- **Private IP Detection**: LoginScreen warns users when enabling HTTP for non-private IP addresses (checks `localhost`, `127.*`, `10.*`, `192.168.*`, `172.16-31.*`).
22+- **Clipboard Safety**: Log export catches `SecurityException` when accessing the clipboard on Android 13+ where background clipboard access may be restricted.
23+
24+### Input Validation
25+- **URL Validation**: Server URLs are validated with `Patterns.WEB_URL`, trimmed of whitespace, and checked for blank hosts before connection attempts.
26+- **Filter Operators**: Only operators from an `ALLOWED_OPERATORS` allowlist are accepted for filter clauses.
27+
28+---
29+
30+## Concurrency & Reliability
31+
32+### CancellationException Handling
33+- **All ViewModels** (`SettingsViewModel`, `StreamsViewModel`, `StreamInfoViewModel`, `LogViewerViewModel`): `async/await` blocks are wrapped in `try-catch` with explicit `CancellationException` handling. On cancellation (e.g., user navigates away), `isLoading` is reset to `false` before re-throwing, preventing the UI from getting stuck in a permanent loading state.
34+
35+### Structured Concurrency
36+- **LogViewerViewModel**: Schema loading uses a dedicated `schemaJob` that is cancelled on re-initialization or `onCleared()`. Search debounce uses a separate `searchJob`. Streaming uses `streamingJob` with a generation counter to prevent stale poll results.
37+- **StreamsViewModel**: Stats loading uses a `statsJob` that is cancelled on refresh. A `Semaphore(8)` limits concurrent stats requests to 8 at a time.
38+- **StreamInfoViewModel**: A `loadJob` is cancelled when `load()` is called again, preventing duplicate parallel loads.
39+
40+### Thread Safety
41+- **LogViewerViewModel**: `streamingGeneration`, `lastSeenTimestamp`, and `consecutiveStreamingErrors` are all `@Volatile` to ensure visibility across coroutines.
42+- **ParseableApiClient**: The `config` field is `@Volatile` and `buildRequest()` captures a local snapshot to avoid reading a partially-updated config during concurrent requests.
43+
44+### Resource Cleanup
45+- **ParseableApiClient**: HTTP responses use `.use { }` blocks to ensure the response body is always closed, even on exceptions. A `shutdown()` method evicts the connection pool and shuts down the dispatcher.
46+- **ViewModels**: All `onCleared()` implementations cancel active jobs (`schemaJob`, `searchJob`, `streamingJob`).
47+- **Streaming**: Consecutive errors during streaming trigger an exponential backoff (1s → 2s → 4s) and auto-stop after 10 consecutive failures with an error message.
48+
49+### Logout Race Condition
50+- **StreamsViewModel**: `logout()` calls `clearConfig()` and `clearCredentials()` sequentially to prevent auth errors from triggering a second logout navigation.
51+- **ParseableRepository**: Auth errors (401) are detected via `checkAuth()` which clears credentials and sends to `authErrors` channel (conflated to prevent duplicate navigations).
52+
53+---
54+
55+## Error Handling & UX
56+
57+### Error Messages
58+- **ParseableApiClient**: Specific exception types produce targeted messages:
59+ - `SocketTimeoutException` → "Connection timed out"
60+ - `UnknownHostException` → "Unable to resolve host"
61+ - `ConnectException` → "Connection refused. Is the server running?"
62+ - `SSLException` → "SSL error: ... Check server certificate."
63+ - Generic `IOException` → the exception message or "Network error"
64+- **SettingsScreen**: Error card is now visible even during a reload (removed the `!isLoading` gate that hid errors during pull-to-refresh).
65+
66+### Empty States
67+- **LogViewerScreen**: Context-aware empty messages:
68+ - During streaming: "Waiting for new logs…"
69+ - With search + filters: "No logs match \"query\" with the active filters"
70+ - With search only: "No logs match \"query\""
71+ - With filters only: "No logs match the active filters"
72+ - Default: "No logs found for the selected time range"
73+- **Result Cap Warning**: When results hit the 5,000 limit, shows "Showing max 5,000 results. Refine your query to see more." in primary color instead of misleading "End of results".
74+
75+### Streaming Error Persistence
76+- **LogViewerViewModel**: `stopStreaming()` now preserves the streaming error in state (only clears `isStreaming`). A separate `dismissStreamingError()` method lets users explicitly dismiss the error banner. Errors are also cleared on `refresh()`.
77+
78+### Schema Failure Visibility
79+- **StreamInfoViewModel**: A `schemaFailed` boolean flag tracks whether the schema request specifically failed (separate from the combined error string).
80+- **StreamInfoScreen**: Shows a "Schema unavailable" card when schema loading fails, instead of silently hiding the entire section.
81+
82+### Alert Deletion Safety
83+- **AlertsScreen**: Alerts without an `id` field have their delete button disabled at the card level, preventing them from reaching the delete confirmation dialog.
84+
85+---
86+
87+## Accessibility
88+
89+### Touch Targets
90+- All interactive elements meet the 48dp minimum touch target size:
91+ - **StreamsScreen**: Stats retry `TextButton` uses `defaultMinSize(minHeight = 48.dp)` instead of a fixed 24dp height.
92+ - **LogViewerScreen**: Filter chips, time range buttons, and all `IconButton` elements meet the 48dp minimum.
93+ - **LoginScreen**: Both login buttons use `defaultMinSize(minHeight = 50.dp)` instead of fixed `.height(50.dp)`, allowing growth for landscape or accessibility text scaling.
94+
95+### Content Descriptions
96+- **StreamsScreen**: The decorative `ChevronRight` icon uses `contentDescription = null` since the parent `Card` already handles the click action (avoids duplicate screen reader announcements).
97+- All other icons have meaningful `contentDescription` values that describe the action, not the icon.
98+
99+### Keyboard / IME
100+- **LogViewerScreen**: Pressing the keyboard Search button (`ImeAction.Search`) now triggers `viewModel.refresh()` in addition to hiding the keyboard.
101+- **Filter Bottom Sheet**: The filter value field has `ImeAction.Done` with a `keyboardActions` handler that applies the filter directly from the keyboard.
102+- **LoginScreen**: IME actions chain correctly: URL → Next → Username → Next → Password → Done (triggers login).
103+
104+### Configuration Changes
105+- **LoginScreen**: `passwordVisible` uses `rememberSaveable` instead of `remember`, so the "show password" toggle survives device rotation.
106+
107+---
108+
109+## Performance
110+
111+### List Operations
112+- **LogViewerViewModel**: `removeFilter()` uses `filterIndexed` instead of `toMutableList().apply { removeAt() }`, avoiding temporary mutable list allocations.
113+- **LazyColumn Keys**: All `LazyColumn` lists use deterministic, stable keys (e.g., `alert.id ?: alert.name ?: "alert_$index"`) to prevent unnecessary recompositions.
114+
115+### Streaming Backpressure
116+- **LogViewerViewModel**: Streaming caps at `STREAMING_MAX_LOGS = 1000` entries, dropping oldest when the cap is reached. A generation counter prevents stale poll results from being applied.
117+
118+### Caching
119+- **ParseableRepository**: TTL-based caching with `ConcurrentHashMap`:
120+ - Streams: 30s
121+ - Schema: 2min
122+ - Stats: 30s
123+ - About: 5min
124+- Force-refresh bypasses the cache when explicitly requested (e.g., pull-to-refresh).
125+
126+### R8 Full Mode
127+- **build.gradle.kts**: `isMinifyEnabled = true` with R8 full mode enabled for release builds.
128+- **proguard-rules.pro**: Comprehensive keep rules for kotlinx.serialization, Room entities, ViewModel state classes, OkHttp, EncryptedSharedPreferences, Google Tink, and Protobuf.
129+
130+---
131+
132+## Testing
133+
134+### Unit Test Coverage
135+Every ViewModel has a dedicated test class:
136+
137+| Test Class | Tests | Covers |
138+|---|---|---|
139+| `LogViewerViewModelTest` | 17 | Initialize, search, filters, time ranges, streaming, custom SQL LIMIT, SQL escaping, IS NULL, error states |
140+| `StreamsViewModelTest` | 8 | Refresh, error handling, stats loading, favorites CRUD, exception resilience |
141+| `SettingsViewModelTest` | 8 | Config loading, about info, user parsing, error consolidation, exception resilience, refresh |
142+| `StreamInfoViewModelTest` | 9 | Parallel loading, partial errors, schemaFailed flag, delete, cancel, idempotency |
143+| `AlertsViewModelTest` | 7 | Refresh, error, delete flow, cancel delete |
144+| `LoginViewModelTest` | 9+ | Validation, URL normalization, login flow, saved credentials |
145+| `ParseableRepositoryTest` | 9+ | Caching, TTL, auth error detection, cache invalidation |
146+
147+### CI
148+- **ci.yml**: Runs `testDebugUnitTest` and `lintDebug` on PRs and pushes to main/master.
149+- **build-release.yml**: Builds both debug and release APKs on `v*` tags and creates a draft GitHub release.
150+
151+---
152+
153+## Architecture & Code Quality
154+
155+### Error Handler
156+- **ErrorHandler**: A `CompositionLocal`-based error handler (`LocalErrorHandler`) provides a centralized `SnackbarHostState` for showing error messages from any screen. Used for session-expired notifications on the login screen.
157+
158+### Navigation
159+- **NavGraph**: All authenticated routes (`streams`, `log_viewer`, `stream_info`, `alerts`, `settings`) have auth guards that redirect to login if the API client isn't configured.
160+- **URL Encoding**: Stream names are URL-encoded/decoded in navigation routes to handle special characters.
161+- **Deep Links**: Supported for `parseable://streams`, `parseable://stream/{streamName}`, and `parseable://alerts`.
162+
163+### Favorites (Room)
164+- **FavoriteStream**: Room entity with a DAO for persisting favorite streams locally.
165+- **ParseableDatabase**: Room database with `fallbackToDestructiveMigration()` and schema export directory.
166+- **StreamsScreen**: Favorite streams are sorted to the top of the list. Toggle via star icon with snackbar feedback.
167+
168+### Dependency Management
169+- **libs.versions.toml**: All dependency versions are centralized in a Gradle version catalog.
170+- **Timber**: Debug logging via `Timber.DebugTree()` planted only in debug builds. Used throughout the API client and repository layer for error logging.
171+
172+---
173+
174+## Files Changed
175+
176+41 files modified across the codebase, with approximately **2,981 lines added** and **664 lines removed**.
README.mdnew file+60
@@ -0,0 +1,60 @@
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+## Building
47+
48+```bash
49+./gradlew assembleDebug
50+```
51+
52+The debug APK will be at `app/build/outputs/apk/debug/`.
53+
54+## CI/CD
55+
56+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.
57+
58+## License
59+
60+MIT
app/build.gradle.ktsnew file+119
@@ -0,0 +1,119 @@
1+plugins {
2+ alias(libs.plugins.android.application)
3+ alias(libs.plugins.kotlin.android)
4+ alias(libs.plugins.kotlin.compose)
5+ alias(libs.plugins.kotlin.serialization)
6+ alias(libs.plugins.hilt)
7+ alias(libs.plugins.ksp)
8+}
9+
10+android {
11+ namespace = "com.parseable.android"
12+ compileSdk = 35
13+
14+ defaultConfig {
15+ applicationId = "com.parseable.android"
16+ minSdk = 26
17+ targetSdk = 35
18+ versionCode = libs.versions.appVersionCode.get().toInt()
19+ versionName = libs.versions.appVersionName.get()
20+
21+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
22+ }
23+
24+ buildTypes {
25+ release {
26+ isMinifyEnabled = true
27+ isShrinkResources = true
28+ proguardFiles(
29+ getDefaultProguardFile("proguard-android-optimize.txt"),
30+ "proguard-rules.pro"
31+ )
32+ }
33+ debug {
34+ isMinifyEnabled = false
35+ applicationIdSuffix = ".debug"
36+ }
37+ }
38+
39+ compileOptions {
40+ sourceCompatibility = JavaVersion.VERSION_17
41+ targetCompatibility = JavaVersion.VERSION_17
42+ }
43+
44+ kotlinOptions {
45+ jvmTarget = "17"
46+ }
47+
48+ buildFeatures {
49+ compose = true
50+ buildConfig = true
51+ }
52+
53+ testOptions {
54+ unitTests.isReturnDefaultValues = true
55+ }
56+}
57+
58+ksp {
59+ arg("room.schemaLocation", "$projectDir/schemas")
60+}
61+
62+dependencies {
63+ // Core Android
64+ implementation(libs.core.ktx)
65+ implementation(libs.lifecycle.runtime)
66+ implementation(libs.lifecycle.viewmodel.compose)
67+ implementation(libs.activity.compose)
68+
69+ // Compose BOM
70+ implementation(platform(libs.compose.bom))
71+ implementation(libs.compose.ui)
72+ implementation(libs.compose.ui.graphics)
73+ implementation(libs.compose.ui.tooling.preview)
74+ implementation(libs.compose.material3)
75+ implementation(libs.compose.material.icons)
76+
77+ // Navigation
78+ implementation(libs.navigation.compose)
79+
80+ // Hilt DI
81+ implementation(libs.hilt.android)
82+ ksp(libs.hilt.compiler)
83+ implementation(libs.hilt.navigation.compose)
84+
85+ // Networking
86+ implementation(libs.okhttp)
87+
88+ // Serialization
89+ implementation(libs.kotlinx.serialization.json)
90+
91+ // DataStore for preferences
92+ implementation(libs.datastore.preferences)
93+
94+ // Encrypted storage — alpha06 is the latest available; stable 1.1.0 has not shipped.
95+ // Fallback to 1.0.0 is not viable as it uses the deprecated MasterKeys API.
96+ implementation(libs.security.crypto)
97+
98+ // Room database for favorites
99+ implementation(libs.room.runtime)
100+ implementation(libs.room.ktx)
101+ ksp(libs.room.compiler)
102+
103+ // Coroutines
104+ implementation(libs.coroutines.android)
105+
106+ // Logging
107+ implementation(libs.timber)
108+
109+ // Testing
110+ testImplementation(libs.junit)
111+ testImplementation(libs.coroutines.test)
112+ testImplementation(libs.mockk)
113+ androidTestImplementation(libs.android.test.ext)
114+ androidTestImplementation(libs.espresso.core)
115+ androidTestImplementation(platform(libs.compose.bom))
116+ androidTestImplementation(libs.compose.ui.test)
117+ debugImplementation(libs.compose.ui.tooling)
118+ debugImplementation(libs.compose.ui.test.manifest)
119+}
app/proguard-rules.pronew file+61
@@ -0,0 +1,61 @@
1+# Parseable Android ProGuard Rules
2+
3+# Keep kotlinx serialization
4+-keepattributes *Annotation*, InnerClasses, Signature
5+-dontnote kotlinx.serialization.AnnotationsKt
6+
7+-keepclassmembers class kotlinx.serialization.json.** {
8+ *** Companion;
9+}
10+-keepclasseswithmembers class kotlinx.serialization.json.** {
11+ kotlinx.serialization.KSerializer serializer(...);
12+}
13+
14+# Keep all @Serializable model classes and their generated serializers
15+-keep,includedescriptorclasses class com.parseable.android.**$$serializer { *; }
16+-keepclassmembers class com.parseable.android.** {
17+ *** Companion;
18+}
19+-keepclasseswithmembers class com.parseable.android.** {
20+ kotlinx.serialization.KSerializer serializer(...);
21+}
22+# Keep @Serializable data classes (fields needed for JSON encoding/decoding)
23+-keep @kotlinx.serialization.Serializable class com.parseable.android.data.model.** { *; }
24+
25+# Keep ViewModel state data classes (used with StateFlow/collectAsState)
26+-keep class com.parseable.android.ui.screens.**State { *; }
27+-keep class com.parseable.android.ui.screens.logviewer.FilterState { *; }
28+-keep class com.parseable.android.ui.screens.logviewer.StreamingState { *; }
29+-keep class com.parseable.android.ui.screens.streams.StreamsViewModel$StreamStatsUi { *; }
30+
31+# Room entities
32+-keep class com.parseable.android.data.local.** { *; }
33+
34+# OkHttp (library ships its own consumer rules; only suppress warnings)
35+-dontwarn okhttp3.**
36+-dontwarn okio.**
37+
38+# Keep the custom X509TrustManager used for insecure TLS config
39+-keep class com.parseable.android.data.api.ParseableApiClient { *; }
40+
41+# EncryptedSharedPreferences (androidx.security:security-crypto)
42+-keep class androidx.security.crypto.** { *; }
43+-keep class * extends android.content.SharedPreferences { *; }
44+
45+# Google Tink (transitive dependency of androidx.security:security-crypto)
46+# Tink's complex class hierarchy and use of Protobuf causes R8 crashes
47+# in security-crypto alpha versions that ship incomplete consumer rules.
48+-keep class com.google.crypto.tink.** { *; }
49+-dontwarn com.google.crypto.tink.**
50+
51+# Protobuf Lite (Tink dependency)
52+-keep class com.google.protobuf.** { *; }
53+-dontwarn com.google.protobuf.**
54+
55+# Annotation libraries referenced by Tink/Google libs but not always on classpath
56+-dontwarn com.google.errorprone.annotations.**
57+-dontwarn javax.annotation.**
58+-dontwarn org.checkerframework.**
59+-dontwarn com.google.api.**
60+-dontwarn com.google.j2objc.annotations.**
61+-dontwarn sun.misc.Unsafe
app/schemas/.gitkeepnew file

No changes to the content.

app/src/main/AndroidManifest.xmlnew file+43
@@ -0,0 +1,43 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3+ xmlns:tools="http://schemas.android.com/tools">
4+
5+ <uses-permission android:name="android.permission.INTERNET" />
6+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
7+
8+ <application
9+ android:name=".ParseableApp"
10+ android:allowBackup="false"
11+ android:icon="@mipmap/ic_launcher"
12+ android:label="@string/app_name"
13+ android:roundIcon="@mipmap/ic_launcher_round"
14+ android:supportsRtl="true"
15+ android:theme="@style/Theme.ParseableAndroid"
16+ android:networkSecurityConfig="@xml/network_security_config"
17+ tools:targetApi="35">
18+ <activity
19+ android:name=".MainActivity"
20+ android:exported="true"
21+ android:theme="@style/Theme.ParseableAndroid">
22+ <intent-filter>
23+ <action android:name="android.intent.action.MAIN" />
24+ <category android:name="android.intent.category.LAUNCHER" />
25+ </intent-filter>
26+ <intent-filter>
27+ <action android:name="android.intent.action.VIEW" />
28+ <category android:name="android.intent.category.DEFAULT" />
29+ <category android:name="android.intent.category.BROWSABLE" />
30+ <data android:scheme="parseable" />
31+ </intent-filter>
32+ </activity>
33+ <provider
34+ android:name="androidx.core.content.FileProvider"
35+ android:authorities="${applicationId}.fileprovider"
36+ android:exported="false"
37+ android:grantUriPermissions="true">
38+ <meta-data
39+ android:name="android.support.FILE_PROVIDER_PATHS"
40+ android:resource="@xml/file_paths" />
41+ </provider>
42+ </application>
43+</manifest>
app/src/main/java/com/parseable/android/MainActivity.ktnew file+90
@@ -0,0 +1,90 @@
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+
56+ val dest = startDestination
57+ if (dest != null) {
58+ val navController = rememberNavController()
59+
60+ // Navigate to login on 401 auth errors
61+ LaunchedEffect(navController) {
62+ repository.authErrors
63+ .onEach {
64+ settingsRepository.clearConfig()
65+ errorHandler.showError("Session expired. Please log in again.")
66+ navController.navigate(Routes.login(sessionExpired = true)) {
67+ popUpTo(0) { inclusive = true }
68+ }
69+ }
70+ .launchIn(this)
71+ }
72+
73+ ParseableNavGraph(
74+ navController = navController,
75+ startDestination = dest,
76+ repository = repository,
77+ )
78+ } else {
79+ Box(
80+ modifier = Modifier.fillMaxSize(),
81+ contentAlignment = Alignment.Center,
82+ ) {
83+ CircularProgressIndicator()
84+ }
85+ }
86+ }
87+ }
88+ }
89+ }
90+}
app/src/main/java/com/parseable/android/ParseableApp.ktnew file+29
@@ -0,0 +1,29 @@
1+package com.parseable.android
2+
3+import android.app.Application
4+import dagger.hilt.android.HiltAndroidApp
5+import timber.log.Timber
6+
7+@HiltAndroidApp
8+class ParseableApp : Application() {
9+
10+ override fun onCreate() {
11+ super.onCreate()
12+
13+ if (BuildConfig.DEBUG) {
14+ Timber.plant(Timber.DebugTree())
15+ }
16+
17+ installUncaughtExceptionHandler()
18+ }
19+
20+ private fun installUncaughtExceptionHandler() {
21+ val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
22+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
23+ Timber.e(throwable, "Uncaught exception on thread ${thread.name}")
24+ // Delegate to the default handler so the system can still terminate
25+ // the process for truly fatal errors (e.g. OOM).
26+ defaultHandler?.uncaughtException(thread, throwable)
27+ }
28+ }
29+}
app/src/main/java/com/parseable/android/data/SqlUtils.ktnew file+7
@@ -0,0 +1,7 @@
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("\"", "\"\"")
app/src/main/java/com/parseable/android/data/TimestampUtils.ktnew file+26
@@ -0,0 +1,26 @@
1+package com.parseable.android.data
2+
3+import java.time.Instant
4+import java.time.ZoneId
5+import java.time.ZonedDateTime
6+import java.time.format.DateTimeFormatter
7+
8+private val displayFormatter = DateTimeFormatter.ofPattern("MMM dd HH:mm:ss.SSS")
9+
10+fun formatTimestamp(raw: String): String {
11+ return try {
12+ val instant = Instant.parse(raw)
13+ val local = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
14+ local.format(displayFormatter)
15+ } catch (_: Exception) {
16+ // Try alternate format with +00:00 suffix
17+ try {
18+ val normalized = raw.replace("+00:00", "Z")
19+ val instant = Instant.parse(normalized)
20+ val local = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
21+ local.format(displayFormatter)
22+ } catch (_: Exception) {
23+ raw
24+ }
25+ }
26+}
app/src/main/java/com/parseable/android/data/api/ParseableApiClient.ktnew file+380
@@ -0,0 +1,380 @@
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(5, 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+ val credentials = "${serverConfig.username}:${serverConfig.password}"
81+ val auth = "Basic " + android.util.Base64.encodeToString(
82+ credentials.toByteArray(),
83+ android.util.Base64.NO_WRAP
84+ )
85+ config = ClientConfig(
86+ baseUrl = url,
87+ authHeader = auth,
88+ allowInsecure = !serverConfig.useTls,
89+ )
90+ }
91+
92+ val isConfigured: Boolean get() = config.baseUrl.isNotEmpty() && config.authHeader.isNotEmpty()
93+
94+ fun clearConfig() {
95+ config = ClientConfig("", "", false)
96+ }
97+
98+ fun shutdown() {
99+ sharedPool.evictAll()
100+ sharedDispatcher.executorService.shutdown()
101+ }
102+
103+ private fun encodePathSegment(segment: String): String =
104+ URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
105+
106+ private fun buildRequest(path: String): Request.Builder {
107+ val snapshot = config
108+ return Request.Builder()
109+ .url("${snapshot.baseUrl}$path")
110+ .header("Authorization", snapshot.authHeader)
111+ }
112+
113+ private suspend fun executeRequest(request: Request): ApiResult<String> =
114+ withContext(Dispatchers.IO) {
115+ try {
116+ client.newCall(request).execute().use { response ->
117+ val body = response.body?.string() ?: ""
118+ if (response.isSuccessful) {
119+ ApiResult.Success(body)
120+ } else {
121+ ApiResult.Error(
122+ message = body.ifEmpty { response.message },
123+ code = response.code
124+ )
125+ }
126+ }
127+ } catch (e: SocketTimeoutException) {
128+ ApiResult.Error(message = "Connection timed out")
129+ } catch (e: UnknownHostException) {
130+ ApiResult.Error(message = "Unable to resolve host \"${e.message}\"")
131+ } catch (e: ConnectException) {
132+ ApiResult.Error(message = "Connection refused. Is the server running?")
133+ } catch (e: SSLException) {
134+ ApiResult.Error(message = "SSL error: ${e.message}. Check server certificate.")
135+ } catch (e: IOException) {
136+ ApiResult.Error(message = e.message ?: "Network error")
137+ } catch (e: Exception) {
138+ Timber.e(e, "Unexpected error")
139+ ApiResult.Error(message = "Unexpected error: ${e.javaClass.simpleName}")
140+ }
141+ }
142+
143+ private fun <T> parseResponse(body: String, description: String, parse: () -> T): ApiResult<T> {
144+ return try {
145+ ApiResult.Success(parse())
146+ } catch (e: SerializationException) {
147+ ApiResult.Error("Invalid response format from server")
148+ } catch (e: IllegalArgumentException) {
149+ ApiResult.Error("Unexpected response format for $description")
150+ }
151+ }
152+
153+ /**
154+ * GET /api/v1/about
155+ */
156+ suspend fun getAbout(): ApiResult<AboutInfo> {
157+ val request = buildRequest("/api/v1/about").get().build()
158+ return when (val result = executeRequest(request)) {
159+ is ApiResult.Success -> parseResponse(result.data, "about info") {
160+ json.decodeFromString<AboutInfo>(result.data)
161+ }
162+ is ApiResult.Error -> result
163+ }
164+ }
165+
166+ /**
167+ * GET /api/v1/liveness - used to test connectivity
168+ */
169+ suspend fun checkLiveness(): ApiResult<String> {
170+ val request = buildRequest("/api/v1/liveness").get().build()
171+ return executeRequest(request)
172+ }
173+
174+ /**
175+ * GET /api/v1/logstream - list all log streams
176+ */
177+ suspend fun listStreams(): ApiResult<List<LogStream>> {
178+ val request = buildRequest("/api/v1/logstream").get().build()
179+ return when (val result = executeRequest(request)) {
180+ is ApiResult.Success -> {
181+ try {
182+ val streams = json.decodeFromString<List<LogStream>>(result.data)
183+ ApiResult.Success(streams)
184+ } catch (e: Exception) {
185+ // Try alternate format: list of objects with "name" key
186+ try {
187+ val parsed = json.parseToJsonElement(result.data)
188+ val arr = (parsed as? JsonArray) ?: throw IllegalArgumentException("Expected JSON array")
189+ val streams = arr.map { element ->
190+ when (element) {
191+ is JsonPrimitive -> LogStream(name = element.content)
192+ is JsonObject -> LogStream(
193+ name = element["name"]?.jsonPrimitive?.content ?: ""
194+ )
195+ else -> LogStream(name = element.toString())
196+ }
197+ }
198+ ApiResult.Success(streams)
199+ } catch (e2: Exception) {
200+ ApiResult.Error("Failed to parse streams: ${e2.message}")
201+ }
202+ }
203+ }
204+ is ApiResult.Error -> result
205+ }
206+ }
207+
208+ /**
209+ * GET /api/v1/logstream/{stream}/schema
210+ */
211+ suspend fun getStreamSchema(stream: String): ApiResult<StreamSchema> {
212+ val encoded = encodePathSegment(stream)
213+ val request = buildRequest("/api/v1/logstream/$encoded/schema").get().build()
214+ return when (val result = executeRequest(request)) {
215+ is ApiResult.Success -> parseResponse(result.data, "schema") {
216+ json.decodeFromString<StreamSchema>(result.data)
217+ }
218+ is ApiResult.Error -> result
219+ }
220+ }
221+
222+ /**
223+ * GET /api/v1/logstream/{stream}/stats
224+ */
225+ suspend fun getStreamStats(stream: String): ApiResult<StreamStats> {
226+ val encoded = encodePathSegment(stream)
227+ val request = buildRequest("/api/v1/logstream/$encoded/stats").get().build()
228+ return when (val result = executeRequest(request)) {
229+ is ApiResult.Success -> parseResponse(result.data, "stats") {
230+ json.decodeFromString<StreamStats>(result.data)
231+ }
232+ is ApiResult.Error -> result
233+ }
234+ }
235+
236+ /**
237+ * GET /api/v1/logstream/{stream}/info
238+ */
239+ suspend fun getStreamInfo(stream: String): ApiResult<JsonObject> {
240+ val encoded = encodePathSegment(stream)
241+ val request = buildRequest("/api/v1/logstream/$encoded/info").get().build()
242+ return when (val result = executeRequest(request)) {
243+ is ApiResult.Success -> parseResponse(result.data, "stream info") {
244+ val element = json.parseToJsonElement(result.data)
245+ (element as? JsonObject)
246+ ?: throw IllegalArgumentException("Expected JSON object, got ${element::class.simpleName}")
247+ }
248+ is ApiResult.Error -> result
249+ }
250+ }
251+
252+ /**
253+ * GET /api/v1/logstream/{stream}/retention
254+ */
255+ suspend fun getStreamRetention(stream: String): ApiResult<List<RetentionConfig>> {
256+ val encoded = encodePathSegment(stream)
257+ val request = buildRequest("/api/v1/logstream/$encoded/retention").get().build()
258+ return when (val result = executeRequest(request)) {
259+ is ApiResult.Success -> {
260+ try {
261+ ApiResult.Success(json.decodeFromString<List<RetentionConfig>>(result.data))
262+ } catch (_: Exception) {
263+ parseResponse(result.data, "retention config") {
264+ listOf(json.decodeFromString<RetentionConfig>(result.data))
265+ }
266+ }
267+ }
268+ is ApiResult.Error -> result
269+ }
270+ }
271+
272+ /**
273+ * POST /api/v1/query - query logs with SQL
274+ */
275+ suspend fun queryLogs(
276+ query: String,
277+ startTime: String,
278+ endTime: String,
279+ ): ApiResult<List<JsonObject>> {
280+ val requestBody = json.encodeToString(
281+ JsonObject.serializer(),
282+ buildJsonObject {
283+ put("query", query)
284+ put("startTime", startTime)
285+ put("endTime", endTime)
286+ }
287+ )
288+
289+ val request = buildRequest("/api/v1/query")
290+ .post(requestBody.toRequestBody("application/json".toMediaType()))
291+ .build()
292+
293+ return when (val result = executeRequest(request)) {
294+ is ApiResult.Success -> parseResponse(result.data, "query results") {
295+ val element = json.parseToJsonElement(result.data)
296+ val elements = (element as? JsonArray)
297+ ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
298+ elements.mapNotNull { it as? JsonObject }
299+ }
300+ is ApiResult.Error -> result
301+ }
302+ }
303+
304+ /**
305+ * DELETE /api/v1/logstream/{stream}
306+ */
307+ suspend fun deleteStream(stream: String): ApiResult<String> {
308+ val encoded = encodePathSegment(stream)
309+ val request = buildRequest("/api/v1/logstream/$encoded").delete().build()
310+ return executeRequest(request)
311+ }
312+
313+ /**
314+ * GET /api/v1/alerts
315+ */
316+ suspend fun listAlerts(): ApiResult<List<Alert>> {
317+ val request = buildRequest("/api/v1/alerts").get().build()
318+ return when (val result = executeRequest(request)) {
319+ is ApiResult.Success -> parseResponse(result.data, "alerts") {
320+ val element = json.parseToJsonElement(result.data)
321+ val alertsArray = when (element) {
322+ is JsonArray -> element
323+ is JsonObject -> element["alerts"]?.jsonArray ?: JsonArray(emptyList())
324+ else -> JsonArray(emptyList())
325+ }
326+ alertsArray.mapNotNull { alertElement ->
327+ try {
328+ json.decodeFromJsonElement<Alert>(alertElement)
329+ } catch (e: Exception) {
330+ Timber.w(e, "Skipping malformed alert entry")
331+ null
332+ }
333+ }
334+ }
335+ is ApiResult.Error -> result
336+ }
337+ }
338+
339+ /**
340+ * DELETE /api/v1/alerts/{alertId}
341+ */
342+ suspend fun deleteAlert(alertId: String): ApiResult<String> {
343+ val encoded = encodePathSegment(alertId)
344+ val request = buildRequest("/api/v1/alerts/$encoded").delete().build()
345+ return executeRequest(request)
346+ }
347+
348+ /**
349+ * POST /api/v1/alerts
350+ */
351+ suspend fun createAlert(alert: Alert): ApiResult<Alert> {
352+ val requestBody = json.encodeToString(Alert.serializer(), alert)
353+ .toRequestBody("application/json".toMediaType())
354+ val request = buildRequest("/api/v1/alerts")
355+ .post(requestBody)
356+ .build()
357+ return when (val result = executeRequest(request)) {
358+ is ApiResult.Success -> parseResponse(result.data, "alert") {
359+ json.decodeFromString<Alert>(result.data)
360+ }
361+ is ApiResult.Error -> result
362+ }
363+ }
364+
365+ /**
366+ * GET /api/v1/user - list users
367+ */
368+ suspend fun listUsers(): ApiResult<List<JsonObject>> {
369+ val request = buildRequest("/api/v1/user").get().build()
370+ return when (val result = executeRequest(request)) {
371+ is ApiResult.Success -> parseResponse(result.data, "users") {
372+ val element = json.parseToJsonElement(result.data)
373+ val elements = (element as? JsonArray)
374+ ?: throw IllegalArgumentException("Expected JSON array, got ${element::class.simpleName}")
375+ elements.mapNotNull { it as? JsonObject }
376+ }
377+ is ApiResult.Error -> result
378+ }
379+ }
380+}
app/src/main/java/com/parseable/android/data/local/FavoriteStream.ktnew file+11
@@ -0,0 +1,11 @@
1+package com.parseable.android.data.local
2+
3+import androidx.room.Entity
4+import androidx.room.PrimaryKey
5+
6+@Entity(tableName = "favorite_streams")
7+data class FavoriteStream(
8+ @PrimaryKey
9+ val streamName: String,
10+ val addedAt: Long = System.currentTimeMillis(),
11+)
app/src/main/java/com/parseable/android/data/local/FavoriteStreamDao.ktnew file+29
@@ -0,0 +1,29 @@
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+ @Query("SELECT streamName FROM favorite_streams")
16+ fun getAllNames(): Flow<List<String>>
17+
18+ @Insert(onConflict = OnConflictStrategy.REPLACE)
19+ suspend fun insert(favorite: FavoriteStream)
20+
21+ @Delete
22+ suspend fun delete(favorite: FavoriteStream)
23+
24+ @Query("DELETE FROM favorite_streams WHERE streamName = :name")
25+ suspend fun deleteByName(name: String)
26+
27+ @Query("SELECT EXISTS(SELECT 1 FROM favorite_streams WHERE streamName = :name)")
28+ fun isFavorite(name: String): Flow<Boolean>
29+}
app/src/main/java/com/parseable/android/data/local/ParseableDatabase.ktnew file+13
@@ -0,0 +1,13 @@
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],
8+ version = 1,
9+ exportSchema = true,
10+)
11+abstract class ParseableDatabase : RoomDatabase() {
12+ abstract fun favoriteStreamDao(): FavoriteStreamDao
13+}
app/src/main/java/com/parseable/android/data/model/Models.ktnew file+162
@@ -0,0 +1,162 @@
1+package com.parseable.android.data.model
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.json.JsonElement
6+import kotlinx.serialization.json.JsonObject
7+
8+/**
9+ * Server connection configuration stored in DataStore.
10+ */
11+data class ServerConfig(
12+ val serverUrl: String,
13+ val username: String,
14+ val password: String,
15+ val useTls: Boolean = true,
16+)
17+
18+/**
19+ * Represents a log stream entry returned by GET /api/v1/logstream
20+ */
21+@Serializable
22+data class LogStream(
23+ val name: String,
24+)
25+
26+/**
27+ * Query request body sent to POST /api/v1/query
28+ */
29+@Serializable
30+data class QueryRequest(
31+ val query: String,
32+ val startTime: String,
33+ val endTime: String,
34+)
35+
36+/**
37+ * Stream stats from GET /api/v1/logstream/{name}/stats
38+ */
39+@Serializable
40+data class StreamStats(
41+ val ingestion: IngestionStats? = null,
42+ val storage: StorageStats? = null,
43+ val stream: String? = null,
44+ val time: String? = null,
45+)
46+
47+@Serializable
48+data class IngestionStats(
49+ val count: Long? = null,
50+ val size: String? = null,
51+ val format: String? = null,
52+ @SerialName("lifetime_count")
53+ val lifetimeCount: Long? = null,
54+ @SerialName("lifetime_size")
55+ val lifetimeSize: String? = null,
56+ @SerialName("deleted_count")
57+ val deletedCount: Long? = null,
58+ @SerialName("deleted_size")
59+ val deletedSize: String? = null,
60+)
61+
62+@Serializable
63+data class StorageStats(
64+ val size: String? = null,
65+ val format: String? = null,
66+ @SerialName("lifetime_size")
67+ val lifetimeSize: String? = null,
68+ @SerialName("deleted_size")
69+ val deletedSize: String? = null,
70+)
71+
72+/**
73+ * Schema field info from GET /api/v1/logstream/{name}/schema
74+ */
75+@Serializable
76+data class StreamSchema(
77+ val fields: List<SchemaField> = emptyList(),
78+)
79+
80+@Serializable
81+data class SchemaField(
82+ val name: String,
83+ @SerialName("data_type")
84+ val dataType: JsonElement? = null,
85+ val nullable: Boolean? = null,
86+ val dict_id: Long? = null,
87+ val dict_is_ordered: Boolean? = null,
88+ val metadata: JsonObject? = null,
89+)
90+
91+/**
92+ * Retention config from GET /api/v1/logstream/{name}/retention
93+ */
94+@Serializable
95+data class RetentionConfig(
96+ val description: String? = null,
97+ val duration: String? = null,
98+ val action: String? = null,
99+)
100+
101+/**
102+ * About info from GET /api/v1/about
103+ */
104+@Serializable
105+data class AboutInfo(
106+ val version: String? = null,
107+ val commit: String? = null,
108+ @SerialName("deploymentId")
109+ val deploymentId: String? = null,
110+ val license: String? = null,
111+ val mode: String? = null,
112+ val staging: String? = null,
113+ val store: JsonObject? = null,
114+ @SerialName("grpcPort")
115+ val grpcPort: Int? = null,
116+ val analytics: JsonObject? = null,
117+ @SerialName("queryEngine")
118+ val queryEngine: String? = null,
119+)
120+
121+/**
122+ * Alert configuration
123+ */
124+@Serializable
125+data class Alert(
126+ val id: String? = null,
127+ val version: String? = null,
128+ val name: String? = null,
129+ val message: String? = null,
130+ val rule: JsonObject? = null,
131+ val targets: List<JsonObject> = emptyList(),
132+ val stream: String? = null,
133+ val enabled: Boolean? = null,
134+)
135+
136+/**
137+ * Result wrapper for API calls
138+ */
139+sealed class ApiResult<out T> {
140+ data class Success<T>(val data: T) : ApiResult<T>()
141+ data class Error(val message: String, val code: Int = 0) : ApiResult<Nothing>() {
142+ val isUnauthorized: Boolean get() = code == 401
143+ val isNotFound: Boolean get() = code == 404
144+ val isServerError: Boolean get() = code in 500..599
145+ val isNetworkError: Boolean get() = code == 0
146+
147+ val userMessage: String get() = when {
148+ code == 401 -> "Session expired. Please log in again."
149+ code == 403 -> "Permission denied."
150+ code == 404 -> "Resource not found. It may have been deleted."
151+ code == 429 -> "Too many requests. Please wait and try again."
152+ code in 500..599 -> "Server error ($code). Please try again later."
153+ code == 0 -> when {
154+ message.contains("timeout", ignoreCase = true) -> "Connection timed out. Check your network."
155+ message.contains("Unable to resolve host", ignoreCase = true) -> "No internet connection."
156+ message.contains("Connection refused", ignoreCase = true) -> "Server is unreachable."
157+ else -> "Network error: $message"
158+ }
159+ else -> message
160+ }
161+ }
162+}
app/src/main/java/com/parseable/android/data/repository/ParseableRepository.ktnew file+151
@@ -0,0 +1,151 @@
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+ suspend fun getAbout(): ApiResult<AboutInfo> {
70+ aboutCache?.let { if (it.isValid(ABOUT_TTL_MS)) return ApiResult.Success(it.data) }
71+ return checkAuth(apiClient.getAbout()).also { result ->
72+ if (result is ApiResult.Success) {
73+ aboutCache = CacheEntry(result.data)
74+ }
75+ }
76+ }
77+
78+ suspend fun listStreams(forceRefresh: Boolean = false): ApiResult<List<LogStream>> {
79+ if (!forceRefresh) {
80+ streamsCache?.let { if (it.isValid(STREAMS_TTL_MS)) return ApiResult.Success(it.data) }
81+ }
82+ return checkAuth(apiClient.listStreams()).also { result ->
83+ if (result is ApiResult.Success) {
84+ streamsCache = CacheEntry(result.data)
85+ }
86+ }
87+ }
88+
89+ suspend fun getStreamSchema(stream: String, forceRefresh: Boolean = false): ApiResult<StreamSchema> {
90+ if (!forceRefresh) {
91+ schemaCache[stream]?.let { if (it.isValid(SCHEMA_TTL_MS)) return ApiResult.Success(it.data) }
92+ }
93+ return checkAuth(apiClient.getStreamSchema(stream)).also { result ->
94+ if (result is ApiResult.Success) {
95+ schemaCache[stream] = CacheEntry(result.data)
96+ }
97+ }
98+ }
99+
100+ suspend fun getStreamStats(stream: String, forceRefresh: Boolean = false): ApiResult<StreamStats> {
101+ if (!forceRefresh) {
102+ statsCache[stream]?.let { if (it.isValid(STATS_TTL_MS)) return ApiResult.Success(it.data) }
103+ }
104+ return checkAuth(apiClient.getStreamStats(stream)).also { result ->
105+ if (result is ApiResult.Success) {
106+ statsCache[stream] = CacheEntry(result.data)
107+ }
108+ }
109+ }
110+
111+ suspend fun getStreamInfo(stream: String): ApiResult<JsonObject> =
112+ checkAuth(apiClient.getStreamInfo(stream))
113+
114+ suspend fun getStreamRetention(stream: String): ApiResult<List<RetentionConfig>> =
115+ checkAuth(apiClient.getStreamRetention(stream))
116+
117+ suspend fun queryLogs(
118+ stream: String,
119+ startTime: String,
120+ endTime: String,
121+ filterSql: String = "",
122+ limit: Int = 1000,
123+ ): ApiResult<List<JsonObject>> {
124+ val whereClause = if (filterSql.isNotBlank()) " WHERE $filterSql" else ""
125+ val safeName = escapeIdentifier(stream)
126+ val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT $limit"
127+ return checkAuth(apiClient.queryLogs(sql, startTime, endTime))
128+ }
129+
130+ suspend fun queryLogsRaw(
131+ sql: String,
132+ startTime: String,
133+ endTime: String,
134+ ): ApiResult<List<JsonObject>> = checkAuth(apiClient.queryLogs(sql, startTime, endTime))
135+
136+ suspend fun deleteStream(stream: String): ApiResult<String> {
137+ return checkAuth(apiClient.deleteStream(stream)).also { result ->
138+ if (result is ApiResult.Success) invalidateAll()
139+ }
140+ }
141+
142+ suspend fun listAlerts(): ApiResult<List<Alert>> = checkAuth(apiClient.listAlerts())
143+
144+ suspend fun deleteAlert(alertId: String): ApiResult<String> =
145+ checkAuth(apiClient.deleteAlert(alertId))
146+
147+ suspend fun createAlert(alert: Alert): ApiResult<Alert> =
148+ checkAuth(apiClient.createAlert(alert))
149+
150+ suspend fun listUsers(): ApiResult<List<JsonObject>> = checkAuth(apiClient.listUsers())
151+}
app/src/main/java/com/parseable/android/data/repository/SettingsRepository.ktnew file+121
@@ -0,0 +1,121 @@
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.model.ServerConfig
12+import dagger.hilt.android.qualifiers.ApplicationContext
13+import kotlinx.coroutines.Dispatchers
14+import kotlinx.coroutines.flow.Flow
15+import kotlinx.coroutines.flow.catch
16+import kotlinx.coroutines.flow.first
17+import kotlinx.coroutines.flow.flowOn
18+import kotlinx.coroutines.flow.map
19+import kotlinx.coroutines.sync.Mutex
20+import kotlinx.coroutines.sync.withLock
21+import kotlinx.coroutines.withContext
22+import javax.inject.Inject
23+import javax.inject.Singleton
24+
25+private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "parseable_settings")
26+
27+@Singleton
28+class SettingsRepository @Inject constructor(
29+ @ApplicationContext private val context: Context,
30+) {
31+ private val serverUrlKey = stringPreferencesKey("server_url")
32+ private val usernameKey = stringPreferencesKey("username")
33+ private val useTlsKey = booleanPreferencesKey("use_tls")
34+
35+ /** Guards concurrent reads/writes across DataStore and EncryptedSharedPreferences. */
36+ private val configMutex = Mutex()
37+
38+ private val encryptedPrefs: SharedPreferences by lazy {
39+ try {
40+ createEncryptedPrefs()
41+ } catch (e: Exception) {
42+ // Keystore corruption (common after backup/restore). Delete and recreate.
43+ Timber.e(e, "EncryptedSharedPreferences corrupted, resetting")
44+ try {
45+ val prefsFile = java.io.File(context.filesDir.parent, "shared_prefs/parseable_secure_prefs.xml")
46+ if (prefsFile.exists()) prefsFile.delete()
47+ } catch (_: Exception) { /* best-effort cleanup */ }
48+ createEncryptedPrefs()
49+ }
50+ }
51+
52+ private fun createEncryptedPrefs(): SharedPreferences {
53+ val masterKey = MasterKey.Builder(context)
54+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
55+ .build()
56+ return EncryptedSharedPreferences.create(
57+ context,
58+ "parseable_secure_prefs",
59+ masterKey,
60+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
61+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
62+ )
63+ }
64+
65+ val serverConfig: Flow<ServerConfig?> = context.dataStore.data
66+ .catch { e ->
67+ Timber.e(e, "Failed to read DataStore")
68+ emit(emptyPreferences())
69+ }
70+ .map { prefs ->
71+ configMutex.withLock {
72+ val url = prefs[serverUrlKey] ?: return@withLock null
73+ val user = prefs[usernameKey] ?: return@withLock null
74+ val pass = try {
75+ encryptedPrefs.getString("password", null)
76+ } catch (e: Exception) {
77+ Timber.e(e, "Failed to read encrypted password")
78+ null
79+ } ?: return@withLock null
80+ ServerConfig(
81+ serverUrl = url,
82+ username = user,
83+ password = pass,
84+ useTls = prefs[useTlsKey] ?: true,
85+ )
86+ }
87+ }
88+ .flowOn(Dispatchers.IO)
89+
90+ suspend fun getSavedPassword(): ServerConfig? = serverConfig.first()
91+
92+ suspend fun saveServerConfig(config: ServerConfig) {
93+ configMutex.withLock {
94+ try {
95+ context.dataStore.edit { prefs ->
96+ prefs[serverUrlKey] = config.serverUrl
97+ prefs[usernameKey] = config.username
98+ prefs[useTlsKey] = config.useTls
99+ }
100+ withContext(Dispatchers.IO) {
101+ encryptedPrefs.edit().putString("password", config.password).apply()
102+ }
103+ } catch (e: Exception) {
104+ Timber.e(e, "Failed to save server config")
105+ }
106+ }
107+ }
108+
109+ suspend fun clearConfig() {
110+ configMutex.withLock {
111+ try {
112+ context.dataStore.edit { it.clear() }
113+ withContext(Dispatchers.IO) {
114+ encryptedPrefs.edit().clear().apply()
115+ }
116+ } catch (e: Exception) {
117+ Timber.e(e, "Failed to clear config")
118+ }
119+ }
120+ }
121+}
app/src/main/java/com/parseable/android/di/AppModule.ktnew file+29
@@ -0,0 +1,29 @@
1+package com.parseable.android.di
2+
3+import android.content.Context
4+import androidx.room.Room
5+import com.parseable.android.data.local.FavoriteStreamDao
6+import com.parseable.android.data.local.ParseableDatabase
7+import dagger.Module
8+import dagger.Provides
9+import dagger.hilt.InstallIn
10+import dagger.hilt.android.qualifiers.ApplicationContext
11+import dagger.hilt.components.SingletonComponent
12+import javax.inject.Singleton
13+
14+@Module
15+@InstallIn(SingletonComponent::class)
16+object AppModule {
17+
18+ @Provides
19+ @Singleton
20+ fun provideDatabase(@ApplicationContext context: Context): ParseableDatabase =
21+ Room.databaseBuilder(context, ParseableDatabase::class.java, "parseable.db")
22+ .fallbackToDestructiveMigration()
23+ .build()
24+
25+ @Provides
26+ @Singleton
27+ fun provideFavoriteStreamDao(db: ParseableDatabase): FavoriteStreamDao =
28+ db.favoriteStreamDao()
29+}
app/src/main/java/com/parseable/android/ui/ErrorHandler.ktnew file+84
@@ -0,0 +1,84 @@
1+package com.parseable.android.ui
2+
3+import androidx.compose.foundation.layout.Box
4+import androidx.compose.foundation.layout.fillMaxSize
5+import androidx.compose.foundation.layout.padding
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+@Composable
49+fun GlobalErrorBoundary(
50+ errorHandler: ErrorHandler = remember { ErrorHandler() },
51+ content: @Composable () -> Unit,
52+) {
53+ val snackbarHostState = remember { SnackbarHostState() }
54+
55+ // Collect errors and show them as Snackbars
56+ LaunchedEffect(errorHandler) {
57+ errorHandler.errors.collect { message ->
58+ snackbarHostState.showSnackbar(
59+ message = message,
60+ duration = SnackbarDuration.Short,
61+ )
62+ }
63+ }
64+
65+ androidx.compose.runtime.CompositionLocalProvider(
66+ LocalErrorHandler provides errorHandler,
67+ ) {
68+ Scaffold(
69+ snackbarHost = {
70+ SnackbarHost(hostState = snackbarHostState) { data ->
71+ Snackbar(snackbarData = data)
72+ }
73+ },
74+ ) { innerPadding ->
75+ Box(
76+ modifier = Modifier
77+ .fillMaxSize()
78+ .padding(innerPadding),
79+ ) {
80+ content()
81+ }
82+ }
83+ }
84+}
app/src/main/java/com/parseable/android/ui/navigation/NavGraph.ktnew file+176
@@ -0,0 +1,176 @@
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.settings.SettingsScreen
17+import com.parseable.android.ui.screens.streams.StreamsScreen
18+import java.net.URLDecoder
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+
29+ fun login(sessionExpired: Boolean = false) =
30+ if (sessionExpired) "login?sessionExpired=true" else LOGIN
31+ fun logViewer(streamName: String) =
32+ "log_viewer/${URLEncoder.encode(streamName, "UTF-8")}"
33+ fun streamInfo(streamName: String) =
34+ "stream_info/${URLEncoder.encode(streamName, "UTF-8")}"
35+}
36+
37+@Composable
38+fun ParseableNavGraph(
39+ navController: NavHostController,
40+ startDestination: String,
41+ repository: ParseableRepository,
42+) {
43+ NavHost(
44+ navController = navController,
45+ startDestination = startDestination,
46+ ) {
47+ composable(
48+ route = "${Routes.LOGIN}?sessionExpired={sessionExpired}",
49+ arguments = listOf(navArgument("sessionExpired") {
50+ type = NavType.BoolType
51+ defaultValue = false
52+ }),
53+ ) { backStackEntry ->
54+ val sessionExpired = backStackEntry.arguments?.getBoolean("sessionExpired") ?: false
55+ LoginScreen(
56+ onLoginSuccess = {
57+ navController.navigate(Routes.STREAMS) {
58+ popUpTo(Routes.LOGIN) { inclusive = true }
59+ }
60+ },
61+ sessionExpired = sessionExpired,
62+ )
63+ }
64+
65+ composable(
66+ route = Routes.STREAMS,
67+ deepLinks = listOf(navDeepLink { uriPattern = "parseable://streams" }),
68+ ) {
69+ if (!repository.isConfigured) {
70+ LaunchedEffect(Unit) {
71+ navController.navigate(Routes.LOGIN) {
72+ popUpTo(0) { inclusive = true }
73+ }
74+ }
75+ return@composable
76+ }
77+ StreamsScreen(
78+ onStreamClick = { streamName ->
79+ navController.navigate(Routes.logViewer(streamName)) {
80+ launchSingleTop = true
81+ }
82+ },
83+ onAlertsClick = {
84+ navController.navigate(Routes.ALERTS) {
85+ launchSingleTop = true
86+ }
87+ },
88+ onSettingsClick = {
89+ navController.navigate(Routes.SETTINGS) {
90+ launchSingleTop = true
91+ }
92+ },
93+ onLogout = {
94+ navController.navigate(Routes.LOGIN) {
95+ popUpTo(0) { inclusive = true }
96+ }
97+ },
98+ )
99+ }
100+
101+ composable(
102+ route = Routes.LOG_VIEWER,
103+ arguments = listOf(navArgument("streamName") { type = NavType.StringType }),
104+ deepLinks = listOf(navDeepLink { uriPattern = "parseable://stream/{streamName}" }),
105+ ) { backStackEntry ->
106+ // Guard: redirect to login if the API client isn't configured
107+ if (!repository.isConfigured) {
108+ LaunchedEffect(Unit) {
109+ navController.navigate(Routes.LOGIN) {
110+ popUpTo(0) { inclusive = true }
111+ }
112+ }
113+ return@composable
114+ }
115+ val streamName = URLDecoder.decode(
116+ backStackEntry.arguments?.getString("streamName") ?: "", "UTF-8"
117+ )
118+ LogViewerScreen(
119+ streamName = streamName,
120+ onBack = { navController.popBackStack() },
121+ onStreamInfo = { name ->
122+ navController.navigate(Routes.streamInfo(name)) {
123+ launchSingleTop = true
124+ }
125+ },
126+ )
127+ }
128+
129+ composable(
130+ route = Routes.STREAM_INFO,
131+ arguments = listOf(navArgument("streamName") { type = NavType.StringType }),
132+ ) { backStackEntry ->
133+ val streamName = URLDecoder.decode(
134+ backStackEntry.arguments?.getString("streamName") ?: "", "UTF-8"
135+ )
136+ StreamInfoScreen(
137+ streamName = streamName,
138+ onBack = { navController.popBackStack() },
139+ onStreamDeleted = {
140+ navController.popBackStack(Routes.STREAMS, inclusive = false)
141+ },
142+ )
143+ }
144+
145+ composable(
146+ route = Routes.ALERTS,
147+ deepLinks = listOf(navDeepLink { uriPattern = "parseable://alerts" }),
148+ ) {
149+ if (!repository.isConfigured) {
150+ LaunchedEffect(Unit) {
151+ navController.navigate(Routes.LOGIN) {
152+ popUpTo(0) { inclusive = true }
153+ }
154+ }
155+ return@composable
156+ }
157+ AlertsScreen(
158+ onBack = { navController.popBackStack() },
159+ )
160+ }
161+
162+ composable(Routes.SETTINGS) {
163+ if (!repository.isConfigured) {
164+ LaunchedEffect(Unit) {
165+ navController.navigate(Routes.LOGIN) {
166+ popUpTo(0) { inclusive = true }
167+ }
168+ }
169+ return@composable
170+ }
171+ SettingsScreen(
172+ onBack = { navController.popBackStack() },
173+ )
174+ }
175+ }
176+}
app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsScreen.ktnew file+268
@@ -0,0 +1,268 @@
1+package com.parseable.android.ui.screens.alerts
2+
3+import androidx.compose.foundation.layout.*
4+import androidx.compose.foundation.lazy.LazyColumn
5+import androidx.compose.foundation.lazy.itemsIndexed
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.Lifecycle
19+import androidx.lifecycle.compose.LocalLifecycleOwner
20+import androidx.lifecycle.compose.collectAsStateWithLifecycle
21+import androidx.lifecycle.repeatOnLifecycle
22+import com.parseable.android.data.model.Alert
23+
24+@OptIn(ExperimentalMaterial3Api::class)
25+@Composable
26+fun AlertsScreen(
27+ onBack: () -> Unit,
28+ viewModel: AlertsViewModel = hiltViewModel(),
29+) {
30+ val state by viewModel.state.collectAsStateWithLifecycle()
31+
32+ val lifecycleOwner = LocalLifecycleOwner.current
33+ LaunchedEffect(lifecycleOwner) {
34+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
35+ viewModel.refresh()
36+ }
37+ }
38+
39+ Scaffold(
40+ topBar = {
41+ TopAppBar(
42+ title = { Text("Alerts") },
43+ navigationIcon = {
44+ IconButton(onClick = onBack) {
45+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
46+ }
47+ },
48+ )
49+ },
50+ ) { padding ->
51+ PullToRefreshBox(
52+ isRefreshing = state.isLoading,
53+ onRefresh = viewModel::refresh,
54+ modifier = Modifier
55+ .fillMaxSize()
56+ .padding(padding),
57+ ) {
58+ if (state.error != null && state.alerts.isEmpty()) {
59+ Box(
60+ modifier = Modifier.fillMaxSize(),
61+ contentAlignment = Alignment.Center,
62+ ) {
63+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
64+ Icon(
65+ Icons.Filled.ErrorOutline,
66+ contentDescription = "Error loading alerts",
67+ modifier = Modifier.size(48.dp),
68+ tint = MaterialTheme.colorScheme.error,
69+ )
70+ Spacer(modifier = Modifier.height(16.dp))
71+ Text(
72+ text = state.error.orEmpty(),
73+ style = MaterialTheme.typography.bodyLarge,
74+ color = MaterialTheme.colorScheme.error,
75+ )
76+ Spacer(modifier = Modifier.height(16.dp))
77+ OutlinedButton(onClick = viewModel::refresh) {
78+ Text("Retry")
79+ }
80+ }
81+ }
82+ } else if (state.alerts.isEmpty() && !state.isLoading) {
83+ Box(
84+ modifier = Modifier.fillMaxSize(),
85+ contentAlignment = Alignment.Center,
86+ ) {
87+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
88+ Icon(
89+ Icons.Filled.NotificationsOff,
90+ contentDescription = "No alerts",
91+ modifier = Modifier.size(48.dp),
92+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
93+ )
94+ Spacer(modifier = Modifier.height(16.dp))
95+ Text(
96+ text = "No alerts configured",
97+ style = MaterialTheme.typography.bodyLarge,
98+ color = MaterialTheme.colorScheme.onSurfaceVariant,
99+ )
100+ }
101+ }
102+ } else {
103+ LazyColumn(
104+ contentPadding = PaddingValues(16.dp),
105+ verticalArrangement = Arrangement.spacedBy(8.dp),
106+ ) {
107+ itemsIndexed(state.alerts, key = { index, alert -> alert.id ?: alert.name ?: "alert_$index" }) { _, alert ->
108+ AlertCard(
109+ alert = alert,
110+ onDelete = if (alert.id != null) {
111+ { viewModel.requestDelete(alert) }
112+ } else {
113+ null
114+ },
115+ )
116+ }
117+ }
118+ }
119+ }
120+ }
121+
122+ state.alertToDelete?.let { alert ->
123+ AlertDialog(
124+ onDismissRequest = viewModel::cancelDelete,
125+ title = { Text("Delete Alert") },
126+ text = {
127+ Text("Are you sure you want to delete \"${alert.name ?: "this alert"}\"? This action cannot be undone.")
128+ },
129+ confirmButton = {
130+ TextButton(
131+ onClick = { alert.id?.let { viewModel.deleteAlert(it) } },
132+ colors = ButtonDefaults.textButtonColors(
133+ contentColor = MaterialTheme.colorScheme.error,
134+ ),
135+ ) {
136+ Text("Delete")
137+ }
138+ },
139+ dismissButton = {
140+ TextButton(onClick = viewModel::cancelDelete) {
141+ Text("Cancel")
142+ }
143+ },
144+ )
145+ }
146+}
147+
148+@Composable
149+private fun AlertCard(alert: Alert, onDelete: (() -> Unit)?) {
150+ var expanded by remember { mutableStateOf(false) }
151+
152+ Card(
153+ onClick = { expanded = !expanded },
154+ modifier = Modifier.fillMaxWidth(),
155+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
156+ ) {
157+ Column(modifier = Modifier.padding(16.dp)) {
158+ Row(
159+ modifier = Modifier.fillMaxWidth(),
160+ horizontalArrangement = Arrangement.SpaceBetween,
161+ verticalAlignment = Alignment.CenterVertically,
162+ ) {
163+ Row(
164+ verticalAlignment = Alignment.CenterVertically,
165+ modifier = Modifier.weight(1f),
166+ ) {
167+ Icon(
168+ if (alert.enabled == true) Icons.Filled.NotificationsActive
169+ else Icons.Filled.NotificationsOff,
170+ contentDescription = if (alert.enabled == true) "Alert enabled" else "Alert disabled",
171+ tint = if (alert.enabled == true) {
172+ MaterialTheme.colorScheme.primary
173+ } else {
174+ MaterialTheme.colorScheme.onSurfaceVariant
175+ },
176+ modifier = Modifier.size(20.dp),
177+ )
178+ Spacer(modifier = Modifier.width(8.dp))
179+ Text(
180+ text = alert.name ?: "Unnamed Alert",
181+ style = MaterialTheme.typography.titleMedium,
182+ fontWeight = FontWeight.SemiBold,
183+ )
184+ }
185+ Row(verticalAlignment = Alignment.CenterVertically) {
186+ if (alert.enabled != null) {
187+ Text(
188+ text = if (alert.enabled) "Enabled" else "Disabled",
189+ style = MaterialTheme.typography.labelSmall,
190+ color = if (alert.enabled) {
191+ MaterialTheme.colorScheme.primary
192+ } else {
193+ MaterialTheme.colorScheme.onSurfaceVariant
194+ },
195+ )
196+ }
197+ IconButton(
198+ onClick = { onDelete?.invoke() },
199+ enabled = onDelete != null,
200+ ) {
201+ Icon(
202+ Icons.Filled.Delete,
203+ contentDescription = if (onDelete != null) "Delete alert" else "Cannot delete (no ID)",
204+ tint = if (onDelete != null) {
205+ MaterialTheme.colorScheme.error
206+ } else {
207+ MaterialTheme.colorScheme.onSurfaceVariant
208+ },
209+ )
210+ }
211+ }
212+ }
213+
214+ if (alert.message != null) {
215+ Spacer(modifier = Modifier.height(8.dp))
216+ Text(
217+ text = alert.message,
218+ style = MaterialTheme.typography.bodyMedium,
219+ color = MaterialTheme.colorScheme.onSurfaceVariant,
220+ )
221+ }
222+
223+ if (alert.stream != null) {
224+ Spacer(modifier = Modifier.height(4.dp))
225+ Text(
226+ text = "Stream: ${alert.stream}",
227+ style = MaterialTheme.typography.labelMedium,
228+ color = MaterialTheme.colorScheme.onSurfaceVariant,
229+ )
230+ }
231+
232+ if (expanded) {
233+ Spacer(modifier = Modifier.height(8.dp))
234+ HorizontalDivider()
235+ Spacer(modifier = Modifier.height(8.dp))
236+
237+ if (alert.rule != null) {
238+ Text(
239+ text = "Rule:",
240+ style = MaterialTheme.typography.labelMedium,
241+ fontWeight = FontWeight.Bold,
242+ )
243+ Text(
244+ text = alert.rule.toString(),
245+ style = MaterialTheme.typography.bodySmall,
246+ fontFamily = FontFamily.Monospace,
247+ )
248+ }
249+
250+ if (alert.targets.isNotEmpty()) {
251+ Spacer(modifier = Modifier.height(8.dp))
252+ Text(
253+ text = "Targets: ${alert.targets.size}",
254+ style = MaterialTheme.typography.labelMedium,
255+ fontWeight = FontWeight.Bold,
256+ )
257+ alert.targets.forEach { target ->
258+ Text(
259+ text = target.toString(),
260+ style = MaterialTheme.typography.bodySmall,
261+ fontFamily = FontFamily.Monospace,
262+ )
263+ }
264+ }
265+ }
266+ }
267+ }
268+}
app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsViewModel.ktnew file+64
@@ -0,0 +1,64 @@
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.flow.MutableStateFlow
10+import kotlinx.coroutines.flow.StateFlow
11+import kotlinx.coroutines.flow.asStateFlow
12+import kotlinx.coroutines.flow.update
13+import kotlinx.coroutines.launch
14+import javax.inject.Inject
15+
16+data class AlertsState(
17+ val alerts: List<Alert> = emptyList(),
18+ val isLoading: Boolean = false,
19+ val error: String? = null,
20+ val alertToDelete: Alert? = null,
21+)
22+
23+@HiltViewModel
24+class AlertsViewModel @Inject constructor(
25+ private val repository: ParseableRepository,
26+) : ViewModel() {
27+
28+ private val _state = MutableStateFlow(AlertsState())
29+ val state: StateFlow<AlertsState> = _state.asStateFlow()
30+
31+ fun refresh() {
32+ viewModelScope.launch {
33+ _state.update { it.copy(isLoading = true, error = null) }
34+ when (val result = repository.listAlerts()) {
35+ is ApiResult.Success -> {
36+ _state.update { it.copy(alerts = result.data, isLoading = false) }
37+ }
38+ is ApiResult.Error -> {
39+ _state.update { it.copy(isLoading = false, error = result.userMessage) }
40+ }
41+ }
42+ }
43+ }
44+
45+ fun requestDelete(alert: Alert) {
46+ _state.update { it.copy(alertToDelete = alert) }
47+ }
48+
49+ fun cancelDelete() {
50+ _state.update { it.copy(alertToDelete = null) }
51+ }
52+
53+ fun deleteAlert(alertId: String) {
54+ viewModelScope.launch {
55+ _state.update { it.copy(alertToDelete = null, isLoading = true, error = null) }
56+ when (val result = repository.deleteAlert(alertId)) {
57+ is ApiResult.Success -> refresh()
58+ is ApiResult.Error -> {
59+ _state.update { it.copy(isLoading = false, error = result.userMessage) }
60+ }
61+ }
62+ }
63+ }
64+}
app/src/main/java/com/parseable/android/ui/screens/login/LoginScreen.ktnew file+248
@@ -0,0 +1,248 @@
1+package com.parseable.android.ui.screens.login
2+
3+import androidx.compose.foundation.layout.*
4+import androidx.compose.foundation.rememberScrollState
5+import androidx.compose.foundation.text.KeyboardActions
6+import androidx.compose.foundation.text.KeyboardOptions
7+import androidx.compose.foundation.verticalScroll
8+import androidx.compose.material.icons.Icons
9+import androidx.compose.material.icons.filled.Visibility
10+import androidx.compose.material.icons.filled.VisibilityOff
11+import androidx.compose.material3.*
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.focus.FocusDirection
17+import androidx.compose.ui.platform.LocalFocusManager
18+import androidx.compose.ui.text.font.FontWeight
19+import com.parseable.android.ui.LocalErrorHandler
20+import androidx.compose.ui.text.input.ImeAction
21+import androidx.compose.ui.text.input.KeyboardType
22+import androidx.compose.ui.text.input.PasswordVisualTransformation
23+import androidx.compose.ui.text.input.VisualTransformation
24+import androidx.compose.ui.unit.dp
25+import androidx.hilt.navigation.compose.hiltViewModel
26+import androidx.lifecycle.compose.collectAsStateWithLifecycle
27+
28+@OptIn(ExperimentalMaterial3Api::class)
29+@Composable
30+fun LoginScreen(
31+ onLoginSuccess: () -> Unit,
32+ sessionExpired: Boolean = false,
33+ viewModel: LoginViewModel = hiltViewModel(),
34+) {
35+ val state by viewModel.state.collectAsStateWithLifecycle()
36+ val focusManager = LocalFocusManager.current
37+ var passwordVisible by rememberSaveable { mutableStateOf(false) }
38+ val errorHandler = LocalErrorHandler.current
39+
40+ LaunchedEffect(state.loginSuccess) {
41+ if (state.loginSuccess) {
42+ onLoginSuccess()
43+ }
44+ }
45+
46+ LaunchedEffect(sessionExpired) {
47+ if (sessionExpired) {
48+ errorHandler.showError("Session expired. Please log in again.")
49+ }
50+ }
51+
52+ Scaffold { padding ->
53+ Column(
54+ modifier = Modifier
55+ .fillMaxSize()
56+ .padding(padding)
57+ .verticalScroll(rememberScrollState())
58+ .padding(16.dp),
59+ horizontalAlignment = Alignment.CenterHorizontally,
60+ verticalArrangement = Arrangement.Center,
61+ ) {
62+ Text(
63+ text = "Parseable",
64+ style = MaterialTheme.typography.headlineLarge,
65+ fontWeight = FontWeight.Bold,
66+ color = MaterialTheme.colorScheme.primary,
67+ )
68+
69+ Spacer(modifier = Modifier.height(8.dp))
70+
71+ Text(
72+ text = "Log Viewer",
73+ style = MaterialTheme.typography.titleMedium,
74+ color = MaterialTheme.colorScheme.onSurfaceVariant,
75+ )
76+
77+ Spacer(modifier = Modifier.height(48.dp))
78+
79+ OutlinedTextField(
80+ value = state.serverUrl,
81+ onValueChange = viewModel::onServerUrlChange,
82+ label = { Text("Server URL") },
83+ placeholder = { Text("https://demo.parseable.com") },
84+ singleLine = true,
85+ modifier = Modifier.fillMaxWidth(),
86+ keyboardOptions = KeyboardOptions(
87+ keyboardType = KeyboardType.Uri,
88+ imeAction = ImeAction.Next,
89+ ),
90+ keyboardActions = KeyboardActions(
91+ onNext = { focusManager.moveFocus(FocusDirection.Down) },
92+ ),
93+ isError = state.serverUrlError != null,
94+ supportingText = state.serverUrlError?.let { { Text(it) } },
95+ )
96+
97+ Spacer(modifier = Modifier.height(12.dp))
98+
99+ OutlinedTextField(
100+ value = state.username,
101+ onValueChange = viewModel::onUsernameChange,
102+ label = { Text("Username") },
103+ singleLine = true,
104+ modifier = Modifier.fillMaxWidth(),
105+ keyboardOptions = KeyboardOptions(
106+ imeAction = ImeAction.Next,
107+ ),
108+ keyboardActions = KeyboardActions(
109+ onNext = { focusManager.moveFocus(FocusDirection.Down) },
110+ ),
111+ isError = state.usernameError != null,
112+ supportingText = state.usernameError?.let { { Text(it) } },
113+ )
114+
115+ Spacer(modifier = Modifier.height(12.dp))
116+
117+ OutlinedTextField(
118+ value = state.password,
119+ onValueChange = viewModel::onPasswordChange,
120+ label = { Text("Password") },
121+ singleLine = true,
122+ modifier = Modifier.fillMaxWidth(),
123+ visualTransformation = if (passwordVisible) {
124+ VisualTransformation.None
125+ } else {
126+ PasswordVisualTransformation()
127+ },
128+ trailingIcon = {
129+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
130+ Icon(
131+ imageVector = if (passwordVisible) {
132+ Icons.Filled.VisibilityOff
133+ } else {
134+ Icons.Filled.Visibility
135+ },
136+ contentDescription = if (passwordVisible) "Hide password" else "Show password",
137+ )
138+ }
139+ },
140+ keyboardOptions = KeyboardOptions(
141+ keyboardType = KeyboardType.Password,
142+ imeAction = ImeAction.Done,
143+ ),
144+ keyboardActions = KeyboardActions(
145+ onDone = {
146+ focusManager.clearFocus()
147+ viewModel.onLogin()
148+ },
149+ ),
150+ isError = state.passwordError != null,
151+ supportingText = state.passwordError?.let { { Text(it) } },
152+ )
153+
154+ Spacer(modifier = Modifier.height(8.dp))
155+
156+ Row(
157+ modifier = Modifier.fillMaxWidth(),
158+ verticalAlignment = Alignment.CenterVertically,
159+ ) {
160+ Checkbox(
161+ checked = state.allowInsecure,
162+ onCheckedChange = viewModel::onAllowInsecureChange,
163+ )
164+ Text(
165+ text = "Allow insecure (HTTP) connections",
166+ style = MaterialTheme.typography.bodyMedium,
167+ )
168+ }
169+
170+ if (state.allowInsecure) {
171+ val isPrivateNetwork = remember(state.serverUrl) {
172+ val host = state.serverUrl
173+ .removePrefix("http://").removePrefix("https://")
174+ .substringBefore("/").substringBefore(":")
175+ host.isBlank() || host == "localhost" || host.startsWith("127.") ||
176+ host.startsWith("10.") || host.startsWith("192.168.") ||
177+ host.matches(Regex("^172\\.(1[6-9]|2[0-9]|3[01])\\..*"))
178+ }
179+ if (!isPrivateNetwork) {
180+ Card(
181+ colors = CardDefaults.cardColors(
182+ containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f),
183+ ),
184+ modifier = Modifier.fillMaxWidth(),
185+ ) {
186+ Text(
187+ text = "Warning: HTTP sends credentials and data in plaintext. " +
188+ "Only use this for trusted private networks.",
189+ modifier = Modifier.padding(12.dp),
190+ color = MaterialTheme.colorScheme.onErrorContainer,
191+ style = MaterialTheme.typography.bodySmall,
192+ )
193+ }
194+ }
195+ }
196+
197+ Spacer(modifier = Modifier.height(24.dp))
198+
199+ Button(
200+ onClick = viewModel::onLogin,
201+ modifier = Modifier
202+ .fillMaxWidth()
203+ .defaultMinSize(minHeight = 50.dp),
204+ enabled = !state.isLoading,
205+ ) {
206+ if (state.isLoading) {
207+ CircularProgressIndicator(
208+ modifier = Modifier.size(20.dp),
209+ strokeWidth = 2.dp,
210+ color = MaterialTheme.colorScheme.onPrimary,
211+ )
212+ Spacer(modifier = Modifier.width(8.dp))
213+ }
214+ Text("Connect")
215+ }
216+
217+ if (state.hasSavedCredentials && state.password.isEmpty()) {
218+ Spacer(modifier = Modifier.height(8.dp))
219+ OutlinedButton(
220+ onClick = viewModel::loginWithSavedCredentials,
221+ modifier = Modifier
222+ .fillMaxWidth()
223+ .defaultMinSize(minHeight = 50.dp),
224+ enabled = !state.isLoading,
225+ ) {
226+ Text("Use saved credentials")
227+ }
228+ }
229+
230+ state.error?.let { errorText ->
231+ Spacer(modifier = Modifier.height(16.dp))
232+ Card(
233+ colors = CardDefaults.cardColors(
234+ containerColor = MaterialTheme.colorScheme.errorContainer,
235+ ),
236+ modifier = Modifier.fillMaxWidth(),
237+ ) {
238+ Text(
239+ text = errorText,
240+ modifier = Modifier.padding(16.dp),
241+ color = MaterialTheme.colorScheme.onErrorContainer,
242+ style = MaterialTheme.typography.bodyMedium,
243+ )
244+ }
245+ }
246+ }
247+ }
248+}
app/src/main/java/com/parseable/android/ui/screens/login/LoginViewModel.ktnew file+180
@@ -0,0 +1,180 @@
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.flow.MutableStateFlow
11+import kotlinx.coroutines.flow.StateFlow
12+import kotlinx.coroutines.flow.asStateFlow
13+import kotlinx.coroutines.flow.first
14+import kotlinx.coroutines.flow.update
15+import kotlinx.coroutines.launch
16+import javax.inject.Inject
17+
18+data class LoginState(
19+ val serverUrl: String = "",
20+ val username: String = "",
21+ val password: String = "",
22+ val allowInsecure: Boolean = false,
23+ val isLoading: Boolean = false,
24+ val error: String? = null,
25+ val loginSuccess: Boolean = false,
26+ val serverUrlError: String? = null,
27+ val usernameError: String? = null,
28+ val passwordError: String? = null,
29+ val hasSavedCredentials: Boolean = false,
30+)
31+
32+@HiltViewModel
33+class LoginViewModel @Inject constructor(
34+ private val repository: ParseableRepository,
35+ private val settingsRepository: SettingsRepository,
36+) : ViewModel() {
37+
38+ private val _state = MutableStateFlow(LoginState())
39+ val state: StateFlow<LoginState> = _state.asStateFlow()
40+
41+ init {
42+ viewModelScope.launch {
43+ val config = settingsRepository.serverConfig.first()
44+ if (config != null) {
45+ _state.update {
46+ it.copy(
47+ serverUrl = config.serverUrl,
48+ username = config.username,
49+ allowInsecure = !config.useTls,
50+ hasSavedCredentials = true,
51+ )
52+ }
53+ }
54+ }
55+ }
56+
57+ fun loginWithSavedCredentials() {
58+ viewModelScope.launch {
59+ val config = settingsRepository.getSavedPassword() ?: return@launch
60+ performLogin(config.password)
61+ }
62+ }
63+
64+ fun onServerUrlChange(value: String) {
65+ _state.update { it.copy(serverUrl = value, serverUrlError = null, error = null) }
66+ }
67+
68+ fun onUsernameChange(value: String) {
69+ _state.update { it.copy(username = value, usernameError = null, error = null) }
70+ }
71+
72+ fun onPasswordChange(value: String) {
73+ _state.update { it.copy(password = value, passwordError = null, error = null) }
74+ }
75+
76+ fun onAllowInsecureChange(value: Boolean) {
77+ _state.update { it.copy(allowInsecure = value) }
78+ }
79+
80+ fun onLogin() {
81+ val current = _state.value
82+
83+ // Validate
84+ var hasError = false
85+ var urlError: String? = null
86+ var userError: String? = null
87+ var passError: String? = null
88+
89+ if (current.serverUrl.isBlank()) {
90+ urlError = "Server URL is required"
91+ hasError = true
92+ }
93+ if (current.username.isBlank()) {
94+ userError = "Username is required"
95+ hasError = true
96+ }
97+ if (current.password.isBlank()) {
98+ passError = "Password is required"
99+ hasError = true
100+ }
101+
102+ if (hasError) {
103+ _state.update {
104+ it.copy(
105+ serverUrlError = urlError,
106+ usernameError = userError,
107+ passwordError = passError,
108+ )
109+ }
110+ return
111+ }
112+
113+ viewModelScope.launch {
114+ performLogin(current.password)
115+ }
116+ }
117+
118+ private suspend fun performLogin(password: String) {
119+ val current = _state.value
120+ _state.update { it.copy(isLoading = true, error = null) }
121+
122+ var url = current.serverUrl.trim()
123+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
124+ url = if (current.allowInsecure) "http://$url" else "https://$url"
125+ }
126+
127+ val isValidUrl = try {
128+ val uri = java.net.URI(url)
129+ uri.scheme != null && !uri.host.isNullOrBlank() && !uri.host!!.contains(" ")
130+ } catch (_: Exception) {
131+ false
132+ }
133+ if (!isValidUrl) {
134+ _state.update {
135+ it.copy(
136+ isLoading = false,
137+ serverUrlError = "Invalid URL format",
138+ )
139+ }
140+ return
141+ }
142+
143+ val config = ServerConfig(
144+ serverUrl = url,
145+ username = current.username.trim(),
146+ password = password,
147+ useTls = url.startsWith("https"),
148+ )
149+
150+ repository.configure(config)
151+
152+ when (val result = repository.testConnection()) {
153+ is ApiResult.Success -> {
154+ // Verify this is actually a Parseable server by fetching /about
155+ when (val aboutResult = repository.getAbout()) {
156+ is ApiResult.Success -> {
157+ settingsRepository.saveServerConfig(config)
158+ _state.update { it.copy(isLoading = false, loginSuccess = true, password = "") }
159+ }
160+ is ApiResult.Error -> {
161+ _state.update {
162+ it.copy(
163+ isLoading = false,
164+ error = "Server responded but doesn't appear to be Parseable: ${aboutResult.userMessage}",
165+ )
166+ }
167+ }
168+ }
169+ }
170+ is ApiResult.Error -> {
171+ _state.update {
172+ it.copy(
173+ isLoading = false,
174+ error = "Connection failed: ${result.userMessage}",
175+ )
176+ }
177+ }
178+ }
179+ }
180+}
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.ktnew file+994
@@ -0,0 +1,994 @@
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 expandedLogKey by remember { mutableStateOf<String?>(null) }
60+ var showDateRangePicker by remember { mutableStateOf(false) }
61+ var showShareWarning by remember { mutableStateOf(false) }
62+
63+ LaunchedEffect(streamName) {
64+ viewModel.initialize(streamName)
65+ }
66+
67+ Scaffold(
68+ snackbarHost = { SnackbarHost(snackbarHostState) },
69+ topBar = {
70+ TopAppBar(
71+ title = {
72+ Column {
73+ Text(
74+ text = streamName,
75+ fontWeight = FontWeight.Bold,
76+ maxLines = 1,
77+ overflow = TextOverflow.Ellipsis,
78+ )
79+ Text(
80+ text = if (state.isStreaming) {
81+ "Live - ${state.logs.size} logs (+${state.streamingNewCount} new)"
82+ } else {
83+ "${state.logs.size} results"
84+ },
85+ style = MaterialTheme.typography.bodySmall,
86+ color = if (state.isStreaming) {
87+ MaterialTheme.colorScheme.primary
88+ } else {
89+ MaterialTheme.colorScheme.onSurfaceVariant
90+ },
91+ )
92+ }
93+ },
94+ navigationIcon = {
95+ IconButton(onClick = onBack) {
96+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
97+ }
98+ },
99+ actions = {
100+ StreamingToggleButton(
101+ isStreaming = state.isStreaming,
102+ onClick = viewModel::toggleStreaming,
103+ )
104+ IconButton(onClick = { onStreamInfo(streamName) }) {
105+ Icon(Icons.Filled.Info, contentDescription = "Stream Info")
106+ }
107+ IconButton(onClick = { showFilterSheet = true }) {
108+ Icon(Icons.Filled.FilterList, contentDescription = "Filters")
109+ }
110+ IconButton(onClick = { showSqlSheet = true }) {
111+ Icon(Icons.Filled.Code, contentDescription = "SQL Query")
112+ }
113+ IconButton(
114+ onClick = { showShareWarning = true },
115+ enabled = state.logs.isNotEmpty(),
116+ ) {
117+ Icon(Icons.Filled.Share, contentDescription = "Share logs")
118+ }
119+ },
120+ )
121+ },
122+ ) { padding ->
123+ Column(
124+ modifier = Modifier
125+ .fillMaxSize()
126+ .padding(padding),
127+ ) {
128+ // Time range chips
129+ TimeRangeBar(
130+ selectedRange = state.selectedTimeRange,
131+ onRangeSelected = { range ->
132+ if (range == TimeRange.CUSTOM) {
133+ showDateRangePicker = true
134+ } else {
135+ viewModel.onTimeRangeChange(range)
136+ }
137+ },
138+ )
139+
140+ // Quick search bar
141+ OutlinedTextField(
142+ value = state.searchQuery,
143+ onValueChange = viewModel::onSearchQueryChange,
144+ placeholder = { Text("Search logs...") },
145+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") },
146+ trailingIcon = {
147+ if (state.isSearching) {
148+ CircularProgressIndicator(
149+ modifier = Modifier.size(20.dp),
150+ strokeWidth = 2.dp,
151+ )
152+ } else if (state.searchQuery.isNotEmpty()) {
153+ IconButton(onClick = { viewModel.onSearchQueryChange("") }) {
154+ Icon(Icons.Filled.Clear, contentDescription = "Clear")
155+ }
156+ }
157+ },
158+ singleLine = true,
159+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
160+ keyboardActions = KeyboardActions(onSearch = {
161+ keyboardController?.hide()
162+ viewModel.refresh()
163+ }),
164+ modifier = Modifier
165+ .fillMaxWidth()
166+ .padding(horizontal = 16.dp, vertical = 4.dp),
167+ textStyle = MaterialTheme.typography.bodyMedium,
168+ )
169+
170+ // Active filters indicator
171+ if (state.activeFilters.isNotEmpty()) {
172+ Row(
173+ modifier = Modifier
174+ .horizontalScroll(rememberScrollState())
175+ .padding(horizontal = 16.dp, vertical = 4.dp)
176+ .animateContentSize(),
177+ horizontalArrangement = Arrangement.spacedBy(8.dp),
178+ ) {
179+ state.activeFilters.forEach { filter ->
180+ FilterChip(
181+ selected = true,
182+ onClick = { viewModel.removeFilter(filter) },
183+ label = { Text(filter, maxLines = 1) },
184+ trailingIcon = {
185+ Icon(
186+ Icons.Filled.Close,
187+ contentDescription = "Remove filter",
188+ modifier = Modifier.size(16.dp),
189+ )
190+ },
191+ )
192+ }
193+ TextButton(onClick = viewModel::clearFilters) {
194+ Text("Clear all")
195+ }
196+ }
197+ }
198+
199+ // Streaming indicator banner
200+ AnimatedVisibility(visible = state.isStreaming) {
201+ Surface(
202+ color = MaterialTheme.colorScheme.primaryContainer,
203+ modifier = Modifier.fillMaxWidth(),
204+ ) {
205+ Row(
206+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
207+ verticalAlignment = Alignment.CenterVertically,
208+ ) {
209+ val infiniteTransition = rememberInfiniteTransition(label = "pulse")
210+ val alpha by infiniteTransition.animateFloat(
211+ initialValue = 1f,
212+ targetValue = 0.3f,
213+ animationSpec = infiniteRepeatable(
214+ animation = tween(800, easing = EaseInOut),
215+ repeatMode = RepeatMode.Reverse,
216+ ),
217+ label = "pulse_alpha",
218+ )
219+ Icon(
220+ Icons.Filled.FiberManualRecord,
221+ contentDescription = "Live streaming active",
222+ tint = MaterialTheme.colorScheme.error,
223+ modifier = Modifier
224+ .size(12.dp)
225+ .alpha(alpha),
226+ )
227+ Spacer(modifier = Modifier.width(8.dp))
228+ Text(
229+ text = "Live tail active - polling every ${state.currentIntervalMs / 1000}s",
230+ style = MaterialTheme.typography.labelMedium,
231+ color = MaterialTheme.colorScheme.onPrimaryContainer,
232+ modifier = Modifier.weight(1f),
233+ )
234+ TextButton(onClick = viewModel::stopStreaming) {
235+ Text("Stop")
236+ }
237+ }
238+ }
239+ }
240+
241+ // Streaming error banner
242+ AnimatedVisibility(visible = state.streamingError != null) {
243+ Surface(
244+ color = MaterialTheme.colorScheme.errorContainer,
245+ modifier = Modifier.fillMaxWidth(),
246+ ) {
247+ Row(
248+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
249+ verticalAlignment = Alignment.CenterVertically,
250+ ) {
251+ Icon(
252+ Icons.Filled.ErrorOutline,
253+ contentDescription = "Streaming error",
254+ tint = MaterialTheme.colorScheme.onErrorContainer,
255+ modifier = Modifier.size(16.dp),
256+ )
257+ Spacer(modifier = Modifier.width(8.dp))
258+ Text(
259+ text = state.streamingError ?: "",
260+ style = MaterialTheme.typography.labelMedium,
261+ color = MaterialTheme.colorScheme.onErrorContainer,
262+ modifier = Modifier.weight(1f),
263+ maxLines = 1,
264+ overflow = TextOverflow.Ellipsis,
265+ )
266+ TextButton(onClick = viewModel::dismissStreamingError) {
267+ Text("Dismiss")
268+ }
269+ }
270+ }
271+ }
272+
273+ // Log entries
274+ PullToRefreshBox(
275+ isRefreshing = state.isLoading,
276+ onRefresh = viewModel::refresh,
277+ modifier = Modifier.fillMaxSize(),
278+ ) {
279+ if (state.error != null && state.logs.isEmpty()) {
280+ Box(
281+ modifier = Modifier.fillMaxSize(),
282+ contentAlignment = Alignment.Center,
283+ ) {
284+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
285+ Icon(
286+ Icons.Filled.ErrorOutline,
287+ contentDescription = "Error loading logs",
288+ modifier = Modifier.size(48.dp),
289+ tint = MaterialTheme.colorScheme.error,
290+ )
291+ Spacer(modifier = Modifier.height(16.dp))
292+ Text(
293+ text = state.error.orEmpty(),
294+ style = MaterialTheme.typography.bodyMedium,
295+ color = MaterialTheme.colorScheme.error,
296+ modifier = Modifier.padding(horizontal = 32.dp),
297+ )
298+ Spacer(modifier = Modifier.height(16.dp))
299+ OutlinedButton(onClick = viewModel::refresh) {
300+ Text("Retry")
301+ }
302+ }
303+ }
304+ } else if (state.logs.isEmpty() && !state.isLoading) {
305+ val emptyMessage = when {
306+ state.isStreaming ->
307+ "Waiting for new logs\u2026"
308+ state.searchQuery.isNotBlank() && state.activeFilters.isNotEmpty() ->
309+ "No logs match \"${state.searchQuery}\" with the active filters"
310+ state.searchQuery.isNotBlank() ->
311+ "No logs match \"${state.searchQuery}\""
312+ state.activeFilters.isNotEmpty() ->
313+ "No logs match the active filters"
314+ else ->
315+ "No logs found for the selected time range"
316+ }
317+ Box(
318+ modifier = Modifier.fillMaxSize(),
319+ contentAlignment = Alignment.Center,
320+ ) {
321+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
322+ Icon(
323+ Icons.Filled.SearchOff,
324+ contentDescription = "No logs found",
325+ modifier = Modifier.size(48.dp),
326+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
327+ )
328+ Spacer(modifier = Modifier.height(16.dp))
329+ Text(
330+ text = emptyMessage,
331+ style = MaterialTheme.typography.bodyLarge,
332+ color = MaterialTheme.colorScheme.onSurfaceVariant,
333+ modifier = Modifier.padding(horizontal = 32.dp),
334+ )
335+ }
336+ }
337+ } else {
338+ val listState = rememberLazyListState()
339+ val clipboard = remember {
340+ context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
341+ }
342+
343+ // Auto-load more when scrolling near the bottom
344+ if (state.hasMore && !state.isLoading) {
345+ val shouldLoadMore by remember {
346+ derivedStateOf {
347+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
348+ lastVisible >= listState.layoutInfo.totalItemsCount - 5
349+ }
350+ }
351+ LaunchedEffect(shouldLoadMore) {
352+ if (shouldLoadMore) viewModel.loadMore()
353+ }
354+ }
355+
356+ // Pre-compute stable keys so each log is assigned exactly one key
357+ val logKeys = remember(state.logs) {
358+ stableLogKeys(state.logs)
359+ }
360+
361+ LazyColumn(
362+ state = listState,
363+ contentPadding = PaddingValues(8.dp),
364+ verticalArrangement = Arrangement.spacedBy(4.dp),
365+ ) {
366+ itemsIndexed(
367+ state.logs,
368+ key = { index, _ -> logKeys[index] },
369+ ) { index, logEntry ->
370+ val key = logKeys[index]
371+ LogEntryCard(
372+ logEntry = logEntry,
373+ isExpanded = expandedLogKey == key,
374+ onClick = {
375+ expandedLogKey = if (expandedLogKey == key) null else key
376+ },
377+ clipboardManager = clipboard,
378+ onCopied = {
379+ scope.launch {
380+ snackbarHostState.showSnackbar(
381+ message = "Log entry copied to clipboard",
382+ duration = SnackbarDuration.Short,
383+ )
384+ }
385+ },
386+ )
387+ }
388+
389+ if (state.hasMore) {
390+ item {
391+ Box(
392+ modifier = Modifier
393+ .fillMaxWidth()
394+ .padding(16.dp),
395+ contentAlignment = Alignment.Center,
396+ ) {
397+ CircularProgressIndicator(
398+ modifier = Modifier.size(24.dp),
399+ strokeWidth = 2.dp,
400+ )
401+ }
402+ }
403+ } else if (state.logs.isNotEmpty()) {
404+ item {
405+ Box(
406+ modifier = Modifier
407+ .fillMaxWidth()
408+ .padding(16.dp),
409+ contentAlignment = Alignment.Center,
410+ ) {
411+ val atCap = state.currentLimit >= 5000 && state.logs.size >= 5000
412+ Text(
413+ text = if (atCap) {
414+ "Showing max 5,000 results. Refine your query to see more."
415+ } else {
416+ "End of results"
417+ },
418+ style = MaterialTheme.typography.labelMedium,
419+ color = if (atCap) {
420+ MaterialTheme.colorScheme.primary
421+ } else {
422+ MaterialTheme.colorScheme.onSurfaceVariant
423+ },
424+ )
425+ }
426+ }
427+ }
428+ }
429+ }
430+ }
431+ }
432+ }
433+
434+ // Filter bottom sheet
435+ if (showFilterSheet) {
436+ FilterBottomSheet(
437+ columns = state.columns,
438+ onDismiss = { showFilterSheet = false },
439+ onApplyFilter = { column, operator, value ->
440+ viewModel.addFilter(column, operator, value)
441+ showFilterSheet = false
442+ },
443+ )
444+ }
445+
446+ // SQL query bottom sheet
447+ if (showSqlSheet) {
448+ SqlQueryBottomSheet(
449+ streamName = streamName,
450+ currentSql = state.customSql,
451+ onDismiss = { showSqlSheet = false },
452+ onExecute = { sql ->
453+ viewModel.executeCustomSql(sql)
454+ showSqlSheet = false
455+ },
456+ )
457+ }
458+
459+ // Share confirmation dialog (logs may contain sensitive data)
460+ if (showShareWarning) {
461+ AlertDialog(
462+ onDismissRequest = { showShareWarning = false },
463+ title = { Text("Share Logs") },
464+ text = {
465+ Text("Logs may contain sensitive information (PII, secrets, tokens). " +
466+ "They will be exported as unencrypted JSON. Continue?")
467+ },
468+ confirmButton = {
469+ TextButton(onClick = {
470+ showShareWarning = false
471+ val logs = state.logs
472+ if (logs.isNotEmpty()) {
473+ scope.launch {
474+ try {
475+ val file = withContext(kotlinx.coroutines.Dispatchers.IO) {
476+ val dir = java.io.File(context.cacheDir, "shared_logs")
477+ if (!dir.mkdirs() && !dir.isDirectory) {
478+ throw java.io.IOException("Failed to create shared_logs directory")
479+ }
480+ // Clean up previous exports
481+ dir.listFiles()?.forEach { old -> old.delete() }
482+ val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
483+ val file = java.io.File(dir, "logs_$safeFileName.json")
484+ file.bufferedWriter().use { writer ->
485+ // Use compact JSON for large exports to reduce memory/file size
486+ val encoder = if (logs.size > 1000) Json else prettyJson
487+ writer.write("[")
488+ logs.forEachIndexed { index, log ->
489+ if (index > 0) writer.write(",")
490+ writer.newLine()
491+ writer.write(encoder.encodeToString(JsonObject.serializer(), log))
492+ }
493+ writer.newLine()
494+ writer.write("]")
495+ }
496+ file
497+ }
498+ val uri = FileProvider.getUriForFile(
499+ context,
500+ "${context.packageName}.fileprovider",
501+ file,
502+ )
503+ val sendIntent = Intent().apply {
504+ action = Intent.ACTION_SEND
505+ putExtra(Intent.EXTRA_STREAM, uri)
506+ putExtra(Intent.EXTRA_SUBJECT, "Logs: $streamName")
507+ type = "application/json"
508+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
509+ }
510+ context.startActivity(
511+ Intent.createChooser(sendIntent, "Share logs")
512+ )
513+ } catch (e: Exception) {
514+ snackbarHostState.showSnackbar(
515+ message = "Failed to share logs: ${e.message}",
516+ duration = SnackbarDuration.Short,
517+ )
518+ }
519+ }
520+ }
521+ }) {
522+ Text("Share")
523+ }
524+ },
525+ dismissButton = {
526+ TextButton(onClick = { showShareWarning = false }) {
527+ Text("Cancel")
528+ }
529+ },
530+ )
531+ }
532+
533+ // Custom date range picker – state is created inside the block so it
534+ // resets every time the dialog reopens (no stale selection carried over).
535+ if (showDateRangePicker) {
536+ val dateRangePickerState = rememberDateRangePickerState()
537+ DatePickerDialog(
538+ onDismissRequest = { showDateRangePicker = false },
539+ confirmButton = {
540+ TextButton(
541+ onClick = {
542+ val start = dateRangePickerState.selectedStartDateMillis
543+ val end = dateRangePickerState.selectedEndDateMillis
544+ if (start != null && end != null) {
545+ // DateRangePicker returns midnight UTC for the selected date.
546+ // Adjust to cover the full end day in the user's local timezone:
547+ // Convert to local date, get end-of-day, convert back to UTC millis.
548+ val localZone = java.time.ZoneId.systemDefault()
549+ val endLocalDate = java.time.Instant.ofEpochMilli(end)
550+ .atZone(localZone).toLocalDate()
551+ val endOfDayUtc = endLocalDate.plusDays(1)
552+ .atStartOfDay(localZone)
553+ .toInstant().toEpochMilli() - 1
554+ val startLocalDate = java.time.Instant.ofEpochMilli(start)
555+ .atZone(localZone).toLocalDate()
556+ val startUtc = startLocalDate
557+ .atStartOfDay(localZone)
558+ .toInstant().toEpochMilli()
559+ viewModel.setCustomTimeRange(startUtc, endOfDayUtc)
560+ }
561+ showDateRangePicker = false
562+ },
563+ enabled = dateRangePickerState.selectedStartDateMillis != null &&
564+ dateRangePickerState.selectedEndDateMillis != null,
565+ ) {
566+ Text("Apply")
567+ }
568+ },
569+ dismissButton = {
570+ TextButton(onClick = { showDateRangePicker = false }) {
571+ Text("Cancel")
572+ }
573+ },
574+ ) {
575+ DateRangePicker(
576+ state = dateRangePickerState,
577+ modifier = Modifier.weight(1f),
578+ )
579+ }
580+ }
581+}
582+
583+@Composable
584+private fun TimeRangeBar(
585+ selectedRange: TimeRange,
586+ onRangeSelected: (TimeRange) -> Unit,
587+) {
588+ Row(
589+ modifier = Modifier
590+ .horizontalScroll(rememberScrollState())
591+ .padding(horizontal = 16.dp, vertical = 4.dp),
592+ horizontalArrangement = Arrangement.spacedBy(8.dp),
593+ ) {
594+ TimeRange.entries.forEach { range ->
595+ FilterChip(
596+ selected = selectedRange == range,
597+ onClick = { onRangeSelected(range) },
598+ label = { Text(range.label) },
599+ )
600+ }
601+ }
602+}
603+
604+@Composable
605+private fun StreamingToggleButton(
606+ isStreaming: Boolean,
607+ onClick: () -> Unit,
608+) {
609+ IconButton(onClick = onClick) {
610+ if (isStreaming) {
611+ val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
612+ val alpha by infiniteTransition.animateFloat(
613+ initialValue = 1f,
614+ targetValue = 0.3f,
615+ animationSpec = infiniteRepeatable(
616+ animation = tween(600, easing = EaseInOut),
617+ repeatMode = RepeatMode.Reverse,
618+ ),
619+ label = "stream_btn_alpha",
620+ )
621+ Icon(
622+ Icons.Filled.Stop,
623+ contentDescription = "Stop streaming",
624+ tint = MaterialTheme.colorScheme.error,
625+ modifier = Modifier.alpha(alpha),
626+ )
627+ } else {
628+ Icon(
629+ Icons.Filled.PlayArrow,
630+ contentDescription = "Start live tail",
631+ tint = MaterialTheme.colorScheme.primary,
632+ )
633+ }
634+ }
635+}
636+
637+@Composable
638+fun LogEntryCard(
639+ logEntry: JsonObject,
640+ isExpanded: Boolean,
641+ onClick: () -> Unit,
642+ onCopied: () -> Unit = {},
643+ clipboardManager: android.content.ClipboardManager,
644+) {
645+ val containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
646+ val cardColors = CardDefaults.cardColors(containerColor = containerColor)
647+ Card(
648+ onClick = onClick,
649+ modifier = Modifier.fillMaxWidth(),
650+ colors = cardColors,
651+ ) {
652+ Column(modifier = Modifier.padding(12.dp)) {
653+ // Timestamp row
654+ val rawTimestamp = remember(logEntry) {
655+ try {
656+ logEntry["p_timestamp"]?.jsonPrimitive?.content
657+ ?: logEntry["datetime"]?.jsonPrimitive?.content
658+ ?: ""
659+ } catch (_: Exception) {
660+ ""
661+ }
662+ }
663+ val displayTimestamp = remember(rawTimestamp) {
664+ if (rawTimestamp.isNotEmpty()) formatTimestamp(rawTimestamp) else ""
665+ }
666+
667+ if (displayTimestamp.isNotEmpty()) {
668+ Text(
669+ text = displayTimestamp,
670+ style = MaterialTheme.typography.labelSmall,
671+ color = MaterialTheme.colorScheme.primary,
672+ fontFamily = FontFamily.Monospace,
673+ )
674+ Spacer(modifier = Modifier.height(4.dp))
675+ }
676+
677+ if (!isExpanded) {
678+ // Compact view: show first meaningful fields
679+ val preview = remember(logEntry) {
680+ logEntry.entries
681+ .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
682+ .take(3)
683+ .joinToString(" | ") { "${it.key}: ${formatJsonValue(it.value)}" }
684+ }
685+ Text(
686+ text = preview.ifEmpty { logEntry.toString() },
687+ style = MaterialTheme.typography.bodySmall,
688+ fontFamily = FontFamily.Monospace,
689+ maxLines = 2,
690+ overflow = TextOverflow.Ellipsis,
691+ fontSize = 12.sp,
692+ )
693+ } else {
694+ // Expanded view: show all fields + copy button
695+ Row(
696+ modifier = Modifier.fillMaxWidth(),
697+ horizontalArrangement = Arrangement.End,
698+ ) {
699+ IconButton(
700+ onClick = {
701+ val text = prettyJson.encodeToString(JsonObject.serializer(), logEntry)
702+ try {
703+ clipboardManager.setPrimaryClip(
704+ android.content.ClipData.newPlainText("Log entry", text)
705+ )
706+ onCopied()
707+ } catch (_: SecurityException) {
708+ // Some devices/custom ROMs restrict clipboard access
709+ }
710+ },
711+ ) {
712+ Icon(
713+ Icons.Filled.ContentCopy,
714+ contentDescription = "Copy log entry to clipboard",
715+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
716+ )
717+ }
718+ }
719+ HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
720+ logEntry.entries.forEach { (key, value) ->
721+ Row(
722+ modifier = Modifier
723+ .fillMaxWidth()
724+ .padding(vertical = 2.dp),
725+ ) {
726+ Text(
727+ text = "$key: ",
728+ style = MaterialTheme.typography.bodySmall,
729+ fontWeight = FontWeight.Bold,
730+ fontFamily = FontFamily.Monospace,
731+ color = MaterialTheme.colorScheme.primary,
732+ fontSize = 12.sp,
733+ )
734+ Text(
735+ text = formatJsonValue(value),
736+ style = MaterialTheme.typography.bodySmall,
737+ fontFamily = FontFamily.Monospace,
738+ fontSize = 12.sp,
739+ )
740+ }
741+ }
742+ }
743+ }
744+ }
745+}
746+
747+/**
748+ * Produces deterministic keys for a list of log entries so that expanded state survives
749+ * list mutations (e.g., new logs prepended during streaming). Keys are content-based
750+ * using p_timestamp + p_metadata + p_tags; duplicate content gets a disambiguation suffix.
751+ */
752+private fun stableLogKeys(logs: List<JsonObject>): List<String> {
753+ val seen = mutableMapOf<String, Int>()
754+ return logs.map { log ->
755+ val ts = log["p_timestamp"]?.toString()
756+ val meta = log["p_metadata"]?.toString()
757+ val tag = log["p_tags"]?.toString()
758+ val base = if (ts != null) "$ts|${meta.orEmpty()}|${tag.orEmpty()}" else log.hashCode().toString()
759+ val count = seen.getOrDefault(base, 0)
760+ seen[base] = count + 1
761+ if (count == 0) base else "$base|$count"
762+ }
763+}
764+
765+private fun formatJsonValue(element: JsonElement): String {
766+ return when (element) {
767+ is JsonPrimitive -> element.content
768+ is JsonNull -> "null"
769+ else -> element.toString()
770+ }
771+}
772+
773+@OptIn(ExperimentalMaterial3Api::class)
774+@Composable
775+fun FilterBottomSheet(
776+ columns: List<String>,
777+ onDismiss: () -> Unit,
778+ onApplyFilter: (column: String, operator: String, value: String) -> Unit,
779+) {
780+ var selectedColumn by remember { mutableStateOf(columns.firstOrNull() ?: "") }
781+ var selectedOperator by remember { mutableStateOf("=") }
782+ var filterValue by remember { mutableStateOf("") }
783+ var columnDropdownExpanded by remember { mutableStateOf(false) }
784+ var operatorDropdownExpanded by remember { mutableStateOf(false) }
785+
786+ val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
787+
788+ ModalBottomSheet(onDismissRequest = onDismiss) {
789+ Column(
790+ modifier = Modifier.padding(16.dp),
791+ verticalArrangement = Arrangement.spacedBy(12.dp),
792+ ) {
793+ Text(
794+ text = "Add Filter",
795+ style = MaterialTheme.typography.titleLarge,
796+ fontWeight = FontWeight.Bold,
797+ )
798+
799+ // Column selector
800+ ExposedDropdownMenuBox(
801+ expanded = columnDropdownExpanded,
802+ onExpandedChange = { columnDropdownExpanded = it },
803+ ) {
804+ OutlinedTextField(
805+ value = selectedColumn,
806+ onValueChange = {},
807+ readOnly = true,
808+ label = { Text("Column") },
809+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = columnDropdownExpanded) },
810+ modifier = Modifier
811+ .fillMaxWidth()
812+ .menuAnchor(MenuAnchorType.PrimaryNotEditable),
813+ )
814+ ExposedDropdownMenu(
815+ expanded = columnDropdownExpanded,
816+ onDismissRequest = { columnDropdownExpanded = false },
817+ ) {
818+ columns.forEach { column ->
819+ DropdownMenuItem(
820+ text = { Text(column) },
821+ onClick = {
822+ selectedColumn = column
823+ columnDropdownExpanded = false
824+ },
825+ )
826+ }
827+ }
828+ }
829+
830+ // Operator selector
831+ ExposedDropdownMenuBox(
832+ expanded = operatorDropdownExpanded,
833+ onExpandedChange = { operatorDropdownExpanded = it },
834+ ) {
835+ OutlinedTextField(
836+ value = selectedOperator,
837+ onValueChange = {},
838+ readOnly = true,
839+ label = { Text("Operator") },
840+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = operatorDropdownExpanded) },
841+ modifier = Modifier
842+ .fillMaxWidth()
843+ .menuAnchor(MenuAnchorType.PrimaryNotEditable),
844+ )
845+ ExposedDropdownMenu(
846+ expanded = operatorDropdownExpanded,
847+ onDismissRequest = { operatorDropdownExpanded = false },
848+ ) {
849+ operators.forEach { op ->
850+ DropdownMenuItem(
851+ text = { Text(op) },
852+ onClick = {
853+ selectedOperator = op
854+ operatorDropdownExpanded = false
855+ },
856+ )
857+ }
858+ }
859+ }
860+
861+ // Value input (hidden for IS NULL / IS NOT NULL)
862+ if (selectedOperator !in listOf("IS NULL", "IS NOT NULL")) {
863+ val valueHint = when (selectedOperator) {
864+ "LIKE", "ILIKE" -> "Pattern (% added automatically)"
865+ ">", "<", ">=", "<=" -> "Numeric or date value"
866+ else -> "Exact value"
867+ }
868+ val valueError = when {
869+ filterValue.isNotEmpty() && selectedOperator in listOf(">", "<", ">=", "<=") &&
870+ filterValue.toDoubleOrNull() == null &&
871+ !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*")) ->
872+ "Expected a number or date"
873+ else -> null
874+ }
875+ OutlinedTextField(
876+ value = filterValue,
877+ onValueChange = { filterValue = it },
878+ label = { Text("Value") },
879+ placeholder = { Text(valueHint) },
880+ modifier = Modifier.fillMaxWidth(),
881+ singleLine = true,
882+ isError = valueError != null,
883+ supportingText = valueError?.let { { Text(it) } },
884+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
885+ keyboardActions = KeyboardActions(onDone = {
886+ if (selectedColumn.isNotBlank() && filterValue.isNotEmpty() && valueError == null) {
887+ onApplyFilter(selectedColumn, selectedOperator, filterValue)
888+ }
889+ }),
890+ )
891+ }
892+
893+ val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
894+ filterValue.isNotEmpty() &&
895+ filterValue.toDoubleOrNull() == null &&
896+ !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*"))
897+
898+ Row(
899+ modifier = Modifier.fillMaxWidth(),
900+ horizontalArrangement = Arrangement.End,
901+ verticalAlignment = Alignment.CenterVertically,
902+ ) {
903+ TextButton(onClick = onDismiss) {
904+ Text("Cancel")
905+ }
906+ Spacer(modifier = Modifier.width(8.dp))
907+ Button(
908+ onClick = {
909+ if (selectedColumn.isNotBlank()) {
910+ onApplyFilter(selectedColumn, selectedOperator, filterValue)
911+ }
912+ },
913+ enabled = selectedColumn.isNotBlank() &&
914+ !hasValueError &&
915+ (selectedOperator in listOf("IS NULL", "IS NOT NULL") || filterValue.isNotEmpty()),
916+ ) {
917+ Text("Apply")
918+ }
919+ }
920+
921+ Spacer(modifier = Modifier.height(32.dp))
922+ }
923+ }
924+}
925+
926+@OptIn(ExperimentalMaterial3Api::class)
927+@Composable
928+fun SqlQueryBottomSheet(
929+ streamName: String,
930+ currentSql: String,
931+ onDismiss: () -> Unit,
932+ onExecute: (String) -> Unit,
933+) {
934+ val safeName = remember(streamName) { escapeIdentifier(streamName) }
935+ var sql by remember {
936+ mutableStateOf(
937+ currentSql.ifEmpty {
938+ "SELECT * FROM \"$safeName\" ORDER BY p_timestamp DESC LIMIT 100"
939+ }
940+ )
941+ }
942+
943+ ModalBottomSheet(onDismissRequest = onDismiss) {
944+ Column(
945+ modifier = Modifier.padding(16.dp),
946+ verticalArrangement = Arrangement.spacedBy(12.dp),
947+ ) {
948+ Text(
949+ text = "SQL Query",
950+ style = MaterialTheme.typography.titleLarge,
951+ fontWeight = FontWeight.Bold,
952+ )
953+
954+ Text(
955+ text = "Use PostgreSQL-compatible SQL to query your logs.",
956+ style = MaterialTheme.typography.bodySmall,
957+ color = MaterialTheme.colorScheme.onSurfaceVariant,
958+ )
959+
960+ OutlinedTextField(
961+ value = sql,
962+ onValueChange = { sql = it },
963+ label = { Text("SQL") },
964+ modifier = Modifier
965+ .fillMaxWidth()
966+ .heightIn(min = 120.dp),
967+ textStyle = MaterialTheme.typography.bodyMedium.copy(
968+ fontFamily = FontFamily.Monospace,
969+ ),
970+ maxLines = 8,
971+ )
972+
973+ Row(
974+ modifier = Modifier.fillMaxWidth(),
975+ horizontalArrangement = Arrangement.End,
976+ ) {
977+ TextButton(onClick = onDismiss) {
978+ Text("Cancel")
979+ }
980+ Spacer(modifier = Modifier.width(8.dp))
981+ Button(
982+ onClick = { onExecute(sql) },
983+ enabled = sql.isNotBlank(),
984+ ) {
985+ Icon(Icons.Filled.PlayArrow, contentDescription = "Execute query")
986+ Spacer(modifier = Modifier.width(4.dp))
987+ Text("Execute")
988+ }
989+ }
990+
991+ Spacer(modifier = Modifier.height(32.dp))
992+ }
993+ }
994+}
app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerViewModel.ktnew file+482
@@ -0,0 +1,482 @@
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.escapeSql
7+import com.parseable.android.data.model.ApiResult
8+import com.parseable.android.data.repository.ParseableRepository
9+import dagger.hilt.android.lifecycle.HiltViewModel
10+import kotlinx.coroutines.Job
11+import kotlinx.coroutines.delay
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.isActive
17+import kotlinx.coroutines.launch
18+import kotlinx.serialization.json.JsonObject
19+import kotlinx.serialization.json.jsonPrimitive
20+import java.time.ZoneOffset
21+import java.time.ZonedDateTime
22+import java.time.format.DateTimeFormatter
23+import javax.inject.Inject
24+
25+enum class TimeRange(val label: String, val minutes: Long) {
26+ LAST_5M("5m", 5),
27+ LAST_15M("15m", 15),
28+ LAST_30M("30m", 30),
29+ LAST_1H("1h", 60),
30+ LAST_6H("6h", 360),
31+ LAST_24H("24h", 1440),
32+ LAST_7D("7d", 10080),
33+ LAST_30D("30d", 43200),
34+ CUSTOM("Custom", 0),
35+}
36+
37+data class FilterState(
38+ val searchQuery: String = "",
39+ val activeFilters: List<String> = emptyList(),
40+ val filterClauses: List<String> = emptyList(),
41+ val customSql: String = "",
42+ val isSearching: Boolean = false,
43+)
44+
45+data class StreamingState(
46+ val isStreaming: Boolean = false,
47+ val streamingNewCount: Int = 0,
48+ val streamingError: String? = null,
49+ val currentIntervalMs: Long = 3000L,
50+)
51+
52+data class LogViewerState(
53+ val streamName: String = "",
54+ val logs: List<JsonObject> = emptyList(),
55+ val columns: List<String> = emptyList(),
56+ val isLoading: Boolean = false,
57+ val error: String? = null,
58+ val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
59+ val filters: FilterState = FilterState(),
60+ val streaming: StreamingState = StreamingState(),
61+ val currentLimit: Int = 500,
62+ val hasMore: Boolean = false,
63+ val customStartTime: Long? = null,
64+ val customEndTime: Long? = null,
65+) {
66+ // Convenience accessors for backward compatibility with Screen
67+ val searchQuery: String get() = filters.searchQuery
68+ val activeFilters: List<String> get() = filters.activeFilters
69+ val filterClauses: List<String> get() = filters.filterClauses
70+ val customSql: String get() = filters.customSql
71+ val isStreaming: Boolean get() = streaming.isStreaming
72+ val streamingNewCount: Int get() = streaming.streamingNewCount
73+ val streamingError: String? get() = streaming.streamingError
74+ val currentIntervalMs: Long get() = streaming.currentIntervalMs
75+ val isSearching: Boolean get() = filters.isSearching
76+}
77+
78+@HiltViewModel
79+class LogViewerViewModel @Inject constructor(
80+ private val repository: ParseableRepository,
81+) : ViewModel() {
82+
83+ private val _state = MutableStateFlow(LogViewerState())
84+ val state: StateFlow<LogViewerState> = _state.asStateFlow()
85+
86+ private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'+00:00'")
87+
88+ private var streamingJob: Job? = null
89+ @Volatile private var streamingGeneration: Int = 0
90+ @Volatile private var lastSeenTimestamp: String? = null
91+ @Volatile private var consecutiveStreamingErrors: Int = 0
92+ private var searchJob: Job? = null
93+ private var schemaJob: Job? = null
94+
95+ companion object {
96+ private const val STREAMING_BASE_INTERVAL_MS = 3000L
97+ private const val STREAMING_MAX_INTERVAL_MS = 30000L
98+ private const val STREAMING_MAX_LOGS = 1000
99+ private const val MAX_LOAD_LIMIT = 5000
100+ private const val LOAD_MORE_INCREMENT = 500
101+ private const val MAX_STREAMING_ERRORS = 5
102+ private val ALLOWED_OPERATORS = setOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
103+ }
104+
105+ fun initialize(streamName: String) {
106+ if (_state.value.streamName == streamName) return
107+ stopStreaming()
108+ _state.update { it.copy(streamName = streamName) }
109+ loadSchema(streamName)
110+ refresh()
111+ }
112+
113+ private fun loadSchema(streamName: String) {
114+ schemaJob?.cancel()
115+ schemaJob = viewModelScope.launch {
116+ when (val result = repository.getStreamSchema(streamName)) {
117+ is ApiResult.Success -> {
118+ val columns = result.data.fields.map { it.name }
119+ _state.update { it.copy(columns = columns) }
120+ }
121+ is ApiResult.Error -> { /* Schema loading failure is non-fatal */ }
122+ }
123+ }
124+ }
125+
126+ fun onTimeRangeChange(range: TimeRange) {
127+ stopStreaming()
128+ _state.update {
129+ it.copy(
130+ selectedTimeRange = range,
131+ currentLimit = 500,
132+ customStartTime = if (range != TimeRange.CUSTOM) null else it.customStartTime,
133+ customEndTime = if (range != TimeRange.CUSTOM) null else it.customEndTime,
134+ )
135+ }
136+ if (range != TimeRange.CUSTOM) refresh()
137+ }
138+
139+ fun setCustomTimeRange(startMillis: Long, endMillis: Long) {
140+ stopStreaming()
141+ _state.update {
142+ it.copy(
143+ selectedTimeRange = TimeRange.CUSTOM,
144+ customStartTime = startMillis,
145+ customEndTime = endMillis,
146+ currentLimit = 500,
147+ )
148+ }
149+ refresh()
150+ }
151+
152+ fun onSearchQueryChange(query: String) {
153+ _state.update { it.copy(filters = it.filters.copy(searchQuery = query, isSearching = query.isNotBlank())) }
154+ searchJob?.cancel()
155+ searchJob = viewModelScope.launch {
156+ delay(300)
157+ _state.update { it.copy(filters = it.filters.copy(isSearching = false)) }
158+ refresh()
159+ }
160+ }
161+
162+ fun addFilter(column: String, operator: String, value: String) {
163+ if (operator !in ALLOWED_OPERATORS) return
164+ val safeColumn = escapeIdentifier(column)
165+ val safeValue = escapeSql(value)
166+ val clause = when (operator) {
167+ "IS NULL", "IS NOT NULL" -> "\"$safeColumn\" $operator"
168+ "LIKE", "ILIKE" -> "\"$safeColumn\" $operator '%$safeValue%'"
169+ else -> "\"$safeColumn\" $operator '$safeValue'"
170+ }
171+ val display = when (operator) {
172+ "IS NULL", "IS NOT NULL" -> "$column $operator"
173+ else -> "$column $operator $value"
174+ }
175+ _state.update {
176+ it.copy(
177+ filters = it.filters.copy(
178+ filterClauses = it.filters.filterClauses + clause,
179+ activeFilters = it.filters.activeFilters + display,
180+ ),
181+ )
182+ }
183+ refresh()
184+ }
185+
186+ fun removeFilter(display: String) {
187+ var removed = false
188+ _state.update {
189+ val index = it.filters.activeFilters.indexOf(display)
190+ if (index >= 0 && index < it.filters.filterClauses.size) {
191+ removed = true
192+ it.copy(
193+ filters = it.filters.copy(
194+ activeFilters = it.filters.activeFilters.filterIndexed { i, _ -> i != index },
195+ filterClauses = it.filters.filterClauses.filterIndexed { i, _ -> i != index },
196+ ),
197+ )
198+ } else {
199+ it
200+ }
201+ }
202+ if (removed) refresh()
203+ }
204+
205+ fun clearFilters() {
206+ _state.update {
207+ it.copy(filters = it.filters.copy(activeFilters = emptyList(), filterClauses = emptyList()))
208+ }
209+ refresh()
210+ }
211+
212+ fun executeCustomSql(sql: String) {
213+ val trimmed = sql.trim()
214+ if (!trimmed.uppercase().startsWith("SELECT")) {
215+ _state.update { it.copy(error = "Only SELECT queries are allowed") }
216+ return
217+ }
218+ // Enforce a LIMIT to prevent OOM from unbounded queries
219+ val safeSql = if (!trimmed.uppercase().contains("LIMIT")) {
220+ "$trimmed LIMIT $MAX_LOAD_LIMIT"
221+ } else {
222+ trimmed
223+ }
224+ _state.update { it.copy(filters = it.filters.copy(customSql = safeSql), currentLimit = 500) }
225+ viewModelScope.launch {
226+ _state.update { it.copy(isLoading = true, error = null) }
227+
228+ val (startTime, endTime) = getTimeRange()
229+ when (val result = repository.queryLogsRaw(safeSql, startTime, endTime)) {
230+ is ApiResult.Success -> {
231+ _state.update {
232+ it.copy(
233+ logs = result.data,
234+ isLoading = false,
235+ hasMore = result.data.size >= it.currentLimit,
236+ )
237+ }
238+ }
239+ is ApiResult.Error -> {
240+ _state.update {
241+ it.copy(isLoading = false, error = result.userMessage)
242+ }
243+ }
244+ }
245+ }
246+ }
247+
248+ private fun buildSearchClause(columns: List<String>, searchQuery: String): String? {
249+ if (searchQuery.isBlank()) return null
250+ val safeSearch = escapeSql(searchQuery)
251+ // Search across ALL non-internal columns (not just 5)
252+ val searchFields = columns.filter { !it.startsWith("p_") }
253+ if (searchFields.isEmpty()) return null
254+ return searchFields.joinToString(" OR ") {
255+ "\"${escapeIdentifier(it)}\" ILIKE '%$safeSearch%'"
256+ }
257+ }
258+
259+ fun refresh() {
260+ if (_state.value.streamName.isEmpty()) return
261+
262+ viewModelScope.launch {
263+ _state.update {
264+ it.copy(
265+ isLoading = true,
266+ error = null,
267+ streaming = it.streaming.copy(streamingError = null),
268+ )
269+ }
270+
271+ // Snapshot state inside the coroutine so we always see the latest filters/columns
272+ val current = _state.value
273+ val (startTime, endTime) = getTimeRange()
274+
275+ // Build WHERE clause from filters + search
276+ val clauses = current.filters.filterClauses.toMutableList()
277+ if (current.filters.searchQuery.isNotBlank()) {
278+ val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
279+ if (searchClause != null) {
280+ clauses.add("($searchClause)")
281+ } else {
282+ _state.update {
283+ it.copy(isLoading = false, error = "Search unavailable: stream schema not loaded")
284+ }
285+ return@launch
286+ }
287+ }
288+
289+ val filterSql = clauses.joinToString(" AND ")
290+
291+ when (val result = repository.queryLogs(
292+ stream = current.streamName,
293+ startTime = startTime,
294+ endTime = endTime,
295+ filterSql = filterSql,
296+ limit = current.currentLimit,
297+ )) {
298+ is ApiResult.Success -> {
299+ _state.update {
300+ it.copy(
301+ logs = result.data,
302+ isLoading = false,
303+ hasMore = result.data.size >= it.currentLimit,
304+ )
305+ }
306+ }
307+ is ApiResult.Error -> {
308+ _state.update {
309+ it.copy(isLoading = false, error = result.userMessage)
310+ }
311+ }
312+ }
313+ }
314+ }
315+
316+ fun loadMore() {
317+ val current = _state.value
318+ if (current.currentLimit >= MAX_LOAD_LIMIT) {
319+ _state.update { it.copy(hasMore = false) }
320+ return
321+ }
322+ val newLimit = (current.currentLimit + LOAD_MORE_INCREMENT).coerceAtMost(MAX_LOAD_LIMIT)
323+ _state.update { it.copy(currentLimit = newLimit) }
324+ refresh()
325+ }
326+
327+ private fun getTimeRange(): Pair<String, String> {
328+ val current = _state.value
329+ if (current.selectedTimeRange == TimeRange.CUSTOM &&
330+ current.customStartTime != null && current.customEndTime != null
331+ ) {
332+ val start = ZonedDateTime.ofInstant(
333+ java.time.Instant.ofEpochMilli(current.customStartTime), ZoneOffset.UTC
334+ )
335+ val end = ZonedDateTime.ofInstant(
336+ java.time.Instant.ofEpochMilli(current.customEndTime), ZoneOffset.UTC
337+ )
338+ return Pair(start.format(dateFormatter), end.format(dateFormatter))
339+ }
340+ val now = ZonedDateTime.now(ZoneOffset.UTC)
341+ val start = now.minusMinutes(current.selectedTimeRange.minutes)
342+ return Pair(
343+ start.format(dateFormatter),
344+ now.format(dateFormatter),
345+ )
346+ }
347+
348+ fun toggleStreaming() {
349+ if (_state.value.streaming.isStreaming) {
350+ stopStreaming()
351+ } else {
352+ startStreaming()
353+ }
354+ }
355+
356+ private fun startStreaming() {
357+ stopStreaming()
358+ consecutiveStreamingErrors = 0
359+ val generation = ++streamingGeneration
360+ _state.update {
361+ it.copy(streaming = StreamingState(isStreaming = true, streamingNewCount = 0))
362+ }
363+
364+ // Set the baseline timestamp to "now" so we only poll for new logs
365+ lastSeenTimestamp = ZonedDateTime.now(ZoneOffset.UTC).format(dateFormatter)
366+
367+ streamingJob = viewModelScope.launch {
368+ while (isActive && streamingGeneration == generation) {
369+ pollNewLogs()
370+ // Exponential backoff on errors, normal interval on success
371+ val intervalMs = if (consecutiveStreamingErrors > 0) {
372+ (STREAMING_BASE_INTERVAL_MS * (1L shl consecutiveStreamingErrors.coerceAtMost(4)))
373+ .coerceAtMost(STREAMING_MAX_INTERVAL_MS)
374+ } else {
375+ STREAMING_BASE_INTERVAL_MS
376+ }
377+ _state.update {
378+ it.copy(streaming = it.streaming.copy(currentIntervalMs = intervalMs))
379+ }
380+ delay(intervalMs)
381+ }
382+ }
383+ }
384+
385+ fun stopStreaming() {
386+ streamingGeneration++
387+ streamingJob?.cancel()
388+ streamingJob = null
389+ _state.update {
390+ it.copy(streaming = it.streaming.copy(isStreaming = false))
391+ }
392+ }
393+
394+ fun dismissStreamingError() {
395+ _state.update {
396+ it.copy(streaming = it.streaming.copy(streamingError = null))
397+ }
398+ }
399+
400+ private suspend fun pollNewLogs() {
401+ val current = _state.value
402+ if (current.streamName.isEmpty()) return
403+
404+ val startTime = lastSeenTimestamp ?: return
405+ val endTime = ZonedDateTime.now(ZoneOffset.UTC).format(dateFormatter)
406+
407+ // Build WHERE clause from active filters + search
408+ val clauses = current.filters.filterClauses.toMutableList()
409+ if (current.filters.searchQuery.isNotBlank()) {
410+ val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
411+ if (searchClause != null) {
412+ clauses.add(searchClause)
413+ }
414+ }
415+
416+ val whereClause = if (clauses.isNotEmpty()) " WHERE ${clauses.joinToString(" AND ")}" else ""
417+ val safeName = escapeIdentifier(current.streamName)
418+ val sql = "SELECT * FROM \"$safeName\"$whereClause ORDER BY p_timestamp DESC LIMIT 200"
419+
420+ when (val result = repository.queryLogsRaw(sql, startTime, endTime)) {
421+ is ApiResult.Success -> {
422+ val newLogs = result.data
423+ if (newLogs.isNotEmpty()) {
424+ // Update the last seen timestamp to the most recent log
425+ val newestTimestamp = try {
426+ newLogs.firstOrNull()
427+ ?.get("p_timestamp")?.jsonPrimitive?.content
428+ } catch (_: Exception) {
429+ null
430+ }
431+ if (newestTimestamp != null) {
432+ lastSeenTimestamp = newestTimestamp
433+ }
434+
435+ _state.update { state ->
436+ // Prepend new logs, cap total
437+ val combined = newLogs + state.logs
438+ val capped = if (combined.size > STREAMING_MAX_LOGS) {
439+ combined.take(STREAMING_MAX_LOGS)
440+ } else {
441+ combined
442+ }
443+ state.copy(
444+ logs = capped,
445+ streaming = state.streaming.copy(
446+ streamingNewCount = state.streaming.streamingNewCount + newLogs.size,
447+ streamingError = null,
448+ ),
449+ )
450+ }
451+ }
452+ consecutiveStreamingErrors = 0
453+ }
454+ is ApiResult.Error -> {
455+ consecutiveStreamingErrors++
456+ if (consecutiveStreamingErrors >= MAX_STREAMING_ERRORS) {
457+ stopStreaming()
458+ _state.update {
459+ it.copy(error = "Streaming stopped after repeated errors: ${result.userMessage}")
460+ }
461+ } else if (consecutiveStreamingErrors >= 3) {
462+ _state.update {
463+ it.copy(
464+ streaming = it.streaming.copy(
465+ streamingError = "Streaming interrupted: ${result.userMessage}. Retrying..."
466+ ),
467+ )
468+ }
469+ }
470+ }
471+ }
472+ }
473+
474+ override fun onCleared() {
475+ super.onCleared()
476+ schemaJob?.cancel()
477+ schemaJob = null
478+ searchJob?.cancel()
479+ searchJob = null
480+ stopStreaming()
481+ }
482+}
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoScreen.ktnew file+254
@@ -0,0 +1,254 @@
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.runtime.*
11+import androidx.compose.ui.Alignment
12+import androidx.compose.ui.Modifier
13+import androidx.compose.ui.text.font.FontFamily
14+import androidx.compose.ui.text.font.FontWeight
15+import androidx.compose.ui.unit.dp
16+import androidx.hilt.navigation.compose.hiltViewModel
17+import androidx.lifecycle.Lifecycle
18+import androidx.lifecycle.compose.LocalLifecycleOwner
19+import androidx.lifecycle.compose.collectAsStateWithLifecycle
20+import androidx.lifecycle.repeatOnLifecycle
21+import kotlinx.serialization.json.Json
22+import kotlinx.serialization.json.JsonObject
23+
24+private val prettyJson = Json { prettyPrint = true }
25+
26+@OptIn(ExperimentalMaterial3Api::class)
27+@Composable
28+fun StreamInfoScreen(
29+ streamName: String,
30+ onBack: () -> Unit,
31+ onStreamDeleted: () -> Unit,
32+ viewModel: StreamInfoViewModel = hiltViewModel(),
33+) {
34+ val state by viewModel.state.collectAsStateWithLifecycle()
35+ var showDeleteConfirmation by remember { mutableStateOf(false) }
36+
37+ val lifecycleOwner = LocalLifecycleOwner.current
38+ LaunchedEffect(streamName, lifecycleOwner) {
39+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
40+ viewModel.load(streamName)
41+ }
42+ }
43+
44+ LaunchedEffect(state.deleteSuccess) {
45+ if (state.deleteSuccess) {
46+ viewModel.consumeDeleteSuccess()
47+ onStreamDeleted()
48+ }
49+ }
50+
51+ Scaffold(
52+ topBar = {
53+ TopAppBar(
54+ title = { Text("Stream: $streamName") },
55+ navigationIcon = {
56+ IconButton(onClick = onBack) {
57+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
58+ }
59+ },
60+ actions = {
61+ IconButton(
62+ onClick = { showDeleteConfirmation = true },
63+ enabled = !state.isDeleting && !state.isLoading,
64+ ) {
65+ Icon(
66+ Icons.Filled.Delete,
67+ contentDescription = "Delete stream",
68+ tint = MaterialTheme.colorScheme.error,
69+ )
70+ }
71+ },
72+ )
73+ },
74+ ) { padding ->
75+ if (state.isLoading) {
76+ Box(
77+ modifier = Modifier
78+ .fillMaxSize()
79+ .padding(padding),
80+ contentAlignment = Alignment.Center,
81+ ) {
82+ CircularProgressIndicator()
83+ }
84+ } else {
85+ Column(
86+ modifier = Modifier
87+ .fillMaxSize()
88+ .padding(padding)
89+ .verticalScroll(rememberScrollState())
90+ .padding(16.dp),
91+ verticalArrangement = Arrangement.spacedBy(16.dp),
92+ ) {
93+ // Stats section
94+ state.stats?.let { stats ->
95+ InfoSection(title = "Statistics") {
96+ InfoRow("Event Count", stats.ingestion?.count?.toString() ?: stats.ingestion?.lifetimeCount?.toString() ?: "N/A")
97+ InfoRow("Ingestion Size", stats.ingestion?.size ?: stats.ingestion?.lifetimeSize ?: "N/A")
98+ InfoRow("Storage Size", stats.storage?.size ?: stats.storage?.lifetimeSize ?: "N/A")
99+ if (stats.ingestion?.lifetimeCount != null) {
100+ InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
101+ }
102+ if (stats.ingestion?.deletedCount != null) {
103+ InfoRow("Deleted Events", stats.ingestion.deletedCount.toString())
104+ }
105+ }
106+ }
107+
108+ // Schema section
109+ if (state.schema.isNotEmpty()) {
110+ InfoSection(title = "Schema (${state.schema.size} fields)") {
111+ state.schema.forEach { field ->
112+ InfoRow(
113+ field.name,
114+ field.dataType?.toString()?.trim('"') ?: "Unknown",
115+ )
116+ }
117+ }
118+ } else if (state.schemaFailed) {
119+ InfoSection(title = "Schema") {
120+ Text(
121+ text = "Schema unavailable",
122+ style = MaterialTheme.typography.bodyMedium,
123+ color = MaterialTheme.colorScheme.onSurfaceVariant,
124+ )
125+ }
126+ }
127+
128+ // Retention section
129+ if (state.retention.isNotEmpty()) {
130+ InfoSection(title = "Retention") {
131+ state.retention.forEach { r ->
132+ InfoRow("Duration", r.duration ?: "N/A")
133+ InfoRow("Action", r.action ?: "N/A")
134+ if (r.description != null) {
135+ InfoRow("Description", r.description)
136+ }
137+ }
138+ }
139+ }
140+
141+ // Raw info section
142+ state.rawInfo?.let { rawInfo ->
143+ val prettyInfo = remember(rawInfo) {
144+ prettyJson.encodeToString(JsonObject.serializer(), rawInfo)
145+ }
146+ InfoSection(title = "Stream Info") {
147+ Text(
148+ text = prettyInfo,
149+ style = MaterialTheme.typography.bodySmall,
150+ fontFamily = FontFamily.Monospace,
151+ )
152+ }
153+ }
154+
155+ state.error?.let { errorText ->
156+ Card(
157+ colors = CardDefaults.cardColors(
158+ containerColor = MaterialTheme.colorScheme.errorContainer,
159+ ),
160+ ) {
161+ Text(
162+ text = errorText,
163+ modifier = Modifier.padding(16.dp),
164+ color = MaterialTheme.colorScheme.onErrorContainer,
165+ )
166+ }
167+ }
168+ }
169+ }
170+ }
171+
172+ if (showDeleteConfirmation) {
173+ AlertDialog(
174+ onDismissRequest = { showDeleteConfirmation = false },
175+ icon = {
176+ Icon(
177+ Icons.Filled.Warning,
178+ contentDescription = "Warning",
179+ tint = MaterialTheme.colorScheme.error,
180+ )
181+ },
182+ title = { Text("Delete Stream") },
183+ text = {
184+ Text(
185+ "Are you sure you want to delete \"$streamName\"? " +
186+ "This will permanently remove all log data and cannot be undone."
187+ )
188+ },
189+ confirmButton = {
190+ Button(
191+ onClick = {
192+ showDeleteConfirmation = false
193+ viewModel.deleteStream()
194+ },
195+ colors = ButtonDefaults.buttonColors(
196+ containerColor = MaterialTheme.colorScheme.error,
197+ ),
198+ ) {
199+ Text("Delete")
200+ }
201+ },
202+ dismissButton = {
203+ TextButton(onClick = { showDeleteConfirmation = false }) {
204+ Text("Cancel")
205+ }
206+ },
207+ )
208+ }
209+}
210+
211+@Composable
212+fun InfoSection(
213+ title: String,
214+ content: @Composable ColumnScope.() -> Unit,
215+) {
216+ Card(
217+ modifier = Modifier.fillMaxWidth(),
218+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
219+ ) {
220+ Column(modifier = Modifier.padding(16.dp)) {
221+ Text(
222+ text = title,
223+ style = MaterialTheme.typography.titleMedium,
224+ fontWeight = FontWeight.Bold,
225+ )
226+ Spacer(modifier = Modifier.height(12.dp))
227+ content()
228+ }
229+ }
230+}
231+
232+@Composable
233+fun InfoRow(label: String, value: String) {
234+ Row(
235+ modifier = Modifier
236+ .fillMaxWidth()
237+ .padding(vertical = 4.dp),
238+ horizontalArrangement = Arrangement.SpaceBetween,
239+ ) {
240+ Text(
241+ text = label,
242+ style = MaterialTheme.typography.bodyMedium,
243+ color = MaterialTheme.colorScheme.onSurfaceVariant,
244+ modifier = Modifier.weight(1f),
245+ )
246+ Text(
247+ text = value,
248+ style = MaterialTheme.typography.bodyMedium,
249+ fontWeight = FontWeight.Medium,
250+ modifier = Modifier.weight(1f),
251+ fontFamily = FontFamily.Monospace,
252+ )
253+ }
254+}
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoViewModel.ktnew file+104
@@ -0,0 +1,104 @@
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.flow.MutableStateFlow
12+import kotlinx.coroutines.flow.StateFlow
13+import kotlinx.coroutines.flow.asStateFlow
14+import kotlinx.coroutines.flow.update
15+import kotlinx.coroutines.launch
16+import kotlinx.serialization.json.JsonObject
17+import javax.inject.Inject
18+
19+data class StreamInfoState(
20+ val streamName: String = "",
21+ val stats: StreamStats? = null,
22+ val schema: List<SchemaField> = emptyList(),
23+ val schemaFailed: Boolean = false,
24+ val retention: List<RetentionConfig> = emptyList(),
25+ val rawInfo: JsonObject? = null,
26+ val isLoading: Boolean = false,
27+ val error: String? = null,
28+ val isDeleting: Boolean = false,
29+ val deleteSuccess: Boolean = false,
30+)
31+
32+@HiltViewModel
33+class StreamInfoViewModel @Inject constructor(
34+ private val repository: ParseableRepository,
35+) : ViewModel() {
36+
37+ private val _state = MutableStateFlow(StreamInfoState())
38+ val state: StateFlow<StreamInfoState> = _state.asStateFlow()
39+ private var loadJob: Job? = null
40+
41+ fun load(streamName: String) {
42+ loadJob?.cancel()
43+ _state.update { it.copy(streamName = streamName, isLoading = true, error = null) }
44+
45+ loadJob = viewModelScope.launch {
46+ try {
47+ val statsDeferred = async { repository.getStreamStats(streamName) }
48+ val schemaDeferred = async { repository.getStreamSchema(streamName) }
49+ val retentionDeferred = async { repository.getStreamRetention(streamName) }
50+ val infoDeferred = async { repository.getStreamInfo(streamName) }
51+
52+ val statsResult = statsDeferred.await()
53+ val schemaResult = schemaDeferred.await()
54+ val retentionResult = retentionDeferred.await()
55+ val infoResult = infoDeferred.await()
56+
57+ _state.update {
58+ it.copy(
59+ stats = (statsResult as? ApiResult.Success)?.data,
60+ schema = (schemaResult as? ApiResult.Success)?.data?.fields ?: emptyList(),
61+ schemaFailed = schemaResult is ApiResult.Error,
62+ retention = (retentionResult as? ApiResult.Success)?.data ?: emptyList(),
63+ rawInfo = (infoResult as? ApiResult.Success)?.data,
64+ isLoading = false,
65+ error = listOfNotNull(
66+ (statsResult as? ApiResult.Error)?.userMessage,
67+ (schemaResult as? ApiResult.Error)?.userMessage,
68+ (retentionResult as? ApiResult.Error)?.userMessage,
69+ (infoResult as? ApiResult.Error)?.userMessage,
70+ ).distinct().joinToString("\n").ifEmpty { null },
71+ )
72+ }
73+ } catch (e: CancellationException) {
74+ throw e
75+ } catch (e: Exception) {
76+ _state.update {
77+ it.copy(isLoading = false, error = e.message ?: "Failed to load stream info")
78+ }
79+ }
80+ }
81+ }
82+
83+ fun consumeDeleteSuccess() {
84+ _state.update { it.copy(deleteSuccess = false) }
85+ }
86+
87+ fun deleteStream() {
88+ val name = _state.value.streamName
89+ if (name.isEmpty()) return
90+ viewModelScope.launch {
91+ _state.update { it.copy(isDeleting = true, error = null) }
92+ when (val result = repository.deleteStream(name)) {
93+ is ApiResult.Success -> {
94+ _state.update { it.copy(isDeleting = false, deleteSuccess = true) }
95+ }
96+ is ApiResult.Error -> {
97+ _state.update {
98+ it.copy(isDeleting = false, error = "Delete failed: ${result.userMessage}")
99+ }
100+ }
101+ }
102+ }
103+ }
104+}
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsScreen.ktnew file+243
@@ -0,0 +1,243 @@
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.unit.dp
16+import androidx.hilt.navigation.compose.hiltViewModel
17+import androidx.lifecycle.compose.collectAsStateWithLifecycle
18+import com.parseable.android.BuildConfig
19+
20+@OptIn(ExperimentalMaterial3Api::class)
21+@Composable
22+fun SettingsScreen(
23+ onBack: () -> Unit,
24+ viewModel: SettingsViewModel = hiltViewModel(),
25+) {
26+ val state by viewModel.state.collectAsStateWithLifecycle()
27+
28+ Scaffold(
29+ topBar = {
30+ TopAppBar(
31+ title = { Text("Settings") },
32+ navigationIcon = {
33+ IconButton(onClick = onBack) {
34+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
35+ }
36+ },
37+ )
38+ },
39+ ) { padding ->
40+ PullToRefreshBox(
41+ isRefreshing = state.isLoading,
42+ onRefresh = viewModel::load,
43+ modifier = Modifier
44+ .fillMaxSize()
45+ .padding(padding),
46+ ) {
47+ Column(
48+ modifier = Modifier
49+ .fillMaxSize()
50+ .verticalScroll(rememberScrollState())
51+ .padding(16.dp),
52+ verticalArrangement = Arrangement.spacedBy(16.dp),
53+ ) {
54+ // Server info
55+ Card(
56+ modifier = Modifier.fillMaxWidth(),
57+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
58+ ) {
59+ Column(modifier = Modifier.padding(16.dp)) {
60+ Row(verticalAlignment = Alignment.CenterVertically) {
61+ Icon(
62+ Icons.Filled.Dns,
63+ contentDescription = "Server connection",
64+ tint = MaterialTheme.colorScheme.primary,
65+ )
66+ Spacer(modifier = Modifier.width(8.dp))
67+ Text(
68+ text = "Server Connection",
69+ style = MaterialTheme.typography.titleMedium,
70+ fontWeight = FontWeight.Bold,
71+ )
72+ }
73+ Spacer(modifier = Modifier.height(12.dp))
74+ SettingsRow("URL", state.serverUrl)
75+ SettingsRow("Username", state.username)
76+ SettingsRow("TLS", if (state.useTls) "Enabled" else "Disabled")
77+ }
78+ }
79+
80+ // Loading indicator for server data
81+ if (state.isLoading) {
82+ Box(
83+ modifier = Modifier
84+ .fillMaxWidth()
85+ .padding(vertical = 8.dp),
86+ contentAlignment = Alignment.Center,
87+ ) {
88+ Row(
89+ verticalAlignment = Alignment.CenterVertically,
90+ horizontalArrangement = Arrangement.spacedBy(8.dp),
91+ ) {
92+ CircularProgressIndicator(
93+ modifier = Modifier.size(16.dp),
94+ strokeWidth = 2.dp,
95+ )
96+ Text(
97+ text = "Loading server info...",
98+ style = MaterialTheme.typography.bodySmall,
99+ color = MaterialTheme.colorScheme.onSurfaceVariant,
100+ )
101+ }
102+ }
103+ }
104+
105+ // Error from loading server data
106+ state.error?.let { errorText ->
107+ Card(
108+ colors = CardDefaults.cardColors(
109+ containerColor = MaterialTheme.colorScheme.errorContainer,
110+ ),
111+ modifier = Modifier.fillMaxWidth(),
112+ ) {
113+ Row(
114+ modifier = Modifier.padding(16.dp),
115+ verticalAlignment = Alignment.CenterVertically,
116+ ) {
117+ Icon(
118+ Icons.Filled.ErrorOutline,
119+ contentDescription = "Error",
120+ tint = MaterialTheme.colorScheme.onErrorContainer,
121+ )
122+ Spacer(modifier = Modifier.width(8.dp))
123+ Text(
124+ text = errorText,
125+ color = MaterialTheme.colorScheme.onErrorContainer,
126+ style = MaterialTheme.typography.bodyMedium,
127+ )
128+ }
129+ }
130+ }
131+
132+ // About info
133+ state.aboutInfo?.let { about ->
134+ Card(
135+ modifier = Modifier.fillMaxWidth(),
136+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
137+ ) {
138+ Column(modifier = Modifier.padding(16.dp)) {
139+ Row(verticalAlignment = Alignment.CenterVertically) {
140+ Icon(
141+ Icons.Filled.Info,
142+ contentDescription = "Server info",
143+ tint = MaterialTheme.colorScheme.primary,
144+ )
145+ Spacer(modifier = Modifier.width(8.dp))
146+ Text(
147+ text = "Server Info",
148+ style = MaterialTheme.typography.titleMedium,
149+ fontWeight = FontWeight.Bold,
150+ )
151+ }
152+ Spacer(modifier = Modifier.height(12.dp))
153+ about.version?.let { SettingsRow("Version", it) }
154+ about.commit?.let { SettingsRow("Commit", it.take(12)) }
155+ about.mode?.let { SettingsRow("Mode", it) }
156+ about.license?.let { SettingsRow("License", it) }
157+ about.deploymentId?.let { SettingsRow("Deployment ID", it.take(12)) }
158+ about.queryEngine?.let { SettingsRow("Query Engine", it) }
159+ }
160+ }
161+ }
162+
163+ // Users section
164+ if (state.users.isNotEmpty()) {
165+ Card(
166+ modifier = Modifier.fillMaxWidth(),
167+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
168+ ) {
169+ Column(modifier = Modifier.padding(16.dp)) {
170+ Row(verticalAlignment = Alignment.CenterVertically) {
171+ Icon(
172+ Icons.Filled.People,
173+ contentDescription = "Users",
174+ tint = MaterialTheme.colorScheme.primary,
175+ )
176+ Spacer(modifier = Modifier.width(8.dp))
177+ Text(
178+ text = "Users (${state.users.size})",
179+ style = MaterialTheme.typography.titleMedium,
180+ fontWeight = FontWeight.Bold,
181+ )
182+ }
183+ Spacer(modifier = Modifier.height(12.dp))
184+ state.users.forEach { user ->
185+ Text(
186+ text = user,
187+ style = MaterialTheme.typography.bodyMedium,
188+ modifier = Modifier.padding(vertical = 4.dp),
189+ )
190+ }
191+ }
192+ }
193+ }
194+
195+ // App info
196+ Card(
197+ modifier = Modifier.fillMaxWidth(),
198+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
199+ ) {
200+ Column(modifier = Modifier.padding(16.dp)) {
201+ Row(verticalAlignment = Alignment.CenterVertically) {
202+ Icon(
203+ Icons.Filled.PhoneAndroid,
204+ contentDescription = "App info",
205+ tint = MaterialTheme.colorScheme.primary,
206+ )
207+ Spacer(modifier = Modifier.width(8.dp))
208+ Text(
209+ text = "App",
210+ style = MaterialTheme.typography.titleMedium,
211+ fontWeight = FontWeight.Bold,
212+ )
213+ }
214+ Spacer(modifier = Modifier.height(12.dp))
215+ SettingsRow("App Version", BuildConfig.VERSION_NAME)
216+ SettingsRow("Platform", "Android")
217+ }
218+ }
219+ }
220+ }
221+ }
222+}
223+
224+@Composable
225+private fun SettingsRow(label: String, value: String) {
226+ Row(
227+ modifier = Modifier
228+ .fillMaxWidth()
229+ .padding(vertical = 4.dp),
230+ horizontalArrangement = Arrangement.SpaceBetween,
231+ ) {
232+ Text(
233+ text = label,
234+ style = MaterialTheme.typography.bodyMedium,
235+ color = MaterialTheme.colorScheme.onSurfaceVariant,
236+ )
237+ Text(
238+ text = value,
239+ style = MaterialTheme.typography.bodyMedium,
240+ fontWeight = FontWeight.Medium,
241+ )
242+ }
243+}
app/src/main/java/com/parseable/android/ui/screens/settings/SettingsViewModel.ktnew file+99
@@ -0,0 +1,99 @@
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.model.AboutInfo
6+import com.parseable.android.data.model.ApiResult
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.CancellationException
11+import kotlinx.coroutines.async
12+import kotlinx.coroutines.coroutineScope
13+import kotlinx.coroutines.flow.MutableStateFlow
14+import kotlinx.coroutines.flow.StateFlow
15+import kotlinx.coroutines.flow.asStateFlow
16+import kotlinx.coroutines.flow.first
17+import kotlinx.coroutines.flow.update
18+import kotlinx.coroutines.launch
19+import kotlinx.serialization.json.jsonPrimitive
20+import javax.inject.Inject
21+
22+data class SettingsState(
23+ val serverUrl: String = "",
24+ val username: String = "",
25+ val useTls: Boolean = true,
26+ val aboutInfo: AboutInfo? = null,
27+ val users: List<String> = emptyList(),
28+ val isLoading: Boolean = false,
29+ val error: String? = null,
30+)
31+
32+@HiltViewModel
33+class SettingsViewModel @Inject constructor(
34+ private val settingsRepository: SettingsRepository,
35+ private val repository: ParseableRepository,
36+) : ViewModel() {
37+
38+ private val _state = MutableStateFlow(SettingsState())
39+ val state: StateFlow<SettingsState> = _state.asStateFlow()
40+
41+ init {
42+ load()
43+ }
44+
45+ fun load() {
46+ viewModelScope.launch {
47+ _state.update { it.copy(isLoading = true, error = null) }
48+
49+ val config = settingsRepository.serverConfig.first()
50+
51+ // Show server info immediately
52+ _state.update {
53+ it.copy(
54+ serverUrl = config?.serverUrl ?: it.serverUrl,
55+ username = config?.username ?: it.username,
56+ useTls = config?.useTls ?: it.useTls,
57+ )
58+ }
59+
60+ try {
61+ val (aboutResult, usersResult) = coroutineScope {
62+ val aboutDeferred = async { repository.getAbout() }
63+ val usersDeferred = async { repository.listUsers() }
64+ aboutDeferred.await() to usersDeferred.await()
65+ }
66+
67+ val userNames = (usersResult as? ApiResult.Success)?.data?.mapNotNull { obj ->
68+ try {
69+ obj["id"]?.jsonPrimitive?.content
70+ ?: obj["username"]?.jsonPrimitive?.content
71+ } catch (_: IllegalStateException) {
72+ null
73+ }
74+ } ?: emptyList()
75+
76+ val errors = listOfNotNull(
77+ (aboutResult as? ApiResult.Error)?.userMessage,
78+ (usersResult as? ApiResult.Error)?.userMessage,
79+ ).distinct().joinToString("\n").ifEmpty { null }
80+
81+ _state.update {
82+ it.copy(
83+ aboutInfo = (aboutResult as? ApiResult.Success)?.data,
84+ users = userNames,
85+ isLoading = false,
86+ error = errors,
87+ )
88+ }
89+ } catch (e: CancellationException) {
90+ _state.update { it.copy(isLoading = false) }
91+ throw e
92+ } catch (e: Exception) {
93+ _state.update {
94+ it.copy(isLoading = false, error = e.message ?: "Failed to load settings")
95+ }
96+ }
97+ }
98+ }
99+}
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsScreen.ktnew file+358
@@ -0,0 +1,358 @@
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.material.icons.Icons
7+import androidx.compose.material.icons.automirrored.filled.Logout
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.lifecycle.compose.LocalLifecycleOwner
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.Lifecycle
19+import androidx.lifecycle.compose.collectAsStateWithLifecycle
20+import androidx.lifecycle.repeatOnLifecycle
21+import kotlinx.coroutines.flow.collectLatest
22+import kotlinx.coroutines.launch
23+
24+@OptIn(ExperimentalMaterial3Api::class)
25+@Composable
26+fun StreamsScreen(
27+ onStreamClick: (String) -> Unit,
28+ onAlertsClick: () -> Unit,
29+ onSettingsClick: () -> Unit,
30+ onLogout: () -> Unit,
31+ viewModel: StreamsViewModel = hiltViewModel(),
32+) {
33+ val state by viewModel.state.collectAsStateWithLifecycle()
34+ val scope = rememberCoroutineScope()
35+ var showLogoutConfirmation by remember { mutableStateOf(false) }
36+ var searchQuery by remember { mutableStateOf("") }
37+ val snackbarHostState = remember { SnackbarHostState() }
38+
39+ val filteredStreams = remember(state.streams, searchQuery, state.favoriteNames) {
40+ val favorites = state.favoriteNames
41+ val filtered = if (searchQuery.isBlank()) {
42+ state.streams
43+ } else {
44+ state.streams.filter { it.name.contains(searchQuery, ignoreCase = true) }
45+ }
46+ // Partition by favorites instead of full sort for O(n) vs O(n log n)
47+ val (favs, rest) = filtered.partition { it.name in favorites }
48+ favs + rest
49+ }
50+
51+ val lifecycleOwner = LocalLifecycleOwner.current
52+ LaunchedEffect(lifecycleOwner) {
53+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
54+ viewModel.refresh()
55+ }
56+ }
57+
58+ LaunchedEffect(Unit) {
59+ viewModel.snackbarEvent.collectLatest { message ->
60+ snackbarHostState.showSnackbar(
61+ message = message,
62+ duration = SnackbarDuration.Short,
63+ )
64+ }
65+ }
66+
67+ Scaffold(
68+ snackbarHost = { SnackbarHost(snackbarHostState) },
69+ topBar = {
70+ TopAppBar(
71+ title = {
72+ Column {
73+ Text("Parseable", fontWeight = FontWeight.Bold)
74+ if (state.aboutInfo != null) {
75+ Text(
76+ text = "v${state.aboutInfo?.version ?: "?"} - ${state.aboutInfo?.mode ?: ""}",
77+ style = MaterialTheme.typography.bodySmall,
78+ color = MaterialTheme.colorScheme.onSurfaceVariant,
79+ )
80+ }
81+ }
82+ },
83+ actions = {
84+ IconButton(onClick = onAlertsClick) {
85+ Icon(Icons.Filled.Notifications, contentDescription = "Alerts")
86+ }
87+ IconButton(onClick = onSettingsClick) {
88+ Icon(Icons.Filled.Settings, contentDescription = "Settings")
89+ }
90+ IconButton(onClick = { showLogoutConfirmation = true }) {
91+ Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Logout")
92+ }
93+ },
94+ )
95+ },
96+ ) { padding ->
97+ PullToRefreshBox(
98+ isRefreshing = state.isLoading,
99+ onRefresh = viewModel::refresh,
100+ modifier = Modifier
101+ .fillMaxSize()
102+ .padding(padding),
103+ ) {
104+ if (state.error != null && state.streams.isEmpty()) {
105+ Box(
106+ modifier = Modifier.fillMaxSize(),
107+ contentAlignment = Alignment.Center,
108+ ) {
109+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
110+ Icon(
111+ Icons.Filled.ErrorOutline,
112+ contentDescription = "Error loading streams",
113+ modifier = Modifier.size(48.dp),
114+ tint = MaterialTheme.colorScheme.error,
115+ )
116+ Spacer(modifier = Modifier.height(16.dp))
117+ Text(
118+ text = state.error.orEmpty(),
119+ style = MaterialTheme.typography.bodyLarge,
120+ color = MaterialTheme.colorScheme.error,
121+ )
122+ Spacer(modifier = Modifier.height(16.dp))
123+ OutlinedButton(onClick = viewModel::refresh) {
124+ Text("Retry")
125+ }
126+ }
127+ }
128+ } else if (state.streams.isEmpty() && !state.isLoading) {
129+ Box(
130+ modifier = Modifier.fillMaxSize(),
131+ contentAlignment = Alignment.Center,
132+ ) {
133+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
134+ Icon(
135+ Icons.Filled.Inbox,
136+ contentDescription = "No log streams found",
137+ modifier = Modifier.size(48.dp),
138+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
139+ )
140+ Spacer(modifier = Modifier.height(16.dp))
141+ Text(
142+ text = "No log streams found",
143+ style = MaterialTheme.typography.bodyLarge,
144+ color = MaterialTheme.colorScheme.onSurfaceVariant,
145+ )
146+ }
147+ }
148+ } else {
149+ Column {
150+ if (state.streams.size > 5) {
151+ OutlinedTextField(
152+ value = searchQuery,
153+ onValueChange = { searchQuery = it },
154+ placeholder = { Text("Search streams...") },
155+ leadingIcon = {
156+ Icon(Icons.Filled.Search, contentDescription = "Search")
157+ },
158+ trailingIcon = {
159+ if (searchQuery.isNotEmpty()) {
160+ IconButton(onClick = { searchQuery = "" }) {
161+ Icon(Icons.Filled.Clear, contentDescription = "Clear search")
162+ }
163+ }
164+ },
165+ singleLine = true,
166+ modifier = Modifier
167+ .fillMaxWidth()
168+ .padding(horizontal = 16.dp, vertical = 8.dp),
169+ )
170+ }
171+ LazyColumn(
172+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
173+ verticalArrangement = Arrangement.spacedBy(8.dp),
174+ modifier = Modifier.weight(1f),
175+ ) {
176+ items(filteredStreams, key = { it.name }) { stream ->
177+ val stats = state.streamStats[stream.name]
178+ val isFavorite = stream.name in state.favoriteNames
179+ StreamCard(
180+ streamName = stream.name,
181+ stats = stats,
182+ statsFailed = stream.name in state.failedStats,
183+ isFavorite = isFavorite,
184+ onToggleFavorite = { viewModel.toggleFavorite(stream.name) },
185+ onRetryStats = { viewModel.retryStats(stream.name) },
186+ onClick = { onStreamClick(stream.name) },
187+ )
188+ }
189+ if (filteredStreams.isEmpty() && searchQuery.isNotBlank()) {
190+ item {
191+ Box(
192+ modifier = Modifier
193+ .fillMaxWidth()
194+ .padding(32.dp),
195+ contentAlignment = Alignment.Center,
196+ ) {
197+ Text(
198+ text = "No streams matching \"$searchQuery\"",
199+ style = MaterialTheme.typography.bodyMedium,
200+ color = MaterialTheme.colorScheme.onSurfaceVariant,
201+ )
202+ }
203+ }
204+ }
205+ }
206+ }
207+ }
208+ }
209+ }
210+
211+ if (showLogoutConfirmation) {
212+ AlertDialog(
213+ onDismissRequest = { showLogoutConfirmation = false },
214+ title = { Text("Logout") },
215+ text = { Text("Are you sure you want to disconnect from this server?") },
216+ confirmButton = {
217+ Button(
218+ onClick = {
219+ showLogoutConfirmation = false
220+ scope.launch {
221+ viewModel.logout()
222+ onLogout()
223+ }
224+ },
225+ ) {
226+ Text("Logout")
227+ }
228+ },
229+ dismissButton = {
230+ TextButton(onClick = { showLogoutConfirmation = false }) {
231+ Text("Cancel")
232+ }
233+ },
234+ )
235+ }
236+}
237+
238+@Composable
239+private fun StreamCard(
240+ streamName: String,
241+ stats: StreamsViewModel.StreamStatsUi?,
242+ statsFailed: Boolean = false,
243+ isFavorite: Boolean = false,
244+ onToggleFavorite: () -> Unit = {},
245+ onRetryStats: () -> Unit = {},
246+ onClick: () -> Unit,
247+) {
248+ Card(
249+ onClick = onClick,
250+ modifier = Modifier.fillMaxWidth(),
251+ colors = CardDefaults.cardColors(
252+ containerColor = MaterialTheme.colorScheme.surface,
253+ ),
254+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
255+ ) {
256+ Column(
257+ modifier = Modifier.padding(16.dp),
258+ ) {
259+ Row(
260+ modifier = Modifier.fillMaxWidth(),
261+ horizontalArrangement = Arrangement.SpaceBetween,
262+ verticalAlignment = Alignment.CenterVertically,
263+ ) {
264+ Row(
265+ verticalAlignment = Alignment.CenterVertically,
266+ modifier = Modifier.weight(1f),
267+ ) {
268+ Icon(
269+ Icons.Filled.Storage,
270+ contentDescription = "Log stream",
271+ tint = MaterialTheme.colorScheme.primary,
272+ modifier = Modifier.size(20.dp),
273+ )
274+ Spacer(modifier = Modifier.width(8.dp))
275+ Text(
276+ text = streamName,
277+ style = MaterialTheme.typography.titleMedium,
278+ fontWeight = FontWeight.SemiBold,
279+ )
280+ }
281+ IconButton(onClick = onToggleFavorite) {
282+ Icon(
283+ if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
284+ contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
285+ tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
286+ )
287+ }
288+ Icon(
289+ Icons.Filled.ChevronRight,
290+ contentDescription = null, // Decorative; parent Card handles click
291+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
292+ )
293+ }
294+
295+ if (stats != null) {
296+ Spacer(modifier = Modifier.height(8.dp))
297+ Row(
298+ modifier = Modifier.fillMaxWidth(),
299+ horizontalArrangement = Arrangement.spacedBy(16.dp),
300+ ) {
301+ if (stats.eventCount != null) {
302+ StatChip(label = "Events", value = formatCount(stats.eventCount))
303+ }
304+ if (stats.ingestionSize != null) {
305+ StatChip(label = "Ingested", value = stats.ingestionSize)
306+ }
307+ if (stats.storageSize != null) {
308+ StatChip(label = "Storage", value = stats.storageSize)
309+ }
310+ }
311+ } else if (statsFailed) {
312+ Spacer(modifier = Modifier.height(8.dp))
313+ Row(
314+ verticalAlignment = Alignment.CenterVertically,
315+ horizontalArrangement = Arrangement.spacedBy(4.dp),
316+ ) {
317+ Text(
318+ text = "Stats unavailable",
319+ style = MaterialTheme.typography.labelSmall,
320+ color = MaterialTheme.colorScheme.onSurfaceVariant,
321+ )
322+ TextButton(
323+ onClick = onRetryStats,
324+ contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
325+ modifier = Modifier.defaultMinSize(minHeight = 48.dp),
326+ ) {
327+ Text("Retry", style = MaterialTheme.typography.labelSmall)
328+ }
329+ }
330+ }
331+ }
332+ }
333+}
334+
335+@Composable
336+private fun StatChip(label: String, value: String) {
337+ Column {
338+ Text(
339+ text = label,
340+ style = MaterialTheme.typography.labelSmall,
341+ color = MaterialTheme.colorScheme.onSurfaceVariant,
342+ )
343+ Text(
344+ text = value,
345+ style = MaterialTheme.typography.bodySmall,
346+ fontWeight = FontWeight.Medium,
347+ )
348+ }
349+}
350+
351+private fun formatCount(count: Long): String {
352+ return when {
353+ count >= 1_000_000_000 -> String.format(java.util.Locale.US, "%.1fB", count / 1_000_000_000.0)
354+ count >= 1_000_000 -> String.format(java.util.Locale.US, "%.1fM", count / 1_000_000.0)
355+ count >= 1_000 -> String.format(java.util.Locale.US, "%.1fK", count / 1_000.0)
356+ else -> count.toString()
357+ }
358+}
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsViewModel.ktnew file+180
@@ -0,0 +1,180 @@
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.model.AboutInfo
8+import com.parseable.android.data.model.ApiResult
9+import com.parseable.android.data.model.LogStream
10+import com.parseable.android.data.repository.ParseableRepository
11+import com.parseable.android.data.repository.SettingsRepository
12+import dagger.hilt.android.lifecycle.HiltViewModel
13+import kotlinx.coroutines.CancellationException
14+import kotlinx.coroutines.Job
15+import kotlinx.coroutines.async
16+import kotlinx.coroutines.coroutineScope
17+import kotlinx.coroutines.channels.Channel
18+import kotlinx.coroutines.flow.MutableStateFlow
19+import kotlinx.coroutines.flow.StateFlow
20+import kotlinx.coroutines.flow.asStateFlow
21+import kotlinx.coroutines.flow.receiveAsFlow
22+import kotlinx.coroutines.flow.update
23+import kotlinx.coroutines.launch
24+import kotlinx.coroutines.sync.Semaphore
25+import kotlinx.coroutines.sync.withPermit
26+import javax.inject.Inject
27+
28+data class StreamsState(
29+ val streams: List<LogStream> = emptyList(),
30+ val streamStats: Map<String, StreamsViewModel.StreamStatsUi> = emptyMap(),
31+ val failedStats: Set<String> = emptySet(),
32+ val aboutInfo: AboutInfo? = null,
33+ val isLoading: Boolean = false,
34+ val error: String? = null,
35+ val favoriteNames: Set<String> = emptySet(),
36+)
37+
38+@HiltViewModel
39+class StreamsViewModel @Inject constructor(
40+ private val repository: ParseableRepository,
41+ private val settingsRepository: SettingsRepository,
42+ private val favoriteDao: FavoriteStreamDao,
43+) : ViewModel() {
44+
45+ data class StreamStatsUi(
46+ val eventCount: Long? = null,
47+ val ingestionSize: String? = null,
48+ val storageSize: String? = null,
49+ )
50+
51+ private val _state = MutableStateFlow(StreamsState())
52+ val state: StateFlow<StreamsState> = _state.asStateFlow()
53+
54+ private val _snackbarEvent = Channel<String>(Channel.BUFFERED)
55+ val snackbarEvent = _snackbarEvent.receiveAsFlow()
56+
57+ init {
58+ viewModelScope.launch {
59+ favoriteDao.getAllNames().collect { names ->
60+ _state.update { it.copy(favoriteNames = names.toSet()) }
61+ }
62+ }
63+ }
64+
65+ fun toggleFavorite(streamName: String) {
66+ viewModelScope.launch {
67+ if (streamName in _state.value.favoriteNames) {
68+ favoriteDao.deleteByName(streamName)
69+ _snackbarEvent.send("Removed from favorites")
70+ } else {
71+ favoriteDao.insert(FavoriteStream(streamName = streamName))
72+ _snackbarEvent.send("Added to favorites")
73+ }
74+ }
75+ }
76+
77+ fun refresh() {
78+ viewModelScope.launch {
79+ _state.update { it.copy(isLoading = true, error = null) }
80+
81+ try {
82+ // Fetch about info and streams in parallel
83+ val (aboutResult, streamsResult) = coroutineScope {
84+ val aboutDeferred = async { repository.getAbout() }
85+ val streamsDeferred = async { repository.listStreams(forceRefresh = true) }
86+ aboutDeferred.await() to streamsDeferred.await()
87+ }
88+
89+ if (aboutResult is ApiResult.Success) {
90+ _state.update { it.copy(aboutInfo = aboutResult.data) }
91+ }
92+
93+ when (streamsResult) {
94+ is ApiResult.Success -> {
95+ _state.update {
96+ it.copy(
97+ streams = streamsResult.data,
98+ isLoading = false,
99+ )
100+ }
101+ // Load stats for each stream in parallel
102+ loadStreamStats(streamsResult.data)
103+ }
104+ is ApiResult.Error -> {
105+ _state.update {
106+ it.copy(
107+ isLoading = false,
108+ error = streamsResult.userMessage,
109+ )
110+ }
111+ }
112+ }
113+ } catch (e: CancellationException) {
114+ _state.update { it.copy(isLoading = false) }
115+ throw e
116+ } catch (e: Exception) {
117+ _state.update {
118+ it.copy(isLoading = false, error = e.message ?: "Failed to load streams")
119+ }
120+ }
121+ }
122+ }
123+
124+ private val statsSemaphore = Semaphore(8)
125+ private var statsJob: Job? = null
126+
127+ private fun loadStreamStats(streams: List<LogStream>) {
128+ statsJob?.cancel()
129+ statsJob = viewModelScope.launch {
130+ streams.forEach { stream ->
131+ launch {
132+ statsSemaphore.withPermit {
133+ loadSingleStreamStats(stream.name)
134+ }
135+ }
136+ }
137+ }
138+ }
139+
140+ fun retryStats(streamName: String) {
141+ viewModelScope.launch {
142+ _state.update { it.copy(failedStats = it.failedStats - streamName) }
143+ statsSemaphore.withPermit {
144+ loadSingleStreamStats(streamName)
145+ }
146+ }
147+ }
148+
149+ private suspend fun loadSingleStreamStats(streamName: String) {
150+ when (val result = repository.getStreamStats(streamName)) {
151+ is ApiResult.Success -> {
152+ val stats = result.data
153+ val ui = StreamStatsUi(
154+ eventCount = stats.ingestion?.count
155+ ?: stats.ingestion?.lifetimeCount,
156+ ingestionSize = stats.ingestion?.size
157+ ?: stats.ingestion?.lifetimeSize,
158+ storageSize = stats.storage?.size
159+ ?: stats.storage?.lifetimeSize,
160+ )
161+ _state.update {
162+ it.copy(
163+ streamStats = it.streamStats + (streamName to ui),
164+ failedStats = it.failedStats - streamName,
165+ )
166+ }
167+ }
168+ is ApiResult.Error -> {
169+ _state.update {
170+ it.copy(failedStats = it.failedStats + streamName)
171+ }
172+ }
173+ }
174+ }
175+
176+ suspend fun logout() {
177+ settingsRepository.clearConfig()
178+ repository.clearCredentials()
179+ }
180+}
app/src/main/java/com/parseable/android/ui/theme/Theme.ktnew file+67
@@ -0,0 +1,67 @@
1+package com.parseable.android.ui.theme
2+
3+import android.os.Build
4+import androidx.compose.foundation.isSystemInDarkTheme
5+import androidx.compose.material3.*
6+import androidx.compose.runtime.Composable
7+import androidx.compose.ui.graphics.Color
8+import androidx.compose.ui.platform.LocalContext
9+
10+private val ParseablePrimary = Color(0xFF545BEB)
11+private val ParseableOnPrimary = Color(0xFFFFFFFF)
12+private val ParseableSecondary = Color(0xFF3D43B0)
13+private val ParseableTertiary = Color(0xFF7C82FF)
14+
15+private val DarkColorScheme = darkColorScheme(
16+ primary = ParseableTertiary, // Lighter variant for dark background readability
17+ onPrimary = Color.White,
18+ secondary = ParseablePrimary,
19+ tertiary = ParseableSecondary,
20+ background = Color(0xFF0F1117),
21+ surface = Color(0xFF1A1B23),
22+ surfaceVariant = Color(0xFF25262F),
23+ onBackground = Color(0xFFE3E3E8),
24+ onSurface = Color(0xFFE3E3E8),
25+ onSurfaceVariant = Color(0xFFA0A1A8),
26+ outline = Color(0xFF3A3B44),
27+ error = Color(0xFFFF6B6B),
28+ onError = Color.White,
29+)
30+
31+private val LightColorScheme = lightColorScheme(
32+ primary = ParseablePrimary,
33+ onPrimary = Color.White,
34+ secondary = ParseableSecondary,
35+ tertiary = ParseableTertiary,
36+ background = Color(0xFFF8F8FC),
37+ surface = Color.White,
38+ surfaceVariant = Color(0xFFF0F0F6),
39+ onBackground = Color(0xFF1A1B23),
40+ onSurface = Color(0xFF1A1B23),
41+ onSurfaceVariant = Color(0xFF5A5B64),
42+ outline = Color(0xFFD0D0D8),
43+ error = Color(0xFFD32F2F),
44+ onError = Color.White,
45+)
46+
47+@Composable
48+fun ParseableTheme(
49+ darkTheme: Boolean = isSystemInDarkTheme(),
50+ dynamicColor: Boolean = false,
51+ content: @Composable () -> Unit,
52+) {
53+ val colorScheme = when {
54+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
55+ val context = LocalContext.current
56+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
57+ }
58+ darkTheme -> DarkColorScheme
59+ else -> LightColorScheme
60+ }
61+
62+ MaterialTheme(
63+ colorScheme = colorScheme,
64+ typography = Typography(),
65+ content = content,
66+ )
67+}
app/src/main/res/drawable/ic_launcher_background.xmlnew file+10
@@ -0,0 +1,10 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="108dp"
4+ android:height="108dp"
5+ android:viewportWidth="108"
6+ android:viewportHeight="108">
7+ <path
8+ android:fillColor="#545BEB"
9+ android:pathData="M0,0h108v108h-108z" />
10+</vector>
app/src/main/res/drawable/ic_launcher_foreground.xmlnew file+15
@@ -0,0 +1,15 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="108dp"
4+ android:height="108dp"
5+ android:viewportWidth="108"
6+ android:viewportHeight="108">
7+ <!-- Background circle -->
8+ <path
9+ android:fillColor="#545BEB"
10+ android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0" />
11+ <!-- Letter P -->
12+ <path
13+ android:fillColor="#FFFFFF"
14+ android:pathData="M40,35h12c6.6,0 12,5.4 12,12s-5.4,12 -12,12h-6v14h-6V35zM46,53h6c3.3,0 6,-2.7 6,-6s-2.7,-6 -6,-6h-6V53z" />
15+</vector>
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xmlnew file+5
@@ -0,0 +1,5 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3+ <background android:drawable="@drawable/ic_launcher_background" />
4+ <foreground android:drawable="@drawable/ic_launcher_foreground" />
5+</adaptive-icon>
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xmlnew file+5
@@ -0,0 +1,5 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3+ <background android:drawable="@drawable/ic_launcher_background" />
4+ <foreground android:drawable="@drawable/ic_launcher_foreground" />
5+</adaptive-icon>
app/src/main/res/values/strings.xmlnew file+78
@@ -0,0 +1,78 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <string name="app_name">Parseable</string>
4+
5+ <!-- Navigation / Screen titles -->
6+ <string name="title_login">Login</string>
7+ <string name="title_streams">Parseable</string>
8+ <string name="title_alerts">Alerts</string>
9+ <string name="title_settings">Settings</string>
10+ <string name="title_stream_info">Stream: %s</string>
11+ <string name="title_add_filter">Add Filter</string>
12+ <string name="title_sql_query">SQL Query</string>
13+
14+ <!-- Common actions -->
15+ <string name="action_back">Back</string>
16+ <string name="action_retry">Retry</string>
17+ <string name="action_cancel">Cancel</string>
18+ <string name="action_apply">Apply</string>
19+ <string name="action_delete">Delete</string>
20+ <string name="action_login">Login</string>
21+ <string name="action_logout">Logout</string>
22+ <string name="action_share">Share</string>
23+ <string name="action_stop">Stop</string>
24+ <string name="action_clear_all">Clear all</string>
25+ <string name="action_search">Search</string>
26+ <string name="action_clear">Clear</string>
27+ <string name="action_execute">Execute</string>
28+
29+ <!-- Search -->
30+ <string name="search_logs_placeholder">Search logs\u2026</string>
31+ <string name="search_streams_placeholder">Search streams\u2026</string>
32+
33+ <!-- Log viewer -->
34+ <string name="live_tail_banner">Live tail active - polling every %ds</string>
35+ <string name="end_of_results">End of results</string>
36+ <string name="log_entry_copied">Log entry copied to clipboard</string>
37+ <string name="no_logs_time_range">No logs found for the selected time range</string>
38+ <string name="no_logs_search">No logs match \"%s\"</string>
39+ <string name="no_logs_filters">No logs match the active filters</string>
40+ <string name="no_logs_search_filters">No logs match \"%s\" with the active filters</string>
41+
42+ <!-- Streams -->
43+ <string name="no_streams_found">No log streams found</string>
44+ <string name="no_streams_matching">No streams matching \"%s\"</string>
45+ <string name="stats_unavailable">Stats unavailable</string>
46+ <string name="added_to_favorites">Added to favorites</string>
47+ <string name="removed_from_favorites">Removed from favorites</string>
48+
49+ <!-- Alerts -->
50+ <string name="no_alerts">No alerts configured</string>
51+ <string name="delete_alert_title">Delete Alert</string>
52+ <string name="delete_alert_message">Are you sure you want to delete \"%s\"? This action cannot be undone.</string>
53+
54+ <!-- Login -->
55+ <string name="login_use_saved">Use saved credentials</string>
56+ <string name="login_server_url">Server URL</string>
57+ <string name="login_username">Username</string>
58+ <string name="login_password">Password</string>
59+ <string name="login_allow_insecure">Allow insecure (HTTP)</string>
60+ <string name="login_http_warning">Warning: HTTP sends credentials and data in plaintext. Only use this for trusted private networks.</string>
61+
62+ <!-- Settings sections -->
63+ <string name="settings_server_connection">Server Connection</string>
64+ <string name="settings_server_info">Server Info</string>
65+ <string name="settings_app">App</string>
66+
67+ <!-- Share warning -->
68+ <string name="share_warning_title">Share Logs</string>
69+ <string name="share_warning_message">Logs may contain sensitive information (PII, secrets, tokens). They will be exported as unencrypted JSON. Continue?</string>
70+
71+ <!-- Logout -->
72+ <string name="logout_confirmation_title">Logout</string>
73+ <string name="logout_confirmation_message">Are you sure you want to disconnect from this server?</string>
74+
75+ <!-- Stream delete -->
76+ <string name="delete_stream_title">Delete Stream</string>
77+ <string name="delete_stream_message">Are you sure you want to delete \"%s\"? This will permanently remove all log data and cannot be undone.</string>
78+</resources>
app/src/main/res/values/themes.xmlnew file+4
@@ -0,0 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <style name="Theme.ParseableAndroid" parent="android:Theme.Material.Light.NoActionBar" />
4+</resources>
app/src/main/res/xml/file_paths.xmlnew file+4
@@ -0,0 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<paths>
3+ <cache-path name="shared_logs" path="shared_logs/" />
4+</paths>
app/src/main/res/xml/network_security_config.xmlnew file+19
@@ -0,0 +1,19 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<network-security-config>
3+ <!--
4+ Cleartext (HTTP) is permitted because many Parseable instances are
5+ self-hosted on private networks without TLS. The app defaults to HTTPS
6+ and shows a security warning in the login screen when the user enables
7+ the "Allow insecure" toggle.
8+
9+ Note: Android's domain-config with includeSubdomains does NOT support
10+ IP subnet matching (e.g. 192.168.x.x), so whitelisting private IP
11+ ranges is not possible via this config. Instead we allow cleartext
12+ globally and rely on the app-level UI warning.
13+ -->
14+ <base-config cleartextTrafficPermitted="true">
15+ <trust-anchors>
16+ <certificates src="system" />
17+ </trust-anchors>
18+ </base-config>
19+</network-security-config>
app/src/test/java/com/parseable/android/AlertsViewModelTest.ktnew file+123
@@ -0,0 +1,123 @@
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+ viewModel = AlertsViewModel(repository)
28+ }
29+
30+ @After
31+ fun tearDown() {
32+ Dispatchers.resetMain()
33+ }
34+
35+ @Test
36+ fun `initial state has empty alerts and no loading`() {
37+ val state = viewModel.state.value
38+ assertTrue(state.alerts.isEmpty())
39+ assertFalse(state.isLoading)
40+ assertNull(state.error)
41+ assertNull(state.alertToDelete)
42+ }
43+
44+ @Test
45+ fun `refresh loads alerts on success`() = runTest {
46+ val alerts = listOf(
47+ Alert(id = "1", name = "Alert 1", stream = "stream1"),
48+ Alert(id = "2", name = "Alert 2", stream = "stream2"),
49+ )
50+ coEvery { repository.listAlerts() } returns ApiResult.Success(alerts)
51+
52+ viewModel.refresh()
53+
54+ val state = viewModel.state.value
55+ assertEquals(alerts, state.alerts)
56+ assertFalse(state.isLoading)
57+ assertNull(state.error)
58+ }
59+
60+ @Test
61+ fun `refresh shows error on failure`() = runTest {
62+ coEvery { repository.listAlerts() } returns ApiResult.Error("Server error", 500)
63+
64+ viewModel.refresh()
65+
66+ val state = viewModel.state.value
67+ assertTrue(state.alerts.isEmpty())
68+ assertFalse(state.isLoading)
69+ assertNotNull(state.error)
70+ }
71+
72+ @Test
73+ fun `requestDelete sets alertToDelete`() {
74+ val alert = Alert(id = "1", name = "Alert 1", stream = "stream1")
75+
76+ viewModel.requestDelete(alert)
77+
78+ val state = viewModel.state.value
79+ assertEquals(alert, state.alertToDelete)
80+ }
81+
82+ @Test
83+ fun `cancelDelete clears alertToDelete`() {
84+ val alert = Alert(id = "1", name = "Alert 1", stream = "stream1")
85+ viewModel.requestDelete(alert)
86+ assertEquals(alert, viewModel.state.value.alertToDelete)
87+
88+ viewModel.cancelDelete()
89+
90+ assertNull(viewModel.state.value.alertToDelete)
91+ }
92+
93+ @Test
94+ fun `deleteAlert on success refreshes list`() = runTest {
95+ val alerts = listOf(
96+ Alert(id = "2", name = "Alert 2", stream = "stream2"),
97+ )
98+ coEvery { repository.deleteAlert("1") } returns ApiResult.Success("deleted")
99+ coEvery { repository.listAlerts() } returns ApiResult.Success(alerts)
100+
101+ viewModel.deleteAlert("1")
102+
103+ val state = viewModel.state.value
104+ assertEquals(alerts, state.alerts)
105+ assertFalse(state.isLoading)
106+ assertNull(state.error)
107+ assertNull(state.alertToDelete)
108+ coVerify { repository.deleteAlert("1") }
109+ coVerify { repository.listAlerts() }
110+ }
111+
112+ @Test
113+ fun `deleteAlert on error shows error message`() = runTest {
114+ coEvery { repository.deleteAlert("1") } returns ApiResult.Error("Not found", 404)
115+
116+ viewModel.deleteAlert("1")
117+
118+ val state = viewModel.state.value
119+ assertFalse(state.isLoading)
120+ assertNotNull(state.error)
121+ assertNull(state.alertToDelete)
122+ }
123+}
app/src/test/java/com/parseable/android/ApiResultTest.ktnew file+89
@@ -0,0 +1,89 @@
1+package com.parseable.android
2+
3+import com.parseable.android.data.model.ApiResult
4+import org.junit.Assert.*
5+import org.junit.Test
6+
7+class ApiResultTest {
8+
9+ @Test
10+ fun `401 error produces session expired message`() {
11+ val error = ApiResult.Error("Unauthorized", code = 401)
12+ assertTrue(error.isUnauthorized)
13+ assertEquals("Session expired. Please log in again.", error.userMessage)
14+ }
15+
16+ @Test
17+ fun `403 error produces permission denied message`() {
18+ val error = ApiResult.Error("Forbidden", code = 403)
19+ assertEquals("Permission denied.", error.userMessage)
20+ }
21+
22+ @Test
23+ fun `404 error produces not found message`() {
24+ val error = ApiResult.Error("Not Found", code = 404)
25+ assertTrue(error.isNotFound)
26+ assertEquals("Resource not found. It may have been deleted.", error.userMessage)
27+ }
28+
29+ @Test
30+ fun `429 error produces rate limit message`() {
31+ val error = ApiResult.Error("Too Many Requests", code = 429)
32+ assertEquals("Too many requests. Please wait and try again.", error.userMessage)
33+ }
34+
35+ @Test
36+ fun `500 error produces server error message`() {
37+ val error = ApiResult.Error("Internal Server Error", code = 500)
38+ assertTrue(error.isServerError)
39+ assertEquals("Server error (500). Please try again later.", error.userMessage)
40+ }
41+
42+ @Test
43+ fun `502 error produces server error message`() {
44+ val error = ApiResult.Error("Bad Gateway", code = 502)
45+ assertTrue(error.isServerError)
46+ assertEquals("Server error (502). Please try again later.", error.userMessage)
47+ }
48+
49+ @Test
50+ fun `network timeout produces timeout message`() {
51+ val error = ApiResult.Error("connect timeout", code = 0)
52+ assertTrue(error.isNetworkError)
53+ assertEquals("Connection timed out. Check your network.", error.userMessage)
54+ }
55+
56+ @Test
57+ fun `DNS resolution failure produces no internet message`() {
58+ val error = ApiResult.Error("Unable to resolve host \"example.com\"", code = 0)
59+ assertTrue(error.isNetworkError)
60+ assertEquals("No internet connection.", error.userMessage)
61+ }
62+
63+ @Test
64+ fun `connection refused produces unreachable message`() {
65+ val error = ApiResult.Error("Connection refused", code = 0)
66+ assertTrue(error.isNetworkError)
67+ assertEquals("Server is unreachable.", error.userMessage)
68+ }
69+
70+ @Test
71+ fun `generic network error includes original message`() {
72+ val error = ApiResult.Error("SSL handshake failed", code = 0)
73+ assertTrue(error.isNetworkError)
74+ assertEquals("Network error: SSL handshake failed", error.userMessage)
75+ }
76+
77+ @Test
78+ fun `non-standard HTTP code returns raw message`() {
79+ val error = ApiResult.Error("Custom error from proxy", code = 418)
80+ assertEquals("Custom error from proxy", error.userMessage)
81+ }
82+
83+ @Test
84+ fun `success result carries data`() {
85+ val result: ApiResult<String> = ApiResult.Success("hello")
86+ assertTrue(result is ApiResult.Success)
87+ assertEquals("hello", (result as ApiResult.Success).data)
88+ }
89+}
app/src/test/java/com/parseable/android/EscapeSqlTest.ktnew file+74
@@ -0,0 +1,74 @@
1+package com.parseable.android
2+
3+import com.parseable.android.data.escapeIdentifier
4+import com.parseable.android.data.escapeSql
5+import org.junit.Assert.assertEquals
6+import org.junit.Test
7+
8+class EscapeSqlTest {
9+
10+ @Test
11+ fun `escapeSql replaces single quotes`() {
12+ assertEquals("it''s", escapeSql("it's"))
13+ }
14+
15+ @Test
16+ fun `escapeSql handles multiple single quotes`() {
17+ assertEquals("it''s a ''test''", escapeSql("it's a 'test'"))
18+ }
19+
20+ @Test
21+ fun `escapeSql handles empty string`() {
22+ assertEquals("", escapeSql(""))
23+ }
24+
25+ @Test
26+ fun `escapeSql handles string without quotes`() {
27+ assertEquals("hello world", escapeSql("hello world"))
28+ }
29+
30+ @Test
31+ fun `escapeSql handles consecutive quotes`() {
32+ assertEquals("''''", escapeSql("''"))
33+ }
34+
35+ @Test
36+ fun `escapeSql does not alter double quotes`() {
37+ assertEquals("say \"hello\"", escapeSql("say \"hello\""))
38+ }
39+
40+ @Test
41+ fun `escapeSql handles backslash`() {
42+ assertEquals("path\\to\\file", escapeSql("path\\to\\file"))
43+ }
44+
45+ @Test
46+ fun `escapeSql handles semicolons`() {
47+ assertEquals("; DROP TABLE users --", escapeSql("; DROP TABLE users --"))
48+ }
49+
50+ @Test
51+ fun `escapeSql handles quote injection attempt`() {
52+ assertEquals("''; DROP TABLE users --", escapeSql("'; DROP TABLE users --"))
53+ }
54+
55+ @Test
56+ fun `escapeIdentifier doubles double quotes`() {
57+ assertEquals("foo\"\"bar", escapeIdentifier("foo\"bar"))
58+ }
59+
60+ @Test
61+ fun `escapeIdentifier handles no quotes`() {
62+ assertEquals("my_stream", escapeIdentifier("my_stream"))
63+ }
64+
65+ @Test
66+ fun `escapeIdentifier handles empty string`() {
67+ assertEquals("", escapeIdentifier(""))
68+ }
69+
70+ @Test
71+ fun `escapeIdentifier handles consecutive double quotes`() {
72+ assertEquals("\"\"\"\"", escapeIdentifier("\"\""))
73+ }
74+}
app/src/test/java/com/parseable/android/FormatTimestampTest.ktnew file+59
@@ -0,0 +1,59 @@
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 `returns raw string for unparseable timestamp`() {
37+ val result = formatTimestamp("not-a-timestamp")
38+ assertEquals("not-a-timestamp", result)
39+ }
40+
41+ @Test
42+ fun `handles empty string`() {
43+ val result = formatTimestamp("")
44+ assertEquals("", result)
45+ }
46+
47+ @Test
48+ fun `converts to local timezone`() {
49+ val original = TimeZone.getDefault()
50+ try {
51+ TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"))
52+ val result = formatTimestamp("2024-06-15T18:30:45.000Z")
53+ // June = EDT = UTC-4
54+ assertEquals("Jun 15 14:30:45.000", result)
55+ } finally {
56+ TimeZone.setDefault(original)
57+ }
58+ }
59+}
app/src/test/java/com/parseable/android/LogViewerViewModelTest.ktnew file+286
@@ -0,0 +1,286 @@
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("\"message\" ILIKE '%error%'") &&
168+ it.contains("\"level\" ILIKE '%error%'") &&
169+ !it.contains("p_timestamp ILIKE") // 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 `toggleStreaming starts and stops`() {
190+ viewModel.initialize("test")
191+
192+ viewModel.toggleStreaming()
193+ assertTrue(viewModel.state.value.isStreaming)
194+
195+ viewModel.toggleStreaming()
196+ assertFalse(viewModel.state.value.isStreaming)
197+ }
198+
199+ @Test
200+ fun `stopStreaming stops but preserves streaming error`() {
201+ viewModel.initialize("test")
202+ viewModel.toggleStreaming()
203+ viewModel.stopStreaming()
204+
205+ val state = viewModel.state.value
206+ assertFalse(state.isStreaming)
207+ }
208+
209+ @Test
210+ fun `dismissStreamingError clears the error`() {
211+ viewModel.initialize("test")
212+ viewModel.toggleStreaming()
213+ viewModel.stopStreaming()
214+ viewModel.dismissStreamingError()
215+
216+ assertNull(viewModel.state.value.streamingError)
217+ }
218+
219+ @Test
220+ fun `executeCustomSql rejects non-SELECT queries`() {
221+ viewModel.initialize("test")
222+ viewModel.executeCustomSql("DELETE FROM test_stream")
223+
224+ assertEquals("Only SELECT queries are allowed", viewModel.state.value.error)
225+ }
226+
227+ @Test
228+ fun `executeCustomSql appends LIMIT when missing`() {
229+ coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
230+
231+ viewModel.initialize("test")
232+ viewModel.executeCustomSql("SELECT * FROM test_stream")
233+
234+ coVerify {
235+ repository.queryLogsRaw(
236+ match { it.contains("LIMIT 5000") },
237+ any(),
238+ any(),
239+ )
240+ }
241+ }
242+
243+ @Test
244+ fun `executeCustomSql preserves existing LIMIT`() {
245+ coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
246+
247+ viewModel.initialize("test")
248+ viewModel.executeCustomSql("SELECT * FROM test_stream LIMIT 50")
249+
250+ coVerify {
251+ repository.queryLogsRaw(
252+ match { it.trim() == "SELECT * FROM test_stream LIMIT 50" },
253+ any(),
254+ any(),
255+ )
256+ }
257+ }
258+
259+ @Test
260+ fun `addFilter escapes column names`() {
261+ viewModel.initialize("test")
262+ viewModel.addFilter("col\"name", "=", "value")
263+
264+ val clause = viewModel.state.value.filterClauses[0]
265+ assertTrue(clause.contains("col\"\"name")) // Double-escaped
266+ }
267+
268+ @Test
269+ fun `addFilter escapes SQL values`() {
270+ viewModel.initialize("test")
271+ viewModel.addFilter("col", "=", "it's")
272+
273+ val clause = viewModel.state.value.filterClauses[0]
274+ assertTrue(clause.contains("it''s")) // Escaped quote
275+ }
276+
277+ @Test
278+ fun `addFilter handles IS NULL without value`() {
279+ viewModel.initialize("test")
280+ viewModel.addFilter("col", "IS NULL", "")
281+
282+ val clause = viewModel.state.value.filterClauses[0]
283+ assertTrue(clause.endsWith("IS NULL"))
284+ assertFalse(clause.contains("''"))
285+ }
286+}
app/src/test/java/com/parseable/android/LoginViewModelTest.ktnew file+177
@@ -0,0 +1,177 @@
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.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 LoginViewModelTest {
20+
21+ private lateinit var repository: ParseableRepository
22+ private lateinit var settingsRepository: SettingsRepository
23+ private lateinit var viewModel: LoginViewModel
24+ private val testDispatcher = UnconfinedTestDispatcher()
25+
26+ @Before
27+ fun setup() {
28+ Dispatchers.setMain(testDispatcher)
29+ repository = mockk(relaxed = true)
30+ settingsRepository = mockk(relaxed = true)
31+ coEvery { settingsRepository.serverConfig } returns flowOf(null)
32+ viewModel = LoginViewModel(repository, settingsRepository)
33+ }
34+
35+ @After
36+ fun tearDown() {
37+ Dispatchers.resetMain()
38+ }
39+
40+ @Test
41+ fun `onLogin validates required fields`() {
42+ viewModel.onLogin()
43+
44+ val state = viewModel.state.value
45+ assertNotNull(state.serverUrlError)
46+ assertNotNull(state.usernameError)
47+ assertNotNull(state.passwordError)
48+ assertFalse(state.isLoading)
49+ }
50+
51+ @Test
52+ fun `onLogin validates URL only when others filled`() {
53+ viewModel.onServerUrlChange("")
54+ viewModel.onUsernameChange("admin")
55+ viewModel.onPasswordChange("password")
56+ viewModel.onLogin()
57+
58+ val state = viewModel.state.value
59+ assertNotNull(state.serverUrlError)
60+ assertNull(state.usernameError)
61+ assertNull(state.passwordError)
62+ }
63+
64+ @Test
65+ fun `onLogin succeeds with valid credentials`() = runTest {
66+ coEvery { repository.testConnection() } returns ApiResult.Success("ok")
67+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo(version = "1.0"))
68+
69+ viewModel.onServerUrlChange("https://test.parseable.com")
70+ viewModel.onUsernameChange("admin")
71+ viewModel.onPasswordChange("password")
72+ viewModel.onLogin()
73+
74+ val state = viewModel.state.value
75+ assertTrue(state.loginSuccess)
76+ assertFalse(state.isLoading)
77+ coVerify { settingsRepository.saveServerConfig(any()) }
78+ }
79+
80+ @Test
81+ fun `onLogin fails when connection fails`() = runTest {
82+ coEvery { repository.testConnection() } returns ApiResult.Error("Connection refused", 0)
83+
84+ viewModel.onServerUrlChange("https://bad.server")
85+ viewModel.onUsernameChange("admin")
86+ viewModel.onPasswordChange("password")
87+ viewModel.onLogin()
88+
89+ val state = viewModel.state.value
90+ assertFalse(state.loginSuccess)
91+ assertNotNull(state.error)
92+ assertTrue(state.error!!.contains("Connection failed"))
93+ }
94+
95+ @Test
96+ fun `onLogin fails when about check fails`() = runTest {
97+ coEvery { repository.testConnection() } returns ApiResult.Success("ok")
98+ coEvery { repository.getAbout() } returns ApiResult.Error("not parseable", 404)
99+
100+ viewModel.onServerUrlChange("https://test.com")
101+ viewModel.onUsernameChange("admin")
102+ viewModel.onPasswordChange("password")
103+ viewModel.onLogin()
104+
105+ val state = viewModel.state.value
106+ assertFalse(state.loginSuccess)
107+ assertNotNull(state.error)
108+ assertTrue(state.error!!.contains("doesn't appear to be Parseable"))
109+ }
110+
111+ @Test
112+ fun `onServerUrlChange clears error`() {
113+ viewModel.onLogin() // triggers error
114+ assertNotNull(viewModel.state.value.serverUrlError)
115+
116+ viewModel.onServerUrlChange("test")
117+ assertNull(viewModel.state.value.serverUrlError)
118+ assertNull(viewModel.state.value.error)
119+ }
120+
121+ @Test
122+ fun `onAllowInsecureChange updates state`() {
123+ assertFalse(viewModel.state.value.allowInsecure)
124+ viewModel.onAllowInsecureChange(true)
125+ assertTrue(viewModel.state.value.allowInsecure)
126+ }
127+
128+ @Test
129+ fun `does not auto-populate password from saved config`() = runTest {
130+ val config = com.parseable.android.data.model.ServerConfig(
131+ serverUrl = "https://test.com",
132+ username = "admin",
133+ password = "secret",
134+ useTls = true,
135+ )
136+ coEvery { settingsRepository.serverConfig } returns flowOf(config)
137+
138+ viewModel = LoginViewModel(repository, settingsRepository)
139+
140+ val state = viewModel.state.value
141+ assertEquals("https://test.com", state.serverUrl)
142+ assertEquals("admin", state.username)
143+ assertEquals("", state.password) // Password should NOT be auto-populated
144+ assertTrue(state.hasSavedCredentials)
145+ }
146+
147+ @Test
148+ fun `onLogin prepends https when not insecure`() = runTest {
149+ coEvery { repository.testConnection() } returns ApiResult.Success("ok")
150+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
151+
152+ viewModel.onServerUrlChange("test.parseable.com")
153+ viewModel.onUsernameChange("admin")
154+ viewModel.onPasswordChange("pass")
155+ viewModel.onLogin()
156+
157+ coVerify {
158+ repository.configure(match { it.serverUrl == "https://test.parseable.com" })
159+ }
160+ }
161+
162+ @Test
163+ fun `onLogin prepends http when insecure`() = runTest {
164+ coEvery { repository.testConnection() } returns ApiResult.Success("ok")
165+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
166+
167+ viewModel.onServerUrlChange("test.parseable.com")
168+ viewModel.onAllowInsecureChange(true)
169+ viewModel.onUsernameChange("admin")
170+ viewModel.onPasswordChange("pass")
171+ viewModel.onLogin()
172+
173+ coVerify {
174+ repository.configure(match { it.serverUrl == "http://test.parseable.com" })
175+ }
176+ }
177+}
app/src/test/java/com/parseable/android/ParseableRepositoryTest.ktnew file+173
@@ -0,0 +1,173 @@
1+package com.parseable.android
2+
3+import com.parseable.android.data.api.ParseableApiClient
4+import com.parseable.android.data.model.*
5+import com.parseable.android.data.repository.ParseableRepository
6+import io.mockk.*
7+import kotlinx.coroutines.test.runTest
8+import org.junit.Assert.*
9+import org.junit.Before
10+import org.junit.Test
11+
12+class ParseableRepositoryTest {
13+
14+ private lateinit var apiClient: ParseableApiClient
15+ private lateinit var repository: ParseableRepository
16+
17+ @Before
18+ fun setup() {
19+ apiClient = mockk(relaxed = true)
20+ repository = ParseableRepository(apiClient)
21+ }
22+
23+ @Test
24+ fun `getAbout caches result on success`() = runTest {
25+ val aboutInfo = AboutInfo(version = "1.0.0", mode = "standalone")
26+ coEvery { apiClient.getAbout() } returns ApiResult.Success(aboutInfo)
27+
28+ val first = repository.getAbout()
29+ val second = repository.getAbout()
30+
31+ assertTrue(first is ApiResult.Success)
32+ assertEquals("1.0.0", (first as ApiResult.Success).data.version)
33+ // Should only call API once due to caching
34+ coVerify(exactly = 1) { apiClient.getAbout() }
35+ }
36+
37+ @Test
38+ fun `getAbout does not cache errors`() = runTest {
39+ coEvery { apiClient.getAbout() } returns ApiResult.Error("fail", 500)
40+
41+ val first = repository.getAbout()
42+ repository.getAbout()
43+
44+ assertTrue(first is ApiResult.Error)
45+ coVerify(exactly = 2) { apiClient.getAbout() }
46+ }
47+
48+ @Test
49+ fun `listStreams forceRefresh bypasses cache`() = runTest {
50+ val streams = listOf(LogStream(name = "test"))
51+ coEvery { apiClient.listStreams() } returns ApiResult.Success(streams)
52+
53+ repository.listStreams()
54+ repository.listStreams(forceRefresh = true)
55+
56+ coVerify(exactly = 2) { apiClient.listStreams() }
57+ }
58+
59+ @Test
60+ fun `listStreams returns cached data within TTL`() = runTest {
61+ val streams = listOf(LogStream(name = "test"))
62+ coEvery { apiClient.listStreams() } returns ApiResult.Success(streams)
63+
64+ repository.listStreams()
65+ val cached = repository.listStreams(forceRefresh = false)
66+
67+ assertTrue(cached is ApiResult.Success)
68+ assertEquals(1, (cached as ApiResult.Success).data.size)
69+ coVerify(exactly = 1) { apiClient.listStreams() }
70+ }
71+
72+ @Test
73+ fun `invalidateAll clears all caches`() = runTest {
74+ val about = AboutInfo(version = "1.0.0")
75+ val streams = listOf(LogStream(name = "s1"))
76+ coEvery { apiClient.getAbout() } returns ApiResult.Success(about)
77+ coEvery { apiClient.listStreams() } returns ApiResult.Success(streams)
78+
79+ repository.getAbout()
80+ repository.listStreams()
81+ repository.invalidateAll()
82+ repository.getAbout()
83+ repository.listStreams()
84+
85+ coVerify(exactly = 2) { apiClient.getAbout() }
86+ coVerify(exactly = 2) { apiClient.listStreams() }
87+ }
88+
89+ @Test
90+ fun `configure invalidates cache and configures client`() = runTest {
91+ val config = ServerConfig("https://test.com", "user", "pass", true)
92+ repository.configure(config)
93+ verify { apiClient.configure(config) }
94+ }
95+
96+ @Test
97+ fun `auth error triggers channel`() = runTest {
98+ coEvery { apiClient.listStreams() } returns ApiResult.Error("Unauthorized", 401)
99+
100+ val result = repository.listStreams()
101+
102+ assertTrue(result is ApiResult.Error)
103+ assertTrue((result as ApiResult.Error).isUnauthorized)
104+ }
105+
106+ @Test
107+ fun `queryLogs builds correct SQL with filter`() = runTest {
108+ coEvery { apiClient.queryLogs(any(), any(), any()) } returns ApiResult.Success(emptyList())
109+
110+ repository.queryLogs(
111+ stream = "my_stream",
112+ startTime = "2024-01-01T00:00:00Z",
113+ endTime = "2024-01-02T00:00:00Z",
114+ filterSql = "\"level\" = 'ERROR'",
115+ limit = 100,
116+ )
117+
118+ coVerify {
119+ apiClient.queryLogs(
120+ match { it.contains("WHERE \"level\" = 'ERROR'") && it.contains("LIMIT 100") },
121+ "2024-01-01T00:00:00Z",
122+ "2024-01-02T00:00:00Z",
123+ )
124+ }
125+ }
126+
127+ @Test
128+ fun `queryLogs without filter builds SQL without WHERE`() = runTest {
129+ coEvery { apiClient.queryLogs(any(), any(), any()) } returns ApiResult.Success(emptyList())
130+
131+ repository.queryLogs(
132+ stream = "test",
133+ startTime = "start",
134+ endTime = "end",
135+ )
136+
137+ coVerify {
138+ apiClient.queryLogs(
139+ match { !it.contains("WHERE") && it.contains("\"test\"") },
140+ "start",
141+ "end",
142+ )
143+ }
144+ }
145+
146+ @Test
147+ fun `deleteStream invalidates cache`() = runTest {
148+ val streams = listOf(LogStream(name = "s1"))
149+ coEvery { apiClient.listStreams() } returns ApiResult.Success(streams)
150+ coEvery { apiClient.deleteStream(any()) } returns ApiResult.Success("ok")
151+
152+ repository.listStreams()
153+ repository.deleteStream("s1")
154+ repository.listStreams()
155+
156+ // After delete, cache was invalidated, so listStreams called twice
157+ coVerify(exactly = 2) { apiClient.listStreams() }
158+ }
159+
160+ @Test
161+ fun `getStreamSchema caches per stream`() = runTest {
162+ val schema = StreamSchema(fields = listOf(SchemaField(name = "col1")))
163+ coEvery { apiClient.getStreamSchema("s1") } returns ApiResult.Success(schema)
164+ coEvery { apiClient.getStreamSchema("s2") } returns ApiResult.Success(schema)
165+
166+ repository.getStreamSchema("s1")
167+ repository.getStreamSchema("s1") // cached
168+ repository.getStreamSchema("s2") // different stream, not cached
169+
170+ coVerify(exactly = 1) { apiClient.getStreamSchema("s1") }
171+ coVerify(exactly = 1) { apiClient.getStreamSchema("s2") }
172+ }
173+}
app/src/test/java/com/parseable/android/SettingsViewModelTest.ktnew file+166
@@ -0,0 +1,166 @@
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 com.parseable.android.data.repository.ParseableRepository
7+import com.parseable.android.data.repository.SettingsRepository
8+import com.parseable.android.ui.screens.settings.SettingsViewModel
9+import io.mockk.*
10+import kotlinx.coroutines.Dispatchers
11+import kotlinx.coroutines.ExperimentalCoroutinesApi
12+import kotlinx.coroutines.flow.flowOf
13+import kotlinx.coroutines.test.*
14+import kotlinx.serialization.json.JsonObject
15+import kotlinx.serialization.json.JsonPrimitive
16+import org.junit.After
17+import org.junit.Assert.*
18+import org.junit.Before
19+import org.junit.Test
20+
21+@OptIn(ExperimentalCoroutinesApi::class)
22+class SettingsViewModelTest {
23+
24+ private lateinit var settingsRepository: SettingsRepository
25+ private lateinit var repository: ParseableRepository
26+ private val testDispatcher = UnconfinedTestDispatcher()
27+
28+ @Before
29+ fun setup() {
30+ Dispatchers.setMain(testDispatcher)
31+ settingsRepository = mockk(relaxed = true)
32+ repository = mockk(relaxed = true)
33+ }
34+
35+ @After
36+ fun tearDown() {
37+ Dispatchers.resetMain()
38+ }
39+
40+ @Test
41+ fun `load populates server config from settings`() = runTest {
42+ val config = ServerConfig(
43+ serverUrl = "https://logs.example.com",
44+ username = "admin",
45+ password = "secret",
46+ useTls = true,
47+ )
48+ coEvery { settingsRepository.serverConfig } returns flowOf(config)
49+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo(version = "1.2.0"))
50+ coEvery { repository.listUsers() } returns ApiResult.Success(emptyList())
51+
52+ val viewModel = SettingsViewModel(settingsRepository, repository)
53+
54+ val state = viewModel.state.value
55+ assertEquals("https://logs.example.com", state.serverUrl)
56+ assertEquals("admin", state.username)
57+ assertTrue(state.useTls)
58+ assertFalse(state.isLoading)
59+ }
60+
61+ @Test
62+ fun `load sets about info on success`() = runTest {
63+ val about = AboutInfo(version = "1.2.0", mode = "standalone", license = "AGPL")
64+ coEvery { settingsRepository.serverConfig } returns flowOf(null)
65+ coEvery { repository.getAbout() } returns ApiResult.Success(about)
66+ coEvery { repository.listUsers() } returns ApiResult.Success(emptyList())
67+
68+ val viewModel = SettingsViewModel(settingsRepository, repository)
69+
70+ val state = viewModel.state.value
71+ assertEquals("1.2.0", state.aboutInfo?.version)
72+ assertEquals("standalone", state.aboutInfo?.mode)
73+ assertNull(state.error)
74+ }
75+
76+ @Test
77+ fun `load parses user list from json objects`() = runTest {
78+ val users = listOf(
79+ JsonObject(mapOf("id" to JsonPrimitive("user1"))),
80+ JsonObject(mapOf("username" to JsonPrimitive("user2"))),
81+ )
82+ coEvery { settingsRepository.serverConfig } returns flowOf(null)
83+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
84+ coEvery { repository.listUsers() } returns ApiResult.Success(users)
85+
86+ val viewModel = SettingsViewModel(settingsRepository, repository)
87+
88+ val state = viewModel.state.value
89+ assertEquals(listOf("user1", "user2"), state.users)
90+ assertFalse(state.isLoading)
91+ }
92+
93+ @Test
94+ fun `load sets error when about fails`() = runTest {
95+ coEvery { settingsRepository.serverConfig } returns flowOf(null)
96+ coEvery { repository.getAbout() } returns ApiResult.Error("Server error", 500)
97+ coEvery { repository.listUsers() } returns ApiResult.Success(emptyList())
98+
99+ val viewModel = SettingsViewModel(settingsRepository, repository)
100+
101+ val state = viewModel.state.value
102+ assertNotNull(state.error)
103+ assertNull(state.aboutInfo)
104+ assertFalse(state.isLoading)
105+ }
106+
107+ @Test
108+ fun `load sets error when users fail`() = runTest {
109+ coEvery { settingsRepository.serverConfig } returns flowOf(null)
110+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
111+ coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
112+
113+ val viewModel = SettingsViewModel(settingsRepository, repository)
114+
115+ val state = viewModel.state.value
116+ assertNotNull(state.error)
117+ assertTrue(state.users.isEmpty())
118+ }
119+
120+ @Test
121+ fun `load consolidates errors from both about and users`() = runTest {
122+ coEvery { settingsRepository.serverConfig } returns flowOf(null)
123+ coEvery { repository.getAbout() } returns ApiResult.Error("Server error", 500)
124+ coEvery { repository.listUsers() } returns ApiResult.Error("Forbidden", 403)
125+
126+ val viewModel = SettingsViewModel(settingsRepository, repository)
127+
128+ val state = viewModel.state.value
129+ assertNotNull(state.error)
130+ // Both error messages should be present
131+ assertTrue(state.error!!.contains("Server error"))
132+ assertTrue(state.error!!.contains("Permission denied"))
133+ }
134+
135+ @Test
136+ fun `load resets isLoading on unexpected exception`() = runTest {
137+ coEvery { settingsRepository.serverConfig } returns flowOf(null)
138+ coEvery { repository.getAbout() } throws RuntimeException("unexpected")
139+ coEvery { repository.listUsers() } returns ApiResult.Success(emptyList())
140+
141+ val viewModel = SettingsViewModel(settingsRepository, repository)
142+
143+ val state = viewModel.state.value
144+ assertFalse(state.isLoading)
145+ assertNotNull(state.error)
146+ assertTrue(state.error!!.contains("unexpected"))
147+ }
148+
149+ @Test
150+ fun `load can be called again to refresh`() = runTest {
151+ coEvery { settingsRepository.serverConfig } returns flowOf(null)
152+ coEvery { repository.getAbout() } returns ApiResult.Error("fail", 500)
153+ coEvery { repository.listUsers() } returns ApiResult.Success(emptyList())
154+
155+ val viewModel = SettingsViewModel(settingsRepository, repository)
156+ assertNotNull(viewModel.state.value.error)
157+
158+ // Fix the mock and reload
159+ coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo(version = "2.0"))
160+ viewModel.load()
161+
162+ val state = viewModel.state.value
163+ assertNull(state.error)
164+ assertEquals("2.0", state.aboutInfo?.version)
165+ }
166+}
app/src/test/java/com/parseable/android/StreamInfoViewModelTest.ktnew file+204
@@ -0,0 +1,204 @@
1+package com.parseable.android
2+
3+import com.parseable.android.data.model.*
4+import com.parseable.android.data.repository.ParseableRepository
5+import com.parseable.android.ui.screens.logviewer.StreamInfoViewModel
6+import io.mockk.*
7+import kotlinx.coroutines.Dispatchers
8+import kotlinx.coroutines.ExperimentalCoroutinesApi
9+import kotlinx.coroutines.test.*
10+import kotlinx.serialization.json.JsonObject
11+import kotlinx.serialization.json.JsonPrimitive
12+import org.junit.After
13+import org.junit.Assert.*
14+import org.junit.Before
15+import org.junit.Test
16+
17+@OptIn(ExperimentalCoroutinesApi::class)
18+class StreamInfoViewModelTest {
19+
20+ private lateinit var repository: ParseableRepository
21+ private lateinit var viewModel: StreamInfoViewModel
22+ private val testDispatcher = UnconfinedTestDispatcher()
23+
24+ @Before
25+ fun setup() {
26+ Dispatchers.setMain(testDispatcher)
27+ repository = mockk(relaxed = true)
28+ viewModel = StreamInfoViewModel(repository)
29+ }
30+
31+ @After
32+ fun tearDown() {
33+ Dispatchers.resetMain()
34+ }
35+
36+ @Test
37+ fun `initial state is empty`() {
38+ val state = viewModel.state.value
39+ assertEquals("", state.streamName)
40+ assertNull(state.stats)
41+ assertTrue(state.schema.isEmpty())
42+ assertTrue(state.retention.isEmpty())
43+ assertNull(state.rawInfo)
44+ assertFalse(state.isLoading)
45+ assertNull(state.error)
46+ assertFalse(state.isDeleting)
47+ assertFalse(state.deleteSuccess)
48+ }
49+
50+ @Test
51+ fun `load fetches all stream info in parallel`() = runTest {
52+ val stats = StreamStats(
53+ ingestion = IngestionStats(count = 1000, size = "5MB"),
54+ storage = StorageStats(size = "2MB"),
55+ )
56+ val schema = StreamSchema(
57+ fields = listOf(
58+ SchemaField(name = "timestamp"),
59+ SchemaField(name = "message"),
60+ ),
61+ )
62+ val retention = listOf(RetentionConfig(duration = "30d", action = "delete"))
63+ val info = JsonObject(mapOf("created_at" to JsonPrimitive("2024-01-01")))
64+
65+ coEvery { repository.getStreamStats("test-stream") } returns ApiResult.Success(stats)
66+ coEvery { repository.getStreamSchema("test-stream") } returns ApiResult.Success(schema)
67+ coEvery { repository.getStreamRetention("test-stream") } returns ApiResult.Success(retention)
68+ coEvery { repository.getStreamInfo("test-stream") } returns ApiResult.Success(info)
69+
70+ viewModel.load("test-stream")
71+
72+ val state = viewModel.state.value
73+ assertEquals("test-stream", state.streamName)
74+ assertEquals(1000L, state.stats?.ingestion?.count)
75+ assertEquals(2, state.schema.size)
76+ assertEquals(1, state.retention.size)
77+ assertNotNull(state.rawInfo)
78+ assertFalse(state.isLoading)
79+ assertNull(state.error)
80+ }
81+
82+ @Test
83+ fun `load shows error when some requests fail`() = runTest {
84+ coEvery { repository.getStreamStats("s1") } returns ApiResult.Error("Stats error", 500)
85+ coEvery { repository.getStreamSchema("s1") } returns ApiResult.Success(StreamSchema())
86+ coEvery { repository.getStreamRetention("s1") } returns ApiResult.Success(emptyList())
87+ coEvery { repository.getStreamInfo("s1") } returns ApiResult.Error("Info error", 500)
88+
89+ viewModel.load("s1")
90+
91+ val state = viewModel.state.value
92+ assertNotNull(state.error)
93+ assertFalse(state.isLoading)
94+ // Successful results should still be populated
95+ assertTrue(state.schema.isEmpty()) // empty but loaded
96+ assertTrue(state.retention.isEmpty())
97+ }
98+
99+ @Test
100+ fun `load sets schemaFailed when schema request fails`() = runTest {
101+ coEvery { repository.getStreamStats("s1") } returns ApiResult.Success(StreamStats())
102+ coEvery { repository.getStreamSchema("s1") } returns ApiResult.Error("Schema error", 500)
103+ coEvery { repository.getStreamRetention("s1") } returns ApiResult.Success(emptyList())
104+ coEvery { repository.getStreamInfo("s1") } returns ApiResult.Success(JsonObject(emptyMap()))
105+
106+ viewModel.load("s1")
107+
108+ val state = viewModel.state.value
109+ assertTrue(state.schemaFailed)
110+ assertTrue(state.schema.isEmpty())
111+ assertFalse(state.isLoading)
112+ }
113+
114+ @Test
115+ fun `load clears schemaFailed on successful schema load`() = runTest {
116+ coEvery { repository.getStreamStats("s1") } returns ApiResult.Success(StreamStats())
117+ coEvery { repository.getStreamSchema("s1") } returns ApiResult.Success(
118+ StreamSchema(fields = listOf(SchemaField(name = "msg")))
119+ )
120+ coEvery { repository.getStreamRetention("s1") } returns ApiResult.Success(emptyList())
121+ coEvery { repository.getStreamInfo("s1") } returns ApiResult.Success(JsonObject(emptyMap()))
122+
123+ viewModel.load("s1")
124+
125+ val state = viewModel.state.value
126+ assertFalse(state.schemaFailed)
127+ assertEquals(1, state.schema.size)
128+ }
129+
130+ @Test
131+ fun `load cancels previous load when called again`() = runTest {
132+ coEvery { repository.getStreamStats(any()) } returns ApiResult.Success(StreamStats())
133+ coEvery { repository.getStreamSchema(any()) } returns ApiResult.Success(StreamSchema())
134+ coEvery { repository.getStreamRetention(any()) } returns ApiResult.Success(emptyList())
135+ coEvery { repository.getStreamInfo(any()) } returns ApiResult.Success(JsonObject(emptyMap()))
136+
137+ viewModel.load("stream-a")
138+ viewModel.load("stream-b")
139+
140+ val state = viewModel.state.value
141+ assertEquals("stream-b", state.streamName)
142+ }
143+
144+ @Test
145+ fun `deleteStream sets success on API success`() = runTest {
146+ coEvery { repository.getStreamStats(any()) } returns ApiResult.Success(StreamStats())
147+ coEvery { repository.getStreamSchema(any()) } returns ApiResult.Success(StreamSchema())
148+ coEvery { repository.getStreamRetention(any()) } returns ApiResult.Success(emptyList())
149+ coEvery { repository.getStreamInfo(any()) } returns ApiResult.Success(JsonObject(emptyMap()))
150+ coEvery { repository.deleteStream("my-stream") } returns ApiResult.Success("deleted")
151+
152+ viewModel.load("my-stream")
153+ viewModel.deleteStream()
154+
155+ val state = viewModel.state.value
156+ assertTrue(state.deleteSuccess)
157+ assertFalse(state.isDeleting)
158+ assertNull(state.error)
159+ }
160+
161+ @Test
162+ fun `deleteStream sets error on API failure`() = runTest {
163+ coEvery { repository.getStreamStats(any()) } returns ApiResult.Success(StreamStats())
164+ coEvery { repository.getStreamSchema(any()) } returns ApiResult.Success(StreamSchema())
165+ coEvery { repository.getStreamRetention(any()) } returns ApiResult.Success(emptyList())
166+ coEvery { repository.getStreamInfo(any()) } returns ApiResult.Success(JsonObject(emptyMap()))
167+ coEvery { repository.deleteStream("my-stream") } returns ApiResult.Error("Forbidden", 403)
168+
169+ viewModel.load("my-stream")
170+ viewModel.deleteStream()
171+
172+ val state = viewModel.state.value
173+ assertFalse(state.deleteSuccess)
174+ assertFalse(state.isDeleting)
175+ assertNotNull(state.error)
176+ assertTrue(state.error!!.contains("Delete failed"))
177+ }
178+
179+ @Test
180+ fun `deleteStream does nothing when streamName is empty`() = runTest {
181+ viewModel.deleteStream()
182+
183+ val state = viewModel.state.value
184+ assertFalse(state.isDeleting)
185+ assertFalse(state.deleteSuccess)
186+ coVerify(exactly = 0) { repository.deleteStream(any()) }
187+ }
188+
189+ @Test
190+ fun `consumeDeleteSuccess resets flag`() = runTest {
191+ coEvery { repository.getStreamStats(any()) } returns ApiResult.Success(StreamStats())
192+ coEvery { repository.getStreamSchema(any()) } returns ApiResult.Success(StreamSchema())
193+ coEvery { repository.getStreamRetention(any()) } returns ApiResult.Success(emptyList())
194+ coEvery { repository.getStreamInfo(any()) } returns ApiResult.Success(JsonObject(emptyMap()))
195+ coEvery { repository.deleteStream("s") } returns ApiResult.Success("ok")
196+
197+ viewModel.load("s")
198+ viewModel.deleteStream()
199+ assertTrue(viewModel.state.value.deleteSuccess)
200+
201+ viewModel.consumeDeleteSuccess()
202+ assertFalse(viewModel.state.value.deleteSuccess)
203+ }
204+}
app/src/test/java/com/parseable/android/StreamsViewModelTest.ktnew file+152
@@ -0,0 +1,152 @@
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+
147+ viewModel = StreamsViewModel(repository, settingsRepository, favoriteDao)
148+ viewModel.toggleFavorite("existing")
149+
150+ coVerify { favoriteDao.deleteByName("existing") }
151+ }
152+}
build.gradle.ktsnew file+8
@@ -0,0 +1,8 @@
1+plugins {
2+ alias(libs.plugins.android.application) apply false
3+ alias(libs.plugins.kotlin.android) apply false
4+ alias(libs.plugins.kotlin.compose) apply false
5+ alias(libs.plugins.kotlin.serialization) apply false
6+ alias(libs.plugins.hilt) apply false
7+ alias(libs.plugins.ksp) apply false
8+}
gradle.propertiesnew file+5
@@ -0,0 +1,5 @@
1+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2+android.useAndroidX=true
3+kotlin.code.style=official
4+android.nonTransitiveRClass=true
5+android.enableR8.fullMode=true
gradle/libs.versions.tomlnew file+93
@@ -0,0 +1,93 @@
1+[versions]
2+# App versioning
3+appVersionCode = "1"
4+appVersionName = "1.0.0"
5+
6+agp = "8.7.3"
7+kotlin = "2.1.0"
8+ksp = "2.1.0-1.0.29"
9+hilt = "2.53.1"
10+okhttp = "4.12.0"
11+room = "2.6.1"
12+compose-bom = "2024.12.01"
13+coroutines = "1.9.0"
14+serialization = "1.7.3"
15+lifecycle = "2.8.7"
16+navigation = "2.8.5"
17+core-ktx = "1.15.0"
18+activity-compose = "1.9.3"
19+datastore = "1.1.1"
20+security-crypto = "1.1.0-alpha06"
21+hilt-navigation = "1.2.0"
22+timber = "5.0.1"
23+
24+# Testing
25+junit = "4.13.2"
26+mockk = "1.13.13"
27+android-test-ext = "1.2.1"
28+espresso = "3.6.1"
29+
30+[libraries]
31+# Core Android
32+core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
33+lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
34+lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
35+activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" }
36+
37+# Compose
38+compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
39+compose-ui = { module = "androidx.compose.ui:ui" }
40+compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
41+compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
42+compose-material3 = { module = "androidx.compose.material3:material3" }
43+compose-material-icons = { module = "androidx.compose.material:material-icons-extended" }
44+
45+# Navigation
46+navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
47+
48+# Hilt DI
49+hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
50+hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
51+hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-navigation" }
52+
53+# Networking
54+okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
55+okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
56+
57+# Serialization
58+kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
59+
60+# DataStore
61+datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
62+
63+# Encrypted storage
64+security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" }
65+
66+# Room
67+room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
68+room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
69+room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
70+
71+# Coroutines
72+coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
73+
74+# Logging
75+timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
76+
77+# Testing
78+junit = { module = "junit:junit", version.ref = "junit" }
79+coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
80+mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
81+android-test-ext = { module = "androidx.test.ext:junit", version.ref = "android-test-ext" }
82+espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
83+compose-ui-test = { module = "androidx.compose.ui:ui-test-junit4" }
84+compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
85+compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
86+
87+[plugins]
88+android-application = { id = "com.android.application", version.ref = "agp" }
89+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
90+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
91+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
92+hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
93+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
gradle/wrapper/gradle-wrapper.propertiesnew file+7
@@ -0,0 +1,7 @@
1+distributionBase=GRADLE_USER_HOME
2+distributionPath=wrapper/dists
3+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
4+networkTimeout=10000
5+validateDistributionUrl=true
6+zipStoreBase=GRADLE_USER_HOME
7+zipStorePath=wrapper/dists
gradlewnew file+168
@@ -0,0 +1,168 @@
1+#!/bin/sh
2+
3+#
4+# Copyright © 2015-2021 the original authors.
5+#
6+# Licensed under the Apache License, Version 2.0 (the "License");
7+# you may not use this file except in compliance with the License.
8+# You may obtain a copy of the License at
9+#
10+# https://www.apache.org/licenses/LICENSE-2.0
11+#
12+# Unless required by applicable law or agreed to in writing, software
13+# distributed under the License is distributed on an "AS IS" BASIS,
14+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+# See the License for the specific language governing permissions and
16+# limitations under the License.
17+#
18+
19+##############################################################################
20+#
21+# Gradle start up script for POSIX generated by Gradle.
22+#
23+##############################################################################
24+
25+# Attempt to set APP_HOME
26+
27+# Resolve links: $0 may be a link
28+app_path=$0
29+
30+# Need this for daisy-chained symlinks.
31+while
32+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
33+ [ -h "$app_path" ]
34+do
35+ ls=$( ls -ld -- "$app_path" )
36+ link=${ls#*' -> '}
37+ case $link in #(
38+ /*) app_path=$link ;; #(
39+ *) app_path=$APP_HOME$link ;;
40+ esac
41+done
42+
43+# This is normally unused
44+# shellcheck disable=SC2034
45+APP_BASE_NAME=${0##*/}
46+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
47+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
48+
49+# Use the maximum available, or set MAX_FD != -1 to use that value.
50+MAX_FD=maximum
51+
52+warn () {
53+ echo "$*"
54+} >&2
55+
56+die () {
57+ echo
58+ echo "$*"
59+ echo
60+ exit 1
61+} >&2
62+
63+# OS specific support (must be 'true' or 'false').
64+cygwin=false
65+msys=false
66+darwin=false
67+nonstop=false
68+case "$( uname )" in #(
69+ CYGWIN* ) cygwin=true ;; #(
70+ Darwin* ) darwin=true ;; #(
71+ MSYS* | MINGW* ) msys=true ;; #(
72+ NonStop* ) nonstop=true ;;
73+esac
74+
75+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
76+
77+
78+# Determine the Java command to use to start the JVM.
79+if [ -n "$JAVA_HOME" ] ; then
80+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
81+ # IBM's JDK on AIX uses strange locations for the executables
82+ JAVACMD=$JAVA_HOME/jre/sh/java
83+ else
84+ JAVACMD=$JAVA_HOME/bin/java
85+ fi
86+ if [ ! -x "$JAVACMD" ] ; then
87+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
88+
89+Please set the JAVA_HOME variable in your environment to match the
90+location of your Java installation."
91+ fi
92+else
93+ JAVACMD=java
94+ if ! command -v java >/dev/null 2>&1 ; then
95+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
96+
97+Please set the JAVA_HOME variable in your environment to match the
98+location of your Java installation."
99+ fi
100+fi
101+
102+# Increase the maximum file descriptors if we can.
103+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
104+ case $MAX_FD in #(
105+ max*)
106+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
107+ # shellcheck disable=SC2039,SC3045
108+ MAX_FD=$( ulimit -H -n ) ||
109+ warn "Could not query maximum file descriptor limit"
110+ ;;
111+ esac
112+ case $MAX_FD in #(
113+ '' | soft) :;; #(
114+ *)
115+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
116+ # shellcheck disable=SC2039,SC3045
117+ ulimit -n "$MAX_FD" ||
118+ warn "Could not set maximum file descriptor limit to $MAX_FD"
119+ ;;
120+ esac
121+fi
122+
123+# Collect all arguments for the java command, stacking://them after any JVM arguments
124+# in DEFAULT_JVM_OPTS or JAVA_OPTS.
125+collect_all_args () {
126+ for arg do
127+ case $arg in
128+ -*=*) set -- "$@" "${arg%%=*}" "${arg#*=}" ;;
129+ *) set -- "$@" "$arg" ;;
130+ esac
131+ shift
132+ done
133+ printf '%s\n' "$@"
134+}
135+
136+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
137+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
138+
139+# Collect all arguments for the java command;
140+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
141+# shell script including quotes and variable substitutions, so put them in
142+# temporary files and parse them as such
143+# * Put all other arguments in the args array
144+
145+set -- \
146+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
147+ -classpath "$CLASSPATH" \
148+ org.gradle.wrapper.GradleWrapperMain \
149+ "$@"
150+
151+# Stop when "xargs" is not available.
152+if ! command -v xargs >/dev/null 2>&1
153+then
154+ die "xargs is not available"
155+fi
156+
157+# Use "xargs" to parse quoted args.
158+#
159+# With -n://1, we get one arg per line, with proper handling of whitespace in each arg
160+# and target strings containing spaces in a proper way.
161+eval "set -- $(
162+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
163+ xargs -n1 |
164+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
165+ tr '\n' ' '
166+ )" '"$@"'
167+
168+exec "$JAVACMD" "$@"
gradlew.batnew file+81
@@ -0,0 +1,81 @@
1+@rem
2+@rem Copyright 2015 the original author or authors.
3+@rem
4+@rem Licensed under the Apache License, Version 2.0 (the "License");
5+@rem you may not use this file except in compliance with the License.
6+@rem You may obtain a copy of the License at
7+@rem
8+@rem https://www.apache.org/licenses/LICENSE-2.0
9+@rem
10+@rem Unless required by applicable law or agreed to in writing, software
11+@rem distributed under the License is distributed on an "AS IS" BASIS,
12+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+@rem See the License for the specific language governing permissions and
14+@rem limitations under the License.
15+@rem
16+
17+@if "%DEBUG%"=="" @echo off
18+@rem ##########################################################################
19+@rem
20+@rem Gradle startup script for Windows
21+@rem
22+@rem ##########################################################################
23+
24+@rem Set local scope for the variables with windows NT shell
25+if "%OS%"=="Windows_NT" setlocal
26+
27+set DIRNAME=%~dp0
28+if "%DIRNAME%"=="" set DIRNAME=.
29+@rem This is normally unused
30+set APP_BASE_NAME=%~n0
31+set APP_HOME=%DIRNAME%
32+
33+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
34+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
35+
36+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
37+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
38+
39+@rem Find java.exe
40+if defined JAVA_HOME goto findJavaFromJavaHome
41+
42+set JAVA_EXE=java.exe
43+%JAVA_EXE% -version >NUL 2>&1
44+if %ERRORLEVEL% equ 0 goto execute
45+
46+echo. 1>&2
47+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
48+echo. 1>&2
49+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
50+echo location of your Java installation. 1>&2
51+
52+goto fail
53+
54+:findJavaFromJavaHome
55+set JAVA_HOME=%JAVA_HOME:"=%
56+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
57+
58+if exist "%JAVA_EXE%" goto execute
59+
60+echo. 1>&2
61+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
62+echo. 1>&2
63+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
64+echo location of your Java installation. 1>&2
65+
66+goto fail
67+
68+:execute
69+@rem Setup the command line
70+
71+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
72+
73+
74+@rem Execute Gradle
75+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
76+
77+:end
78+@rem End local scope for the variables with windows NT shell
79+if %OS%==Windows_NT endlocal
80+
81+:omega
settings.gradle.ktsnew file+24
@@ -0,0 +1,24 @@
1+pluginManagement {
2+ repositories {
3+ google {
4+ content {
5+ includeGroupByRegex("com\\.android.*")
6+ includeGroupByRegex("com\\.google.*")
7+ includeGroupByRegex("androidx.*")
8+ }
9+ }
10+ mavenCentral()
11+ gradlePluginPortal()
12+ }
13+}
14+
15+dependencyResolutionManagement {
16+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
17+ repositories {
18+ google()
19+ mavenCentral()
20+ }
21+}
22+
23+rootProject.name = "ParseableAndroid"
24+include(":app")