Compare commits
1 Commits
v2.7.1
...
6640794721
| Author | SHA1 | Date | |
|---|---|---|---|
| 6640794721 |
10
CHANGELOG.md
10
CHANGELOG.md
@@ -7,16 +7,6 @@ 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 = 20701
|
versionCode = 20700
|
||||||
versionName = "2.7.1"
|
versionName = "2.7.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -61,6 +61,21 @@ android {
|
|||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
}
|
}
|
||||||
|
// A locally-installable twin of `release`: same R8 shrinking + obfuscation
|
||||||
|
// and resource shrinking, but debug-signed and given its own applicationId
|
||||||
|
// suffix so it installs alongside both the production app (signed with the
|
||||||
|
// real key) and the debug build. Used to smoke-test a release candidate on
|
||||||
|
// a real device before tagging — R8-only breakage and first-run/permission
|
||||||
|
// states don't surface in the unminified debug build, nor on a device that
|
||||||
|
// already holds the permission. Never published. See docs/RELEASING.md.
|
||||||
|
create("releaseTest") {
|
||||||
|
initWith(getByName("release"))
|
||||||
|
applicationIdSuffix = ".releasetest"
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
matchingFallbacks += "release"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
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
|
||||||
@@ -13,7 +11,6 @@ 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
|
||||||
@@ -165,52 +162,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
) : CalendarDataSource {
|
) : CalendarDataSource {
|
||||||
|
|
||||||
private val resolver: ContentResolver get() = context.contentResolver
|
private val resolver: ContentResolver get() = context.contentResolver
|
||||||
|
|
||||||
// All access to these two collections is guarded by [observerLock] because
|
|
||||||
// listeners are registered on the main thread (repository init, via ViewModel
|
|
||||||
// creation) while [ensureObserversRegistered] runs on the IO dispatcher.
|
|
||||||
private val observerLock = Any()
|
|
||||||
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
|
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
|
||||||
private val registeredObservers = mutableSetOf<ContentObserver>()
|
|
||||||
|
|
||||||
private fun hasCalendarPermission(): Boolean =
|
override fun calendars(): List<CalendarSource> = resolver.query(
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
CalendarContract.Calendars.CONTENT_URI,
|
||||||
PackageManager.PERMISSION_GRANTED
|
CalendarProjection.COLUMNS,
|
||||||
|
null, null,
|
||||||
/**
|
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
|
||||||
* Attach any not-yet-registered observers to the provider, but only once the
|
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
|
||||||
* 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
|
||||||
@@ -283,7 +242,6 @@ 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)
|
||||||
@@ -760,20 +718,16 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
listener()
|
listener()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
synchronized(observerLock) {
|
observers[listener] = obs
|
||||||
observers[listener] = obs
|
resolver.registerContentObserver(
|
||||||
// Attach now if we already hold the permission; otherwise it stays
|
CalendarContract.CONTENT_URI,
|
||||||
// pending and re-attaches on the first read after the grant.
|
/* notifyForDescendants = */ true,
|
||||||
registerObserverLocked(obs)
|
obs,
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun unregisterChangeListener(listener: () -> Unit) {
|
override fun unregisterChangeListener(listener: () -> Unit) {
|
||||||
synchronized(observerLock) {
|
observers.remove(listener)?.let { resolver.unregisterContentObserver(it) }
|
||||||
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(
|
||||||
|
|||||||
@@ -22,20 +22,44 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`
|
|||||||
|
|
||||||
## Cutting a release
|
## Cutting a release
|
||||||
|
|
||||||
1. Move the `## [Unreleased]` section of `CHANGELOG.md` under a new
|
1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the
|
||||||
|
feature/fix branches that make up this release into it. This branch is the
|
||||||
|
release candidate — everything below happens on it, before it reaches `main`.
|
||||||
|
2. Move the `## [Unreleased]` section of `CHANGELOG.md` under a new
|
||||||
`## [X.Y.Z] — <date>` heading (Keep a Changelog format). The text between
|
`## [X.Y.Z] — <date>` heading (Keep a Changelog format). The text between
|
||||||
that heading and the next `## [` becomes both the Gitea release notes and
|
that heading and the next `## [` becomes both the Gitea release notes and
|
||||||
the F-Droid per-version changelog.
|
the F-Droid per-version changelog.
|
||||||
2. Optionally bump the committed `versionCode`/`versionName` in
|
3. Bump the committed `versionCode`/`versionName` in `app/build.gradle.kts` to
|
||||||
`app/build.gradle.kts` to match the new version (keeps local builds tidy).
|
match the new version (keeps local builds tidy; CI overwrites from the tag).
|
||||||
3. Commit, then tag and push:
|
4. **Verify the release build on a real device** — the mandatory gate. The
|
||||||
|
shipped APK is R8-shrunk/obfuscated, and bugs that only appear there, or
|
||||||
|
only on first run, never show up in the debug build or on a device that
|
||||||
|
already granted the calendar permission (this is how the v2.7.0 launch
|
||||||
|
crash slipped through). Run:
|
||||||
|
```bash
|
||||||
|
scripts/verify-release.sh
|
||||||
|
```
|
||||||
|
It builds the `releaseTest` variant (same R8 config as `release`, debug-signed
|
||||||
|
with a `.releasetest` suffix so it installs alongside the real app) and resets
|
||||||
|
it to a first-run state. Then, on the device:
|
||||||
|
- launch from a **clean / permission-not-granted** state — the permission
|
||||||
|
screen must appear, no crash;
|
||||||
|
- grant access — the calendar must load;
|
||||||
|
- add both home-screen **widgets** and confirm they render;
|
||||||
|
- exercise this release's headline changes.
|
||||||
|
|
||||||
|
Only proceed once all of that passes on-device.
|
||||||
|
5. Merge `release/vX.Y.Z` into `main`, then tag and push:
|
||||||
```bash
|
```bash
|
||||||
git tag vX.Y.Z
|
git tag vX.Y.Z
|
||||||
git push origin vX.Y.Z
|
git push origin vX.Y.Z
|
||||||
```
|
```
|
||||||
4. The push triggers the release workflow. **Hold UI releases for on-device
|
6. The push triggers the release workflow. **Hold UI releases for on-device
|
||||||
review and explicit go-ahead before tagging.**
|
review and explicit go-ahead before tagging.**
|
||||||
|
|
||||||
|
> The `releaseTest` build type exists only for step 4 — it is never published.
|
||||||
|
> The pipeline always builds and signs the real `release` variant from the tag.
|
||||||
|
|
||||||
## What the pipeline does
|
## What the pipeline does
|
||||||
|
|
||||||
`release.yaml` has three jobs:
|
`release.yaml` has three jobs:
|
||||||
|
|||||||
42
scripts/verify-release.sh
Executable file
42
scripts/verify-release.sh
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Build the release-candidate APK and install it on a connected device for the
|
||||||
|
# mandatory pre-tag on-device check (see docs/RELEASING.md).
|
||||||
|
#
|
||||||
|
# It builds the `releaseTest` variant: the same R8 shrinking + obfuscation and
|
||||||
|
# resource shrinking as the published `release` build, but debug-signed and
|
||||||
|
# with a `.releasetest` applicationId suffix so it installs alongside the
|
||||||
|
# production and debug apps. This is what surfaces release-only breakage (R8
|
||||||
|
# stripping) and first-run states (permission not yet granted) that the
|
||||||
|
# unminified debug build — or a device that already holds the permission —
|
||||||
|
# silently hides.
|
||||||
|
#
|
||||||
|
# Usage: scripts/verify-release.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
PKG="de.jeanlucmakiola.calendula.releasetest"
|
||||||
|
APK="app/build/outputs/apk/releaseTest/app-releaseTest.apk"
|
||||||
|
|
||||||
|
echo "==> Building release-candidate APK (releaseTest, R8 minified)…"
|
||||||
|
./gradlew :app:assembleReleaseTest
|
||||||
|
|
||||||
|
echo "==> Installing $PKG …"
|
||||||
|
adb install -r "$APK"
|
||||||
|
|
||||||
|
echo "==> Resetting to a first-run state (revoking calendar permission)…"
|
||||||
|
# The release build crashed at launch precisely because this state was never
|
||||||
|
# tested. Force it so the permission gate / onboarding is exercised every time.
|
||||||
|
adb shell pm revoke "$PKG" android.permission.READ_CALENDAR 2>/dev/null || true
|
||||||
|
adb shell pm revoke "$PKG" android.permission.WRITE_CALENDAR 2>/dev/null || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Installed and reset. Now verify ON THE DEVICE before tagging:"
|
||||||
|
echo " 1. Launch from a clean state — the permission screen must appear (no crash)."
|
||||||
|
echo " 2. Grant calendar access — the calendar must load with events."
|
||||||
|
echo " 3. Add both home-screen widgets and confirm they render (not a spinner)."
|
||||||
|
echo " 4. Exercise the release's headline changes end to end."
|
||||||
|
echo
|
||||||
|
echo "Watch for crashes with: adb logcat -b crash"
|
||||||
|
echo "Only tag the release once all of the above pass on a real device."
|
||||||
Reference in New Issue
Block a user