Compare commits
19 Commits
v2.7.2
...
5e8cb686e9
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e8cb686e9 | |||
| 62ebd48e3c | |||
| c3dbcca989 | |||
| 37452be3bd | |||
| 006cce96a9 | |||
| 88d770bebe | |||
| b6bee55fc6 | |||
| 7ab277b434 | |||
| d0b6823385 | |||
| 5c80f99179 | |||
| 0875e69c2d | |||
| ac572da40a | |||
| f27811d215 | |||
| 36e2199dff | |||
| eb51175ceb | |||
| bc59200f77 | |||
| ed6b0fa89f | |||
| c503199e06 | |||
| f3f155b5ff |
@@ -30,6 +30,12 @@ jobs:
|
|||||||
# Full history so the base..HEAD diff below has a merge-base.
|
# Full history so the base..HEAD diff below has a merge-base.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# Cheap, always-on guard: the release build must stay reproducible for the
|
||||||
|
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
|
||||||
|
# change scope so a regression can't slip through on a "docs-only" PR.
|
||||||
|
- name: Reproducible-release invariant
|
||||||
|
run: bash scripts/check_reproducible_release.sh
|
||||||
|
|
||||||
# Decide whether anything that affects the app build changed. Docs,
|
# Decide whether anything that affects the app build changed. Docs,
|
||||||
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
|
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
|
||||||
# Gradle work below but still report a green `ci`.
|
# Gradle work below but still report a green `ci`.
|
||||||
@@ -38,10 +44,20 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
BASE="${{ github.base_ref }}"
|
BASE="${{ github.base_ref }}"
|
||||||
git fetch --no-tags --depth=1 origin "$BASE"
|
# Full (not --depth=1) base fetch so the merge-base is present even when
|
||||||
CHANGED=$(git diff --name-only "origin/$BASE...HEAD")
|
# the PR branch forked several commits back; a shallow tip has no merge
|
||||||
|
# base with a divergent branch and `git diff base...HEAD` aborts.
|
||||||
|
git fetch --no-tags origin "$BASE"
|
||||||
|
MB=$(git merge-base "origin/$BASE" HEAD 2>/dev/null || true)
|
||||||
|
if [ -z "$MB" ]; then
|
||||||
|
# No common ancestor available — don't risk skipping the build.
|
||||||
|
echo "No merge base with origin/$BASE — running the full build to be safe."
|
||||||
|
echo "code=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
CHANGED=$(git diff --name-only "$MB" HEAD)
|
||||||
echo "Changed files:"; echo "$CHANGED"
|
echo "Changed files:"; echo "$CHANGED"
|
||||||
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^LICENSE$)' | grep -q .; then
|
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
|
||||||
echo "code=true" >> "$GITHUB_OUTPUT"
|
echo "code=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "code=false" >> "$GITHUB_OUTPUT"
|
echo "code=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|||||||
@@ -222,31 +222,27 @@ jobs:
|
|||||||
mkdir -p fdroid/repo
|
mkdir -p fdroid/repo
|
||||||
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
|
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
|
||||||
|
|
||||||
- name: Copy metadata to F-Droid repo
|
# Per-version "What's New": ensure this version's changelog exists in the
|
||||||
|
# fastlane tree (committed at release-cut time for the official repo; this
|
||||||
|
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on
|
||||||
|
# the commit having happened). The transform below then carries it across.
|
||||||
|
- name: Ensure this version's changelog is in the fastlane tree
|
||||||
|
if: env.IS_RELEASE == 'true'
|
||||||
|
run: bash scripts/sync_changelog_to_fastlane.sh
|
||||||
|
|
||||||
|
- name: Build F-Droid metadata from fastlane (single source of truth)
|
||||||
run: |
|
run: |
|
||||||
mkdir -p fdroid/metadata
|
mkdir -p fdroid/metadata
|
||||||
cp -r fdroid-metadata/* fdroid/metadata/
|
# App-level control file (Categories/License/links) for the self-hosted
|
||||||
|
# repo's `fdroid update`.
|
||||||
# Per-version "What's New" for F-Droid clients: this version's CHANGELOG
|
cp fdroid-metadata/de.jeanlucmakiola.calendula.yml fdroid/metadata/
|
||||||
# section written to changelogs/<versionCode>.txt (same extraction as the
|
# Localized text + graphics + per-version changelogs come from the SAME
|
||||||
# Gitea release notes). en-US only — F-Droid falls back to it for locales
|
# fastlane tree the official F-Droid repo harvests from source,
|
||||||
# without their own changelog. fdroid update bakes this into the index.
|
# transformed into the F-Droid repo "localized" layout. One source of
|
||||||
- name: Generate F-Droid changelog for this version
|
# truth, both channels.
|
||||||
if: env.IS_RELEASE == 'true'
|
bash scripts/fastlane_to_fdroid_localized.sh \
|
||||||
run: |
|
fastlane/metadata/android \
|
||||||
set -e
|
fdroid/metadata/de.jeanlucmakiola.calendula
|
||||||
awk -v ver="$VERSION" '
|
|
||||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
|
||||||
/^## \[/ { flag = 0 }
|
|
||||||
flag' CHANGELOG.md > /tmp/changelog.txt
|
|
||||||
sed -i -e '/./,$!d' /tmp/changelog.txt
|
|
||||||
if [ ! -s /tmp/changelog.txt ]; then
|
|
||||||
echo "See CHANGELOG.md for $VERSION." > /tmp/changelog.txt
|
|
||||||
fi
|
|
||||||
CL_DIR="fdroid/metadata/de.jeanlucmakiola.calendula/en-US/changelogs"
|
|
||||||
mkdir -p "$CL_DIR"
|
|
||||||
cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt"
|
|
||||||
echo "Wrote $CL_DIR/${VERSION_CODE}.txt"
|
|
||||||
|
|
||||||
- name: Generate F-Droid Index
|
- name: Generate F-Droid Index
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
31
CHANGELOG.md
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Snooze and dismiss buttons on reminder notifications. Dismiss clears the
|
||||||
|
reminder; snooze hides it and brings it back after a delay you pick in
|
||||||
|
Settings → Notifications (5 to 60 minutes, default 10). Android's calendar
|
||||||
|
system won't re-post a reminder on its own, so Calendula schedules an exact
|
||||||
|
alarm to bring a snoozed one back on time.
|
||||||
|
|
||||||
|
## [2.7.5] — 2026-06-21
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Further build cleanup for the official F-Droid repository: stopped embedding
|
||||||
|
AGP's dependency-metadata block in the APK, which F-Droid's reproducible-build
|
||||||
|
scanner rejects as an extra signing block. No functional or visible changes —
|
||||||
|
the same app as 2.7.4, just without that Play-oriented metadata blob.
|
||||||
|
|
||||||
|
## [2.7.4] — 2026-06-21
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Build cleanup that lets Calendula ship in the official F-Droid repository:
|
||||||
|
removed an unused Gradle toolchain-resolver plugin, which F-Droid's offline,
|
||||||
|
reproducible build process disallows. No functional or visible changes — this
|
||||||
|
is the same app as 2.7.3.
|
||||||
|
|
||||||
|
## [2.7.3] — 2026-06-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Home-screen widgets no longer get stuck on a loading spinner in the published
|
||||||
|
(F-Droid) release build. They render via Android's background-work system, and
|
||||||
|
release optimisation (R8) was stripping a helper class it loads by name, so the
|
||||||
|
render job never ran. Added the missing keep rule — widgets now load normally.
|
||||||
|
|
||||||
## [2.7.2] — 2026-06-21
|
## [2.7.2] — 2026-06-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
12
README.md
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/icon.png" width="112" alt="Calendula icon">
|
<img src="fastlane/metadata/android/en-US/images/icon.png" width="112" alt="Calendula icon">
|
||||||
|
|
||||||
<h1>Calendula</h1>
|
<h1>Calendula</h1>
|
||||||
|
|
||||||
@@ -16,11 +16,11 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/01-week.png" width="19%" alt="Week view">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="19%" alt="Week view">
|
||||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/02-month.png" width="19%" alt="Month view">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">
|
||||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">
|
||||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/05-edit.png" width="19%" alt="Event form">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-edit.png" width="19%" alt="Event form">
|
||||||
<img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 20702
|
versionCode = 20705
|
||||||
versionName = "2.7.2"
|
versionName = "2.7.5"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,11 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
|
// Keep release builds reproducible for F-Droid: don't let AGP embed
|
||||||
|
// build-environment git metadata (META-INF/version-control-info.textproto),
|
||||||
|
// whose `revision`/path content varies by build machine and is the only
|
||||||
|
// thing that otherwise differs from a clean from-source rebuild.
|
||||||
|
vcsInfo { include = false }
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
@@ -87,6 +92,16 @@ android {
|
|||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
|
||||||
|
// a Play-oriented blob, and F-Droid's reproducible-build scanner rejects any
|
||||||
|
// "extra signing block" — so leaving it in blocks publishing to the official
|
||||||
|
// repo. It lives in the signing block, not the zip entries, so disabling it
|
||||||
|
// doesn't change the build output (reproducibility is unaffected).
|
||||||
|
dependenciesInfo {
|
||||||
|
includeInApk = false
|
||||||
|
includeInBundle = false
|
||||||
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
|||||||
11
app/proguard-rules.pro
vendored
@@ -15,3 +15,14 @@
|
|||||||
# Keep the generated Room database implementations fully intact.
|
# Keep the generated Room database implementations fully intact.
|
||||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||||
-dontwarn androidx.room.paging.**
|
-dontwarn androidx.room.paging.**
|
||||||
|
|
||||||
|
# WorkManager instantiates an InputMerger reflectively (Class.newInstance) from
|
||||||
|
# the fully-qualified class name persisted in the WorkSpec, so the class must
|
||||||
|
# keep both its name and a no-arg constructor. Glance renders every widget
|
||||||
|
# through a WorkManager worker (androidx.glance.session.SessionWorker) whose
|
||||||
|
# default merger is androidx.work.OverwritingInputMerger. Under R8 full mode
|
||||||
|
# (AGP 9 default) that unused no-arg constructor was stripped, so WorkManager
|
||||||
|
# threw "OverwritingInputMerger has no zero argument constructor", the
|
||||||
|
# SessionWorker never ran, and widgets were stuck on their loading layout
|
||||||
|
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
|
||||||
|
-keep class * extends androidx.work.InputMerger { <init>(...); }
|
||||||
|
|||||||
@@ -13,6 +13,17 @@
|
|||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Re-fire a snoozed reminder at an exact time (the calendar provider won't —
|
||||||
|
its alert is already fired). USE_EXACT_ALARM is auto-granted to calendar
|
||||||
|
apps on API 33+; SCHEDULE_EXACT_ALARM covers API 31–32 (user-revocable,
|
||||||
|
with an inexact fallback if withheld). F-Droid-clean: no Play allowlisting.
|
||||||
|
-->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.SCHEDULE_EXACT_ALARM"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||||
|
|
||||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||||
returns null and the calendar manager's per-account "manage" button can't
|
returns null and the calendar manager's per-account "manage" button can't
|
||||||
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||||
@@ -91,6 +102,13 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Snooze / dismiss actions on a reminder notification, plus the snooze
|
||||||
|
re-show alarm. Not exported: only our own notification buttons and
|
||||||
|
AlarmManager PendingIntents target it. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".data.reminders.ReminderActionReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.agenda.AgendaWidgetReceiver"
|
android:name=".widget.agenda.AgendaWidgetReceiver"
|
||||||
|
|||||||
@@ -177,6 +177,20 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) }
|
store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the notification "Snooze" action defers a reminder, in minutes.
|
||||||
|
* Tapping Snooze cancels the notification and re-posts it after this delay
|
||||||
|
* via an exact alarm (the calendar provider won't re-fire). Default 10 min;
|
||||||
|
* clamped to at least 1.
|
||||||
|
*/
|
||||||
|
val snoozeMinutes: Flow<Int> = store.data.map { prefs ->
|
||||||
|
(prefs[SNOOZE_MINUTES_KEY] ?: DEFAULT_SNOOZE_MINUTES).coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSnoozeMinutes(minutes: Int) {
|
||||||
|
store.edit { it[SNOOZE_MINUTES_KEY] = minutes.coerceAtLeast(1) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
||||||
* keyed by calendar id. A calendar **present** in the map overrides the global
|
* keyed by calendar id. A calendar **present** in the map overrides the global
|
||||||
@@ -243,6 +257,9 @@ class SettingsPrefs @Inject constructor(
|
|||||||
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
|
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
|
||||||
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
||||||
private const val MINUTES_PER_DAY = 1_440
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
|
||||||
|
/** Default snooze delay for the notification "Snooze" action. */
|
||||||
|
const val DEFAULT_SNOOZE_MINUTES = 10
|
||||||
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
||||||
stringPreferencesKey("per_calendar_reminder_override")
|
stringPreferencesKey("per_calendar_reminder_override")
|
||||||
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the "Snooze" and "Dismiss" actions on a reminder notification, plus
|
||||||
|
* the internal re-show when a snooze elapses. All three are app-internal
|
||||||
|
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||||
|
* alarm), so the receiver is not exported.
|
||||||
|
*
|
||||||
|
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
|
||||||
|
* already fired, so nothing re-posts it.
|
||||||
|
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
||||||
|
* it after the user's snooze delay.
|
||||||
|
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
||||||
|
* or dismiss it again.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class ReminderActionReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject lateinit var notifier: ReminderNotifier
|
||||||
|
@Inject lateinit var scheduler: ReminderSnoozeScheduler
|
||||||
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val alert = alertFrom(intent) ?: return
|
||||||
|
when (intent.action) {
|
||||||
|
ACTION_DISMISS -> notifier.cancel(alert)
|
||||||
|
|
||||||
|
ACTION_SNOOZE -> {
|
||||||
|
// Cancel now so the notification doesn't linger until the alarm;
|
||||||
|
// the snooze delay read is the only async work.
|
||||||
|
notifier.cancel(alert)
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
val minutes = settingsPrefs.snoozeMinutes.first()
|
||||||
|
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
|
||||||
|
scheduler.schedule(alert, triggerAt)
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ACTION_SHOW -> {
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
|
||||||
|
notifier.post(alert)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_SNOOZE = "de.jeanlucmakiola.calendula.reminders.SNOOZE"
|
||||||
|
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
||||||
|
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
||||||
|
|
||||||
|
private const val EXTRA_ALERT_ID = "alert_id"
|
||||||
|
private const val EXTRA_EVENT_ID = "event_id"
|
||||||
|
private const val EXTRA_BEGIN = "begin"
|
||||||
|
private const val EXTRA_END = "end"
|
||||||
|
private const val EXTRA_TITLE = "title"
|
||||||
|
private const val EXTRA_LOCATION = "location"
|
||||||
|
private const val EXTRA_ALL_DAY = "all_day"
|
||||||
|
|
||||||
|
/** An explicit intent to this receiver carrying [alert] as extras. */
|
||||||
|
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||||
|
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||||
|
this.action = action
|
||||||
|
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
||||||
|
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||||
|
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||||
|
putExtra(EXTRA_END, alert.endMillis)
|
||||||
|
putExtra(EXTRA_TITLE, alert.title)
|
||||||
|
putExtra(EXTRA_LOCATION, alert.location)
|
||||||
|
putExtra(EXTRA_ALL_DAY, alert.isAllDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stable request code per (alert, action) so the three PendingIntents
|
||||||
|
* of one notification stay distinct and don't clobber each other.
|
||||||
|
*/
|
||||||
|
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||||
|
val actionOffset = when (action) {
|
||||||
|
ACTION_SNOOZE -> 1
|
||||||
|
ACTION_DISMISS -> 2
|
||||||
|
ACTION_SHOW -> 3
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
return alert.alertId.toInt() * 8 + actionOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||||
|
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
|
||||||
|
return ReminderAlert(
|
||||||
|
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
||||||
|
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||||
|
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||||
|
endMillis = intent.getLongExtra(EXTRA_END, 0L),
|
||||||
|
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
|
||||||
|
location = intent.getStringExtra(EXTRA_LOCATION),
|
||||||
|
isAllDay = intent.getBooleanExtra(EXTRA_ALL_DAY, false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,16 @@ class ReminderNotifier @Inject constructor(
|
|||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setContentIntent(detailIntent(alert))
|
.setContentIntent(detailIntent(alert))
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_notification_snooze,
|
||||||
|
context.getString(R.string.reminder_action_snooze),
|
||||||
|
actionIntent(alert, ReminderActionReceiver.ACTION_SNOOZE),
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_notification_dismiss,
|
||||||
|
context.getString(R.string.reminder_action_dismiss),
|
||||||
|
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
|
||||||
|
)
|
||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
NotificationManagerCompat.from(context)
|
NotificationManagerCompat.from(context)
|
||||||
@@ -71,6 +81,19 @@ class ReminderNotifier @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||||
|
fun cancel(alert: ReminderAlert) {
|
||||||
|
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
ReminderActionReceiver.requestCode(alert, action),
|
||||||
|
ReminderActionReceiver.intent(context, action, alert),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
/* requestCode = */ alert.alertId.toInt(),
|
/* requestCode = */ alert.alertId.toInt(),
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
|
||||||
|
*
|
||||||
|
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
|
||||||
|
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
|
||||||
|
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
|
||||||
|
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
|
||||||
|
* back to an inexact allow-while-idle alarm only if the OS withholds the
|
||||||
|
* exact-alarm capability (API 31–32 where the user revoked it).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReminderSnoozeScheduler @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun schedule(alert: ReminderAlert, triggerAtMillis: Long) {
|
||||||
|
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||||
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_SHOW),
|
||||||
|
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
if (canScheduleExact(alarmManager)) {
|
||||||
|
alarmManager.setExactAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Exact alarms revoked (API 31–32): an inexact wake is the honest
|
||||||
|
// best we can do without nagging for SCHEDULE_EXACT_ALARM.
|
||||||
|
alarmManager.setAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True on API < 31 (no restriction), and on 31+ when the exact-alarm
|
||||||
|
* capability is held — auto-granted via `USE_EXACT_ALARM` on API 33+
|
||||||
|
* (Calendula is a calendar app), user-revocable on 31–32.
|
||||||
|
*/
|
||||||
|
private fun canScheduleExact(alarmManager: AlarmManager): Boolean =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -546,6 +547,7 @@ private fun NotificationsScreen(
|
|||||||
var showDefaultReminder by remember { mutableStateOf(false) }
|
var showDefaultReminder by remember { mutableStateOf(false) }
|
||||||
var showAllDayReminder by remember { mutableStateOf(false) }
|
var showAllDayReminder by remember { mutableStateOf(false) }
|
||||||
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
||||||
|
var showSnooze by remember { mutableStateOf(false) }
|
||||||
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
||||||
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||||
|
|
||||||
@@ -666,8 +668,27 @@ private fun NotificationsScreen(
|
|||||||
},
|
},
|
||||||
onClick = { openBatteryOptimizationSettings(context) },
|
onClick = { openBatteryOptimizationSettings(context) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Snooze: how long the notification's "Snooze" action defers a reminder.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
|
summary = snoozeDurationLabel(state.snoozeMinutes),
|
||||||
|
position = Position.Alone,
|
||||||
|
onClick = { showSnooze = true },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showSnooze) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
|
options = SNOOZE_PRESETS,
|
||||||
|
selected = state.snoozeMinutes,
|
||||||
|
label = { snoozeDurationLabel(it) },
|
||||||
|
onSelect = { viewModel.setSnoozeMinutes(it) },
|
||||||
|
onDismiss = { showSnooze = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showDefaultReminder) {
|
if (showDefaultReminder) {
|
||||||
ReminderDefaultPicker(
|
ReminderDefaultPicker(
|
||||||
title = stringResource(R.string.settings_default_reminder),
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
@@ -792,6 +813,18 @@ private fun openBatteryOptimizationSettings(context: Context) {
|
|||||||
*/
|
*/
|
||||||
private val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
|
private val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
|
||||||
|
|
||||||
|
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
||||||
|
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||||
|
|
||||||
|
/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
|
||||||
|
@Composable
|
||||||
|
private fun snoozeDurationLabel(minutes: Int): String =
|
||||||
|
if (minutes % 60 == 0) {
|
||||||
|
pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
|
||||||
|
} else {
|
||||||
|
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
|
||||||
|
}
|
||||||
|
|
||||||
/** A minute-of-day formatted in the device's 12/24-hour convention (e.g. "09:00"). */
|
/** A minute-of-day formatted in the device's 12/24-hour convention (e.g. "09:00"). */
|
||||||
private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
|
private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
|
||||||
val time = Calendar.getInstance().apply {
|
val time = Calendar.getInstance().apply {
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ data class SettingsUiState(
|
|||||||
val defaultAllDayReminderMinutes: Int? = null,
|
val defaultAllDayReminderMinutes: Int? = null,
|
||||||
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
||||||
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
|
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
|
||||||
|
/** How long the notification "Snooze" action defers a reminder; default 10 min. */
|
||||||
|
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
|
||||||
/**
|
/**
|
||||||
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
|
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
|
||||||
* calendar present in the map overrides the global default (null value = no
|
* calendar present in the map overrides the global default (null value = no
|
||||||
|
|||||||
@@ -59,8 +59,11 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.defaultReminderMinutes,
|
prefs.defaultReminderMinutes,
|
||||||
prefs.defaultAllDayReminderMinutes,
|
prefs.defaultAllDayReminderMinutes,
|
||||||
prefs.allDayReminderTimeMinutes,
|
prefs.allDayReminderTimeMinutes,
|
||||||
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime ->
|
prefs.snoozeMinutes,
|
||||||
ReminderDefaults(allowColor, defaultReminder, allDayReminder, allDayReminderTime)
|
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze ->
|
||||||
|
ReminderDefaults(
|
||||||
|
allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
prefs.perCalendarReminderOverride,
|
prefs.perCalendarReminderOverride,
|
||||||
@@ -75,6 +78,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
allDayReminderTimeMinutes = defaults.allDayReminderTime,
|
allDayReminderTimeMinutes = defaults.allDayReminderTime,
|
||||||
|
snoozeMinutes = defaults.snoozeMinutes,
|
||||||
perCalendarReminderOverride = overrides.timed,
|
perCalendarReminderOverride = overrides.timed,
|
||||||
perCalendarAllDayReminderOverride = overrides.allDay,
|
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||||
writableCalendars = overrides.calendars,
|
writableCalendars = overrides.calendars,
|
||||||
@@ -90,6 +94,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val defaultReminder: Int?,
|
val defaultReminder: Int?,
|
||||||
val allDayReminder: Int?,
|
val allDayReminder: Int?,
|
||||||
val allDayReminderTime: Int,
|
val allDayReminderTime: Int,
|
||||||
|
val snoozeMinutes: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class ReminderOverrides(
|
private data class ReminderOverrides(
|
||||||
@@ -130,6 +135,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
|
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setSnoozeMinutes(minutes: Int) {
|
||||||
|
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
||||||
}
|
}
|
||||||
|
|||||||
12
app/src/main/res/drawable/ic_notification_dismiss.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Monochrome notification-action mark: Material "close" glyph. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
|
||||||
|
</vector>
|
||||||
12
app/src/main/res/drawable/ic_notification_snooze.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Monochrome notification-action mark: Material "snooze" glyph. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M7.88,3.39L6.6,1.86 2,5.71l1.29,1.53 4.59,-3.85zM22,5.72l-4.6,-3.86 -1.29,1.53 4.6,3.86L22,5.72zM12,4c-4.97,0 -9,4.03 -9,9s4.02,9 9,9c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,20c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7zM9,11h3.63L9,15.2L9,17h6v-2h-3.63L15,10.8L15,9L9,9z" />
|
||||||
|
</vector>
|
||||||
@@ -177,6 +177,15 @@
|
|||||||
<item quantity="one">%d Woche vorher</item>
|
<item quantity="one">%d Woche vorher</item>
|
||||||
<item quantity="other">%d Wochen vorher</item>
|
<item quantity="other">%d Wochen vorher</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<!-- Reine Zeitdauern (ohne „vorher"), z. B. die Schlummerdauer. -->
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d Minute</item>
|
||||||
|
<item quantity="other">%d Minuten</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="one">%d Stunde</item>
|
||||||
|
<item quantity="other">%d Stunden</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
<!-- Geteilte Event-Strings -->
|
<!-- Geteilte Event-Strings -->
|
||||||
<string name="event_untitled">(Ohne Titel)</string>
|
<string name="event_untitled">(Ohne Titel)</string>
|
||||||
@@ -194,6 +203,8 @@
|
|||||||
<string name="reminder_benefit_reversible_body">Der Schalter liegt in den Einstellungen unter Benachrichtigungen.</string>
|
<string name="reminder_benefit_reversible_body">Der Schalter liegt in den Einstellungen unter Benachrichtigungen.</string>
|
||||||
<string name="reminder_onboarding_enable_button">Erinnerungen einschalten</string>
|
<string name="reminder_onboarding_enable_button">Erinnerungen einschalten</string>
|
||||||
<string name="reminder_onboarding_skip_button">Später</string>
|
<string name="reminder_onboarding_skip_button">Später</string>
|
||||||
|
<string name="reminder_action_snooze">Schlummern</string>
|
||||||
|
<string name="reminder_action_dismiss">Verwerfen</string>
|
||||||
|
|
||||||
<!-- View-Switcher (M1) -->
|
<!-- View-Switcher (M1) -->
|
||||||
<string name="view_month">Monat</string>
|
<string name="view_month">Monat</string>
|
||||||
@@ -265,6 +276,7 @@
|
|||||||
<string name="settings_reliable_delivery">Zuverlässige Zustellung</string>
|
<string name="settings_reliable_delivery">Zuverlässige Zustellung</string>
|
||||||
<string name="settings_reliable_delivery_hint">Android verzögert Erinnerungen womöglich, um Akku zu sparen. Nimm Calendula aus, damit sie pünktlich ankommen.</string>
|
<string name="settings_reliable_delivery_hint">Android verzögert Erinnerungen womöglich, um Akku zu sparen. Nimm Calendula aus, damit sie pünktlich ankommen.</string>
|
||||||
<string name="settings_reliable_delivery_exempt">Von der Akku-Optimierung ausgenommen — Erinnerungen kommen pünktlich.</string>
|
<string name="settings_reliable_delivery_exempt">Von der Akku-Optimierung ausgenommen — Erinnerungen kommen pünktlich.</string>
|
||||||
|
<string name="settings_snooze_duration">Schlummerdauer</string>
|
||||||
<string name="settings_section_calendars">Kalender</string>
|
<string name="settings_section_calendars">Kalender</string>
|
||||||
<string name="settings_manage_calendars">Kalender verwalten</string>
|
<string name="settings_manage_calendars">Kalender verwalten</string>
|
||||||
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
|
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
|
||||||
|
|||||||
@@ -178,6 +178,15 @@
|
|||||||
<item quantity="one">%d week before</item>
|
<item quantity="one">%d week before</item>
|
||||||
<item quantity="other">%d weeks before</item>
|
<item quantity="other">%d weeks before</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<!-- Plain durations (no "before"), e.g. the snooze delay. -->
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d minute</item>
|
||||||
|
<item quantity="other">%d minutes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="one">%d hour</item>
|
||||||
|
<item quantity="other">%d hours</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
<!-- Shared event strings -->
|
<!-- Shared event strings -->
|
||||||
<string name="event_untitled">(No title)</string>
|
<string name="event_untitled">(No title)</string>
|
||||||
@@ -195,6 +204,8 @@
|
|||||||
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
|
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
|
||||||
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
|
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
|
||||||
<string name="reminder_onboarding_skip_button">Not now</string>
|
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||||
|
<string name="reminder_action_snooze">Snooze</string>
|
||||||
|
<string name="reminder_action_dismiss">Dismiss</string>
|
||||||
|
|
||||||
<!-- View switcher (M1) -->
|
<!-- View switcher (M1) -->
|
||||||
<string name="view_month">Month</string>
|
<string name="view_month">Month</string>
|
||||||
@@ -262,6 +273,7 @@
|
|||||||
<string name="settings_reliable_delivery">Reliable delivery</string>
|
<string name="settings_reliable_delivery">Reliable delivery</string>
|
||||||
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
|
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
|
||||||
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
|
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
|
||||||
|
<string name="settings_snooze_duration">Snooze duration</string>
|
||||||
<string name="settings_section_calendars">Calendars</string>
|
<string name="settings_section_calendars">Calendars</string>
|
||||||
<string name="settings_manage_calendars">Manage calendars</string>
|
<string name="settings_manage_calendars">Manage calendars</string>
|
||||||
<string name="settings_manage_calendars_hint">Create local calendars; manage synced ones</string>
|
<string name="settings_manage_calendars_hint">Create local calendars; manage synced ones</string>
|
||||||
@@ -355,6 +367,6 @@
|
|||||||
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
|
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
|
||||||
<string name="crash_report_body_template">Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%1$s\n</string>
|
<string name="crash_report_body_template">Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%1$s\n</string>
|
||||||
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
||||||
<string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues/new</string>
|
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
|
||||||
<string name="report_issue_choose_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues/new/choose</string>
|
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -233,6 +233,23 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(0)
|
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `snooze duration defaults to 10 minutes`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.snoozeMinutes.first()).isEqualTo(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `snooze duration round-trips and clamps to at least one minute`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setSnoozeMinutes(30)
|
||||||
|
assertThat(prefs.snoozeMinutes.first()).isEqualTo(30)
|
||||||
|
prefs.setSnoozeMinutes(0)
|
||||||
|
assertThat(prefs.snoozeMinutes.first()).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||||
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ Where to look for what:
|
|||||||
| [`../.planning/STATE.md`](../.planning/STATE.md) | Snapshot of where development currently stands |
|
| [`../.planning/STATE.md`](../.planning/STATE.md) | Snapshot of where development currently stands |
|
||||||
| [`superpowers/specs/`](superpowers/specs/) | The original design spec (2026-06-08) — historical record, not updated |
|
| [`superpowers/specs/`](superpowers/specs/) | The original design spec (2026-06-08) — historical record, not updated |
|
||||||
| [`superpowers/plans/`](superpowers/plans/) | Per-milestone implementation plans with task checklists — historical record of how each slice was built, including provider lessons learned |
|
| [`superpowers/plans/`](superpowers/plans/) | Per-milestone implementation plans with task checklists — historical record of how each slice was built, including provider lessons learned |
|
||||||
| [`../fdroid-metadata/`](../fdroid-metadata/) | F-Droid/fastlane store metadata: descriptions, icon, screenshots (DE + EN) |
|
| [`../fastlane/metadata/android/`](../fastlane/metadata/android/) | Store metadata (single source of truth): descriptions, title, icon, screenshots (DE + EN). Harvested directly by the official F-Droid repo; transformed into the self-hosted repo layout at release time by [`../scripts/fastlane_to_fdroid_localized.sh`](../scripts/fastlane_to_fdroid_localized.sh) |
|
||||||
|
| [`../fdroid-metadata/`](../fdroid-metadata/) | App-level F-Droid control file (`*.yml`: Categories, License, links) for the self-hosted repo's `fdroid update` |
|
||||||
|
| [`fdroid-official/`](fdroid-official/) | Draft recipe + notes for publishing to the **official** F-Droid repo (reproducible build + developer-signed binary) |
|
||||||
|
|
||||||
Conventions: plans and specs under `superpowers/` are point-in-time
|
Conventions: plans and specs under `superpowers/` are point-in-time
|
||||||
artifacts of the agentic workflow that built each milestone — they get
|
artifacts of the agentic workflow that built each milestone — they get
|
||||||
|
|||||||
@@ -33,7 +33,15 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`
|
|||||||
the F-Droid per-version changelog.
|
the F-Droid per-version changelog.
|
||||||
3. Bump the committed `versionName` (and `versionCode`) in
|
3. Bump the committed `versionName` (and `versionCode`) in
|
||||||
`app/build.gradle.kts` to the new version. **This bump is what triggers the
|
`app/build.gradle.kts` to the new version. **This bump is what triggers the
|
||||||
release** when the branch merges to `main`.
|
release** when the branch merges to `main`. Then run
|
||||||
|
```bash
|
||||||
|
scripts/sync_changelog_to_fastlane.sh
|
||||||
|
```
|
||||||
|
and commit the generated
|
||||||
|
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`. This is what
|
||||||
|
makes the **official** F-Droid repo show this version's changelog (it reads
|
||||||
|
the changelog from the tagged source tree). The self-hosted pipeline
|
||||||
|
regenerates it regardless, so forgetting only affects the official listing.
|
||||||
4. **Verify the release build on a real device** — the mandatory gate. The
|
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
|
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
|
only on first run, never show up in the debug build or on a device that
|
||||||
|
|||||||
94
docs/fdroid-official/README.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Official F-Droid submission (draft)
|
||||||
|
|
||||||
|
Goal: publish Calendula to the **official F-Droid repo** *alongside* the
|
||||||
|
self-hosted repo, as **one app** — same signed binary in both channels, so
|
||||||
|
existing self-hosted users migrate with no reinstall and no data loss.
|
||||||
|
|
||||||
|
This folder holds the **draft** fdroiddata recipe. Nothing here is submitted
|
||||||
|
yet. The self-hosted pipeline (`../../fdroid-metadata/`, `.gitea/workflows/release.yaml`)
|
||||||
|
is unaffected by these files.
|
||||||
|
|
||||||
|
## Publishing model: reproducible build + developer-signed binary
|
||||||
|
|
||||||
|
F-Droid builds Calendula from source on its buildserver, then verifies the
|
||||||
|
output is byte-for-byte identical to **our** signed APK (fetched via the
|
||||||
|
`Binaries` URL). On a match it publishes **our** binary, signed with **our**
|
||||||
|
key — identified by `AllowedAPKSigningKeys`. Result: official and self-hosted
|
||||||
|
both carry the same signature. If a build ever fails to match, F-Droid simply
|
||||||
|
skips that version (fails safe — no bad publish).
|
||||||
|
|
||||||
|
## Verification status (2026-06-21)
|
||||||
|
|
||||||
|
- ✅ **Run-to-run reproducible** — `v2.7.0` built twice from a clean worktree
|
||||||
|
(R8 minify + resource-shrink, build cache off) → byte-for-byte identical
|
||||||
|
(`sha256 568c944a…`).
|
||||||
|
- ✅ **Cross-JDK reproducible** — rebuilt with JDK 17 and JDK 21 → identical
|
||||||
|
APK. JDK version is not a sensitivity, so it need not be pinned.
|
||||||
|
- ✅ **Tag self-consistent** — committed `versionCode`/`versionName` already
|
||||||
|
equal the tag-derived values, so F-Droid building the tag as-is matches the
|
||||||
|
CI-published binary (CI's `sed` substitution is a no-op).
|
||||||
|
- ✅ **App signing cert SHA-256 extracted** from published APKs (identical
|
||||||
|
across v1.0.0 / v2.0.0 / v2.4.0): `5cdaee8e…`. No keystore needed.
|
||||||
|
- ✅ **Eligibility** — MIT, 100% FOSS deps (no Play Services / Firebase /
|
||||||
|
analytics / billing), no `INTERNET` permission, no native code.
|
||||||
|
- ✅ **End-to-end reproducible vs the DISTRIBUTED binary** — built tag `v2.7.1`
|
||||||
|
from source and compared against the published `calendula_v2.7.1.apk`: every
|
||||||
|
app entry (dex / resources / assets / manifest) byte-identical. The only
|
||||||
|
difference was `META-INF/version-control-info.textproto` (AGP's git metadata,
|
||||||
|
env-dependent). Fixed with `vcsInfo { include = false }` on the release build;
|
||||||
|
re-validated → 0 non-signature differences. **Takes effect from the first
|
||||||
|
release built after that change — submit the recipe starting at that version**
|
||||||
|
(v2.7.1 and earlier still embed the textproto and won't verify).
|
||||||
|
- ⚠️ **Not yet proven**: cross-host / fixed-build-path reproducibility on
|
||||||
|
F-Droid's buildserver (low risk for a no-NDK pure-JVM app; with vcsInfo
|
||||||
|
disabled, the env-dependent field is gone; F-Droid confirms at review).
|
||||||
|
- ✅ **Buildserver toolchain supported** (checked 2026-06-21):
|
||||||
|
- **Gradle 9.5.1** is in F-Droid's gradle-transparency-log (`checksums.json`)
|
||||||
|
with a verified sha256, so `gradlew.py` will download + verify + run it.
|
||||||
|
- **AGP 9.2** is in `gradlew.py`'s `MIN_GRADLE_VERSION` map (`9.2 -> 9.4.1`).
|
||||||
|
- **JDK 17** is standard on the buildserver (AGP 9.2 requires exactly 17).
|
||||||
|
- **build-tools 36.0.0 / android-37**: not statically preinstalled (baseline
|
||||||
|
stops at 33), but `provision-android-sdk` makes `$ANDROID_HOME/build-tools`
|
||||||
|
and `/platforms` group-writable so AGP/Gradle install newer ones on demand.
|
||||||
|
|
||||||
|
## Before submitting — checklist
|
||||||
|
|
||||||
|
1. Confirm the `Binaries` URL is publicly reachable and stable long-term, e.g.
|
||||||
|
`https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo/calendula_v2.7.0.apk`
|
||||||
|
resolves to the dev-signed APK. F-Droid re-fetches it on every build.
|
||||||
|
2. Confirm F-Droid's buildserver supports the toolchain (see timing risk).
|
||||||
|
3. (Recommended) Reproduce one published release end-to-end: build the tag from
|
||||||
|
source, strip signatures, and diff against the downloaded `calendula_v<ver>.apk`
|
||||||
|
to confirm the from-source build matches the *distributed* binary — not just
|
||||||
|
another local build.
|
||||||
|
|
||||||
|
## Submission steps (fdroiddata, GitLab)
|
||||||
|
|
||||||
|
1. Fork `https://gitlab.com/fdroid/fdroiddata`.
|
||||||
|
2. Copy `de.jeanlucmakiola.calendula.yml` to `metadata/` in the fork.
|
||||||
|
3. Test locally with `fdroid build -v de.jeanlucmakiola.calendula` and
|
||||||
|
`fdroid lint de.jeanlucmakiola.calendula` (and `fdroid readmeta`).
|
||||||
|
4. Open a Merge Request. Initial review can take weeks to months; the
|
||||||
|
self-hosted repo keeps serving in the meantime, so there's no rush.
|
||||||
|
|
||||||
|
## Listing metadata (descriptions, screenshots, icon)
|
||||||
|
|
||||||
|
These are **not** in the recipe `.yml`. F-Droid's `fdroid update` harvests them
|
||||||
|
automatically from the fastlane tree in the app's source repo:
|
||||||
|
|
||||||
|
fastlane/metadata/android/<locale>/
|
||||||
|
short_description.txt full_description.txt title.txt
|
||||||
|
images/icon.png images/phoneScreenshots/*.png
|
||||||
|
changelogs/<versionCode>.txt (optional, per release)
|
||||||
|
|
||||||
|
This is the single source of truth: en-US + de-DE already exist there, and the
|
||||||
|
self-hosted repo is generated from the same tree at release time via
|
||||||
|
`scripts/fastlane_to_fdroid_localized.sh`. Nothing extra to add to fdroiddata —
|
||||||
|
F-Droid picks the listing up from source. (Screenshots may not be committed to
|
||||||
|
the fdroiddata repo anyway, so the source-tree fastlane layout is required.)
|
||||||
|
|
||||||
|
Per-version changelogs: the self-hosted release workflow generates
|
||||||
|
`changelogs/<versionCode>.txt` at release time. For the official repo to show a
|
||||||
|
changelog, that file must be committed into `fastlane/metadata/android/<locale>/changelogs/`
|
||||||
|
at the tagged commit — wire this into the release/version-bump step, or accept
|
||||||
|
no in-client changelog for the official listing initially.
|
||||||
65
docs/fdroid-official/de.jeanlucmakiola.calendula.yml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DRAFT fdroiddata metadata for the OFFICIAL F-Droid repository.
|
||||||
|
#
|
||||||
|
# This is NOT the self-hosted metadata (that lives in ../../fdroid-metadata/).
|
||||||
|
# When submitting, this file's contents go to fdroiddata on GitLab as
|
||||||
|
# metadata/de.jeanlucmakiola.calendula.yml
|
||||||
|
# See README.md in this folder for the verification status and submission steps.
|
||||||
|
#
|
||||||
|
# Publishing model: REPRODUCIBLE BUILD + developer-signed binary.
|
||||||
|
# F-Droid builds from source on its buildserver, then verifies the result is
|
||||||
|
# identical to our own signed APK (fetched via `Binaries`). If it matches,
|
||||||
|
# F-Droid publishes OUR binary signed with OUR key (`AllowedAPKSigningKeys`),
|
||||||
|
# so the official and self-hosted channels carry the SAME signature and users
|
||||||
|
# migrate between them with no reinstall / no data loss.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Categories:
|
||||||
|
- Calendar & Agenda
|
||||||
|
License: MIT
|
||||||
|
AuthorName: Jean-Luc Makiola
|
||||||
|
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
|
||||||
|
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
||||||
|
Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md
|
||||||
|
|
||||||
|
AutoName: Calendula
|
||||||
|
|
||||||
|
RepoType: git
|
||||||
|
Repo: https://gitea.jeanlucmakiola.de/makiolaj/calendula.git
|
||||||
|
|
||||||
|
# First build entry = v2.7.5, the first release that clears ALL three F-Droid
|
||||||
|
# blockers: (1) AGP VCS-info disabled (`vcsInfo { include = false }`, since
|
||||||
|
# v2.7.3); (2) the unused Gradle foojay toolchain-resolver plugin removed (since
|
||||||
|
# v2.7.4) — the offline build scanner rejects it as it can fetch a JDK; (3) AGP's
|
||||||
|
# dependency-metadata block no longer embedded (`dependenciesInfo { includeInApk
|
||||||
|
# = false }`) — the binary scanner rejects it as an "extra signing block". All
|
||||||
|
# three live outside the zip entries or are inert, so v2.7.5 is functionally
|
||||||
|
# identical to v2.7.3 and reproduces byte-for-byte from source. v2.7.4 and
|
||||||
|
# earlier trip one of these checks — do not target them. Subsequent versions are
|
||||||
|
# added automatically (AutoUpdateMode).
|
||||||
|
Builds:
|
||||||
|
- versionName: 2.7.5
|
||||||
|
versionCode: 20705
|
||||||
|
commit: v2.7.5
|
||||||
|
subdir: app
|
||||||
|
gradle:
|
||||||
|
- yes
|
||||||
|
# No NDK / no flavors. The release buildType applies a signingConfig only
|
||||||
|
# when key.properties exists; on the buildserver it does not, so this
|
||||||
|
# produces the unsigned APK F-Droid compares against our binary.
|
||||||
|
|
||||||
|
# SHA-256 of our app signing certificate (public; embedded in every published
|
||||||
|
# APK). Locks F-Droid to publish only binaries signed with our key.
|
||||||
|
AllowedAPKSigningKeys: 5cdaee8eb31cb0df9157c646ae3ec8b3dc43b5bb72624e088c9ddea0c4eb5ec1
|
||||||
|
|
||||||
|
# Our own developer-signed APK for reproducible-build verification. %v -> the
|
||||||
|
# versionName, so v2.7.5 resolves to calendula_v2.7.5.apk on the self-hosted repo.
|
||||||
|
Binaries: https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo/calendula_v%v.apk
|
||||||
|
|
||||||
|
# After this one-time submission F-Droid auto-tracks new vX.Y.Z tags and creates
|
||||||
|
# build entries itself — no manual recipe edits per release. The tag regex keeps
|
||||||
|
# it to release tags only.
|
||||||
|
AutoUpdateMode: Version
|
||||||
|
UpdateCheckMode: Tags ^v[0-9.]+$
|
||||||
|
CurrentVersion: 2.7.5
|
||||||
|
CurrentVersionCode: 20705
|
||||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 156 KiB |
1
fastlane/metadata/android/de-DE/title.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Calendula
|
||||||
8
fastlane/metadata/android/en-US/changelogs/20701.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
### 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.
|
||||||
|
|
||||||
6
fastlane/metadata/android/en-US/changelogs/20703.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
### Fixed
|
||||||
|
- Home-screen widgets no longer get stuck on a loading spinner in the published
|
||||||
|
(F-Droid) release build. They render via Android's background-work system, and
|
||||||
|
release optimisation (R8) was stripping a helper class it loads by name, so the
|
||||||
|
render job never ran. Added the missing keep rule — widgets now load normally.
|
||||||
|
|
||||||
6
fastlane/metadata/android/en-US/changelogs/20704.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
### Changed
|
||||||
|
- Build cleanup that lets Calendula ship in the official F-Droid repository:
|
||||||
|
removed an unused Gradle toolchain-resolver plugin, which F-Droid's offline,
|
||||||
|
reproducible build process disallows. No functional or visible changes — this
|
||||||
|
is the same app as 2.7.3.
|
||||||
|
|
||||||
6
fastlane/metadata/android/en-US/changelogs/20705.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
### Changed
|
||||||
|
- Further build cleanup for the official F-Droid repository: stopped embedding
|
||||||
|
AGP's dependency-metadata block in the APK, which F-Droid's reproducible-build
|
||||||
|
scanner rejects as an extra signing block. No functional or visible changes —
|
||||||
|
the same app as 2.7.4, just without that Play-oriented metadata blob.
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
1
fastlane/metadata/android/en-US/title.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Calendula
|
||||||
64
scripts/check_reproducible_release.sh
Executable file
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Reproducibility guard for the official F-Droid repo (de.jeanlucmakiola.calendula).
|
||||||
|
#
|
||||||
|
# F-Droid only republishes OUR signed binary if a from-source build reproduces it
|
||||||
|
# byte-for-byte and the binary carries no extra signing blocks. If any invariant
|
||||||
|
# below regresses, the official repo silently stalls on the last good version
|
||||||
|
# (fails safe — but you'd be stuck on an old release without noticing). So fail
|
||||||
|
# loudly here, on every PR.
|
||||||
|
#
|
||||||
|
# Each invariant was learned from a real fdroiddata CI rejection (see
|
||||||
|
# docs/fdroid-official/ and the v2.7.3 -> v2.7.5 history):
|
||||||
|
# 1. vcsInfo { include = false } — else AGP embeds env-dependent git
|
||||||
|
# metadata (META-INF/version-control-info.textproto) -> not reproducible.
|
||||||
|
# 2. no foojay toolchain resolver — F-Droid's offline source scanner
|
||||||
|
# rejects org.gradle.toolchains.foojay-resolver (it can fetch a JDK over
|
||||||
|
# the network at build time).
|
||||||
|
# 3. dependenciesInfo { includeInApk = false } — else AGP embeds a "Dependency
|
||||||
|
# metadata" block (id 0x504b4453) in the APK Signing Block, which F-Droid's
|
||||||
|
# binary scanner rejects as an extra signing block.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP="app/build.gradle.kts"
|
||||||
|
SETTINGS="settings.gradle.kts"
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
# 1. AGP VCS-info must be disabled on the release build. -z reads the whole file
|
||||||
|
# as one record so the match can span newlines; [^}] keeps it inside the block.
|
||||||
|
if grep -Pzoq 'vcsInfo\s*\{[^}]*include\s*=\s*false' "$APP"; then
|
||||||
|
echo "OK: vcsInfo { include = false } — no env-dependent VCS metadata embedded."
|
||||||
|
else
|
||||||
|
echo "ERROR: '$APP' release build is missing 'vcsInfo { include = false }'." >&2
|
||||||
|
echo " AGP would embed version-control-info.textproto, breaking reproducibility." >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. The foojay toolchain resolver must not be present in any Gradle script.
|
||||||
|
gradle_files=("$SETTINGS" "$APP")
|
||||||
|
[ -f build.gradle.kts ] && gradle_files+=(build.gradle.kts)
|
||||||
|
if grep -qi 'foojay' "${gradle_files[@]}"; then
|
||||||
|
echo "ERROR: foojay toolchain resolver found in: $(grep -li foojay "${gradle_files[@]}" | tr '\n' ' ')" >&2
|
||||||
|
echo " F-Droid's source scanner rejects org.gradle.toolchains.foojay-resolver" >&2
|
||||||
|
echo " (it can fetch a JDK over the network). Remove the plugin." >&2
|
||||||
|
fail=1
|
||||||
|
else
|
||||||
|
echo "OK: no foojay toolchain resolver — offline build scanner stays happy."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. AGP dependency-metadata block must not be embedded in the APK.
|
||||||
|
if grep -Pzoq 'dependenciesInfo\s*\{[^}]*includeInApk\s*=\s*false' "$APP"; then
|
||||||
|
echo "OK: dependenciesInfo { includeInApk = false } — no extra APK signing block."
|
||||||
|
else
|
||||||
|
echo "ERROR: '$APP' is missing 'dependenciesInfo { includeInApk = false }'." >&2
|
||||||
|
echo " AGP would embed a 'Dependency metadata' block (0x504b4453) in the APK" >&2
|
||||||
|
echo " Signing Block, which F-Droid's binary scanner rejects." >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$fail" -ne 0 ]; then
|
||||||
|
echo >&2
|
||||||
|
echo "Reproducible-release invariant(s) violated — official F-Droid publishing would" >&2
|
||||||
|
echo "stall. Fix the above before merging. See docs/fdroid-official/." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "All reproducible-release invariants hold."
|
||||||
48
scripts/fastlane_to_fdroid_localized.sh
Executable file
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Single source of truth: fastlane/metadata/android/<locale>/ feeds BOTH the
|
||||||
|
# official F-Droid repo (harvested from source automatically) and the
|
||||||
|
# self-hosted repo. This script transforms the fastlane layout into the F-Droid
|
||||||
|
# "localized" layout that the self-hosted `fdroid update` consumes, so we don't
|
||||||
|
# maintain two copies.
|
||||||
|
#
|
||||||
|
# usage: fastlane_to_fdroid_localized.sh <fastlane_android_dir> <out_localized_dir>
|
||||||
|
# e.g. scripts/fastlane_to_fdroid_localized.sh \
|
||||||
|
# fastlane/metadata/android \
|
||||||
|
# fdroid/metadata/de.jeanlucmakiola.calendula
|
||||||
|
#
|
||||||
|
# Mapping (fastlane -> F-Droid repo localized):
|
||||||
|
# short_description.txt -> summary.txt
|
||||||
|
# full_description.txt -> description.txt
|
||||||
|
# title.txt -> name.txt
|
||||||
|
# images/icon.png -> icon.png
|
||||||
|
# images/phoneScreenshots/* -> phoneScreenshots/*
|
||||||
|
# changelogs/<versionCode>.txt -> changelogs/<versionCode>.txt
|
||||||
|
# (changelogs are seeded into the fastlane tree by
|
||||||
|
# scripts/sync_changelog_to_fastlane.sh.)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SRC="${1:?need fastlane android dir, e.g. fastlane/metadata/android}"
|
||||||
|
OUT="${2:?need output localized dir, e.g. fdroid/metadata/<appid>}"
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
for locdir in "$SRC"/*/; do
|
||||||
|
loc="$(basename "$locdir")"
|
||||||
|
dst="$OUT/$loc"
|
||||||
|
mkdir -p "$dst"
|
||||||
|
[ -f "$locdir/short_description.txt" ] && cp "$locdir/short_description.txt" "$dst/summary.txt"
|
||||||
|
[ -f "$locdir/full_description.txt" ] && cp "$locdir/full_description.txt" "$dst/description.txt"
|
||||||
|
[ -f "$locdir/title.txt" ] && cp "$locdir/title.txt" "$dst/name.txt"
|
||||||
|
[ -f "$locdir/images/icon.png" ] && cp "$locdir/images/icon.png" "$dst/icon.png"
|
||||||
|
if [ -d "$locdir/images/phoneScreenshots" ]; then
|
||||||
|
mkdir -p "$dst/phoneScreenshots"
|
||||||
|
cp "$locdir"images/phoneScreenshots/* "$dst/phoneScreenshots/"
|
||||||
|
fi
|
||||||
|
# Per-version changelogs live in the same fastlane tree (see
|
||||||
|
# scripts/sync_changelog_to_fastlane.sh) and map straight across.
|
||||||
|
if [ -d "$locdir/changelogs" ]; then
|
||||||
|
mkdir -p "$dst/changelogs"
|
||||||
|
cp "$locdir"changelogs/* "$dst/changelogs/"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Built F-Droid localized metadata in '$OUT' from '$SRC'"
|
||||||
41
scripts/sync_changelog_to_fastlane.sh
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Write the current version's CHANGELOG.md section into the fastlane changelog
|
||||||
|
# file that F-Droid harvests: fastlane/metadata/android/en-US/changelogs/<code>.txt
|
||||||
|
# (en-US is F-Droid's fallback locale, so it covers every language).
|
||||||
|
#
|
||||||
|
# Run this when cutting a release (after editing CHANGELOG.md and bumping
|
||||||
|
# versionName in app/build.gradle.kts) and COMMIT the result, so the OFFICIAL
|
||||||
|
# F-Droid repo — which reads the changelog from the tagged source tree — shows
|
||||||
|
# this version's "What's New". The self-hosted release pipeline also runs it so
|
||||||
|
# its changelog never depends on the file having been committed. Idempotent.
|
||||||
|
#
|
||||||
|
# Extraction matches the awk used for the Gitea release notes so all three
|
||||||
|
# (release notes, self-hosted changelog, official changelog) stay in sync.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.." # repo root
|
||||||
|
|
||||||
|
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||||
|
[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; }
|
||||||
|
MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
|
||||||
|
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
||||||
|
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||||
|
|
||||||
|
CL_DIR="fastlane/metadata/android/en-US/changelogs"
|
||||||
|
mkdir -p "$CL_DIR"
|
||||||
|
OUT="$CL_DIR/${VERSION_CODE}.txt"
|
||||||
|
|
||||||
|
awk -v ver="$VERSION" '
|
||||||
|
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||||
|
/^## \[/ { flag = 0 }
|
||||||
|
flag' CHANGELOG.md > "$OUT"
|
||||||
|
# Trim leading blank lines (same as the pipeline did).
|
||||||
|
sed -i -e '/./,$!d' "$OUT"
|
||||||
|
if [ ! -s "$OUT" ]; then
|
||||||
|
echo "See CHANGELOG.md for $VERSION." > "$OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CHARS=$(wc -m < "$OUT" | tr -d ' ')
|
||||||
|
echo "Wrote $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)"
|
||||||
|
if [ "$CHARS" -gt 500 ]; then
|
||||||
|
echo " note: >500 chars — F-Droid may truncate this changelog in-client." >&2
|
||||||
|
fi
|
||||||
@@ -11,9 +11,6 @@ pluginManagement {
|
|||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
plugins {
|
|
||||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
|||||||