Buschauffeurswekker v1: CalDAV-dagcheck 12:00, kwartier-afronding, wekkers Opstaan/Wally/Vertrek, fullscreen alarm-UI
This commit is contained in:
parent
97b7d3090e
commit
34505be9b8
31 changed files with 1881 additions and 1 deletions
116
README.md
Normal file
116
README.md
Normal file
|
|
@ -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`
|
||||||
43
app/build.gradle.kts
Normal file
43
app/build.gradle.kts
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
69
app/src/main/AndroidManifest.xml
Normal file
69
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- Netwerk: agenda ophalen over wifi én mobiele data -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<!-- Meldingen voor afgaande wekkers + status -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!-- Scherm aanhouden tijdens het afgaan van de wekker -->
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<!-- Na herstart de wekkers opnieuw inplannen -->
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<!-- Exacte wekkers (exact op tijd afgaan, ook in Doze) -->
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
|
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||||
|
<!-- Volle weergave bovenop het vergrendelscherm -->
|
||||||
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
|
<!-- Begeleiden naar batterijoptimalisatie-uitsluiting -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".App"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Buschauffeurswekker">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- Afgaande wekker: toont bovenop het vergrendelscherm, zonder in de recente apps -->
|
||||||
|
<activity
|
||||||
|
android:name=".AlarmActivity"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:showWhenLocked="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
|
android:theme="@style/Theme.Buschauffeurswekker.Alarm" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".receivers.BootReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".receivers.DailyCheckReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".receivers.AlarmRingReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
|
@ -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<Button>(R.id.btn_dismiss).setOnClickListener { dismiss() }
|
||||||
|
findViewById<Button>(R.id.btn_snooze).setOnClickListener { snooze() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
// Een volgende wekker (bv. na snooze) vervangt de huidige weergave.
|
||||||
|
ringer?.stop()
|
||||||
|
bindUi()
|
||||||
|
startRinging()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindUi() {
|
||||||
|
val name = intent?.getStringExtra(AlarmScheduler.EXTRA_NAME) ?: "Wekker"
|
||||||
|
val ringTime = intent?.getLongExtra(AlarmScheduler.EXTRA_RING_TIME, System.currentTimeMillis())
|
||||||
|
?: System.currentTimeMillis()
|
||||||
|
val snoozed = intent?.getBooleanExtra(AlarmScheduler.EXTRA_SNOOZED, false) ?: false
|
||||||
|
|
||||||
|
findViewById<TextView>(R.id.tv_alarm_name).text = if (snoozed) "$name (snooze)" else name
|
||||||
|
findViewById<TextView>(R.id.tv_planned).text =
|
||||||
|
"Gepland: " + Instant.ofEpochMilli(ringTime)
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||||
|
updateClock()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateClock() {
|
||||||
|
findViewById<TextView>(R.id.tv_now).text =
|
||||||
|
DateTimeFormatter.ofPattern("HH:mm:ss").format(LocalTime.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startRinging() {
|
||||||
|
ringer = Ringer(this).also { it.start() }
|
||||||
|
handler.removeCallbacks(clockTick)
|
||||||
|
handler.post(clockTick)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dismiss() {
|
||||||
|
ringer?.stop()
|
||||||
|
finishAndRemoveTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snooze() {
|
||||||
|
val name = intent?.getStringExtra(AlarmScheduler.EXTRA_NAME) ?: "Wekker"
|
||||||
|
val ringTime = intent?.getLongExtra(AlarmScheduler.EXTRA_RING_TIME, System.currentTimeMillis())
|
||||||
|
?: System.currentTimeMillis()
|
||||||
|
ringer?.stop()
|
||||||
|
AlarmScheduler.scheduleSnooze(this, name, ringTime)
|
||||||
|
finishAndRemoveTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
ringer?.stop()
|
||||||
|
ringer = null
|
||||||
|
handler.removeCallbacks(clockTick)
|
||||||
|
AlarmRingReceiver.releaseHeldWakelock()
|
||||||
|
Notifier.cancelAlarmNotification(this)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/src/main/java/nl/eddydevink/buschauffeurswekker/App.kt
Normal file
14
app/src/main/java/nl/eddydevink/buschauffeurswekker/App.kt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
package nl.eddydevink.buschauffeurswekker
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import nl.eddydevink.buschauffeurswekker.core.AlarmScheduler
|
||||||
|
import nl.eddydevink.buschauffeurswekker.core.Notifier
|
||||||
|
|
||||||
|
class App : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
Notifier.ensureChannels(this)
|
||||||
|
// De dagelijkse controle staat altijd klaar, ook vóór de eerste keer instellen.
|
||||||
|
AlarmScheduler.scheduleNoonCheck(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
package nl.eddydevink.buschauffeurswekker
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.button.MaterialButton
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import nl.eddydevink.buschauffeurswekker.core.AlarmScheduler
|
||||||
|
import nl.eddydevink.buschauffeurswekker.core.Prefs
|
||||||
|
import nl.eddydevink.buschauffeurswekker.core.ShiftCheck
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private lateinit var prefs: Prefs
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
|
prefs = Prefs(this)
|
||||||
|
requestNotificationPermission()
|
||||||
|
|
||||||
|
findViewById<MaterialButton>(R.id.btn_check).setOnClickListener { runCheck() }
|
||||||
|
findViewById<MaterialButton>(R.id.btn_test).setOnClickListener {
|
||||||
|
AlarmScheduler.scheduleTest(this)
|
||||||
|
Toast.makeText(this, "Testwekker gaat over 10 seconden af", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
findViewById<MaterialButton>(R.id.btn_save).setOnClickListener { saveSettings() }
|
||||||
|
|
||||||
|
findViewById<MaterialButton>(R.id.btn_perm_notifications).setOnClickListener {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQ_NOTIFICATIONS)
|
||||||
|
} else {
|
||||||
|
toast("Meldingen zijn al toegestaan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
findViewById<MaterialButton>(R.id.btn_perm_exact).setOnClickListener { openExactAlarmSettings() }
|
||||||
|
findViewById<MaterialButton>(R.id.btn_perm_battery).setOnClickListener { openBatterySettings() }
|
||||||
|
findViewById<MaterialButton>(R.id.btn_perm_fullscreen).setOnClickListener { openFullScreenSettings() }
|
||||||
|
|
||||||
|
// Staat altijd zichtbaar in de status-edit: pionnen van de volgende controle alvast zetten.
|
||||||
|
AlarmScheduler.scheduleNoonCheck(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
renderStatus()
|
||||||
|
renderSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderStatus() {
|
||||||
|
val p = prefs
|
||||||
|
val last = p.lastCheckedAtMillis.takeIf { it > 0 }?.let {
|
||||||
|
Instant.ofEpochMilli(it)
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.format(DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm"))
|
||||||
|
} ?: "nooit"
|
||||||
|
findViewById<TextView>(R.id.tv_status).text = buildString {
|
||||||
|
append("Dagelijkse controle: elke dag om 12:00\n")
|
||||||
|
append("Laatst gecontroleerd: $last\n\n")
|
||||||
|
append(p.lastStatusTxt ?: "Nog niet gecontroleerd.")
|
||||||
|
val (day, alarms) = p.loadPlan()
|
||||||
|
if (day != null && alarms.isNotEmpty()) {
|
||||||
|
append("\n\nGepland voor $day:")
|
||||||
|
alarms.forEach { append("\n ${it.name} om ${it.time.toLocalTime()}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderSettings() {
|
||||||
|
findViewById<EditText>(R.id.et_url).setText(prefs.calendarUrl.orEmpty())
|
||||||
|
findViewById<EditText>(R.id.et_user).setText(prefs.username.orEmpty())
|
||||||
|
findViewById<EditText>(R.id.et_pass).setText(prefs.password.orEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveSettings() {
|
||||||
|
prefs.calendarUrl = findViewById<EditText>(R.id.et_url).text.toString().trim()
|
||||||
|
prefs.username = findViewById<EditText>(R.id.et_user).text.toString().trim()
|
||||||
|
prefs.password = findViewById<EditText>(R.id.et_pass).text.toString()
|
||||||
|
toast("Instellingen opgeslagen")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runCheck() {
|
||||||
|
saveSettings()
|
||||||
|
val btn = findViewById<MaterialButton>(R.id.btn_check)
|
||||||
|
btn.isEnabled = false
|
||||||
|
btn.text = "Bezig…"
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val result = ShiftCheck.run(this@MainActivity)
|
||||||
|
renderStatus()
|
||||||
|
toast(result.message)
|
||||||
|
} finally {
|
||||||
|
btn.isEnabled = true
|
||||||
|
btn.text = "Nu controleren"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openExactAlarmSettings() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
runCatching {
|
||||||
|
startActivity(
|
||||||
|
Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, Uri.parse("package:$packageName")),
|
||||||
|
)
|
||||||
|
}.onFailure { toast("Niet beschikbaar op dit toestel") }
|
||||||
|
} else {
|
||||||
|
toast("Niet nodig op dit toestel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openBatterySettings() {
|
||||||
|
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
if (pm.isIgnoringBatteryOptimizations(packageName)) {
|
||||||
|
toast("Deze app is al uitgezonderd van batterijoptimalisatie")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
startActivity(
|
||||||
|
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:$packageName")),
|
||||||
|
)
|
||||||
|
}.onFailure { toast("Niet beschikbaar op dit toestel") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openFullScreenSettings() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
runCatching {
|
||||||
|
startActivity(
|
||||||
|
Intent(
|
||||||
|
Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT,
|
||||||
|
Uri.parse("package:$packageName"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}.onFailure { toast("Niet beschikbaar op dit toestel") }
|
||||||
|
} else {
|
||||||
|
toast("Op dit toestel (Android < 14) is volledig scherm standaard geregeld")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNotificationPermission() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQ_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toast(msg: String) = Toast.makeText(this, msg, Toast.LENGTH_LONG).show()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val REQ_NOTIFICATIONS = 42
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
package nl.eddydevink.buschauffeurswekker.caldav
|
||||||
|
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CalDAV-client volgens het bestaande recept uit de scrapedienst
|
||||||
|
* (zie vault-notitie Homelab/Software ontwikkeling/scrapedienst.md):
|
||||||
|
* REPORT calendar-query met time-range op de agenda-URL, basic auth.
|
||||||
|
*/
|
||||||
|
object CalDavClient {
|
||||||
|
|
||||||
|
fun reportQuery(
|
||||||
|
calendarUrl: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
startUtc: String, // yyyyMMdd'T'HHmmss'Z'
|
||||||
|
endUtc: String,
|
||||||
|
): List<ShiftEvent> {
|
||||||
|
val xml = """
|
||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<d:prop>
|
||||||
|
<d:getetag/>
|
||||||
|
<c:calendar-data/>
|
||||||
|
</d:prop>
|
||||||
|
<c:filter>
|
||||||
|
<c:comp-filter name="VCALENDAR">
|
||||||
|
<c:comp-filter name="VEVENT">
|
||||||
|
<c:time-range start="$startUtc" end="$endUtc"/>
|
||||||
|
</c:comp-filter>
|
||||||
|
</c:comp-filter>
|
||||||
|
</c:filter>
|
||||||
|
</c:calendar-query>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(calendarUrl)
|
||||||
|
.method("REPORT", xml.toRequestBody("application/xml; charset=utf-8".toMediaType()))
|
||||||
|
.header("Depth", "1")
|
||||||
|
.header("Authorization", Credentials.basic(username, password))
|
||||||
|
.header("User-Agent", "Buschauffeurswekker/1.0")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw IOException("CalDAV REPORT mislukt (HTTP ${response.code})")
|
||||||
|
}
|
||||||
|
val body = response.body?.string() ?: throw IOException("Lege CalDAV-response")
|
||||||
|
return Ical.extractCalendarBlocks(body)
|
||||||
|
.mapNotNull { Ical.firstTimedEvent(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
package nl.eddydevink.buschauffeurswekker.caldav
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
/** Eén getimed kalender-event, opgelost naar een absoluut tijdstip. */
|
||||||
|
data class ShiftEvent(
|
||||||
|
val start: ZonedDateTime,
|
||||||
|
val summary: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimale iCal(RFC 5545)-parser, precies genoeg voor de EBS-Diensten-agenda:
|
||||||
|
* VEVENT-blokken herkennen, DTSTART (met TZID / UTC / lokale tijd) oplossen,
|
||||||
|
* all-day-events en geannuleerde events overslaan.
|
||||||
|
*/
|
||||||
|
object Ical {
|
||||||
|
|
||||||
|
private val DATE_TIME = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haalt de ruwe VCALENDAR-payloads uit een CalDAV multi-status REPORT-response.
|
||||||
|
* Werkt met elk namespace-prefix (Nextcloud/SabreDAV gebruikt `cal:`, soms `c:` of geen).
|
||||||
|
*/
|
||||||
|
fun extractCalendarBlocks(xml: String): List<String> {
|
||||||
|
val regex = Regex(
|
||||||
|
"<(?:[A-Za-z0-9_]+:)?calendar-data[^>]*>(.*?)</(?:[A-Za-z0-9_]+:)?calendar-data>",
|
||||||
|
setOf(RegexOption.DOT_MATCHES_ALL),
|
||||||
|
)
|
||||||
|
return regex.findAll(xml).map { unescapeXml(it.groupValues[1]) }.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Geeft het VEVENT met de vroegste aanvangstijd uit één VCALENDAR, of null. */
|
||||||
|
fun firstTimedEvent(vcalendar: String): ShiftEvent? {
|
||||||
|
return splitBlocks(vcalendar, "VEVENT")
|
||||||
|
.mapNotNull { parseVEvent(it, ZoneId.systemDefault()) }
|
||||||
|
.minByOrNull { it.start.toInstant() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vouwt RFC 5545-regels uit (regel vervolgd door spatie/tab) en geeft regels terug. */
|
||||||
|
fun unfold(text: String): List<String> {
|
||||||
|
val lines = mutableListOf<String>()
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (line in text.split("\r\n", "\n")) {
|
||||||
|
if ((line.startsWith(" ") || line.startsWith("\t")) && sb.isNotEmpty()) {
|
||||||
|
sb.append(line.drop(1))
|
||||||
|
} else {
|
||||||
|
if (sb.isNotEmpty()) lines += sb.toString()
|
||||||
|
sb.setLength(0)
|
||||||
|
sb.append(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sb.isNotEmpty()) lines += sb.toString()
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseVEvent(block: String, defaultZone: ZoneId): ShiftEvent? {
|
||||||
|
if (block.uppercase().contains("STATUS:CANCELLED")) return null
|
||||||
|
val props = unfold(block).mapNotNull { parseProperty(it) }.toMap()
|
||||||
|
val start = props["DTSTART"]?.let { resolveStart(it, defaultZone) } ?: return null
|
||||||
|
return ShiftEvent(start, props["SUMMARY"]?.value.orEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Prop(val params: Map<String, String>, val value: String)
|
||||||
|
|
||||||
|
private fun parseProperty(line: String): Pair<String, Prop>? {
|
||||||
|
val colon = line.indexOf(':')
|
||||||
|
if (colon <= 0) return null
|
||||||
|
val head = line.substring(0, colon)
|
||||||
|
val value = line.substring(colon + 1)
|
||||||
|
val parts = head.split(';')
|
||||||
|
val name = parts[0].uppercase()
|
||||||
|
val params = parts.drop(1).mapNotNull { p ->
|
||||||
|
val eq = p.indexOf('=')
|
||||||
|
if (eq <= 0) null else p.substring(0, eq).uppercase() to p.substring(eq + 1)
|
||||||
|
}.toMap()
|
||||||
|
return name to Prop(params, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveStart(prop: Prop, defaultZone: ZoneId): ZonedDateTime? {
|
||||||
|
if (prop.params["VALUE"] == "DATE") return null // all-day-event: geen diensttijd
|
||||||
|
val value = prop.value.trim()
|
||||||
|
return try {
|
||||||
|
val tzid = prop.params["TZID"]
|
||||||
|
when {
|
||||||
|
tzid != null -> {
|
||||||
|
val zone = runCatching { ZoneId.of(tzid) }.getOrDefault(defaultZone)
|
||||||
|
ZonedDateTime.of(LocalDateTime.parse(value, DATE_TIME), zone)
|
||||||
|
}
|
||||||
|
value.endsWith("Z") -> {
|
||||||
|
val ldt = LocalDateTime.parse(value.dropLast(1), DATE_TIME)
|
||||||
|
ZonedDateTime.of(ldt, ZoneOffset.UTC)
|
||||||
|
}
|
||||||
|
else -> ZonedDateTime.of(LocalDateTime.parse(value, DATE_TIME), defaultZone)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun splitBlocks(text: String, component: String): List<String> {
|
||||||
|
val upper = text.uppercase()
|
||||||
|
val begin = "BEGIN:$component"
|
||||||
|
val end = "END:$component"
|
||||||
|
val result = mutableListOf<String>()
|
||||||
|
var i = 0
|
||||||
|
while (true) {
|
||||||
|
val from = upper.indexOf(begin, i)
|
||||||
|
if (from < 0) break
|
||||||
|
val to = upper.indexOf(end, from)
|
||||||
|
if (to < 0) break
|
||||||
|
result += text.substring(from, to + end.length)
|
||||||
|
i = to + end.length
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unescapeXml(s: String): String = s
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace(Regex("&#(\\d+);")) { m ->
|
||||||
|
m.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
|
||||||
|
}
|
||||||
|
.replace("&", "&")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
package nl.eddydevink.buschauffeurswekker.core
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import nl.eddydevink.buschauffeurswekker.receivers.AlarmRingReceiver
|
||||||
|
import nl.eddydevink.buschauffeurswekker.receivers.DailyCheckReceiver
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inplannen van exacte wekkers (AlarmManager.setExactAndAllowWhileIdle).
|
||||||
|
* Valt bij gebrek aan exact-alarm-recht terug op schemerige alarmen.
|
||||||
|
*/
|
||||||
|
object AlarmScheduler {
|
||||||
|
|
||||||
|
const val RC_OPSTAAN = 1001
|
||||||
|
const val RC_WALLY = 1002
|
||||||
|
const val RC_VERTREK = 1003
|
||||||
|
const val RC_TEST = 1004
|
||||||
|
const val RC_NOON = 2001
|
||||||
|
|
||||||
|
const val EXTRA_NAME = "alarm_name"
|
||||||
|
const val EXTRA_RING_TIME = "ring_time"
|
||||||
|
const val EXTRA_SNOOZED = "snoozed"
|
||||||
|
|
||||||
|
private const val SNOOZE_MS = 10 * 60 * 1000L
|
||||||
|
|
||||||
|
private fun requestCodeFor(name: String): Int = when (name) {
|
||||||
|
"Opstaan" -> RC_OPSTAAN
|
||||||
|
"Wally" -> RC_WALLY
|
||||||
|
"Vertrek" -> RC_VERTREK
|
||||||
|
else -> name.hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alarmManager(context: Context): AlarmManager =
|
||||||
|
context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
|
|
||||||
|
private fun ringPendingIntent(context: Context, name: String, ringTime: Long, snoozed: Boolean): PendingIntent {
|
||||||
|
val intent = Intent(context, AlarmRingReceiver::class.java)
|
||||||
|
.putExtra(EXTRA_NAME, name)
|
||||||
|
.putExtra(EXTRA_RING_TIME, ringTime)
|
||||||
|
.putExtra(EXTRA_SNOOZED, snoozed)
|
||||||
|
return PendingIntent.getBroadcast(
|
||||||
|
context, requestCodeFor(name), intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zet een exacte wekker; false = teruggevallen op schemerige wekker. */
|
||||||
|
private fun scheduleExact(context: Context, requestCode: Int, epochMillis: Long, pendingIntent: PendingIntent): Boolean {
|
||||||
|
val am = alarmManager(context)
|
||||||
|
return try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !am.canScheduleExactAlarms()) {
|
||||||
|
throw SecurityException("Geen exact-alarm-recht")
|
||||||
|
}
|
||||||
|
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, epochMillis, pendingIntent)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, epochMillis, pendingIntent)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plant één wekker (Opstaan/Wally/Vertrek) op zijn absolute tijdstip. */
|
||||||
|
fun scheduleAlarm(context: Context, spec: AlarmSpec) {
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val millis = spec.time.atZone(zone).toInstant().toEpochMilli()
|
||||||
|
val pi = ringPendingIntent(context, spec.name, millis, snoozed = false)
|
||||||
|
scheduleExact(context, requestCodeFor(spec.name), millis, pi)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snooze: zelfde wekker opnieuw over 10 minuten laten afgaan. */
|
||||||
|
fun scheduleSnooze(context: Context, name: String, originalRingTime: Long) {
|
||||||
|
val millis = System.currentTimeMillis() + SNOOZE_MS
|
||||||
|
val pi = ringPendingIntent(context, name, originalRingTime, snoozed = true)
|
||||||
|
scheduleExact(context, requestCodeFor(name), millis, pi)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Testwekker om de werking te controleren. */
|
||||||
|
fun scheduleTest(context: Context) {
|
||||||
|
val millis = System.currentTimeMillis() + 10_000
|
||||||
|
val pi = ringPendingIntent(context, "Testwekker", millis, snoozed = false)
|
||||||
|
scheduleExact(context, RC_TEST, millis, pi)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zet de dagelijkse 12:00-controle; plant zichzelf na afloop opnieuw. */
|
||||||
|
fun scheduleNoonCheck(context: Context) {
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val now = LocalDateTime.now(zone)
|
||||||
|
var next = now.toLocalDate().atTime(12, 0)
|
||||||
|
if (!next.isAfter(now)) next = next.plusDays(1)
|
||||||
|
val millis = next.atZone(zone).toInstant().toEpochMilli()
|
||||||
|
val pi = PendingIntent.getBroadcast(
|
||||||
|
context, RC_NOON,
|
||||||
|
Intent(context, DailyCheckReceiver::class.java),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
scheduleExact(context, RC_NOON, millis, pi)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAlarm(context: Context, name: String) {
|
||||||
|
cancelRequest(context, requestCodeFor(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAll(context: Context) {
|
||||||
|
cancelRequest(context, RC_OPSTAAN)
|
||||||
|
cancelRequest(context, RC_WALLY)
|
||||||
|
cancelRequest(context, RC_VERTREK)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelRequest(context: Context, requestCode: Int) {
|
||||||
|
val am = alarmManager(context)
|
||||||
|
val pi = PendingIntent.getBroadcast(
|
||||||
|
context, requestCode,
|
||||||
|
Intent(context, AlarmRingReceiver::class.java),
|
||||||
|
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
if (pi != null) am.cancel(pi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
package nl.eddydevink.buschauffeurswekker.core
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import nl.eddydevink.buschauffeurswekker.AlarmActivity
|
||||||
|
import nl.eddydevink.buschauffeurswekker.R
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
object Notifier {
|
||||||
|
|
||||||
|
const val CHANNEL_ALARM = "wekkers"
|
||||||
|
const val CHANNEL_STATUS = "status"
|
||||||
|
private const val NOTIF_ID_ALARM = 1
|
||||||
|
private const val NOTIF_ID_STATUS = 2
|
||||||
|
|
||||||
|
fun ensureChannels(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val nm = context.getSystemService(NotificationManager::class.java)
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL_ALARM, "Wekkers", NotificationManager.IMPORTANCE_HIGH).apply {
|
||||||
|
description = "Afgaande wekkers (volledig scherm)"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL_STATUS, "Status", NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||||
|
description = "Meldingen van de dagelijkse controle"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canNotify(context: Context): Boolean =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(context, "android.permission.POST_NOTIFICATIONS") ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toont de afgaande wekker als melding met full-screen intent, zodat de
|
||||||
|
* alarmactivity ook bovenop het (vergrendelde) scherm verschijnt.
|
||||||
|
*/
|
||||||
|
fun showAlarm(context: Context, name: String, ringTimeEpochMillis: Long, fullScreenPi: PendingIntent) {
|
||||||
|
if (!canNotify(context)) return
|
||||||
|
val time = Instant.ofEpochMilli(ringTimeEpochMillis)
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||||
|
val contentPi = PendingIntent.getActivity(
|
||||||
|
context, 0,
|
||||||
|
Intent(context, AlarmActivity::class.java),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ALARM)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_alarm)
|
||||||
|
.setContentTitle(name)
|
||||||
|
.setContentText("Wekker gaat af · gepland om $time")
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setFullScreenIntent(fullScreenPi, true)
|
||||||
|
.setContentIntent(contentPi)
|
||||||
|
.build()
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_ALARM, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAlarmNotification(context: Context) {
|
||||||
|
if (!canNotify(context)) return
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_ALARM)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showStatus(context: Context, title: String, text: String) {
|
||||||
|
if (!canNotify(context)) return
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_STATUS)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_alarm)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_STATUS, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
package nl.eddydevink.buschauffeurswekker.core
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
data class CalDavConfig(
|
||||||
|
val calendarUrl: String,
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Instellingen + staat van de app, versleuteld opgeslagen. */
|
||||||
|
class Prefs(context: Context) {
|
||||||
|
|
||||||
|
private val sp: SharedPreferences = encrypted(context)
|
||||||
|
|
||||||
|
private fun encrypted(context: Context): SharedPreferences {
|
||||||
|
return try {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"buschauffeurswekker_secure",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
context.getSharedPreferences("buschauffeurswekker_fallback", Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var calendarUrl: String?
|
||||||
|
get() = sp.getString(KEY_URL, null)
|
||||||
|
set(value) = sp.edit().putString(KEY_URL, value).apply()
|
||||||
|
|
||||||
|
var username: String?
|
||||||
|
get() = sp.getString(KEY_USER, null)
|
||||||
|
set(value) = sp.edit().putString(KEY_USER, value).apply()
|
||||||
|
|
||||||
|
var password: String?
|
||||||
|
get() = sp.getString(KEY_PASS, null)
|
||||||
|
set(value) = sp.edit().putString(KEY_PASS, value).apply()
|
||||||
|
|
||||||
|
var lastStatusTxt: String?
|
||||||
|
get() = sp.getString(KEY_STATUS, null)
|
||||||
|
set(value) = sp.edit().putString(KEY_STATUS, value).apply()
|
||||||
|
|
||||||
|
var lastCheckedAtMillis: Long
|
||||||
|
get() = sp.getLong(KEY_CHECKED_AT, 0L)
|
||||||
|
set(value) = sp.edit().putLong(KEY_CHECKED_AT, value).apply()
|
||||||
|
|
||||||
|
/** Datum (ISO) van de laatste succesvolle 12:00-controle (alleen via DailyCheckReceiver). */
|
||||||
|
var lastNoonRunIso: String?
|
||||||
|
get() = sp.getString(KEY_NOON_RUN, null)
|
||||||
|
set(value) = sp.edit().putString(KEY_NOON_RUN, value).apply()
|
||||||
|
|
||||||
|
private var plannedDayIso: String?
|
||||||
|
get() = sp.getString(KEY_PLAN_DAY, null)
|
||||||
|
set(value) = sp.edit().putString(KEY_PLAN_DAY, value).apply()
|
||||||
|
|
||||||
|
private var plannedAlarms: String?
|
||||||
|
get() = sp.getString(KEY_PLAN_ALARMS, null)
|
||||||
|
set(value) = sp.edit().putString(KEY_PLAN_ALARMS, value).apply()
|
||||||
|
|
||||||
|
fun config(): CalDavConfig? {
|
||||||
|
val url = calendarUrl?.trim().orEmpty()
|
||||||
|
val user = username?.trim().orEmpty()
|
||||||
|
val pass = password.orEmpty()
|
||||||
|
return if (url.isNotEmpty() && user.isNotEmpty() && pass.isNotEmpty()) {
|
||||||
|
CalDavConfig(url, user, pass)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun storePlan(day: LocalDate, alarms: List<AlarmSpec>) {
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
plannedDayIso = day.toString()
|
||||||
|
plannedAlarms = alarms.joinToString("|") {
|
||||||
|
"${it.name}@${it.time.atZone(zone).toInstant().toEpochMilli()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadPlan(): Pair<LocalDate?, List<AlarmSpec>> {
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val day = plannedDayIso?.let {
|
||||||
|
runCatching { LocalDate.parse(it) }.getOrNull()
|
||||||
|
}
|
||||||
|
val alarms = plannedAlarms?.split("|")?.mapNotNull { entry ->
|
||||||
|
val parts = entry.split("@")
|
||||||
|
if (parts.size != 2) return@mapNotNull null
|
||||||
|
val millis = parts[1].toLongOrNull() ?: return@mapNotNull null
|
||||||
|
AlarmSpec(parts[0], Instant.ofEpochMilli(millis).atZone(zone).toLocalDateTime())
|
||||||
|
}.orEmpty()
|
||||||
|
return day to alarms
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearPlan() {
|
||||||
|
plannedDayIso = null
|
||||||
|
plannedAlarms = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val KEY_URL = "caldav_url"
|
||||||
|
const val KEY_USER = "caldav_user"
|
||||||
|
const val KEY_PASS = "caldav_pass"
|
||||||
|
const val KEY_STATUS = "last_status"
|
||||||
|
const val KEY_CHECKED_AT = "last_checked_at"
|
||||||
|
const val KEY_NOON_RUN = "last_noon_run"
|
||||||
|
const val KEY_PLAN_DAY = "plan_day"
|
||||||
|
const val KEY_PLAN_ALARMS = "plan_alarms"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
package nl.eddydevink.buschauffeurswekker.core
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.media.RingtoneManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.os.VibratorManager
|
||||||
|
|
||||||
|
/** Speelt het standaard wekkergeluid (herhalend) en laat de telefoon trillen. */
|
||||||
|
class Ringer(private val context: Context) {
|
||||||
|
|
||||||
|
private var player: MediaPlayer? = null
|
||||||
|
private var vibrator: Vibrator? = null
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
val uri = RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_ALARM)
|
||||||
|
?: RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE)
|
||||||
|
playLooping(uri)
|
||||||
|
vibrate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playLooping(uri: Uri?) {
|
||||||
|
if (uri == null) return
|
||||||
|
try {
|
||||||
|
player = MediaPlayer().apply {
|
||||||
|
setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_ALARM)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
setDataSource(context, uri)
|
||||||
|
isLooping = true
|
||||||
|
prepare()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
player = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun vibrate() {
|
||||||
|
try {
|
||||||
|
val v = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
context.getSystemService(VibratorManager::class.java).defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||||
|
}
|
||||||
|
vibrator = v
|
||||||
|
if (!v.hasVibrator()) return
|
||||||
|
v.vibrate(VibrationEffect.createWaveform(longArrayOf(0, 1000, 1000), 0))
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
vibrator = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
runCatching { player?.stop() }
|
||||||
|
player?.release()
|
||||||
|
player = null
|
||||||
|
runCatching { vibrator?.cancel() }
|
||||||
|
vibrator = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
package nl.eddydevink.buschauffeurswekker.core
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import nl.eddydevink.buschauffeurswekker.caldav.CalDavClient
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrator van de dagelijkse controle: agenda van morgen ophalen,
|
||||||
|
* dienstbegintijd afronden, wekkers plannen (of annuleren als er geen dienst is).
|
||||||
|
* Gedeeld door DailyCheckReceiver, BootReceiver en de knop in het hoofdscherm.
|
||||||
|
*/
|
||||||
|
object ShiftCheck {
|
||||||
|
|
||||||
|
data class Result(
|
||||||
|
val ok: Boolean,
|
||||||
|
val message: String,
|
||||||
|
val alarms: List<AlarmSpec>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val UTC_FMT = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC)
|
||||||
|
private val DISPLAY = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
|
||||||
|
suspend fun run(context: Context): Result {
|
||||||
|
val prefs = Prefs(context)
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val today = LocalDate.now(zone)
|
||||||
|
val tomorrow = today.plusDays(1)
|
||||||
|
|
||||||
|
val config = prefs.config()
|
||||||
|
if (config == null) {
|
||||||
|
val msg = "Instellingen ontbreken: vul CalDAV-url, gebruikersnaam en app-wachtwoord in"
|
||||||
|
prefs.lastStatusTxt = msg
|
||||||
|
prefs.lastCheckedAtMillis = System.currentTimeMillis()
|
||||||
|
AlarmScheduler.scheduleNoonCheck(context)
|
||||||
|
return Result(false, msg, emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val startUtc = UTC_FMT.format(tomorrow.atStartOfDay(zone).toInstant())
|
||||||
|
val endUtc = UTC_FMT.format(tomorrow.plusDays(1).atStartOfDay(zone).toInstant())
|
||||||
|
val events = withContext(Dispatchers.IO) {
|
||||||
|
CalDavClient.reportQuery(config.calendarUrl, config.username, config.password, startUtc, endUtc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alleen events waarvan de aanvang op morgen valt; vroegste aanvang = dienstbegintijd.
|
||||||
|
val starts = events
|
||||||
|
.map { it.start.withZoneSameInstant(zone).toLocalDateTime() }
|
||||||
|
.filter { it.toLocalDate() == tomorrow }
|
||||||
|
|
||||||
|
if (starts.isEmpty()) {
|
||||||
|
AlarmScheduler.cancelAll(context)
|
||||||
|
prefs.clearPlan()
|
||||||
|
val msg = "Morgen ($tomorrow) geen dienst — er zijn geen wekkers gezet"
|
||||||
|
prefs.lastStatusTxt = msg
|
||||||
|
prefs.lastCheckedAtMillis = System.currentTimeMillis()
|
||||||
|
AlarmScheduler.scheduleNoonCheck(context)
|
||||||
|
return Result(true, msg, emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
val shiftStart = starts.minOrNull()!!
|
||||||
|
val plan = ShiftPlanner.plan(shiftStart)
|
||||||
|
plan.forEach { AlarmScheduler.scheduleAlarm(context, it) }
|
||||||
|
prefs.storePlan(tomorrow, plan)
|
||||||
|
val times = plan.joinToString(" · ") { "${it.name} ${DISPLAY.format(it.time)}" }
|
||||||
|
val msg = "Dienst morgen om ${DISPLAY.format(shiftStart)} → $times"
|
||||||
|
prefs.lastStatusTxt = msg
|
||||||
|
prefs.lastCheckedAtMillis = System.currentTimeMillis()
|
||||||
|
AlarmScheduler.scheduleNoonCheck(context)
|
||||||
|
Notifier.showStatus(context, "Wekkers voor morgen", msg)
|
||||||
|
Result(true, msg, plan)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
val msg = "Controle mislukt: ${t.message ?: t.javaClass.simpleName}"
|
||||||
|
prefs.lastStatusTxt = msg
|
||||||
|
prefs.lastCheckedAtMillis = System.currentTimeMillis()
|
||||||
|
AlarmScheduler.scheduleNoonCheck(context)
|
||||||
|
Notifier.showStatus(context, "Controle mislukt", msg)
|
||||||
|
Result(false, msg, emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package nl.eddydevink.buschauffeurswekker.core
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
/** Een geplande wekker: naam + absoluut tijdstip (lokale tijd). */
|
||||||
|
data class AlarmSpec(val name: String, val time: LocalDateTime)
|
||||||
|
|
||||||
|
object ShiftPlanner {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rondt een tijd naar beneden af 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.
|
||||||
|
*/
|
||||||
|
fun roundDownToQuarter(time: LocalTime): LocalTime {
|
||||||
|
val quarters = time.minute / 15
|
||||||
|
return time.withMinute(quarters * 15).withSecond(0).withNano(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bouwt het wekkerplan voor één dienst:
|
||||||
|
* - Opstaan: 2 uur voor de afgeronde aanvang
|
||||||
|
* - Wally: 1,5 uur voor de afgeronde aanvang
|
||||||
|
* - Vertrek: 0,5 uur voor de afgeronde aanvang
|
||||||
|
* Geen dienst (null) → lege lijst, dus geen wekkers.
|
||||||
|
*/
|
||||||
|
fun plan(shiftStart: LocalDateTime?): List<AlarmSpec> {
|
||||||
|
if (shiftStart == null) return emptyList()
|
||||||
|
val start = shiftStart.with(roundDownToQuarter(shiftStart.toLocalTime()))
|
||||||
|
return listOf(
|
||||||
|
AlarmSpec("Opstaan", start.minusHours(2)),
|
||||||
|
AlarmSpec("Wally", start.minusHours(1).minusMinutes(30)),
|
||||||
|
AlarmSpec("Vertrek", start.minusMinutes(30)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
package nl.eddydevink.buschauffeurswekker.receivers
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.PowerManager
|
||||||
|
import nl.eddydevink.buschauffeurswekker.AlarmActivity
|
||||||
|
import nl.eddydevink.buschauffeurswekker.core.AlarmScheduler
|
||||||
|
import nl.eddydevink.buschauffeurswekker.core.Notifier
|
||||||
|
|
||||||
|
/** Afgaande wekker: toont de alarmactivity (via full-screen intent) en houdt het scherm wakker. */
|
||||||
|
class AlarmRingReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val name = intent.getStringExtra(AlarmScheduler.EXTRA_NAME) ?: "Wekker"
|
||||||
|
val ringTime = intent.getLongExtra(AlarmScheduler.EXTRA_RING_TIME, System.currentTimeMillis())
|
||||||
|
val snoozed = intent.getBooleanExtra(AlarmScheduler.EXTRA_SNOOZED, false)
|
||||||
|
|
||||||
|
// Wakkere CPU houden tot de alarmactivity het overneemt.
|
||||||
|
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val wakelock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "buschauffeurswekker:ring")
|
||||||
|
wakelock.acquire(RING_WAKELOCK_MS)
|
||||||
|
heldWakelock?.release()
|
||||||
|
heldWakelock = wakelock
|
||||||
|
|
||||||
|
val alarmIntent = Intent(context, AlarmActivity::class.java)
|
||||||
|
.putExtra(AlarmScheduler.EXTRA_NAME, name)
|
||||||
|
.putExtra(AlarmScheduler.EXTRA_RING_TIME, ringTime)
|
||||||
|
.putExtra(AlarmScheduler.EXTRA_SNOOZED, snoozed)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
|
|
||||||
|
// Full-screen intent: het systeem toont de activity bovenop het vergrendelscherm.
|
||||||
|
val fullScreenPi = PendingIntent.getActivity(
|
||||||
|
context, name.hashCode(), alarmIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
Notifier.showAlarm(context, name, ringTime, fullScreenPi)
|
||||||
|
|
||||||
|
// Directe start als het mag (scherm aan/niet vergrendeld); anders neemt de
|
||||||
|
// full-screen intent het over.
|
||||||
|
runCatching { context.startActivity(alarmIntent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val RING_WAKELOCK_MS = 10 * 60 * 1000L
|
||||||
|
|
||||||
|
/** Wordt door AlarmActivity vrijgegeven zodra de wekker is afgehandeld. */
|
||||||
|
@Volatile
|
||||||
|
var heldWakelock: PowerManager.WakeLock? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun releaseHeldWakelock() {
|
||||||
|
heldWakelock?.let {
|
||||||
|
if (it.isHeld) it.release()
|
||||||
|
}
|
||||||
|
heldWakelock = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package nl.eddydevink.buschauffeurswekker.receivers
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import nl.eddydevink.buschauffeurswekker.core.AlarmScheduler
|
||||||
|
import nl.eddydevink.buschauffeurswekker.core.Prefs
|
||||||
|
import nl.eddydevink.buschauffeurswekker.core.ShiftCheck
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/** Na (her)start van het toestel: 12:00-controle opnieuw plannen en wekkers herstellen. */
|
||||||
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val prefs = Prefs(context)
|
||||||
|
|
||||||
|
// Exacte alarmen overleven een reboot niet: geplande wekkers voor morgen opnieuw zetten.
|
||||||
|
val (day, alarms) = prefs.loadPlan()
|
||||||
|
if (day == LocalDate.now(zone).plusDays(1) && alarms.isNotEmpty()) {
|
||||||
|
alarms.forEach { AlarmScheduler.scheduleAlarm(context, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dagelijkse controle altijd opnieuw plannen.
|
||||||
|
AlarmScheduler.scheduleNoonCheck(context)
|
||||||
|
|
||||||
|
// Als de 12:00-controle vandaag nog niet is gedraaid en het is al na 12:00, nu inhalen.
|
||||||
|
val noonToday = LocalDate.now(zone).atTime(12, 0)
|
||||||
|
val doneToday = prefs.lastNoonRunIso == LocalDate.now(zone).toString()
|
||||||
|
if (!doneToday && LocalDateTime.now(zone).isAfter(noonToday)) {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch { ShiftCheck.run(context) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package nl.eddydevink.buschauffeurswekker.receivers
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import nl.eddydevink.buschauffeurswekker.core.Prefs
|
||||||
|
import nl.eddydevink.buschauffeurswekker.core.ShiftCheck
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/** Dagelijkse 12:00-controle: leest de agenda van morgen en plant de wekkers. */
|
||||||
|
class DailyCheckReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
val result = ShiftCheck.run(context)
|
||||||
|
if (result.ok) {
|
||||||
|
Prefs(context).lastNoonRunIso = LocalDate.now(ZoneId.systemDefault()).toString()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
28
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M54,32.5 a21.5,21.5 0 1,1 -0.01,0 z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M54,47 L54,40"
|
||||||
|
android:strokeColor="#00695C"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeWidth="3.5" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M54,47 L60.5,52"
|
||||||
|
android:strokeColor="#00695C"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeWidth="3.5" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M46,30.5 L52,27 M62,30.5 L56,27"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeWidth="3" />
|
||||||
|
</vector>
|
||||||
24
app/src/main/res/drawable/ic_stat_alarm.xml
Normal file
24
app/src/main/res/drawable/ic_stat_alarm.xml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M12,4.5 a7.5,7.5 0 1,1 -0.01,0 z"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="2" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M12,12 L12,9"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeWidth="2" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M12,12 L14.5,13.5"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeWidth="2" />
|
||||||
|
</vector>
|
||||||
54
app/src/main/res/layout/activity_alarm.xml
Normal file
54
app/src/main/res/layout/activity_alarm.xml
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="24dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_alarm_name"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:textSize="40sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_now"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textColor="#B3FFFFFF"
|
||||||
|
android:textSize="72sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_planned"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textColor="#80FFFFFF"
|
||||||
|
android:textSize="16sp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="48dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btn_snooze"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:text="Snooze (10 min)" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btn_dismiss"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Wekker uit" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
136
app/src/main/res/layout/activity_main.xml
Normal file
136
app/src/main/res/layout/activity_main.xml
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_status"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btn_check"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="Nu controleren" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btn_test"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="Testwekker (over 10 seconden)" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:background="?android:attr/listDivider" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Instellingen (CalDAV)"
|
||||||
|
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/et_url"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:hint="Agenda-URL (bv. https://nextcloud.eddydevink.nl/remote.php/dav/calendars/eddy/ebs_diensten-1/)"
|
||||||
|
android:inputType="textUri" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/et_user"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="Gebruikersnaam (Nextcloud)"
|
||||||
|
android:inputType="text" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/et_pass"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="App-wachtwoord (Nextcloud → Beveiliging → App-wachtwoorden)"
|
||||||
|
android:inputType="textPassword" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btn_save"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="Opslaan" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:background="?android:attr/listDivider" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Machtigingen"
|
||||||
|
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btn_perm_notifications"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Meldingen toestaan (nodig voor afgaande wekker)" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btn_perm_exact"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Exacte alarmen toestaan (wekker op tijd)" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btn_perm_battery"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Batterijoptimalisatie uitzonderen (achtergrondtaken)" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btn_perm_fullscreen"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Volledig scherm inschakelen (wekker bovenop slotscherm)" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:background="?android:attr/listDivider" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_info"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:text="Elke dag om 12:00 leest de app de volgende dag uit de CalDAV-agenda EBS-Diensten. 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). Daarna worden drie wekkers gezet:\nOpstaan 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, dan worden geen wekkers gezet. De app gebruikt hiervoor wifi én mobiele data en voert ook op de achtergrond (vergrendeld scherm) zijn taken uit." />
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
6
app/src/main/res/values/colors.xml
Normal file
6
app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<resources>
|
||||||
|
<color name="primary">#00695C</color>
|
||||||
|
<color name="on_primary">#FFFFFF</color>
|
||||||
|
<color name="secondary">#4DB6AC</color>
|
||||||
|
<color name="ic_launcher_background">#00695C</color>
|
||||||
|
</resources>
|
||||||
3
app/src/main/res/values/strings.xml
Normal file
3
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Buschauffeurswekker</string>
|
||||||
|
</resources>
|
||||||
14
app/src/main/res/values/themes.xml
Normal file
14
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.Buschauffeurswekker" parent="Theme.Material3.DayNight">
|
||||||
|
<item name="colorPrimary">@color/primary</item>
|
||||||
|
<item name="colorOnPrimary">@color/on_primary</item>
|
||||||
|
<item name="colorSecondary">@color/secondary</item>
|
||||||
|
<item name="android:statusBarColor">@color/primary</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Theme.Buschauffeurswekker.Alarm" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@android:color/black</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/black</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/black</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
package nl.eddydevink.buschauffeurswekker.caldav
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class IcalTest {
|
||||||
|
|
||||||
|
private val amsterdam = ZoneId.of("Europe/Amsterdam")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extractCalendarBlocks haalt blokken met elk namespace-prefix`() {
|
||||||
|
val xml = """
|
||||||
|
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote.php/dav/calendars/eddy/ebs_diensten-1/abc.ics</d:href>
|
||||||
|
<d:propstat><d:prop>
|
||||||
|
<c:calendar-data>BEGIN:VCALENDAR${'\n'}X:een</c:calendar-data>
|
||||||
|
</d:prop></d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:propstat><d:prop>
|
||||||
|
<calendar-data>BEGIN:VCALENDAR${'\n'}X:twee</calendar-data>
|
||||||
|
</d:prop></d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:propstat><d:prop>
|
||||||
|
<cal:calendar-data>BEGIN:VCALENDAR${'\n'}X:drie</cal:calendar-data>
|
||||||
|
</d:prop></d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>
|
||||||
|
""".trimIndent()
|
||||||
|
val blocks = Ical.extractCalendarBlocks(xml)
|
||||||
|
assertEquals(3, blocks.size)
|
||||||
|
assertEquals("BEGIN:VCALENDAR${'\n'}X:drie", blocks[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `DTSTART met TZID lost op naar lokale tijd in die zone`() {
|
||||||
|
val block = buildVEvent(
|
||||||
|
summary = "1234",
|
||||||
|
dtstart = "DTSTART;TZID=Europe/Amsterdam:20261015T050000",
|
||||||
|
)
|
||||||
|
val event = Ical.parseVEvent(block, amsterdam)!!
|
||||||
|
assertEquals(LocalDateTime.of(2026, 10, 15, 5, 0), event.start.toLocalDateTime())
|
||||||
|
assertEquals(amsterdam, event.start.zone)
|
||||||
|
assertEquals("1234", event.summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `DTSTART in UTC wordt omgerekend naar de lokale zone`() {
|
||||||
|
// 05:00 UTC op 15 juni = 07:00 zomertijd Amsterdam
|
||||||
|
val block = buildVEvent(
|
||||||
|
summary = "1234",
|
||||||
|
dtstart = "DTSTART:20260615T050000Z",
|
||||||
|
)
|
||||||
|
val event = Ical.parseVEvent(block, amsterdam)!!
|
||||||
|
val local = event.start.withZoneSameInstant(amsterdam)
|
||||||
|
assertEquals(LocalDateTime.of(2026, 6, 15, 7, 0), local.toLocalDateTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day-event geeft geen diensttijd`() {
|
||||||
|
val block = buildVEvent(
|
||||||
|
summary = "vrij",
|
||||||
|
dtstart = "DTSTART;VALUE=DATE:20261015",
|
||||||
|
)
|
||||||
|
assertNull(Ical.parseVEvent(block, amsterdam))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `geannuleerd event wordt overgeslagen`() {
|
||||||
|
val block = buildVEvent(
|
||||||
|
summary = "1234",
|
||||||
|
dtstart = "DTSTART;TZID=Europe/Amsterdam:20261015T050000",
|
||||||
|
extra = "STATUS:CANCELLED",
|
||||||
|
)
|
||||||
|
assertNull(Ical.parseVEvent(block, amsterdam))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `eerste event is de vroegste aanvang`() {
|
||||||
|
val vcalendar = """
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Nextcloud//NEXTCLOUD//NL
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:laat
|
||||||
|
SUMMARY:2222
|
||||||
|
DTSTART;TZID=Europe/Amsterdam:20261015T140000
|
||||||
|
DTEND;TZID=Europe/Amsterdam:20261015T210000
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:vroeg
|
||||||
|
SUMMARY:1111
|
||||||
|
DTSTART;TZID=Europe/Amsterdam:20261015T043000
|
||||||
|
DTEND;TZID=Europe/Amsterdam:20261015T120000
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
""".trimIndent()
|
||||||
|
val event = Ical.firstTimedEvent(vcalendar)!!
|
||||||
|
assertEquals("1111", event.summary)
|
||||||
|
assertEquals(LocalDateTime.of(2026, 10, 15, 4, 30), event.start.toLocalDateTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unfold voegt gevouwen regels samen`() {
|
||||||
|
// RFC 5545: CRLF + spatie/tab na de break is het vouwteken en wordt verwijderd.
|
||||||
|
val lines = Ical.unfold("SUMMARY:een \r\n lange samenvatting\r\nDTSTART:20260615T050000")
|
||||||
|
assertEquals("SUMMARY:een lange samenvatting", lines[0])
|
||||||
|
assertEquals("DTSTART:20260615T050000", lines[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `XML-ampex wordt ontsnapt in calendar-data`() {
|
||||||
|
val xml = ("<c:calendar-data>" +
|
||||||
|
"BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:Dienst & meer\n" +
|
||||||
|
"DTSTART;TZID=Europe/Amsterdam:20261015T050000\nEND:VEVENT\nEND:VCALENDAR" +
|
||||||
|
"</c:calendar-data>")
|
||||||
|
val block = Ical.extractCalendarBlocks(xml).single()
|
||||||
|
val event = Ical.parseVEvent(block, amsterdam)!!
|
||||||
|
assertEquals("Dienst & meer", event.summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildVEvent(summary: String, dtstart: String, extra: String = ""): String {
|
||||||
|
val extraLine = if (extra.isBlank()) "" else "$extra\n"
|
||||||
|
return """
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:test-1
|
||||||
|
SUMMARY:$summary
|
||||||
|
$dtstart
|
||||||
|
${extraLine}END:VEVENT
|
||||||
|
""".trimIndent() + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
package nl.eddydevink.buschauffeurswekker.core
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.LocalTime
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ShiftPlannerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `afronding naar beneden op kwartier - vier gegeven voorbeelden`() {
|
||||||
|
// 05:00-05:14 -> 05:00
|
||||||
|
assertEquals(LocalTime.of(5, 0), ShiftPlanner.roundDownToQuarter(LocalTime.of(5, 0)))
|
||||||
|
assertEquals(LocalTime.of(5, 0), ShiftPlanner.roundDownToQuarter(LocalTime.of(5, 14)))
|
||||||
|
// 05:15-05:29 -> 05:15
|
||||||
|
assertEquals(LocalTime.of(5, 15), ShiftPlanner.roundDownToQuarter(LocalTime.of(5, 15)))
|
||||||
|
assertEquals(LocalTime.of(5, 15), ShiftPlanner.roundDownToQuarter(LocalTime.of(5, 29)))
|
||||||
|
// 05:30-05:44 -> 05:30
|
||||||
|
assertEquals(LocalTime.of(5, 30), ShiftPlanner.roundDownToQuarter(LocalTime.of(5, 30)))
|
||||||
|
assertEquals(LocalTime.of(5, 30), ShiftPlanner.roundDownToQuarter(LocalTime.of(5, 44)))
|
||||||
|
// 05:45-05:59 -> 05:45
|
||||||
|
assertEquals(LocalTime.of(5, 45), ShiftPlanner.roundDownToQuarter(LocalTime.of(5, 45)))
|
||||||
|
assertEquals(LocalTime.of(5, 45), ShiftPlanner.roundDownToQuarter(LocalTime.of(5, 59)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `afronding randen en middernacht`() {
|
||||||
|
assertEquals(LocalTime.of(0, 0), ShiftPlanner.roundDownToQuarter(LocalTime.of(0, 0)))
|
||||||
|
assertEquals(LocalTime.of(0, 0), ShiftPlanner.roundDownToQuarter(LocalTime.of(0, 14)))
|
||||||
|
assertEquals(LocalTime.of(23, 45), ShiftPlanner.roundDownToQuarter(LocalTime.of(23, 59)))
|
||||||
|
// seconden/nanoseconden worden op nul gezet
|
||||||
|
assertEquals(
|
||||||
|
LocalTime.of(5, 30),
|
||||||
|
ShiftPlanner.roundDownToQuarter(LocalTime.of(5, 44, 59)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `plan zet Opstaan Wally en Vertrek op de juiste offsets`() {
|
||||||
|
val shift = LocalDateTime.of(2026, 10, 15, 5, 10) // -> afgerond 05:00
|
||||||
|
val plan = ShiftPlanner.plan(shift)
|
||||||
|
|
||||||
|
assertEquals(3, plan.size)
|
||||||
|
assertEquals("Opstaan", plan[0].name)
|
||||||
|
assertEquals(LocalDateTime.of(2026, 10, 15, 3, 0), plan[0].time) // 2u voor 05:00
|
||||||
|
assertEquals("Wally", plan[1].name)
|
||||||
|
assertEquals(LocalDateTime.of(2026, 10, 15, 3, 30), plan[1].time) // 1,5u voor 05:00
|
||||||
|
assertEquals("Vertrek", plan[2].name)
|
||||||
|
assertEquals(LocalDateTime.of(2026, 10, 15, 4, 30), plan[2].time) // 0,5u voor 05:00
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `plan rekent over middernacht heen`() {
|
||||||
|
val shift = LocalDateTime.of(2026, 10, 15, 0, 10) // -> afgerond 00:00
|
||||||
|
val plan = ShiftPlanner.plan(shift)
|
||||||
|
|
||||||
|
assertEquals(LocalDateTime.of(2026, 10, 14, 22, 0), plan[0].time) // Opstaan
|
||||||
|
assertEquals(LocalDateTime.of(2026, 10, 14, 22, 30), plan[1].time) // Wally
|
||||||
|
assertEquals(LocalDateTime.of(2026, 10, 14, 23, 30), plan[2].time) // Vertrek
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `geen dienst geeft geen wekkers`() {
|
||||||
|
val plan = ShiftPlanner.plan(null)
|
||||||
|
assertTrue(plan.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
4
build.gradle.kts
Normal file
4
build.gradle.kts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.7.3" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||||
|
}
|
||||||
4
gradle.properties
Normal file
4
gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
|
@ -1 +1,23 @@
|
||||||
rootProject.name = "buschauffeurswekker"
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "buschauffeurswekker"
|
||||||
|
include(":app")
|
||||||
Loading…
Reference in a new issue