~cytrogen/vbhelper

e9d2cce8024f3fd3bacb258c183c31f59d3f4b40 — Cytrogen a month ago 366f425
Add UnifiedPush notification system with Ktor server

Implement push notifications via UnifiedPush protocol for timer-based
events (adventure completion, character evolution, special missions,
BE training, item effects). Includes:

- Ktor server module for timer scheduling and push delivery
- Android UnifiedPush 3.x PushService integration
- Push preferences in DataStore, HTTP client for server communication
- Settings UI for server URL, push toggle, and test notifications
- Timer registration in Adventure, Home, and Scan controllers
- Docker support for server deployment
- CLAUDE.md and editor rules for project conventions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
158 files changed, 1100 insertions(+), 1 deletions(-)

A .claude/rules/compose.md
A .claude/rules/database.md
A .claude/rules/kotlin.md
A .claude/rules/project-structure.md
A .dockerignore
A CLAUDE.md
M app/build.gradle.kts
M app/src/main/AndroidManifest.xml
M app/src/main/java/com/github/nacabaro/vbhelper/di/AppContainer.kt
M app/src/main/java/com/github/nacabaro/vbhelper/di/DefaultAppContainer.kt
A app/src/main/java/com/github/nacabaro/vbhelper/network/PushServerClient.kt
A app/src/main/java/com/github/nacabaro/vbhelper/network/TimerRegistrationRequest.kt
A app/src/main/java/com/github/nacabaro/vbhelper/push/NotificationHelper.kt
A app/src/main/java/com/github/nacabaro/vbhelper/push/TimerRegistrationService.kt
A app/src/main/java/com/github/nacabaro/vbhelper/push/VBHelperUnifiedPushReceiver.kt
M app/src/main/java/com/github/nacabaro/vbhelper/screens/adventureScreen/AdventureScreenControllerImpl.kt
M app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/HomeScreenControllerImpl.kt
M app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt
M app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreen.kt
M app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenController.kt
M app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenControllerImpl.kt
A app/src/main/java/com/github/nacabaro/vbhelper/source/PushPreferencesRepository.kt
M app/src/main/res/values/strings.xml
M build.gradle.kts
M gradle/libs.versions.toml
A server/Dockerfile
A server/build.gradle.kts
A server/build/classes/kotlin/main/META-INF/server.kotlin_module
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/ApplicationKt.class
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/models/TimerRegistration$$serializer.class
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/models/TimerRegistration$Companion.class
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/models/TimerRegistration.class
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/push/PushNotification$$serializer.class
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/push/PushNotification$Companion.class
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/push/PushNotification.class
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/push/UnifiedPushSender$sendNotification$1.class
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/push/UnifiedPushSender.class
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/routes/TimerRoutesKt$timerRoutes$1$1.class
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/routes/TimerRoutesKt$timerRoutes$1$2.class
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/routes/TimerRoutesKt$timerRoutes$1$3.class
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/routes/TimerRoutesKt$timerRoutes$1$4.class
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/routes/TimerRoutesKt.class
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/scheduler/TimerScheduler$TimerEntry.class
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/scheduler/TimerScheduler$scheduleTimer$1.class
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/scheduler/TimerScheduler$scheduleTimer$job$1.class
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/scheduler/TimerScheduler.class
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab.keystream
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab.keystream.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab.values.at
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab_i.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab.keystream
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab.keystream.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab.values.at
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab_i.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.values.at
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.values.at
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.values.at
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.keystream
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.keystream.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.values.at
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab_i
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab_i.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.keystream
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.keystream.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.values.at
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.values.at
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/counters.tab
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.values.at
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.values.at
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab.len
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.at
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab_i
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab_i.len
A server/build/kotlin/compileKotlin/cacheable/last-build.bin
A server/build/kotlin/compileKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin
A server/build/libs/server-all.jar
A server/build/resources/main/application.conf
A server/build/resources/main/logback.xml
A server/build/tmp/shadowJar/MANIFEST.MF
A server/docker-compose.yml
A server/src/main/kotlin/com/github/nacabaro/vbhelper/server/Application.kt
A server/src/main/kotlin/com/github/nacabaro/vbhelper/server/models/TimerRegistration.kt
A server/src/main/kotlin/com/github/nacabaro/vbhelper/server/push/UnifiedPushSender.kt
A server/src/main/kotlin/com/github/nacabaro/vbhelper/server/routes/TimerRoutes.kt
A server/src/main/kotlin/com/github/nacabaro/vbhelper/server/scheduler/TimerScheduler.kt
A server/src/main/resources/application.conf
A server/src/main/resources/logback.xml
M settings.gradle.kts
A .claude/rules/compose.md => .claude/rules/compose.md +17 -0
@@ 0,0 1,17 @@
# Compose UI Conventions

## Screen Structure
- Screen composables receive `NavController` + a `Controller` interface (not impl)
- Controller interfaces define callbacks and state flows for the screen
- **Do not use ViewModel** — this project uses Controller pattern instead
- Controllers use callback lambdas passed to composables, not StateFlow for events

## Material 3
- Use Material 3 components (`androidx.compose.material3.*`)
- Support dynamic color (Monet) on Android 12+ — avoid hardcoded colors
- Use `MaterialTheme.colorScheme` and `MaterialTheme.typography` tokens

## State Management
- Collect Flows as Compose state via `collectAsState()` or `collectAsStateWithLifecycle()`
- Keep UI state in the Controller, not in the composable (except transient UI state like scroll position)
- Composable functions should be stateless where possible — receive data and callbacks as parameters

A .claude/rules/database.md => .claude/rules/database.md +21 -0
@@ 0,0 1,21 @@
# Database Conventions

## Room
- DAO interfaces go in `database/daos/` package
- New DAOs must be registered as abstract functions in `AppDatabase`
- All DAO query functions must be `suspend` or return `Flow`
- Entity classes go in `database/` with `@Entity` annotation

## Repository Layer
- Every DAO is wrapped by a Repository in `source/` package
- Repositories are the only way screens/controllers access the database
- New repositories must be registered in `AppContainer` interface and `DefaultAppContainer`

## DataStore (Secrets)
- Cryptographic secrets (AES keys, HMAC keys, device ciphers) use **DataStore + Protobuf**, not Room
- Proto definitions are in `app/src/main/proto/` — do not modify without understanding migration implications
- User preferences (currency, settings) use DataStore Preferences

## Migrations
- Room version bumps require a `Migration` object registered in the database builder
- The `items.db` asset database must stay in sync with the Room schema

A .claude/rules/kotlin.md => .claude/rules/kotlin.md +14 -0
@@ 0,0 1,14 @@
# Kotlin Conventions

## Coroutines & Concurrency
- Use `suspend` functions for all database and I/O operations
- Use `Flow` (not LiveData) for observable data streams from repositories
- Collect Flows using `lifecycleScope` or `repeatOnLifecycle` in UI layer
- **Never** use `GlobalScope` or `runBlocking` in production code
- Use `withContext(Dispatchers.IO)` for blocking I/O inside suspend functions

