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")