git.iko.soy/parseable-android
on master help sign in
Add Parseable Android log viewer app (#1)

* Add Parseable Android log viewer app Full Android app using Kotlin and Jetpack Compose to browse and search logs from a Parseable server instance. Features include: - Server connection with Basic Auth (HTTP/HTTPS) - Stream browser with stats (event count, ingestion/storage size) - Log viewer with expandable entries and pull-to-refresh - Time range filtering (5m to 30d) - Full-text search across log fields - Column-level SQL filters (=, !=, LIKE, ILIKE, >, <, IS NULL, etc.) - Custom SQL query editor with PostgreSQL syntax - Stream detail view (schema, stats, retention) - Alerts browser - Settings with server info and user list - Material 3 dark/light theme - GitHub Actions CI that builds APKs and creates draft releases on tags Tech stack: Hilt DI, OkHttp, Kotlinx Serialization, DataStore, Navigation Compose. https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Add CI workflow for PRs and implement live log streaming CI: - Add ci.yml workflow that runs on PRs and pushes to main/master - Runs debug build, unit tests, and Android lint in parallel jobs - Uploads debug APK and lint results as artifacts - Uses concurrency groups to cancel stale PR builds Log streaming (live tail): - Add play/stop toggle button in the log viewer toolbar - When active, polls for new logs every 3 seconds via the query API - New logs are prepended to the list with a running count - Animated pulsing red dot banner indicates live mode - Respects active search query and column filters during streaming - Auto-caps log buffer at 5000 entries to prevent memory issues - Stops streaming when time range is changed - Cleans up polling job when ViewModel is cleared https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Add CLAUDE.md with project conventions and architecture guide https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix CI: use gradle/actions/setup-gradle instead of broken wrapper JAR The gradle-wrapper.jar copied from the local Gradle 8.14.3 installation was a modular JAR missing the IDownload class, causing NoClassDefFoundError in CI. Fix by: - Using gradle/actions/setup-gradle@v4 to install Gradle in CI - Generating the wrapper at build time via `gradle wrapper --gradle-version 8.9` - Removing the broken JAR from the repo and gitignoring it https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Improve security, performance, and UX across the app Security: - Escape single quotes in SQL filter/search values to prevent injection - Store passwords in EncryptedSharedPreferences instead of plaintext DataStore - Make TLS certificate bypass conditional on "Allow insecure" opt-in - Add @Volatile to API client mutable state for thread safety Performance: - Replace O(n) indexOf with itemsIndexed in log list rendering - Limit concurrent stream stats requests to 4 via Semaphore - Debounce search input with 300ms delay Features: - Add custom date range picker for log queries - Add share/export button for current log results - Surface streaming poll errors after 3 consecutive failures - Add delete stream button with confirmation dialog on stream info https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Remove unused material3-adaptive dependency This artifact doesn't exist at the specified version and isn't imported anywhere. PullToRefreshBox comes from the core material3 library. https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix startup blocking, thread safety, and robustness issues - Replace runBlocking in MainActivity with LaunchedEffect + loading state - Make API client configure() atomic via single immutable data class reference - URL-encode stream names in all API path construction - Stop streaming job when switching streams in initialize() - Fix CAST(*) search retry to preserve existing filters in fallback - Include retention/info API errors in StreamInfoViewModel error display - Track and display failed stream stats with "Stats unavailable" indicator - Use commit() instead of apply() for encrypted password writes - Add content descriptions to all decorative icons for accessibility - Clarify date picker end-of-day calculation with explicit comment https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix Compose stability, crash safety, and ProGuard hardening - Use stable content-based keys for log entries instead of positional index so expanded state survives streaming prepends - Guard .jsonPrimitive access in streaming poll to prevent crash on unexpected JSON types - Add key parameter to AlertsScreen items() for efficient recomposition - Replace Modifier.clickable with Card onClick on StreamCard for proper accessibility and ripple handling - Enhance ProGuard rules: keep @Serializable model fields, OkHttp platform/SSL internals, okio, and EncryptedSharedPreferences https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Switch CI to release builds only, remove debug builds - CI: assembleRelease, testReleaseUnitTest, lintRelease instead of debug - Release workflow: remove debug APK build and artifact upload - Draft releases now only include the release APK https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Improve CI workflows: remove wrapper step, merge jobs, add release tests - Remove redundant "gradle wrapper" generation — the wrapper is already committed to the repo, so regenerating it is unnecessary and fragile - Merge the separate build and lint CI jobs into one to avoid duplicate runner setup and cut CI minutes - Add testReleaseUnitTest to the release workflow so tagged releases are validated before publishing https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix multiple app-level bugs and add repository caching SettingsRepository: - Move EncryptedSharedPreferences read to Dispatchers.IO to avoid blocking the UI thread inside the Flow map - Replace blocking .commit() with .apply() wrapped in withContext(Dispatchers.IO) ParseableApiClient: - Return ApiResult.Error instead of ApiResult.Success(emptyList()) when parsing fails in listAlerts(), listUsers(), and getStreamRetention(), so callers can show error messages instead of empty screens LogViewerViewModel: - Cancel searchJob in onCleared() to prevent orphaned coroutines after navigation SettingsViewModel: - Add init block to auto-trigger load() so the screen doesn't show empty state until manual interaction ParseableRepository: - Add TTL-based in-memory caching for listStreams (30s), getStreamSchema (2min), and getStreamStats (30s) - Invalidate all caches on configure() and deleteStream() - Pull-to-refresh in StreamsViewModel bypasses cache with forceRefresh https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Restore Gradle wrapper generation step in CI The wrapper jar was previously removed from the repo (gitignored) due to a broken modular JAR. The "gradle wrapper" generation step is needed to produce a working wrapper at build time. https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix security, thread safety, navigation, and UX issues - Disable allowBackup in AndroidManifest to prevent encrypted credentials from leaking through device backups - Use ConcurrentHashMap for repository caches to prevent race conditions from concurrent coroutine access - Remove duplicate load() call in SettingsScreen (init block in ViewModel already triggers it) - Show error message in retryWithSimpleSearch when schema columns are empty instead of silently returning - Add launchSingleTop to all user-triggered navigation calls to prevent duplicate destinations from rapid taps https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix R8 compilation failure by removing conflicting ProGuard rules The broad -keep rules for javax.net.ssl.**, java.security.cert.**, okhttp3.**, okio.**, and androidx.security.crypto.** conflicted with R8's optimization passes and the libraries' own consumer ProGuard rules. Replaced with minimal rules: - Keep only kotlinx-serialization model classes (app-specific) - Use -dontwarn for OkHttp/Okio (their AARs ship consumer rules) - Keep ParseableApiClient to preserve the anonymous X509TrustManager https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Improve LogViewer performance: memoize previews, fix keys, reduce cap - Memoize LogEntryCard preview string with remember(logEntry) to avoid recomputing filter/join on every recomposition for every visible card - Remove unused columns parameter from LogEntryCard to prevent all cards from recomposing when the schema loads - Fix stableLogKey to not include list index, which shifts when streaming prepends new logs, breaking expanded state and causing unnecessary recomposition of the entire list - Reduce STREAMING_MAX_LOGS from 5000 to 1000 to prevent excessive memory usage (~100K+ map entries with 5000 JsonObjects) https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Disable R8 full mode to fix release build crash AGP 8.7.3 enables R8 full mode by default, which has known compatibility issues with Kotlin 2.1.0 metadata. The aggressive optimizations cause R8 to crash with "Compilation failed to complete" during minifyReleaseWithR8. R8 compat mode still provides minification and shrinking but avoids the unstable full-mode optimization passes. https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Add Tink/Protobuf ProGuard rules to fix R8 crash The security-crypto:1.1.0-alpha06 alpha pulls in Google Tink which has complex class hierarchies and Protobuf dependencies that crash R8. The alpha library ships incomplete consumer ProGuard rules, so we need explicit keep rules for Tink and Protobuf Lite classes, plus dontwarn rules for annotation libraries referenced but not on the classpath. Also added Signature to keepattributes for generic type preservation needed by kotlinx-serialization. https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Improve performance across log viewer and streams screens - StreamsViewModel: Update stats progressively as each stream responds instead of waiting for all to complete before updating the UI - LogViewerScreen: Auto-load-more via infinite scroll using derivedStateOf on scroll position, replacing manual "Load more" button - LogEntryCard: Memoize timestamp extraction, card colors, and expanded entry list to avoid recomputation on recomposition - LogViewerViewModel: Poll immediately on stream start then delay, instead of delaying first (fixes effective 2x interval when polls are slow) - Log list: Memoize key computation to avoid redundant stableLogKey calls per item https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Cache about info, async log sharing, batch settings updates - ParseableRepository: Cache getAbout() with 5-minute TTL. This endpoint is called by both StreamsViewModel and SettingsViewModel but server version/mode rarely changes. - LogViewerScreen: Move log serialization for sharing off the main thread using Dispatchers.Default. Serializing hundreds of log entries as pretty-printed JSON was blocking the UI. - SettingsViewModel: Consolidate 3 sequential _state.update calls into a single atomic update, reducing unnecessary intermediate recompositions. https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix UX, correctness, accessibility, and add unit tests - Differentiate API errors by HTTP status code (401/403/404/429/5xx/network) with user-friendly messages via ApiResult.userMessage - Add session expiry: 401 errors trigger automatic logout via auth error channel from ParseableRepository to MainActivity - Fix timezone bug in date picker: end-of-day now calculated in user's local timezone instead of raw UTC arithmetic - Add copy-to-clipboard button on expanded log entries - Pretty-print JSON in StreamInfoScreen raw info section - Format log timestamps to user's local timezone (MMM dd HH:mm:ss.SSS) - Fix streaming race condition with generation counter to prevent duplicate polling when toggling streaming rapidly - Fix dark mode color scheme: correct secondary/tertiary mapping - Fix AlertsScreen: separate error state from empty state, add retry button - Add loading indicator to SettingsScreen while fetching server info - Fix padding inconsistency: LoginScreen 24dp -> 16dp to match other screens - Fix accessibility: add meaningful contentDescriptions to all icons (empty states, error states, alert status indicators) - Show all errors in StreamInfoViewModel instead of only the first - Add unit tests for escapeSql, ApiResult.userMessage, and formatTimestamp https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix navigation safety, UX feedback, error handling, and compilation - URL-encode stream names in navigation routes to prevent crashes with special characters (spaces, slashes) in stream names - Add snackbar confirmation after copy-to-clipboard on log entries - Add error handling to SettingsRepository DataStore/EncryptedSharedPrefs operations with try-catch and logging to prevent crashes - Make empty state message context-aware: reflects active search query and/or filters instead of always showing generic time range message - Add pull-to-refresh to AlertsScreen for consistency with other screens - Show API errors in SettingsScreen instead of silently swallowing them - Validate server is actually Parseable on login by calling /about after liveness check succeeds - Extract escapeSql and formatTimestamp to standalone utility files (SqlUtils.kt, TimestampUtils.kt) to fix compilation errors in unit tests that couldn't resolve Hilt/Compose dependencies https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix menuAnchor() for Material3 1.3+ API change menuAnchor() without parameters was removed in Material3 1.3.0. Use menuAnchor(MenuAnchorType.PrimaryNotEditable) for read-only dropdown text fields. Also remove unused LocalContext import. https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix deletion navigation, add logout confirmation, stream search, URL validation, and dynamic version - Navigate to streams list (not back to deleted stream's viewer) after stream deletion - Add confirmation dialog before logout to prevent accidental disconnects - Use BuildConfig.VERSION_NAME instead of hardcoded "1.0.0" in settings - Add search bar to StreamsScreen (shown when >5 streams) with empty state - Validate URL format with Patterns.WEB_URL before attempting server connection https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix streaming exception type, unsafe jsonPrimitive access, and share crash on large logs - Catch Exception (not just IllegalArgumentException) when reading p_timestamp during streaming, since jsonPrimitive throws IllegalStateException - Wrap jsonPrimitive access in LogEntryCard with try-catch to prevent composable crashes on non-primitive timestamp values - Share logs via FileProvider temp file instead of Intent EXTRA_TEXT to avoid TransactionTooLargeException with large log sets https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix settings crash, stale stream list after deletion, and silent 401 logout - Wrap jsonPrimitive access in SettingsViewModel with try-catch to prevent crash on unexpected JSON structure - Replace LaunchedEffect(Unit) with lifecycle-aware repeatOnLifecycle in StreamsScreen so the stream list auto-refreshes when returning from stream deletion or any other screen - Add sessionExpired parameter to login route so users see a "Session expired" snackbar when auto-logged out due to 401, instead of being silently kicked to the login screen https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix compilation errors: wrong import and exception type - Use androidx.lifecycle.compose.LocalLifecycleOwner instead of the deprecated compose.ui.platform location (removed in Compose BOM 2024.12) - Catch IllegalStateException (thrown by jsonPrimitive) instead of IllegalArgumentException in SettingsViewModel https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix broken search, SQL identifier injection, and unsafe share filename - Remove CAST(* AS TEXT) from search queries (invalid PostgreSQL that always failed on first attempt, doubling search latency). Use column-based ILIKE search directly instead. - Add escapeIdentifier() to double-quote escape stream names used as SQL identifiers, preventing malformed queries from stream names containing double-quote characters. - Sanitize stream name in shared log filename to prevent path traversal or filesystem errors from special characters. - Add unit tests for escapeIdentifier. https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix @Composable invocation inside remember block CardDefaults.cardColors() is a @Composable function and cannot be called inside a remember {} lambda. Move it to the composable scope directly. https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Comprehensive improvements: security, reliability, UX, and testing Security: - Stop auto-populating password in login form; add "Use saved credentials" button - Restrict cleartext HTTP to localhost/127.0.0.1/10.0.2.2 only - Add share warning dialog for log exports (may contain PII/secrets) - Improve exception handling with specific catch blocks (SSL, timeout, DNS) Reliability: - Add exponential backoff for streaming error recovery (3s-30s) - Auto-stop streaming after 5 consecutive errors - Cap loadMore() at 5000 entries to prevent OOM - Search all non-internal columns instead of hardcoded first 5 UX: - Add alert delete with confirmation dialog - Add filter validation (numeric/date hints for comparison operators) - Add deep linking support (parseable://streams, stream/{name}, alerts) - Add global error boundary with central snackbar-based error display - Increase stats loading concurrency from 4 to 8 Architecture: - Refactor LogViewerState into nested FilterState and StreamingState - Add parseResponse() helper to standardize API response parsing - Add uncaught exception handler logging in MainActivity Testing: - Add LogViewerViewModelTest (15 tests covering filters, search, streaming) - Add LoginViewModelTest (10 tests covering validation, auth flow) - Add StreamsViewModelTest (4 tests covering refresh, stats, logout) - Add ParseableRepositoryTest (9 tests covering caching, invalidation) - Add mockk and coroutines-test dependencies https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix remaining issues: security config, favorites, error handling Security: - Re-allow cleartext HTTP globally (self-hosted Parseable is common) but add prominent warning banner on LoginScreen for non-local HTTP - Add ProGuard keep rules for UI state classes and Room entities - Update security-crypto dependency comment (no stable 1.1.0 exists) Reliability: - Fix SettingsRepository race condition with Mutex guarding concurrent reads/writes across DataStore and EncryptedSharedPreferences - Move UncaughtExceptionHandler from MainActivity to ParseableApp (Application class) to avoid Activity reference leak UX: - Add favorites feature: Room database with FavoriteStream entity, star toggle on each stream card, favorites sorted to top of list - LoginScreen now uses GlobalErrorBoundary for session-expired message instead of local SnackbarHost (consistent error display) Testing: - Update StreamsViewModelTest for new FavoriteStreamDao dependency - Add 3 new tests: favorites loading, toggle add, toggle remove https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Polish: accessibility, Room schema export, and code quality fixes - Add contentDescription to decorative icons in SettingsScreen and StreamInfoScreen - Enable Room schema export with KSP schemaLocation arg for migration support - Remove unnecessary .toList() allocation in LogEntryCard - Replace unstable hashCode() keys with deterministic alternatives - Use explicit Locale.US in formatCount() for consistent number formatting https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fourth round improvements: UX polish, Timber logging, version catalog, tests - Dynamic streaming banner: shows actual polling interval during backoff - End-of-results indicator in log viewer when all logs loaded - Retry button for failed stream stats with extracted retry logic - Resilient alert parsing: skip malformed entries instead of failing - Search debounce indicator: spinner in search bar during 300ms delay - Keyboard dismiss on search submit via ImeAction.Search - Filter chip row animates size changes with animateContentSize() - Favorite toggle snackbar feedback via Channel-based events - Stricter server URL validation (hostname checks) - Timber logging framework replacing android.util.Log - Gradle version catalog (libs.versions.toml) for all dependencies - Version code/name extracted to version catalog entries - AlertsViewModel unit tests (7 tests covering CRUD + error states) - Comprehensive strings.xml for localization readiness https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix resource leak, Timber consistency, Json allocation, schemas dir - Wrap OkHttp Response in .use {} to prevent connection pool leaks - Replace android.util.Log with Timber in SettingsRepository - Hoist Json { prettyPrint = true } to file-level shared instance - Create app/schemas/ directory for Room schema export https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix 12 bugs across repository, DI, and UI layers - Invalidate all caches on 401 auth errors in ParseableRepository - Wrap async calls in try/catch in StreamInfoViewModel to prevent infinite loading state on unexpected exceptions - Replace O(n²) indexOf() with itemsIndexed for AlertsScreen keys - Hoist DateRangePickerState above conditional block so user selections survive recomposition - Include hashCode in stableLogKey() to prevent key collisions for logs with identical timestamps - Add @Singleton to provideFavoriteStreamDao() in AppModule - Disable alert delete confirmation button when alert.id is null - Hoist Json { prettyPrint = true } to file-level in StreamInfoScreen - Replace all unsafe !! with ?.let or .orEmpty() across LoginScreen, SettingsScreen, AlertsScreen, StreamInfoScreen, and LogViewerScreen - Consume deleteSuccess flag in StreamInfoScreen to prevent LaunchedEffect re-firing on back navigation - Use isNotBlank() for filter column validation - Check mkdirs() return value in share flow, throw on failure https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix security, concurrency, and build issues Security: - Validate SQL filter operators against whitelist before interpolation - Add safe type checks for .jsonObject/.jsonArray casts in API client to prevent ClassCastException on unexpected server responses - Restrict cleartext HTTP to localhost and private network ranges only; default base-config now denies cleartext - Add ProGuard keep rules for EncryptedSharedPreferences and androidx.security.crypto to prevent release build crashes Concurrency: - Add @Volatile to streaming state variables (streamingGeneration, lastSeenTimestamp, consecutiveStreamingErrors) accessed across coroutine dispatchers - Re-derive filter index inside _state.update{} in removeFilter() to eliminate TOCTOU race between parallel lists Database: - Add fallbackToDestructiveMigration() to Room builder so future schema changes don't crash existing installs Build: - Remove unused okhttp-logging dependency https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix correctness, security, and UX issues across 11 files Correctness: - LoginViewModel.init: use .first() instead of .collect to avoid overwriting state after login on subsequent config emissions - LoginViewModel: extract performLogin() to avoid leaking password into LoginState (which persists in Compose state layer) - stableLogKey: use monotonic counter to guarantee LazyColumn key uniqueness and prevent collisions from hashCode - Validate custom SQL starts with SELECT to prevent destructive queries (DELETE, DROP) via the SQL query sheet Thread safety: - Add @Volatile to ParseableRepository.streamsCache and aboutCache to ensure visibility across coroutine dispatchers Security: - Escape streamName in SqlQueryBottomSheet default SQL template via escapeIdentifier() to prevent SQL injection - Add login guard to deep-linked routes (streams, log_viewer, alerts) so deep links redirect to login when API is not configured Robustness: - Handle EncryptedSharedPreferences keystore corruption gracefully by deleting corrupted prefs file and recreating (common after backup/restore on some OEMs) - Clean up previous shared_logs files before creating new exports to prevent unbounded cache growth UX: - Add PullToRefreshBox to SettingsScreen for retry on error - Refresh AlertsScreen and StreamInfoScreen on lifecycle RESUMED (consistent with StreamsScreen behavior) Performance: - Hoist ClipboardManager lookup to LazyColumn parent instead of per-card, pass as parameter to LogEntryCard - Use partition() instead of sortedByDescending() for O(n) favorite sorting in StreamsScreen https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix message loss, thread safety, memory leaks, and UX issues ErrorHandler: - Replace MutableStateFlow with Channel so rapid-fire showError() calls queue messages instead of overwriting each other Thread safety: - Use AtomicLong for logKeyCounter instead of bare var to prevent duplicate LazyColumn keys under concurrent recomposition Security / memory: - Clear password from LoginState on successful login so it doesn't linger in the Compose state layer - Add clearConfig() to ParseableApiClient and clearCredentials() to ParseableRepository; call on logout and 401 to zero out Base64 credentials from memory Correctness: - Cancel previous loadJob in StreamInfoViewModel.load() before starting a new one, preventing interleaved state updates from concurrent calls (triggered by repeatOnLifecycle RESUMED) - Move invalidateAll() in deleteStream() to after success—failed deletes no longer needlessly clear the cache - Remove stale-data fallbacks in SettingsViewModel.load() so re-login to a different server doesn't show old aboutInfo/users Performance: - Share ConnectionPool and Dispatcher between secure and insecure OkHttpClients to halve thread overhead UX: - Disable delete button on StreamInfoScreen while data is still loading (prevents confusing errors if stream no longer exists) https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix stale state, crashes, memory, and CI coverage issues - Deduplicate stableLogKey calls with pre-computed logKeys list - Move state snapshot inside coroutine in refresh() to avoid stale data - Fix state.error!! null dereference crash in StreamsScreen - Reset dateRangePickerState on reopen so stale selections don't persist - Reset currentLimit when executing custom SQL - Use compact JSON for large exports (>1000 logs) to reduce memory - Add testDebugUnitTest to CI pipeline https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix race conditions, navigation guards, and UX issues - Cancel in-flight stats jobs when StreamsViewModel.refresh() is called again, preventing duplicate network requests - Expand HTTP warning to cover all RFC1918 private IP ranges (10.x, 192.168.x, 172.16-31.x), not just localhost - Make logout() suspend so navigation waits for credential cleanup - Add login guard to Settings route for consistency with other screens - Use flowOn(Dispatchers.IO) instead of withContext inside Flow.map in SettingsRepository for idiomatic dispatcher handling - Replace startDestination!! with smart-cast local val in MainActivity https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix key stability, dead code, and formatting issues - Replace counter-based stableLogKey with deterministic content-based keys so expanded state survives streaming list mutations - Remove dead clipboard fallback in LogEntryCard (conditional remember violated Compose rules; caller always provides clipboard) - Remove redundant 10.0.2.2 check (already covered by 10.x prefix) - Add testDebugUnitTest to build-release.yml for parity with ci.yml - Fix SettingsScreen indentation (Column children were under-indented) https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix accessibility, clipboard safety, type safety, and test coverage - Remove undersized touch targets (32dp) on favorite star and copy buttons to meet 48dp accessibility minimum - Wrap clipboard setPrimaryClip in try-catch for SecurityException on restricted devices - Make NavGraph repository parameter non-nullable, removing redundant null checks across 4 route guards - Add unit tests for SettingsViewModel (7 tests) and StreamInfoViewModel (7 tests) https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix coroutine cancellation, schema race, JSON export, and network config - Re-throw CancellationException in StreamInfoViewModel to preserve structured concurrency (was showing spurious errors on navigation) - Track and cancel schema loading job in LogViewerViewModel to prevent race when initialize() is called rapidly with different streams - Enable R8 full mode for smaller release APK - Export logs as valid JSON array instead of JSON Lines format - Fix network_security_config: includeSubdomains doesn't work for IP addresses, so allow cleartext globally (app-level UI warning remains) https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix undersized touch target on stats retry button The "Retry" TextButton in StreamCard used a hardcoded 24dp height, well below the 48dp accessibility minimum. Replace with defaultMinSize(minHeight = 48dp) so the touch target meets guidelines while keeping compact visual padding. https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Improve UX: SQL LIMIT safety, IME actions, streaming states, accessibility - Enforce LIMIT on custom SQL queries that lack one to prevent OOM - Make keyboard Search action actually execute the query refresh - Add IME Done action on filter value field to apply filter from keyboard - Show "Waiting for new logs…" during streaming instead of generic empty state - Warn when results are truncated at the 5,000 cap - Preserve streaming error after stopping so users can see what went wrong - Add dismissStreamingError() for explicit error dismissal - Fix decorative ChevronRight icon (null contentDescription, parent is clickable) - Add tests for custom SQL LIMIT enforcement and dismiss streaming error https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix stuck loading state, schema error visibility, and settings error display - SettingsViewModel: wrap async/await in try-catch so isLoading resets on CancellationException (e.g. user navigates away mid-load) - StreamInfoScreen: show "Schema unavailable" card when schema load fails instead of silently hiding the section - StreamInfoViewModel: track schemaFailed flag separately from the combined error string - SettingsScreen: remove !isLoading gate on error card so errors remain visible during a retry/reload https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix remaining issues: alert null safety, connection errors, list ops, cleanup - AlertsScreen: guard delete action for alerts without IDs — disable the delete button and prevent selecting them for deletion - ParseableApiClient: add ConnectException handler with "Connection refused. Is the server running?" message for clearer diagnostics - ParseableApiClient: add shutdown() to evict connections and shut down the dispatcher thread pool for clean resource release - LogViewerViewModel: replace toMutableList().apply { removeAt() } with filterIndexed for immutable, allocation-efficient list operations - Add SettingsViewModel test for unexpected exception resilience - Add StreamInfoViewModel tests for schemaFailed flag https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix password visibility on rotation, StreamsVM cancellation, button heights - LoginScreen: use rememberSaveable for passwordVisible so "show password" toggle survives device rotation - LoginScreen: replace fixed .height(50.dp) with .defaultMinSize on both login buttons so they adapt to landscape/large-text - StreamsViewModel: wrap async/await in try-catch so isLoading resets on CancellationException (matches the SettingsViewModel fix) - Add StreamsViewModel test for unexpected exception resilience https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Add IMPROVEMENTS.md documenting all codebase improvements Comprehensive changelog covering security, concurrency, error handling, accessibility, performance, testing, and architecture improvements made across 14 rounds of fixes (41 files, ~2,981 lines added). https://claude.ai/code/session_013AQtnCaaN1PH82ZFf6yjhQ * Fix unit tests by replacing android.util.Patterns with java.net.URI (#2) LoginViewModel used android.util.Patterns.WEB_URL for URL validation, which is unavailable in JVM unit tests (Android framework stub). Replace with java.net.URI which works in both Android and JVM environments. Also add unitTests.isReturnDefaultValues to prevent similar Android framework stub issues in future tests. https://claude.ai/code/session_01GNVVNsce1NcjA31FRtA4Ui Co-authored-by: Claude <noreply@anthropic.com> * Fix async exception propagation in SettingsViewModel and StreamsViewModel (#3) Wrap parallel async calls in coroutineScope so that exceptions thrown inside async blocks are properly caught by the surrounding try-catch. Without coroutineScope, exceptions from async children propagate through structured concurrency's Job hierarchy, bypassing the try-catch and causing unhandled RuntimeExceptions in tests. Fixes: SettingsViewModelTest.load resets isLoading on unexpected exception Fixes: StreamsViewModelTest.refresh resets isLoading on unexpected exception https://claude.ai/code/session_01KYKUhp5HU35oBFgz2TN95n Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>

commit5e8f3d7e6a3e5934b2a7e28ab036bc9769ed907b authoriko <mail@iko.soy> dateSun 15 Feb 2026, 14:12 (8 months ago) parenta7bfb47
62 files changed+7546
.github/workflows/build-release.yml+55 .github/workflows/ci.yml+60 .gitignore+12 CLAUDE.md+82 IMPROVEMENTS.md+176 README.md+60 app/build.gradle.kts+119 app/proguard-rules.pro+61 app/schemas/.gitkeep app/src/main/AndroidManifest.xml+43 app/src/main/java/com/parseable/android/MainActivity.kt+90 app/src/main/java/com/parseable/android/ParseableApp.kt+29 app/src/main/java/com/parseable/android/data/SqlUtils.kt+7 app/src/main/java/com/parseable/android/data/TimestampUtils.kt+26 app/src/main/java/com/parseable/android/data/api/ParseableApiClient.kt+380 app/src/main/java/com/parseable/android/data/local/FavoriteStream.kt+11 app/src/main/java/com/parseable/android/data/local/FavoriteStreamDao.kt+29 app/src/main/java/com/parseable/android/data/local/ParseableDatabase.kt+13 app/src/main/java/com/parseable/android/data/model/Models.kt+162 app/src/main/java/com/parseable/android/data/repository/ParseableRepository.kt+151 app/src/main/java/com/parseable/android/data/repository/SettingsRepository.kt+121 app/src/main/java/com/parseable/android/di/AppModule.kt+29 app/src/main/java/com/parseable/android/ui/ErrorHandler.kt+84 app/src/main/java/com/parseable/android/ui/navigation/NavGraph.kt+176 app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsScreen.kt+268 app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsViewModel.kt+64 app/src/main/java/com/parseable/android/ui/screens/login/LoginScreen.kt+248 app/src/main/java/com/parseable/android/ui/screens/login/LoginViewModel.kt+180 app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerScreen.kt+994 app/src/main/java/com/parseable/android/ui/screens/logviewer/LogViewerViewModel.kt+482 app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoScreen.kt+254 app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoViewModel.kt+104 app/src/main/java/com/parseable/android/ui/screens/settings/SettingsScreen.kt+243 app/src/main/java/com/parseable/android/ui/screens/settings/SettingsViewModel.kt+99 app/src/main/java/com/parseable/android/ui/screens/streams/StreamsScreen.kt+358 app/src/main/java/com/parseable/android/ui/screens/streams/StreamsViewModel.kt+180 app/src/main/java/com/parseable/android/ui/theme/Theme.kt+67 app/src/main/res/drawable/ic_launcher_background.xml+10 app/src/main/res/drawable/ic_launcher_foreground.xml+15 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml+5 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml+5 app/src/main/res/values/strings.xml+78 app/src/main/res/values/themes.xml+4 app/src/main/res/xml/file_paths.xml+4 app/src/main/res/xml/network_security_config.xml+19 app/src/test/java/com/parseable/android/AlertsViewModelTest.kt+123 app/src/test/java/com/parseable/android/ApiResultTest.kt+89 app/src/test/java/com/parseable/android/EscapeSqlTest.kt+74 app/src/test/java/com/parseable/android/FormatTimestampTest.kt+59 app/src/test/java/com/parseable/android/LogViewerViewModelTest.kt+286 app/src/test/java/com/parseable/android/LoginViewModelTest.kt+177 app/src/test/java/com/parseable/android/ParseableRepositoryTest.kt+173 app/src/test/java/com/parseable/android/SettingsViewModelTest.kt+166 app/src/test/java/com/parseable/android/StreamInfoViewModelTest.kt+204 app/src/test/java/com/parseable/android/StreamsViewModelTest.kt+152 build.gradle.kts+8 gradle.properties+5 gradle/libs.versions.toml+93 gradle/wrapper/gradle-wrapper.properties+7 gradlew+168 gradlew.bat+81 settings.gradle.kts+24
.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")