## General
- Prefer `data class` for DTOs and domain models
- Use sealed classes/interfaces for representing finite state sets
- Prefer extension functions over utility classes
- Null safety: avoid `!!` — use `?.let`, `?:`, or require non-null at boundaries

A .claude/rules/project-structure.md => .claude/rules/project-structure.md +22 -0
@@ 0,0 1,22 @@
# Project Structure Rules

## Single Module
- This is a single-module Android project — all code lives under `:app`
- Do not create new Gradle modules or suggest modularization

## Dependency Injection
- Manual DI via `AppContainer` interface + `DefaultAppContainer` implementation
- **Do not introduce Hilt, Dagger, Koin, or any DI framework**
- New repositories/services are added to `AppContainer` and lazily initialized in `DefaultAppContainer`
- Access the container via `(application as VBHelper).container`

## External Libraries
- `vb-nfc-reader` is resolved from `mavenLocal()` — it must be installed locally before building
- Version catalog is in `gradle/libs.versions.toml` — all dependency versions go there
- New dependencies must be added to the version catalog first, then referenced via `libs.*` accessor

## Package Conventions
- New screens: create a sub-package under `screens/` (e.g., `screens/myFeature/`)
- Each screen package contains: `MyFeatureController.kt` (interface), `MyFeatureControllerImpl.kt`, `MyFeatureScreen.kt`
- Shared UI components go in `components/`
- Domain models go in `domain/` organized by feature sub-package

A .dockerignore => .dockerignore +18 -0
@@ 0,0 1,18 @@
# Android app source (not needed for server build)
app/src/
app/build/
app/libs/

# Build outputs
.gradle/
**/build/
*.apk
*.aab

# IDE and local config
.idea/
local.properties
.claude/

# Git
.git/

A CLAUDE.md => CLAUDE.md +90 -0
@@ 0,0 1,90 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

VBHelper is an Android app (Kotlin + Jetpack Compose) for interacting with Vital Bracelet series devices (VB, VH, VBC, VBBE) via NFC. It reads/writes characters, manages cards, tracks adventures, and handles cryptographic secrets for device communication.

## Build & Development Commands

All Gradle commands require JDK 17. Set `JAVA_HOME` before building:

```bash
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk
```

```bash
# Build the project
./gradlew build

# Run unit tests
./gradlew test

# Run a single unit test class
./gradlew testDebugUnitTest --tests "com.github.nacabaro.vbhelper.ExampleUnitTest"

# Run instrumented (Android device) tests
./gradlew connectedAndroidTest

# Install debug APK
./gradlew installDebug

# Lint check (has baseline in lint-baseline.xml)
./gradlew lint
```

Single-module project — all Gradle tasks target `:app`.

## Architecture

**Pattern:** Controller-based screens with Repository data access and a manual service locator for DI.

**Data flow:** Screen composables → Controller (interface + impl) → Repository → Room DAO → SQLite

**Key architectural decisions:**
- `DefaultAppContainer` (in `di/`) is the DI root — lazily creates the Room DB, DataStore instances, and all repositories. Accessed via `(application as VBHelper).container`.
- Each major screen has a `Controller` interface and `ControllerImpl`. Controllers hold business logic and expose state via Kotlin Flow.
- Cryptographic secrets (AES keys, HMAC keys, ciphers for device communication) are stored in Protocol Buffers via DataStore, not Room.
- NFC operations use `vb-nfc-reader` library (0.2.0-SNAPSHOT from mavenLocal). Card parsing uses `vb-dim-reader`.
- Pre-loaded item data ships in `app/src/main/assets/items.db`.

**Source layout** (`app/src/main/java/com/github/nacabaro/vbhelper/`):

| Directory | Purpose |
|-----------|---------|
| `di/` | App-level DI container and Application class |
| `database/` | Room database, DAOs (11 DAOs, 17 entities) |
| `domain/` | Domain model classes (card, characters, device_data, items) |
| `source/` | Repositories (8) for data access |
| `screens/` | Compose UI screens, each with its own Controller |
| `navigation/` | Compose NavHost, route definitions, bottom nav bar |
| `components/` | Shared UI components |
| `dtos/` | Data transfer objects for screen-layer queries |
| `utils/` | DeviceType enum, bitmap helpers |
| `proto/` | Protobuf definitions for secrets |

## Key Technical Details

- **Kotlin 2.3.0**, JVM target 11, Min SDK 28, Target/Compile SDK 36
- **Room** for structured data; **DataStore + Protobuf** for secrets; **DataStore Preferences** for currency/settings
- **Compose Material 3** with dynamic color on Android 12+
- **KSP** for Room annotation processing; **Protobuf Gradle plugin** generates Java lite classes
- `vb-nfc-reader` is resolved from `mavenLocal()` — must be installed locally to build
- Version catalog in `gradle/libs.versions.toml`

## Conventions

- **No ViewModel** — screens use the Controller pattern (interface + impl), not Android ViewModel
- **No DI framework** — manual DI via `AppContainer`/`DefaultAppContainer`. Do not introduce Hilt, Dagger, or Koin
- **Controller callback pattern** — Controllers expose callbacks (lambdas) to composables for user actions, and `Flow` for observable state
- **Version catalog** — all dependency versions are declared in `gradle/libs.versions.toml`, never inline in `build.gradle.kts`

## Dangerous Operations

The following modifications carry high risk and require careful consideration:

- **`app/src/main/assets/items.db`** — Pre-loaded item database. Must stay in sync with Room schema. A mismatch crashes the app on fresh install.
- **`app/src/main/proto/*.proto`** — Protobuf definitions for cryptographic secret storage. Changing field numbers or removing fields can make existing user secrets unreadable, effectively bricking their device pairing.
- **`vb-nfc-reader` version** — Resolved from `mavenLocal()` as a SNAPSHOT. Changing the version requires the new version to be installed locally. Version mismatches cause build failures with confusing error messages.
- **`gradlew` / `gradle/wrapper/`** — Managed by Gradle. Manual edits can break the build for all developers. Use `./gradlew wrapper --gradle-version=X.Y` to upgrade.

M app/build.gradle.kts => app/build.gradle.kts +7 -0
@@ 4,6 4,7 @@ plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
    alias(libs.plugins.kotlin.serialization)
    id("com.google.devtools.ksp") version "2.3.0"
    id("com.google.protobuf")
}


