Buschauffeurswekker v1: CalDAV-dagcheck 12:00, kwartier-afronding, wekkers Opstaan/Wally/Vertrek, fullscreen alarm-UI

This commit is contained in:
eddy 2026-10-14 16:31:50 +02:00
commit 34505be9b8
31 changed files with 1881 additions and 1 deletions

116
README.md Normal file
View 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
View 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")
}

View 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>

View file

@ -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()
}
}

View 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)
}
}

View file

@ -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
}
}

View file

@ -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) }
}
}
}

View file

@ -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("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace(Regex("&#(\\d+);")) { m ->
m.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
}
.replace("&amp;", "&")
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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"
}
}

View file

@ -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
}
}

View file

@ -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())
}
}
}

View file

@ -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)),
)
}
}

View file

@ -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
}
}
}

View file

@ -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) }
}
}
}

View file

@ -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()
}
}
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Buschauffeurswekker</string>
</resources>

View 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>

View file

@ -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 &amp; 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"
}
}

View file

@ -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
View 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
View file

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
android.useAndroidX=true
android.nonTransitiveRClass=true

View file

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