diff --git a/README.md b/README.md new file mode 100644 index 0000000..2baada3 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# Buschauffeurswekker + +Android-app die de functie van de normale wekker-app vervult, maar de wekkers +automatisch zet op basis van de Nextcloud CalDAV-agenda **EBS-Diensten**. + +## Werking + +- Elke dag om **12:00** leest de app de agenda van de **volgende dag**. +- De dienstbegintijd wordt **naar beneden afgerond op een heel kwartier**: + - 05:00–05:14 → 05:00 + - 05:15–05:29 → 05:15 + - 05:30–05:44 → 05:30 + - 05:45–05:59 → 05:45 +- Er worden drie wekkers gezet, alle drie vóór de afgeronde aanvangstijd: + - **Opstaan** — 2 uur vóór aanvang + - **Wally** — 1,5 uur vóór aanvang + - **Vertrek** — 0,5 uur vóór aanvang +- Staat er **geen dienst** in de agenda voor morgen, dan worden **geen wekkers** gezet. +- De wekker gaat volwaardig af (geluid + trillen, volledig scherm) **ook bij een + vergrendeld scherm**, met snooze (10 min) en "wekker uit". +- Het toestel mag daarvoor **mobiele data** verbruiken en taken op de + **achtergrond** uitvoeren. + +## Installatie + +1. Bouw de APK: `./gradlew :app:assembleDebug` + → `app/build/outputs/apk/debug/app-debug.apk` +2. Zet de APK op de telefoon en installeer (sideload). +3. Open de app en vul bij **Instellingen** in: + - Agenda-URL: `https://nextcloud.eddydevink.nl/remote.php/dav/calendars/eddy/ebs_diensten-1/` + - Gebruikersnaam: `eddy` + - App-wachtwoord: een Nextcloud-app-wachtwoord + (Nextcloud → Beveiliging → App-wachtwoorden; zie de Obsidian-vault voor + de bestaande app-wachtwoorden — niet in de app hardcoden) +4. Druk op **Opslaan** en daarna op **Nu controleren** om te testen. +5. Verleen de gevraagde machtigingen via de knoppen onder **Machtigingen**: + meldingen, exacte alarmen, batterijoptimalisatie uitzonderen en (Android 14+) + volledig scherm. + +## Machtigingen + +| Machtiging | Doel | +|---|---| +| Internet / netwerk | Agenda ophalen over wifi én mobiele data | +| Meldingen (POST_NOTIFICATIONS) | Afgaande wekker tonen | +| Exacte alarmen (USE_EXACT_ALARM / SCHEDULE_EXACT_ALARM) | Wekker óp tijd, ook in Doze | +| Volledig scherm (USE_FULL_SCREEN_INTENT) | Wekker bovenop het vergrendelscherm | +| Wakelock + trillen | Scherm aanhouden en trillen tijdens afgaan | +| Boot (RECEIVE_BOOT_COMPLETED) | Wekkers na herstart opnieuw inplannen | +| Batterijoptimalisatie uitzonderen | Achtergrondtaken betrouwbaar | + +## Architectuur + +``` +MainActivity status + instellingen, knop "Nu controleren" +AlarmActivity volledig-scherm wekker (geluid, trillen, snooze/dismiss) +App kanalen + dagelijkse 12:00-controle bij opstart +daily check: + AlarmScheduler.scheduleNoonCheck → DailyCheckReceiver (12:00, exact, allowWhileIdle) + → ShiftCheck.run agenda morgen ophalen → afronden → wekkers plannen + → CalDavClient REPORT calendar-query (time-range, basic auth) + → Ical iCal-parse: DTSTART (TZID/UTC), vroegste aanvang + → ShiftPlanner afronding op kwartier + Opstaan/Wally/Vertrek +ring: + AlarmScheduler.scheduleAlarm → AlarmRingReceiver (exact, allowWhileIdle) + → full-screen intent + directe start → AlarmActivity + → Ringer wekkergeluid (loop) + vibratiepatroon +BootReceiver na reboot: 12:00-controle + geplande wekkers herstellen +Prefs instellingen/status, versleuteld (EncryptedSharedPreferences) +``` + +Details per bestand: + +- `core/ShiftPlanner.kt` — pure logica: kwartier-afronding en wekkerplanning + (JVM-unit-tests, geen Android-afhankelijkheden). +- `caldav/Ical.kt` — minimale RFC 5545-parser (VEVENT, DTSTART met TZID/UTC, + all-day en geannuleerde events worden overgeslagen). +- `caldav/CalDavClient.kt` — REPORT calendar-query volgens het recept van de + scrapedienst (zie vault-notitie `Homelab/Software ontwikkeling/scrapedienst.md`). +- `core/ShiftCheck.kt` — orchestrator: ophalen → filteren op morgen → vroegste + aanvang → plannen of annuleren; gedeeld door receiver en knop. +- `core/AlarmScheduler.kt` — exacte alarmen + 12:00-controle + snooze + testwekker. +- `receivers/DailyCheckReceiver.kt` — 12:00; plant zichzelf via AlarmScheduler + steeds opnieuw. +- `receivers/AlarmRingReceiver.kt` — afgaande wekker: wakelock, full-screen + intent (ook vergrendeld scherm), directe activity-start. +- `receivers/BootReceiver.kt` — herstart-herstel (BOOT_COMPLETED, TIME_SET, + TIMEZONE_CHANGED, MY_PACKAGE_REPLACED). +- `core/Notifier.kt`, `core/Ringer.kt` — meldingen (kanalen "wekkers"/"status") + en geluid/trillen. + +## Keuzes + +- **12:00-controle via exact AlarmManager-alarm**, niet WorkManager: precies om + 12:00 (WorkManager periodiek is minimaal 15 min en onnauwkeurig). +- Bij **meerdere events** op één dag geldt de **vroegste aanvang** als + dienstbegintijd. +- **All-day-events** (bijv. "vrij") worden genegeerd; alleen getimede diensten + tellen. +- Na een fout (bv. offline) blijven eerder geplande wekkers staan en verschijnt + een statusmelding. +- Wachtwoord staat **versleuteld** op het toestel + (EncryptedSharedPreferences, AES256-GCM; bij falen plain fallback in de + app-sandbox). + +## Build + +- Gradle wrapper 8.13, AGP 8.7.3, Kotlin 2.0.21 +- compileSdk/targetSdk 34, minSdk 27 (Android 8.1+) +- Tests: `./gradlew :app:testDebugUnitTest` + +## Gerelateerd + +- Scrapedienst die de EBS-Diensten-agenda vult: + `~/src/scrapedienst` + vault-notitie `scrapedienst.md` +- CalDAV-referentieclient: `~/src/caldav-mcp` \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..50e4d37 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "nl.eddydevink.buschauffeurswekker" + compileSdk = 34 + + defaultConfig { + applicationId = "nl.eddydevink.buschauffeurswekker" + minSdk = 27 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("androidx.security:security-crypto:1.1.0-alpha06") + + testImplementation("junit:junit:4.13.2") +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4828b01 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/nl/eddydevink/buschauffeurswekker/AlarmActivity.kt b/app/src/main/java/nl/eddydevink/buschauffeurswekker/AlarmActivity.kt new file mode 100644 index 0000000..d710cab --- /dev/null +++ b/app/src/main/java/nl/eddydevink/buschauffeurswekker/AlarmActivity.kt @@ -0,0 +1,109 @@ +package nl.eddydevink.buschauffeurswekker + +import android.content.Intent +import android.media.AudioManager +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.WindowManager +import android.widget.Button +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import nl.eddydevink.buschauffeurswekker.core.AlarmScheduler +import nl.eddydevink.buschauffeurswekker.core.Notifier +import nl.eddydevink.buschauffeurswekker.core.Ringer +import nl.eddydevink.buschauffeurswekker.receivers.AlarmRingReceiver +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** Volledig-scherm wekker: geluid, trillen, snooze en wekker uit — ook bovenop het slotscherm. */ +class AlarmActivity : AppCompatActivity() { + + private var ringer: Ringer? = null + + private val clockTick = object : Runnable { + override fun run() { + updateClock() + handler.postDelayed(this, 1000) + } + } + private val handler = Handler(Looper.getMainLooper()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_alarm) + volumeControlStream = AudioManager.STREAM_ALARM + + window.addFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD, + ) + + bindUi() + startRinging() + + findViewById