@@ 90,6 91,12 @@ dependencies {
    implementation(libs.androidx.ui.tooling.preview)
    implementation(libs.androidx.material3)
    implementation(libs.androidx.compose.material.icons.extended)
    implementation(libs.ktor.client.core)
    implementation(libs.ktor.client.okhttp)
    implementation(libs.ktor.client.content.negotiation)
    implementation(libs.ktor.serialization.kotlinx.json)
    implementation(libs.kotlinx.serialization.json)
    implementation(libs.unifiedpush.connector)

    debugImplementation(libs.androidx.ui.tooling)
    debugImplementation(libs.androidx.ui.test.manifest)

M app/src/main/AndroidManifest.xml => app/src/main/AndroidManifest.xml +10 -0
@@ 3,6 3,8 @@
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.NFC" />
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
    <uses-feature android:name="android.hardware.nfc" android:required="true" />

    <application


@@ 27,6 29,14 @@
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <service
            android:name=".push.VBHelperPushService"
            android:exported="false">
            <intent-filter>
                <action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
            </intent-filter>
        </service>
    </application>

</manifest>
\ No newline at end of file

M app/src/main/java/com/github/nacabaro/vbhelper/di/AppContainer.kt => app/src/main/java/com/github/nacabaro/vbhelper/di/AppContainer.kt +6 -0
@@ 1,11 1,17 @@
package com.github.nacabaro.vbhelper.di

import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.network.PushServerClient
import com.github.nacabaro.vbhelper.push.TimerRegistrationService
import com.github.nacabaro.vbhelper.source.CurrencyRepository
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
import com.github.nacabaro.vbhelper.source.PushPreferencesRepository

interface AppContainer {
    val db: AppDatabase
    val dataStoreSecretsRepository: DataStoreSecretsRepository
    val currencyRepository: CurrencyRepository
    val pushPreferencesRepository: PushPreferencesRepository
    val pushServerClient: PushServerClient
    val timerRegistrationService: TimerRegistrationService
}
\ No newline at end of file

M app/src/main/java/com/github/nacabaro/vbhelper/di/DefaultAppContainer.kt => app/src/main/java/com/github/nacabaro/vbhelper/di/DefaultAppContainer.kt +11 -0
@@ 6,8 6,11 @@ import androidx.datastore.preferences.preferencesDataStore
import androidx.room.Room
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.AppContainer
import com.github.nacabaro.vbhelper.network.PushServerClient
import com.github.nacabaro.vbhelper.push.TimerRegistrationService
import com.github.nacabaro.vbhelper.source.CurrencyRepository
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
import com.github.nacabaro.vbhelper.source.PushPreferencesRepository
import com.github.nacabaro.vbhelper.source.SecretsSerializer
import com.github.nacabaro.vbhelper.source.proto.Secrets



@@ 37,5 40,13 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
    override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore)

    override val currencyRepository = CurrencyRepository(context.currencyStore)

    override val pushPreferencesRepository = PushPreferencesRepository(context.currencyStore)

    override val pushServerClient = PushServerClient()

    override val timerRegistrationService by lazy {
        TimerRegistrationService(pushPreferencesRepository, pushServerClient, db)
    }
}


A app/src/main/java/com/github/nacabaro/vbhelper/network/PushServerClient.kt => app/src/main/java/com/github/nacabaro/vbhelper/network/PushServerClient.kt +28 -0
@@ 0,0 1,28 @@
package com.github.nacabaro.vbhelper.network

import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json

class PushServerClient {
    private val client = HttpClient(OkHttp) {
        install(ContentNegotiation) {
            json(Json { ignoreUnknownKeys = true })
        }
    }

    suspend fun registerTimer(serverUrl: String, registration: TimerRegistrationRequest) {
        client.post("${serverUrl.trimEnd('/')}/api/v1/timers") {
            contentType(ContentType.Application.Json)
            setBody(registration)
        }
    }

    suspend fun cancelTimer(serverUrl: String, timerId: String) {
        client.delete("${serverUrl.trimEnd('/')}/api/v1/timers/$timerId")
    }
}

A app/src/main/java/com/github/nacabaro/vbhelper/network/TimerRegistrationRequest.kt => app/src/main/java/com/github/nacabaro/vbhelper/network/TimerRegistrationRequest.kt +12 -0
@@ 0,0 1,12 @@
package com.github.nacabaro.vbhelper.network

import kotlinx.serialization.Serializable

@Serializable
data class TimerRegistrationRequest(
    val id: String,
    val pushEndpoint: String,
    val expiresAtEpochSeconds: Long,
    val title: String,
    val message: String
)

A app/src/main/java/com/github/nacabaro/vbhelper/push/NotificationHelper.kt => app/src/main/java/com/github/nacabaro/vbhelper/push/NotificationHelper.kt +41 -0
@@ 0,0 1,41 @@
package com.github.nacabaro.vbhelper.push

import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import com.github.nacabaro.vbhelper.R

object NotificationHelper {
    private const val CHANNEL_ID = "vbhelper_timer_alerts"
    private var notificationId = 0

    fun createNotificationChannel(context: Context) {
        val channel = NotificationChannel(
            CHANNEL_ID,
            context.getString(R.string.notification_channel_timers),
            NotificationManager.IMPORTANCE_DEFAULT
        ).apply {
            description = context.getString(R.string.notification_channel_timers_desc)
        }

        val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        manager.createNotificationChannel(channel)
    }

    fun showTimerNotification(context: Context, title: String, message: String) {
        createNotificationChannel(context)

        val notification = NotificationCompat.Builder(context, CHANNEL_ID)
            .setSmallIcon(R.mipmap.ic_launcher)
            .setContentTitle(title)
            .setContentText(message)
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
            .setAutoCancel(true)
            .build()

        val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        manager.notify(notificationId++, notification)
    }
}

A app/src/main/java/com/github/nacabaro/vbhelper/push/TimerRegistrationService.kt => app/src/main/java/com/github/nacabaro/vbhelper/push/TimerRegistrationService.kt +131 -0
@@ 0,0 1,131 @@
package com.github.nacabaro.vbhelper.push

import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.network.PushServerClient
import com.github.nacabaro.vbhelper.network.TimerRegistrationRequest
import com.github.nacabaro.vbhelper.source.PushPreferencesRepository
import kotlinx.coroutines.flow.first

