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
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue