Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 297a2350e3 | |||
| 997ee44792 | |||
| 81baadfaf3 | |||
| 35022267dc | |||
| 588e024036 | |||
| eeef089e4a | |||
| 9023899ddb | |||
| 2f153fef56 | |||
| 290a905f8b |
42
.gitea/workflows/renovate.yml
Normal file
42
.gitea/workflows/renovate.yml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
name: Renovate
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
|
||||||
|
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
|
||||||
|
schedule:
|
||||||
|
- cron: '0 5 * * 1'
|
||||||
|
# Manual run for an on-demand sweep from the Actions tab.
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Never let two Renovate runs touch the repo at once.
|
||||||
|
concurrency:
|
||||||
|
group: renovate
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
runs-on: docker
|
||||||
|
# Run the Renovate image *as* the job container and invoke the `renovate`
|
||||||
|
# binary directly. The renovatebot/github-action wrapper is a thin Node
|
||||||
|
# action that shells out to `docker run …` — it needs a Docker CLI + socket
|
||||||
|
# inside the job, which the Gitea runner's plain node container has not, so
|
||||||
|
# it died on "Unable to locate executable file: docker". Running the image
|
||||||
|
# directly drops the docker-in-docker requirement entirely.
|
||||||
|
# Full tag pinned; Renovate's github-actions manager keeps it bumped.
|
||||||
|
container:
|
||||||
|
image: ghcr.io/renovatebot/renovate:43.232.0
|
||||||
|
steps:
|
||||||
|
- name: Run Renovate
|
||||||
|
run: renovate
|
||||||
|
env:
|
||||||
|
# Self-hosted Gitea, not github.com.
|
||||||
|
RENOVATE_PLATFORM: gitea
|
||||||
|
RENOVATE_ENDPOINT: https://gitea.jeanlucmakiola.de/api/v1
|
||||||
|
# Bot-account token (Gitea secret). Needs repo read/write + PR scope.
|
||||||
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
# Scope to this repo only — no org-wide autodiscovery.
|
||||||
|
RENOVATE_AUTODISCOVER: 'false'
|
||||||
|
RENOVATE_REPOSITORIES: '["makiolaj/calendula"]'
|
||||||
|
# Commits/PRs authored as the bot, not a real maintainer.
|
||||||
|
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
|
||||||
|
LOG_LEVEL: info
|
||||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.7.1] — 2026-06-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed the app crashing immediately on launch whenever calendar access hadn't
|
||||||
|
been granted yet (a fresh install, or after revoking the permission). The app
|
||||||
|
set up its live calendar-change listener before the permission screen could
|
||||||
|
appear, which newer Android versions reject outright — so the app died before
|
||||||
|
you could grant access. The listener now waits for the permission and attaches
|
||||||
|
itself the moment it's granted.
|
||||||
|
|
||||||
## [2.7.0] — 2026-06-18
|
## [2.7.0] — 2026-06-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
||||||
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
|
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
|
||||||
// default; keep them matching the latest released tag. See docs/RELEASING.md.
|
// default; keep them matching the latest released tag. See docs/RELEASING.md.
|
||||||
versionCode = 20700
|
versionCode = 20701
|
||||||
versionName = "2.7.0"
|
versionName = "2.7.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.database.ContentObserver
|
import android.database.ContentObserver
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -11,6 +13,7 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
@@ -162,14 +165,52 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
) : CalendarDataSource {
|
) : CalendarDataSource {
|
||||||
|
|
||||||
private val resolver: ContentResolver get() = context.contentResolver
|
private val resolver: ContentResolver get() = context.contentResolver
|
||||||
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
|
|
||||||
|
|
||||||
override fun calendars(): List<CalendarSource> = resolver.query(
|
// All access to these two collections is guarded by [observerLock] because
|
||||||
CalendarContract.Calendars.CONTENT_URI,
|
// listeners are registered on the main thread (repository init, via ViewModel
|
||||||
CalendarProjection.COLUMNS,
|
// creation) while [ensureObserversRegistered] runs on the IO dispatcher.
|
||||||
null, null,
|
private val observerLock = Any()
|
||||||
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
|
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
|
||||||
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
|
private val registeredObservers = mutableSetOf<ContentObserver>()
|
||||||
|
|
||||||
|
private fun hasCalendarPermission(): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach any not-yet-registered observers to the provider, but only once the
|
||||||
|
* calendar permission is held. Registering a ContentObserver on the calendar
|
||||||
|
* provider without that permission throws SecurityException on newer Android
|
||||||
|
* (it used to silently no-op), which crashed the app at launch — the repo
|
||||||
|
* registers its observer eagerly, before the permission gate. Called from the
|
||||||
|
* observed reads ([calendars]/[instances]) so the observer re-attaches the
|
||||||
|
* first time a screen queries after the permission is granted.
|
||||||
|
*/
|
||||||
|
private fun ensureObserversRegistered() {
|
||||||
|
if (!hasCalendarPermission()) return
|
||||||
|
synchronized(observerLock) {
|
||||||
|
if (registeredObservers.size == observers.size) return
|
||||||
|
observers.values.forEach(::registerObserverLocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerObserverLocked(obs: ContentObserver) {
|
||||||
|
if (obs in registeredObservers || !hasCalendarPermission()) return
|
||||||
|
runCatching {
|
||||||
|
resolver.registerContentObserver(CalendarContract.CONTENT_URI, true, obs)
|
||||||
|
}.onSuccess { registeredObservers += obs }
|
||||||
|
.onFailure { Log.w(TAG, "Calendar observer registration skipped", it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun calendars(): List<CalendarSource> {
|
||||||
|
ensureObserversRegistered()
|
||||||
|
return resolver.query(
|
||||||
|
CalendarContract.Calendars.CONTENT_URI,
|
||||||
|
CalendarProjection.COLUMNS,
|
||||||
|
null, null,
|
||||||
|
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
|
||||||
|
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar-row writes must address the provider as a sync adapter and name
|
* Calendar-row writes must address the provider as a sync adapter and name
|
||||||
@@ -242,6 +283,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||||
|
ensureObserversRegistered()
|
||||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||||
ContentUris.appendId(this, beginMillis)
|
ContentUris.appendId(this, beginMillis)
|
||||||
ContentUris.appendId(this, endMillis)
|
ContentUris.appendId(this, endMillis)
|
||||||
@@ -718,16 +760,20 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
listener()
|
listener()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
observers[listener] = obs
|
synchronized(observerLock) {
|
||||||
resolver.registerContentObserver(
|
observers[listener] = obs
|
||||||
CalendarContract.CONTENT_URI,
|
// Attach now if we already hold the permission; otherwise it stays
|
||||||
/* notifyForDescendants = */ true,
|
// pending and re-attaches on the first read after the grant.
|
||||||
obs,
|
registerObserverLocked(obs)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun unregisterChangeListener(listener: () -> Unit) {
|
override fun unregisterChangeListener(listener: () -> Unit) {
|
||||||
observers.remove(listener)?.let { resolver.unregisterContentObserver(it) }
|
synchronized(observerLock) {
|
||||||
|
observers.remove(listener)?.let { obs ->
|
||||||
|
if (registeredObservers.remove(obs)) resolver.unregisterContentObserver(obs)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun queryAttendees(eventId: Long): List<Attendee> = resolver.query(
|
private fun queryAttendees(eventId: Long): List<Attendee> = resolver.query(
|
||||||
|
|||||||
56
renovate.json5
Normal file
56
renovate.json5
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
|
||||||
|
extends: [
|
||||||
|
"config:recommended",
|
||||||
|
// chore(deps): … — match the repo's conventional-commit style.
|
||||||
|
":semanticCommits",
|
||||||
|
],
|
||||||
|
|
||||||
|
// No automerge: a dependency bump goes through the same review (and, for
|
||||||
|
// anything touching the build, the same on-device check) as a feature
|
||||||
|
// before it can ride a release — see docs/RELEASING.md and the
|
||||||
|
// "hold release for approval" rule.
|
||||||
|
automerge: false,
|
||||||
|
|
||||||
|
// One reviewable surface; the dashboard issue lists everything pending.
|
||||||
|
dependencyDashboard: true,
|
||||||
|
labels: ["dependencies"],
|
||||||
|
prConcurrentLimit: 5,
|
||||||
|
prHourlyLimit: 0,
|
||||||
|
|
||||||
|
// Cadence is owned by the Gitea Actions cron (.gitea/workflows/renovate.yml,
|
||||||
|
// Mondays) — no internal `schedule` here, so the two don't double-gate and
|
||||||
|
// silently skip a run.
|
||||||
|
|
||||||
|
// Gitea Actions workflows live under .gitea/workflows, not .github — extend
|
||||||
|
// the github-actions manager (same syntax) to watch them too.
|
||||||
|
"github-actions": {
|
||||||
|
fileMatch: ["^\\.gitea/workflows/[^/]+\\.ya?ml$"],
|
||||||
|
},
|
||||||
|
|
||||||
|
packageRules: [
|
||||||
|
// material3 is deliberately pinned to the 1.5 *alpha* line for the
|
||||||
|
// Expressive APIs (see gradle/libs.versions.toml). Follow the alpha train
|
||||||
|
// but keep it in its own PR, reviewed in isolation; revisit the pin when
|
||||||
|
// 1.5.0 stable lands.
|
||||||
|
{
|
||||||
|
matchPackageNames: ["androidx.compose.material3:material3"],
|
||||||
|
ignoreUnstable: false,
|
||||||
|
groupName: "material3 (alpha)",
|
||||||
|
},
|
||||||
|
// Test-only deps: group into one low-noise PR.
|
||||||
|
{
|
||||||
|
matchPackageNames: [
|
||||||
|
"org.junit.jupiter:**",
|
||||||
|
"org.junit.platform:**",
|
||||||
|
"com.google.truth:**",
|
||||||
|
"app.cash.turbine:**",
|
||||||
|
"androidx.test:**",
|
||||||
|
"androidx.test.espresso:**",
|
||||||
|
"androidx.test.ext:**",
|
||||||
|
],
|
||||||
|
groupName: "test dependencies",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user