class TimerRegistrationService(
    private val pushPreferencesRepository: PushPreferencesRepository,
    private val pushServerClient: PushServerClient,
    private val database: AppDatabase
) {
    private suspend fun isEnabled(): Boolean = pushPreferencesRepository.pushEnabled.first()
    private suspend fun serverUrl(): String = pushPreferencesRepository.pushServerUrl.first()
    private suspend fun endpoint(): String = pushPreferencesRepository.pushEndpoint.first()

    suspend fun registerAdventureTimer(characterId: Long, finishesAtEpochSeconds: Long) {
        if (!isEnabled()) return
        val request = TimerRegistrationRequest(
            id = "adventure_$characterId",
            pushEndpoint = endpoint(),
            expiresAtEpochSeconds = finishesAtEpochSeconds,
            title = "Adventure Complete",
            message = "Your character has finished their adventure!"
        )
        try {
            pushServerClient.registerTimer(serverUrl(), request)
        } catch (_: Exception) {}
    }

    suspend fun cancelAdventureTimer(characterId: Long) {
        if (!isEnabled()) return
        try {
            pushServerClient.cancelTimer(serverUrl(), "adventure_$characterId")
        } catch (_: Exception) {}
    }

    suspend fun registerTransformationTimer(characterId: Long, countdownMinutes: Int) {
        if (!isEnabled() || countdownMinutes <= 0) return
        val expiresAt = System.currentTimeMillis() / 1000 + countdownMinutes * 60L
        val request = TimerRegistrationRequest(
            id = "transformation_$characterId",
            pushEndpoint = endpoint(),
            expiresAtEpochSeconds = expiresAt,
            title = "Evolution Ready",
            message = "Your character is ready to evolve!"
        )
        try {
            pushServerClient.registerTimer(serverUrl(), request)
        } catch (_: Exception) {}
    }

    suspend fun registerSpecialMissionTimer(missionId: Long, remainingMinutes: Int) {
        if (!isEnabled() || remainingMinutes <= 0) return
        val expiresAt = System.currentTimeMillis() / 1000 + remainingMinutes * 60L
        val request = TimerRegistrationRequest(
            id = "special_mission_$missionId",
            pushEndpoint = endpoint(),
            expiresAtEpochSeconds = expiresAt,
            title = "Special Mission",
            message = "Your special mission time limit is up!"
        )
        try {
            pushServerClient.registerTimer(serverUrl(), request)
        } catch (_: Exception) {}
    }

    suspend fun cancelSpecialMissionTimer(missionId: Long) {
        if (!isEnabled()) return
        try {
            pushServerClient.cancelTimer(serverUrl(), "special_mission_$missionId")
        } catch (_: Exception) {}
    }

    suspend fun registerBETrainingTimer(characterId: Long, remainingMinutes: Int) {
        if (!isEnabled() || remainingMinutes <= 0) return
        val expiresAt = System.currentTimeMillis() / 1000 + remainingMinutes * 60L
        val request = TimerRegistrationRequest(
            id = "be_training_$characterId",
            pushEndpoint = endpoint(),
            expiresAtEpochSeconds = expiresAt,
            title = "Training Complete",
            message = "Your BE character's training is complete!"
        )
        try {
            pushServerClient.registerTimer(serverUrl(), request)
        } catch (_: Exception) {}
    }

    suspend fun registerBEItemEffectTimers(characterId: Long, mentalMinutes: Int, activityMinutes: Int, vitalPointsMinutes: Int, itemMinutes: Int) {
        if (!isEnabled()) return
        val now = System.currentTimeMillis() / 1000

        if (mentalMinutes > 0) {
            registerBEItemTimer("be_item_mental_$characterId", now + mentalMinutes * 60L, "Mental State Effect Expired")
        }
        if (activityMinutes > 0) {
            registerBEItemTimer("be_item_activity_$characterId", now + activityMinutes * 60L, "Activity Level Effect Expired")
        }
        if (vitalPointsMinutes > 0) {
            registerBEItemTimer("be_item_vitals_$characterId", now + vitalPointsMinutes * 60L, "Vital Points Effect Expired")
        }
        if (itemMinutes > 0) {
            registerBEItemTimer("be_item_general_$characterId", now + itemMinutes * 60L, "Item Effect Expired")
        }
    }

    private suspend fun registerBEItemTimer(id: String, expiresAt: Long, message: String) {
        val request = TimerRegistrationRequest(
            id = id,
            pushEndpoint = endpoint(),
            expiresAtEpochSeconds = expiresAt,
            title = "Item Effect",
            message = message
        )
        try {
            pushServerClient.registerTimer(serverUrl(), request)
        } catch (_: Exception) {}
    }

    suspend fun registerAllActiveTimers() {
        if (!isEnabled()) return
        try {
            val adventures = database.adventureDao().getAdventureCharacters().first()
            for (adventure in adventures) {
                registerAdventureTimer(adventure.id, adventure.finishesAdventure)
            }
        } catch (_: Exception) {}
    }
}

A app/src/main/java/com/github/nacabaro/vbhelper/push/VBHelperUnifiedPushReceiver.kt => app/src/main/java/com/github/nacabaro/vbhelper/push/VBHelperUnifiedPushReceiver.kt +56 -0
@@ 0,0 1,56 @@
package com.github.nacabaro.vbhelper.push

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
import com.github.nacabaro.vbhelper.di.VBHelper

@Serializable
data class PushNotification(
    val title: String,
    val message: String
)

class VBHelperPushService : PushService() {
    private val json = Json { ignoreUnknownKeys = true }

    override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
        val application = applicationContext as VBHelper
        val pushPrefs = application.container.pushPreferencesRepository

        CoroutineScope(Dispatchers.IO).launch {
            pushPrefs.setPushEndpoint(endpoint.url)
            application.container.timerRegistrationService.registerAllActiveTimers()
        }
    }

    override fun onMessage(message: PushMessage, instance: String) {
        val content = message.content.decodeToString()
        try {
            val pushNotification = json.decodeFromString<PushNotification>(content)
            NotificationHelper.showTimerNotification(this, pushNotification.title, pushNotification.message)
        } catch (e: Exception) {
            NotificationHelper.showTimerNotification(this, "VBHelper", content)
        }
    }

    override fun onUnregistered(instance: String) {
        val application = applicationContext as VBHelper
        val pushPrefs = application.container.pushPreferencesRepository

        CoroutineScope(Dispatchers.IO).launch {
            pushPrefs.setPushEndpoint("")
            pushPrefs.setPushEnabled(false)
        }
    }

    override fun onRegistrationFailed(reason: FailedReason, instance: String) {
        // Registration failed — user can retry from Settings
    }
}

M app/src/main/java/com/github/nacabaro/vbhelper/screens/adventureScreen/AdventureScreenControllerImpl.kt => app/src/main/java/com/github/nacabaro/vbhelper/screens/adventureScreen/AdventureScreenControllerImpl.kt +8 -0
@@ 16,6 16,7 @@ class AdventureScreenControllerImpl(
) : AdventureScreenController {
    private val application = componentActivity.applicationContext as VBHelper
    private val database = application.container.db
    private val timerRegistrationService = application.container.timerRegistrationService

    override fun sendCharacterToAdventure(characterId: Long, timeInMinutes: Long) {
        val finishesAdventureAt = timeInMinutes * 60


@@ 33,6 34,9 @@ class AdventureScreenControllerImpl(
            database
                .adventureDao()
                .insertNewAdventure(characterId, timeInMinutes, finishesAdventureAt)

            val finishesAtEpoch = System.currentTimeMillis() / 1000 + finishesAdventureAt
            timerRegistrationService.registerAdventureTimer(characterId, finishesAtEpoch)
        }
    }



@@ 45,6 49,8 @@ class AdventureScreenControllerImpl(
                .adventureDao()
                .deleteAdventure(characterId)

            timerRegistrationService.cancelAdventureTimer(characterId)

            val generatedCurrency = generateRandomCurrency()

            val generatedItem = generateItem(characterId)


@@ 67,6 73,8 @@ class AdventureScreenControllerImpl(
                .adventureDao()
                .deleteAdventure(characterId)

            timerRegistrationService.cancelAdventureTimer(characterId)

            componentActivity
                .runOnUiThread {
                    Toast.makeText(

M app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/HomeScreenControllerImpl.kt => app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/HomeScreenControllerImpl.kt +3 -0
@@ 16,6 16,7 @@ class HomeScreenControllerImpl(
): HomeScreenController {
    private val application = componentActivity.applicationContext as VBHelper
    private val database = application.container.db
    private val timerRegistrationService = application.container.timerRegistrationService

    override fun didAdventureMissionsFinish(onCompletion: (Boolean) -> Unit) {
        componentActivity.lifecycleScope.launch {


@@ 44,6 45,8 @@ class HomeScreenControllerImpl(
                .specialMissionDao()
                .clearSpecialMission(missionId)

            timerRegistrationService.cancelSpecialMissionTimer(missionId)

            if (missionStatus.status == SpecialMission.Status.COMPLETED) {
                val randomItem = database
                    .itemDao()

M app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt => app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt +35 -0
@@ 16,6 16,8 @@ import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.domain.card.Card
import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.push.TimerRegistrationService
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConverter
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.ToNfcConverter
import com.github.nacabaro.vbhelper.source.getCryptographicTransformerMap


@@ 35,6 37,7 @@ class ScanScreenControllerImpl(
): ScanScreenController {
    private var lastScannedCharacter: NfcCharacter? = null
    private val nfcAdapter: NfcAdapter
    private val timerRegistrationService: TimerRegistrationService

    init {
        val maybeNfcAdapter = NfcAdapter.getDefaultAdapter(componentActivity)


@@ 42,6 45,8 @@ class ScanScreenControllerImpl(
            Toast.makeText(componentActivity,  componentActivity.getString(R.string.scan_no_nfc_on_device), Toast.LENGTH_SHORT).show()
        }
        nfcAdapter = maybeNfcAdapter
        val application = componentActivity.applicationContext as VBHelper
        timerRegistrationService = application.container.timerRegistrationService
        checkSecrets()
    }



@@ 53,11 58,41 @@ class ScanScreenControllerImpl(
                onMultipleCards(cards)

            }
            registerTimersFromNfcCharacter(character)
            onComplete.invoke()
            resultMessage
        }
    }

    private fun registerTimersFromNfcCharacter(character: NfcCharacter) {
        componentActivity.lifecycleScope.launch(Dispatchers.IO) {
            if (character is VBNfcCharacter) {
                val countdown = character.transformationCountdownInMinutes.toInt()
                if (countdown > 0) {
                    timerRegistrationService.registerTransformationTimer(
                        character.dimId.toLong(),
                        countdown
                    )
                }
            } else if (character is BENfcCharacter) {
                val trainingTime = character.remainingTrainingTimeInMinutes.toInt()
                if (trainingTime > 0) {
                    timerRegistrationService.registerBETrainingTimer(
                        character.dimId.toLong(),
                        trainingTime
                    )
                }
                timerRegistrationService.registerBEItemEffectTimers(
                    character.dimId.toLong(),
                    character.itemEffectMentalStateMinutesRemaining.toInt(),
                    character.itemEffectActivityLevelMinutesRemaining.toInt(),
                    character.itemEffectVitalPointsChangeMinutesRemaining.toInt(),
                    character.itemRemainingTime.toInt()
                )
            }
        }
    }

    override fun cancelRead() {
        if(nfcAdapter.isEnabled) {
            nfcAdapter.disableReaderMode(componentActivity)

M app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreen.kt => app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreen.kt +91 -0
@@ 3,17 3,29 @@ package com.github.nacabaro.vbhelper.screens.settingsScreen
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource


@@ 98,11 110,90 @@ fun SettingsScreen(
            ) {
                settingsScreenController.onClickImportDatabase()
            }

            NotificationsSection(settingsScreenController)
        }
    }
}

@Composable
fun NotificationsSection(
    settingsScreenController: SettingsScreenControllerImpl
) {
    val pushServerUrl by settingsScreenController.pushServerUrl.collectAsState(initial = "")
    val pushEnabled by settingsScreenController.pushEnabled.collectAsState(initial = false)
    var showUrlDialog by remember { mutableStateOf(false) }
    var urlInput by remember { mutableStateOf("") }

    SettingsSection(title = stringResource(R.string.settings_section_notifications))

    SettingsEntry(
        title = stringResource(R.string.settings_push_server_url_title),
        description = if (pushServerUrl.isNotBlank()) pushServerUrl
                      else stringResource(R.string.settings_push_server_url_desc)
    ) {
        urlInput = pushServerUrl
        showUrlDialog = true
    }

    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 8.dp),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Column(modifier = Modifier.weight(1f)) {
            Text(text = stringResource(R.string.settings_push_enabled_title))
            Text(
                text = stringResource(R.string.settings_push_enabled_desc),
                fontSize = 12.sp,
                color = MaterialTheme.colorScheme.outline
            )
        }
        Switch(
            checked = pushEnabled,
            onCheckedChange = { settingsScreenController.onClickTogglePush(it) }
        )
    }

    SettingsEntry(
        title = stringResource(R.string.settings_push_test_title),
        description = stringResource(R.string.settings_push_test_desc)
    ) {
        settingsScreenController.onClickTestNotification()
    }

    if (showUrlDialog) {
        AlertDialog(
            onDismissRequest = { showUrlDialog = false },
            title = { Text(stringResource(R.string.settings_push_server_url_dialog_title)) },
            text = {
                OutlinedTextField(
                    value = urlInput,
                    onValueChange = { urlInput = it },
                    singleLine = true,
                    placeholder = { Text("http://192.168.1.100:8080") }
                )
            },
            confirmButton = {
                TextButton(onClick = {
                    settingsScreenController.onClickSetPushServerUrl(urlInput)
                    showUrlDialog = false
                }) {
                    Text(stringResource(R.string.settings_push_server_url_dialog_confirm))
                }
            },
            dismissButton = {
                TextButton(onClick = { showUrlDialog = false }) {
                    Text(stringResource(R.string.settings_push_server_url_dialog_cancel))
                }
            }
        )
    }
}

@Composable
fun SettingsEntry(
    title: String,
    description: String,

M app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenController.kt => app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenController.kt +8 -1
@@ 1,8 1,15 @@
package com.github.nacabaro.vbhelper.screens.settingsScreen

import kotlinx.coroutines.flow.Flow

interface SettingsScreenController {
    fun onClickOpenDirectory()
    fun onClickImportDatabase()
    fun onClickImportApk()
    fun onClickImportCard()
}
\ No newline at end of file
    val pushServerUrl: Flow<String>
    val pushEnabled: Flow<Boolean>
    fun onClickSetPushServerUrl(url: String)
    fun onClickTogglePush(enabled: Boolean)
    fun onClickTestNotification()
}

M app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenControllerImpl.kt => app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenControllerImpl.kt +75 -0
@@ 13,9 13,15 @@ import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.CardImpor
import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.DatabaseManagementController
import com.github.nacabaro.vbhelper.source.ApkSecretsImporter
import com.github.nacabaro.vbhelper.source.SecretsImporter
import com.github.nacabaro.vbhelper.network.PushServerClient
import com.github.nacabaro.vbhelper.network.TimerRegistrationRequest
import com.github.nacabaro.vbhelper.source.PushPreferencesRepository
import com.github.nacabaro.vbhelper.source.SecretsRepository
import com.github.nacabaro.vbhelper.source.proto.Secrets
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import org.unifiedpush.android.connector.UnifiedPush


class SettingsScreenControllerImpl(


@@ 33,6 39,8 @@ class SettingsScreenControllerImpl(
        componentActivity = context,
        application = application
    )
    private val pushPreferencesRepository: PushPreferencesRepository = application.container.pushPreferencesRepository
    private val pushServerClient: PushServerClient = application.container.pushServerClient

    init {
        filePickerLauncher = context.registerForActivityResult(


@@ 101,6 109,73 @@ class SettingsScreenControllerImpl(
        filePickerCard.launch(arrayOf("*/*"))
    }

    override val pushServerUrl: Flow<String> = pushPreferencesRepository.pushServerUrl
    override val pushEnabled: Flow<Boolean> = pushPreferencesRepository.pushEnabled

    override fun onClickSetPushServerUrl(url: String) {
        context.lifecycleScope.launch(Dispatchers.IO) {
            pushPreferencesRepository.setPushServerUrl(url)
            context.runOnUiThread {
                Toast.makeText(context, "Server URL saved", Toast.LENGTH_SHORT).show()
            }
        }
    }

    override fun onClickTogglePush(enabled: Boolean) {
        if (enabled) {
            UnifiedPush.tryUseCurrentOrDefaultDistributor(context) { success ->
                if (success) {
                    UnifiedPush.register(context)
                    context.lifecycleScope.launch(Dispatchers.IO) {
                        pushPreferencesRepository.setPushEnabled(true)
                    }
                } else {
                    context.runOnUiThread {
                        Toast.makeText(context, "No UnifiedPush distributor found. Install ntfy or another distributor.", Toast.LENGTH_LONG).show()
                    }
                }
            }
        } else {
            UnifiedPush.unregister(context)
            context.lifecycleScope.launch(Dispatchers.IO) {
                pushPreferencesRepository.setPushEnabled(false)
            }
        }
    }

    override fun onClickTestNotification() {
        context.lifecycleScope.launch(Dispatchers.IO) {
            val serverUrl = pushPreferencesRepository.pushServerUrl.first()
            val endpoint = pushPreferencesRepository.pushEndpoint.first()
            val enabled = pushPreferencesRepository.pushEnabled.first()

            if (!enabled || serverUrl.isBlank() || endpoint.isBlank()) {
                context.runOnUiThread {
                    Toast.makeText(context, context.getString(R.string.settings_push_not_configured), Toast.LENGTH_SHORT).show()
                }
                return@launch
            }

            try {
                val testRequest = TimerRegistrationRequest(
                    id = "test_${System.currentTimeMillis()}",
                    pushEndpoint = endpoint,
                    expiresAtEpochSeconds = System.currentTimeMillis() / 1000 + 3,
                    title = "Test Notification",
                    message = "Push notifications are working!"
                )
                pushServerClient.registerTimer(serverUrl, testRequest)
                context.runOnUiThread {
                    Toast.makeText(context, context.getString(R.string.settings_push_test_sent), Toast.LENGTH_SHORT).show()
                }
            } catch (e: Exception) {
                context.runOnUiThread {
                    Toast.makeText(context, "Failed: ${e.message}", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }

    private fun importCard(uri: Uri) {
        context.lifecycleScope.launch(Dispatchers.IO) {
            val contentResolver = context.contentResolver

A app/src/main/java/com/github/nacabaro/vbhelper/source/PushPreferencesRepository.kt => app/src/main/java/com/github/nacabaro/vbhelper/source/PushPreferencesRepository.kt +47 -0
@@ 0,0 1,47 @@
package com.github.nacabaro.vbhelper.source

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

class PushPreferencesRepository(private val dataStore: DataStore<Preferences>) {
    private companion object {
        val PUSH_SERVER_URL = stringPreferencesKey("push_server_url")
        val PUSH_ENDPOINT = stringPreferencesKey("push_endpoint")
        val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
    }

    val pushServerUrl: Flow<String> = dataStore.data.map { preferences ->
        preferences[PUSH_SERVER_URL] ?: ""
    }

    val pushEndpoint: Flow<String> = dataStore.data.map { preferences ->
        preferences[PUSH_ENDPOINT] ?: ""
    }

    val pushEnabled: Flow<Boolean> = dataStore.data.map { preferences ->
        preferences[PUSH_ENABLED] ?: false
    }

    suspend fun setPushServerUrl(url: String) {
        dataStore.edit { preferences ->
            preferences[PUSH_SERVER_URL] = url
        }
    }

    suspend fun setPushEndpoint(endpoint: String) {
        dataStore.edit { preferences ->
            preferences[PUSH_ENDPOINT] = endpoint
        }
    }

    suspend fun setPushEnabled(enabled: Boolean) {
        dataStore.edit { preferences ->
            preferences[PUSH_ENABLED] = enabled
        }
    }
}

M app/src/main/res/values/strings.xml => app/src/main/res/values/strings.xml +16 -0
@@ 226,4 226,20 @@
    <string name="home_special_mission_delete_dismiss">Dismiss</string>
    <string name="home_special_mission_delete_remove">Remove</string>

    <string name="settings_section_notifications">Notifications</string>
    <string name="settings_push_server_url_title">Push server URL</string>
    <string name="settings_push_server_url_desc">Set the UnifiedPush server address</string>
    <string name="settings_push_enabled_title">Enable push notifications</string>
    <string name="settings_push_enabled_desc">Requires a UnifiedPush distributor app (e.g. ntfy)</string>
    <string name="settings_push_test_title">Test notification</string>
    <string name="settings_push_test_desc">Send a test push notification</string>
    <string name="settings_push_server_url_dialog_title">Push Server URL</string>
    <string name="settings_push_server_url_dialog_confirm">Save</string>
    <string name="settings_push_server_url_dialog_cancel">Cancel</string>
    <string name="settings_push_test_sent">Test notification sent</string>
    <string name="settings_push_not_configured">Please configure server URL and enable push first</string>

    <string name="notification_channel_timers">Timer Alerts</string>
    <string name="notification_channel_timers_desc">Notifications for adventure, evolution, and other timer events</string>

</resources>
\ No newline at end of file

M build.gradle.kts => build.gradle.kts +3 -0
@@ 3,6 3,9 @@ plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.android) apply false
    alias(libs.plugins.kotlin.compose) apply false
    alias(libs.plugins.kotlin.jvm) apply false
    alias(libs.plugins.kotlin.serialization) apply false
    alias(libs.plugins.ktor) apply false
}

buildscript {

M gradle/libs.versions.toml => gradle/libs.versions.toml +17 -0
@@ 17,6 17,10 @@ protobufJavalite = "4.33.4"
roomRuntime = "2.8.4"
vbNfcReader = "0.2.0-SNAPSHOT"
dimReader = "2.1.0"
ktor = "3.1.1"
unifiedpush = "3.3.2"
kotlinxSerialization = "1.8.1"
logback = "1.5.18"

[libraries]
androidx-compose-material = { module = "androidx.compose.material:material" }


@@ 46,9 50,22 @@ protobuf-gradle-plugin = { module = "com.google.protobuf:protobuf-gradle-plugin"
protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" }
vb-nfc-reader = { module = "com.github.cfogrady:vb-nfc-reader", version.ref = "vbNfcReader" }
dim-reader = { module = "com.github.cfogrady:vb-dim-reader", version.ref = "dimReader" }
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
unifiedpush-connector = { module = "org.unifiedpush.android:connector", version.ref = "unifiedpush" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }


A server/Dockerfile => server/Dockerfile +35 -0
@@ 0,0 1,35 @@
# Stage 1: Build
FROM gradle:8.11-jdk17 AS build
WORKDIR /project

# Copy Gradle config files
COPY gradle/libs.versions.toml gradle/libs.versions.toml
COPY server/build.gradle.kts server/build.gradle.kts

# Create server-only settings and root build files (skip Android module entirely)
RUN echo 'pluginManagement { repositories { mavenCentral(); gradlePluginPortal() } }\n\
dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS); repositories { mavenCentral() } }\n\
rootProject.name = "VBHelper"\n\
include(":server")' > settings.gradle.kts

RUN echo 'plugins {\n\
    alias(libs.plugins.kotlin.jvm) apply false\n\
    alias(libs.plugins.kotlin.serialization) apply false\n\
    alias(libs.plugins.ktor) apply false\n\
}' > build.gradle.kts

# Copy server source
COPY server/src server/src

# Build fat JAR
RUN gradle :server:buildFatJar --no-daemon

# Stage 2: Runtime
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app

COPY --from=build /project/server/build/libs/*-all.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

A server/build.gradle.kts => server/build.gradle.kts +21 -0
@@ 0,0 1,21 @@
plugins {
    alias(libs.plugins.kotlin.jvm)
    alias(libs.plugins.kotlin.serialization)
    alias(libs.plugins.ktor)
}

application {
    mainClass.set("com.github.nacabaro.vbhelper.server.ApplicationKt")
}

dependencies {
    implementation(libs.ktor.server.core)
    implementation(libs.ktor.server.netty)
    implementation(libs.ktor.server.content.negotiation)
    implementation(libs.ktor.serialization.kotlinx.json)
    implementation(libs.ktor.client.core)
    implementation(libs.ktor.client.okhttp)
    implementation(libs.ktor.client.content.negotiation)
    implementation(libs.kotlinx.serialization.json)
    implementation(libs.logback.classic)
}

A server/build/classes/kotlin/main/META-INF/server.kotlin_module => server/build/classes/kotlin/main/META-INF/server.kotlin_module +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/ApplicationKt.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/ApplicationKt.class +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/models/TimerRegistration$$serializer.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/models/TimerRegistration$$serializer.class +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/models/TimerRegistration$Companion.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/models/TimerRegistration$Companion.class +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/models/TimerRegistration.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/models/TimerRegistration.class +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/push/PushNotification$$serializer.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/push/PushNotification$$serializer.class +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/push/PushNotification$Companion.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/push/PushNotification$Companion.class +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/push/PushNotification.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/push/PushNotification.class +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/push/UnifiedPushSender$sendNotification$1.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/push/UnifiedPushSender$sendNotification$1.class +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/push/UnifiedPushSender.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/push/UnifiedPushSender.class +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/routes/TimerRoutesKt$timerRoutes$1$1.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/routes/TimerRoutesKt$timerRoutes$1$1.class +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/routes/TimerRoutesKt$timerRoutes$1$2.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/routes/TimerRoutesKt$timerRoutes$1$2.class +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/routes/TimerRoutesKt$timerRoutes$1$3.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/routes/TimerRoutesKt$timerRoutes$1$3.class +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/routes/TimerRoutesKt$timerRoutes$1$4.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/routes/TimerRoutesKt$timerRoutes$1$4.class +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/routes/TimerRoutesKt.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/routes/TimerRoutesKt.class +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/scheduler/TimerScheduler$TimerEntry.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/scheduler/TimerScheduler$TimerEntry.class +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/scheduler/TimerScheduler$scheduleTimer$1.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/scheduler/TimerScheduler$scheduleTimer$1.class +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/scheduler/TimerScheduler$scheduleTimer$job$1.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/scheduler/TimerScheduler$scheduleTimer$job$1.class +0 -0
A server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/scheduler/TimerScheduler.class => server/build/classes/kotlin/main/com/github/nacabaro/vbhelper/server/scheduler/TimerScheduler.class +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab => server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab.keystream => server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab.keystream +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab.keystream.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab.keystream.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab.values.at => server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab.values.at +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab_i.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/outputs-generated-for-plugins.tab_i.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab => server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab.keystream => server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab.keystream +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab.keystream.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab.keystream.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab.values.at => server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab.values.at +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab_i.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/compilerPluginFiles/sources-referenced-by-plugins.tab_i.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab => server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream => server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.keystream.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.values.at => server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab.values.at +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i => server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/inputs/source-to-output.tab_i.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.keystream.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.values.at => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab.values.at +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab_i.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.keystream.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.values.at => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab.values.at +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab_i.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.keystream => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.keystream +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.keystream.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.keystream.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.values.at => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.values.at +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab_i => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab_i +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab_i.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/package-parts.tab_i.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab_i.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.keystream => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.keystream +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.keystream.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.keystream.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.values.at => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab.values.at +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/subtypes.tab_i.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.keystream.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.values.at => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab.values.at +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/supertypes.tab_i.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/counters.tab => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/counters.tab +2 -0
@@ 0,0 1,2 @@
5
0
\ No newline at end of file

A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.keystream.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.values.at => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab.values.at +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/file-to-id.tab_i.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.values.at => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab.values.at +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/id-to-file.tab_i.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab.keystream.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.at => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab.values.at +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab_i => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab_i +0 -0
A server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab_i.len => server/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/lookups.tab_i.len +0 -0
A server/build/kotlin/compileKotlin/cacheable/last-build.bin => server/build/kotlin/compileKotlin/cacheable/last-build.bin +0 -0
A server/build/kotlin/compileKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin => server/build/kotlin/compileKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin +0 -0
A server/build/libs/server-all.jar => server/build/libs/server-all.jar +0 -0
A server/build/resources/main/application.conf => server/build/resources/main/application.conf +9 -0
@@ 0,0 1,9 @@
ktor {
    deployment {
        port = 8080
        host = "0.0.0.0"
    }
    application {
        modules = [ com.github.nacabaro.vbhelper.server.ApplicationKt.configureServer ]
    }
}

A server/build/resources/main/logback.xml => server/build/resources/main/logback.xml +14 -0
@@ 0,0 1,14 @@
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="STDOUT"/>
    </root>

    <logger name="io.ktor" level="INFO"/>
    <logger name="com.github.nacabaro.vbhelper.server" level="DEBUG"/>
</configuration>

A server/build/tmp/shadowJar/MANIFEST.MF => server/build/tmp/shadowJar/MANIFEST.MF +3 -0
@@ 0,0 1,3 @@
Manifest-Version: 1.0
Main-Class: com.github.nacabaro.vbhelper.server.ApplicationKt


A server/docker-compose.yml => server/docker-compose.yml +9 -0
@@ 0,0 1,9 @@
services:
  vbhelper-push:
    build:
      context: ..
      dockerfile: server/Dockerfile
    container_name: vbhelper-push
    restart: unless-stopped
    ports:
      - "8080:8080"

A server/src/main/kotlin/com/github/nacabaro/vbhelper/server/Application.kt => server/src/main/kotlin/com/github/nacabaro/vbhelper/server/Application.kt +34 -0
@@ 0,0 1,34 @@
package com.github.nacabaro.vbhelper.server

import com.github.nacabaro.vbhelper.server.push.UnifiedPushSender
import com.github.nacabaro.vbhelper.server.routes.timerRoutes
import com.github.nacabaro.vbhelper.server.scheduler.TimerScheduler
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.routing.*
import kotlinx.serialization.json.Json

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
        configureServer()
    }.start(wait = true)
}

fun Application.configureServer() {
    install(ContentNegotiation) {
        json(Json {
            prettyPrint = true
            ignoreUnknownKeys = true
        })
    }

    val pushSender = UnifiedPushSender()
    val scheduler = TimerScheduler(pushSender)

    routing {
        timerRoutes(scheduler)
    }
}

A server/src/main/kotlin/com/github/nacabaro/vbhelper/server/models/TimerRegistration.kt => server/src/main/kotlin/com/github/nacabaro/vbhelper/server/models/TimerRegistration.kt +12 -0
@@ 0,0 1,12 @@
package com.github.nacabaro.vbhelper.server.models

import kotlinx.serialization.Serializable

@Serializable
data class TimerRegistration(
    val id: String,
    val pushEndpoint: String,
    val expiresAtEpochSeconds: Long,
    val title: String,
    val message: String
)

A server/src/main/kotlin/com/github/nacabaro/vbhelper/server/push/UnifiedPushSender.kt => server/src/main/kotlin/com/github/nacabaro/vbhelper/server/push/UnifiedPushSender.kt +41 -0
@@ 0,0 1,41 @@
package com.github.nacabaro.vbhelper.server.push

import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory

@Serializable
data class PushNotification(
    val title: String,
    val message: String
)

class UnifiedPushSender {
    private val logger = LoggerFactory.getLogger(UnifiedPushSender::class.java)
    private val client = HttpClient(OkHttp) {
        install(ContentNegotiation) {
            json(Json { ignoreUnknownKeys = true })
        }
    }

    suspend fun sendNotification(endpoint: String, title: String, message: String): Boolean {
        return try {
            val payload = Json.encodeToString(PushNotification.serializer(), PushNotification(title, message))
            val response = client.post(endpoint) {
                contentType(ContentType.Application.Json)
                setBody(payload)
            }
            logger.info("Push sent to endpoint, status: ${response.status}")
            response.status.isSuccess()
        } catch (e: Exception) {
            logger.error("Failed to send push notification to $endpoint", e)
            false
        }
    }
}

A server/src/main/kotlin/com/github/nacabaro/vbhelper/server/routes/TimerRoutes.kt => server/src/main/kotlin/com/github/nacabaro/vbhelper/server/routes/TimerRoutes.kt +40 -0
@@ 0,0 1,40 @@
package com.github.nacabaro.vbhelper.server.routes

import com.github.nacabaro.vbhelper.server.models.TimerRegistration
import com.github.nacabaro.vbhelper.server.scheduler.TimerScheduler
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Route.timerRoutes(scheduler: TimerScheduler) {
    route("/api/v1") {
        get("/health") {
            call.respond(mapOf("status" to "ok"))
        }

        post("/timers") {
            val registration = call.receive<TimerRegistration>()
            scheduler.scheduleTimer(registration)
            call.respond(HttpStatusCode.Created, mapOf("id" to registration.id, "status" to "scheduled"))
        }

        delete("/timers/{id}") {
            val id = call.parameters["id"] ?: return@delete call.respond(
                HttpStatusCode.BadRequest, mapOf("error" to "Missing timer id")
            )
            val cancelled = scheduler.cancelTimer(id)
            if (cancelled) {
                call.respond(mapOf("id" to id, "status" to "cancelled"))
            } else {
                call.respond(HttpStatusCode.NotFound, mapOf("error" to "Timer not found"))
            }
        }

        get("/timers") {
            val endpoint = call.request.queryParameters["endpoint"]
            val timers = scheduler.getActiveTimers(endpoint)
            call.respond(timers)
        }
    }
}

A server/src/main/kotlin/com/github/nacabaro/vbhelper/server/scheduler/TimerScheduler.kt => server/src/main/kotlin/com/github/nacabaro/vbhelper/server/scheduler/TimerScheduler.kt +69 -0
@@ 0,0 1,69 @@
package com.github.nacabaro.vbhelper.server.scheduler

import com.github.nacabaro.vbhelper.server.models.TimerRegistration
import com.github.nacabaro.vbhelper.server.push.UnifiedPushSender
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import java.util.concurrent.ConcurrentHashMap

class TimerScheduler(
    private val pushSender: UnifiedPushSender,
    private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
) {
    private val logger = LoggerFactory.getLogger(TimerScheduler::class.java)
    private val activeTimers = ConcurrentHashMap<String, TimerEntry>()

    data class TimerEntry(
        val registration: TimerRegistration,
        val job: Job
    )

    fun scheduleTimer(registration: TimerRegistration) {
        cancelTimer(registration.id)

        val nowSeconds = System.currentTimeMillis() / 1000
        val delayMs = (registration.expiresAtEpochSeconds - nowSeconds) * 1000

        if (delayMs <= 0) {
            logger.info("Timer ${registration.id} already expired, sending immediately")
            scope.launch {
                pushSender.sendNotification(
                    registration.pushEndpoint,
                    registration.title,
                    registration.message
                )
            }
            return
        }

        val job = scope.launch {
            logger.info("Timer ${registration.id} scheduled, fires in ${delayMs / 1000}s")
            delay(delayMs)
            logger.info("Timer ${registration.id} expired, sending push")
            pushSender.sendNotification(
                registration.pushEndpoint,
                registration.title,
                registration.message
            )
            activeTimers.remove(registration.id)
        }

        activeTimers[registration.id] = TimerEntry(registration, job)
    }

    fun cancelTimer(id: String): Boolean {
        val entry = activeTimers.remove(id)
        if (entry != null) {
            entry.job.cancel()
            logger.info("Timer $id cancelled")
            return true
        }
        return false
    }

    fun getActiveTimers(endpoint: String? = null): List<TimerRegistration> {
        return activeTimers.values
            .map { it.registration }
            .filter { endpoint == null || it.pushEndpoint == endpoint }
    }
}

A server/src/main/resources/application.conf => server/src/main/resources/application.conf +9 -0
@@ 0,0 1,9 @@
ktor {
    deployment {
        port = 8080
        host = "0.0.0.0"
    }
    application {
        modules = [ com.github.nacabaro.vbhelper.server.ApplicationKt.configureServer ]
    }
}

A server/src/main/resources/logback.xml => server/src/main/resources/logback.xml +14 -0
@@ 0,0 1,14 @@
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="STDOUT"/>
    </root>

    <logger name="io.ktor" level="INFO"/>
    <logger name="com.github.nacabaro.vbhelper.server" level="DEBUG"/>
</configuration>

M settings.gradle.kts => settings.gradle.kts +1 -0
@@ 22,3 22,4 @@ dependencyResolutionManagement {

rootProject.name = "VBHelper"
include(":app")
include(":server")