Compare commits

..

1 Commits

Author SHA1 Message Date
54739a5348 chore(deps): update kotlin to v2.4.0
Some checks failed
CI / ci (push) Failing after 3m22s
2026-06-19 09:17:54 +00:00
58 changed files with 267 additions and 1435 deletions

View File

@@ -1,23 +0,0 @@
---
name: Bug report
about: Something doesn't work the way it should
title: ""
labels:
- bug
---
### What happened
### What you expected
### Steps to reproduce
1.
2.
3.
### Environment
- Calendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:

View File

@@ -1,26 +0,0 @@
---
name: Crash report
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
title: "Crash: "
labels:
- bug
- crash
---
<!--
Thanks for reporting a crash in Calendula!
If the app prefilled this for you, the crash report is already below — just add
what you were doing and submit. Otherwise, paste the report from your clipboard
into the code block. The report contains only app/Android/device versions and the
stack trace — no personal data or calendar content.
-->
### What happened
### Crash report
```
(paste the crash report here)
```

View File

@@ -1,16 +0,0 @@
---
name: Feature request
about: Suggest an idea or improvement
title: ""
labels:
- enhancement
---
### What would you like Calendula to do?
### Why — what problem does it solve?
### Anything else
<!-- mockups, examples from other apps, alternatives you considered -->

View File

@@ -1,23 +1,18 @@
name: CI name: CI
# One gate per pull request. Branch pushes no longer trigger CI on their own,
# so a change is built once on its PR (covering feature -> release/* and
# release/* -> main) instead of once per push and again on the merge to main.
# The merge itself is handled by release.yaml, which only does heavy work when
# the merge actually cuts a release.
on: on:
pull_request: push:
branches:
- '**'
tags-ignore:
- '**'
# Cancel superseded runs for the same PR. # Cancel superseded runs on the same branch.
concurrency: concurrency:
group: ci-${{ github.ref }} group: ci-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
# Single job named `ci` so the required "CI" status check is always reported,
# even for docs-only PRs: those just skip the Android build and the job still
# succeeds (fast green check) instead of being filtered out and leaving the
# required check pending forever.
ci: ci:
runs-on: docker runs-on: docker
env: env:
@@ -26,53 +21,14 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
# Full history so the base..HEAD diff below has a merge-base.
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,
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
# Gradle work below but still report a green `ci`.
- name: Classify change scope
id: scope
run: |
set -e
BASE="${{ github.base_ref }}"
# Full (not --depth=1) base fetch so the merge-base is present even when
# 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"
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
echo "code=true" >> "$GITHUB_OUTPUT"
else
echo "code=false" >> "$GITHUB_OUTPUT"
echo "Docs/metadata-only change — skipping the Android build."
fi
- name: Setup Java - name: Setup Java
if: steps.scope.outputs.code == 'true'
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: '17' java-version: '17'
- name: Setup Android SDK - name: Setup Android SDK
if: steps.scope.outputs.code == 'true'
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
with: with:
# Default ("tools platform-tools") drags in the Android Emulator # Default ("tools platform-tools") drags in the Android Emulator
@@ -80,14 +36,12 @@ jobs:
packages: '' packages: ''
- name: Setup Android SDK cache - name: Setup Android SDK cache
if: steps.scope.outputs.code == 'true'
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: /opt/android-sdk path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0 key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages - name: Install Android SDK packages
if: steps.scope.outputs.code == 'true'
run: | run: |
yes | sdkmanager --licenses >/dev/null || true yes | sdkmanager --licenses >/dev/null || true
sdkmanager \ sdkmanager \
@@ -96,7 +50,6 @@ jobs:
"build-tools;36.0.0" "build-tools;36.0.0"
- name: Setup Gradle cache - name: Setup Gradle cache
if: steps.scope.outputs.code == 'true'
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
@@ -107,25 +60,21 @@ jobs:
${{ runner.os }}-gradle- ${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
if: steps.scope.outputs.code == 'true'
run: chmod +x ./gradlew run: chmod +x ./gradlew
# No --no-daemon: the daemon lives only as long as this job container # No --no-daemon: the daemon lives only as long as this job container
# and lets the following steps skip JVM startup + reconfiguration. # and lets the following steps skip JVM startup + reconfiguration.
- name: Lint (debug variant only) - name: Lint (debug variant only)
if: steps.scope.outputs.code == 'true'
run: ./gradlew lintDebug run: ./gradlew lintDebug
- name: Unit tests - name: Unit tests
if: steps.scope.outputs.code == 'true'
run: ./gradlew testDebugUnitTest run: ./gradlew testDebugUnitTest
- name: Assemble debug APK - name: Assemble debug APK
if: steps.scope.outputs.code == 'true'
run: ./gradlew assembleDebug run: ./gradlew assembleDebug
- name: Trivy filesystem scan - name: Trivy filesystem scan
if: steps.scope.outputs.code == 'true' if: github.ref == 'refs/heads/main'
run: | run: |
set -e set -e
SUDO="" SUDO=""

View File

@@ -1,84 +1,75 @@
name: Release — F-Droid repo + Gitea release name: Release — F-Droid repo + Gitea release
# A release is cut by merging a release branch into main with a bumped
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
# it to the F-Droid repo, and only then creates the vX.Y.Z tag + Gitea release
# itself — the tag is an output of the pipeline, not its trigger. Ordinary
# merges (no version bump) fall through `detect` and do nothing.
#
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
# without building an APK or creating a release. Used for key rotation / repo
# recovery.
on: on:
push: push:
branches: [main] tags:
- '*'
workflow_dispatch: workflow_dispatch:
concurrency:
group: release
cancel-in-progress: false
jobs: jobs:
# Cheap gate: resolve the version from the committed build.gradle and decide ci:
# whether this push actually cuts a new release (no tag for it yet). Keeps the runs-on: docker
# heavy job from running on every merge to main. env:
detect: ANDROID_HOME: /opt/android-sdk
runs-on: docker ANDROID_SDK_ROOT: /opt/android-sdk
outputs: steps:
is_release: ${{ steps.v.outputs.is_release }} - name: Checkout
version: ${{ steps.v.outputs.version }} uses: actions/checkout@v4
version_code: ${{ steps.v.outputs.version_code }}
steps: - name: Setup Java
- name: Checkout uses: actions/setup-java@v4
uses: actions/checkout@v4 with:
distribution: 'zulu'
- name: Resolve version and whether it is a new release java-version: '17'
id: v
env: - name: Setup Android SDK
TOKEN: ${{ secrets.GITHUB_TOKEN }} uses: android-actions/setup-android@v3
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} with:
run: | packages: ''
set -e
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts) - name: Setup Android SDK cache
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi uses: actions/cache@v4
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3) with:
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0} path: /opt/android-sdk
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) key: ${{ runner.os }}-android-sdk-37-36.0.0
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT" - name: Install Android SDK packages
echo "Resolved version $VERSION (code $VERSION_CODE)" run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then yes | sdkmanager --licenses >/dev/null || true
echo "Manual dispatch — re-sign path, not a release." sdkmanager \
echo "is_release=false" >> "$GITHUB_OUTPUT" "platform-tools" \
exit 0 "platforms;android-37.0" \
fi "build-tools;36.0.0"
# A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release. - name: Setup Gradle cache
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \ uses: actions/cache@v4
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION") with:
if [ "$STATUS" = "200" ]; then path: |
echo "Tag v$VERSION already exists — nothing to release." ~/.gradle/caches
echo "is_release=false" >> "$GITHUB_OUTPUT" ~/.gradle/wrapper
else key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
echo "No tag for v$VERSION yet — cutting the release." restore-keys: |
echo "is_release=true" >> "$GITHUB_OUTPUT" ${{ runner.os }}-gradle-
fi
- name: Grant execute permission for gradlew
# Releases: build + sign + publish, then mint the tag and Gitea release. run: chmod +x ./gradlew
# Also runs on manual dispatch, where it skips the build and just re-signs and
# re-uploads the existing index (recovery path). # Lint already enforced on every push to main via ci.yaml.
release: # Release sanity only re-runs tests + a debug build to catch
needs: detect # any tag-resolved drift (e.g. version code substitution issues).
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
- name: Unit tests
run: ./gradlew testDebugUnitTest
- name: Assemble debug APK (sanity)
run: ./gradlew assembleDebug
build-and-deploy:
needs: ci
runs-on: docker runs-on: docker
env: env:
ANDROID_HOME: /opt/android-sdk ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
IS_RELEASE: ${{ needs.detect.outputs.is_release }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -130,26 +121,31 @@ jobs:
$SUDO apk add --no-cache jq $SUDO apk add --no-cache jq
fi fi
- name: Grant execute permission for gradlew # Tag-only build steps. On a manual workflow_dispatch (ref = a branch,
run: chmod +x ./gradlew # not a tag) these are skipped: the job then just re-signs the existing
# index with the configured repo key and re-uploads — used for key
# The committed versionName is the source of truth. Pin versionCode to the # rotation / repo recovery without publishing a new APK.
# value derived from it so the published APK's code is always - name: Set version from git tag
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten. if: startsWith(github.ref, 'refs/tags/')
- name: Pin versionCode to versionName
if: env.IS_RELEASE == 'true'
run: | run: |
set -e set -e
RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
VERSION="${RAW_TAG#v}"
MAJOR=$(echo "$VERSION" | cut -d. -f1)
MINOR=$(echo "$VERSION" | cut -d. -f2)
PATCH=$(echo "$VERSION" | cut -d. -f3)
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "Version: $VERSION, VersionCode: $VERSION_CODE"
sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
grep -E 'versionName|versionCode' app/build.gradle.kts grep -E 'versionName|versionCode' app/build.gradle.kts
# Export for later steps (F-Droid changelog, mapping asset name).
# Test the exact commit being shipped (only on a real release). echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Unit tests echo "VERSION_CODE=$VERSION_CODE" >> "$GITHUB_ENV"
if: env.IS_RELEASE == 'true'
run: ./gradlew testDebugUnitTest
- name: Setup Android Keystore - name: Setup Android Keystore
if: env.IS_RELEASE == 'true' if: startsWith(github.ref, 'refs/tags/')
env: env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
@@ -164,8 +160,11 @@ jobs:
storeFile=upload-keystore.jks storeFile=upload-keystore.jks
EOF EOF
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Build release APK - name: Build release APK
if: env.IS_RELEASE == 'true' if: startsWith(github.ref, 'refs/tags/')
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
- name: Setup F-Droid Server Tools - name: Setup F-Droid Server Tools
@@ -203,7 +202,8 @@ jobs:
set -euo pipefail set -euo pipefail
# Fail loudly if the repo key is not configured. NEVER auto-generate # Fail loudly if the repo key is not configured. NEVER auto-generate
# one: a fresh key changes the repo fingerprint and breaks every # one: a fresh key changes the repo fingerprint and breaks every
# user's pinned repo. # user's pinned repo. (Replaces the old `fdroid update --create-key`
# path, which silently rotated the key on a wiped server.)
if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then
echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2 echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2
echo "Refusing to continue — will not auto-generate a new repo key." >&2 echo "Refusing to continue — will not auto-generate a new repo key." >&2
@@ -216,33 +216,42 @@ jobs:
mkdir -p fdroid/repo/icons mkdir -p fdroid/repo/icons
- name: Copy new APK to repo - name: Copy new APK to repo
if: env.IS_RELEASE == 'true' if: startsWith(github.ref, 'refs/tags/')
run: | run: |
set -e set -e
mkdir -p fdroid/repo mkdir -p fdroid/repo
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk" REF_NAME="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
SAFE_REF_NAME="$(echo "$REF_NAME" | tr '/ ' '__' | tr -cd '[:alnum:]_.-')"
if [ -z "$SAFE_REF_NAME" ]; then
SAFE_REF_NAME="${GITHUB_SHA:-manual}"
fi
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_${SAFE_REF_NAME}.apk"
# Per-version "What's New": ensure this version's changelog exists in the - name: Copy metadata to F-Droid repo
# 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
# App-level control file (Categories/License/links) for the self-hosted cp -r fdroid-metadata/* fdroid/metadata/
# repo's `fdroid update`.
cp fdroid-metadata/de.jeanlucmakiola.calendula.yml fdroid/metadata/ # Per-version "What's New" for F-Droid clients: the tag's CHANGELOG
# Localized text + graphics + per-version changelogs come from the SAME # section written to changelogs/<versionCode>.txt (same extraction as the
# fastlane tree the official F-Droid repo harvests from source, # Gitea release notes). en-US only — F-Droid falls back to it for locales
# transformed into the F-Droid repo "localized" layout. One source of # without their own changelog. fdroid update bakes this into the index.
# truth, both channels. - name: Generate F-Droid changelog for this version
bash scripts/fastlane_to_fdroid_localized.sh \ if: startsWith(github.ref, 'refs/tags/')
fastlane/metadata/android \ run: |
fdroid/metadata/de.jeanlucmakiola.calendula set -e
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: |
@@ -263,46 +272,97 @@ jobs:
SFTP SFTP
# Publish the signed repo/ plus metadata/ (descriptions, screenshots, # Publish the signed repo/ plus metadata/ (descriptions, screenshots,
# per-version changelogs) so changelog history survives across # per-version changelogs) so changelog history survives across
# releases. keystore.p12 and config.yml are NEVER uploaded. # releases. keystore.p12 and config.yml are NEVER uploaded, so they
# can't re-enter the web-served tree; nginx serves only repo/ anyway.
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/" sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/"
# The APK is published and the index re-signed — now record the release. # Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at # Attached to the Gitea release (it's not an APK, so it fits the
# this commit, so the tag only ever marks a fully-shipped release (and a # no-binaries rule). Best-effort: never fail a release over it.
# failure before here leaves no tag, so re-running the workflow retries). - name: Attach R8 mapping to Gitea release
- name: Create tag + Gitea release if: startsWith(github.ref, 'refs/tags/')
if: env.IS_RELEASE == 'true' continue-on-error: true
env: env:
TOKEN: ${{ secrets.GITHUB_TOKEN }} TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
SHA: ${{ github.sha }}
run: | run: |
set -e set -e
TAG="v$VERSION" MAP="app/build/outputs/mapping/release/mapping.txt"
# Notes = this version's CHANGELOG section. if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
ASSET="mapping-${VERSION:-$TAG}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
# The release is created by the gitea-release job; ensure it exists
# (idempotent) so this job doesn't race it to a 404.
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then
ID=$(curl -s -X POST -H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}" \
"$API/releases" | jq -r '.id // empty')
fi
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
# Replace any prior asset of the same name (re-run safe).
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
# A Gitea release per tag, carrying the tag's CHANGELOG section as its
# notes. Deliberately no APK assets — distribution stays with the F-Droid
# repo; the release is the human-readable record. Gated on the tests-only
# ci job (not the deploy) so notes appear even if the F-Droid upload has
# an infrastructure hiccup.
gitea-release:
needs: ci
if: startsWith(github.ref, 'refs/tags/')
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract changelog section for this tag
run: |
set -e
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
VERSION="${TAG#v}"
# Everything between "## [<version>]" and the next "## [" heading.
awk -v ver="$VERSION" ' awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next } $0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 } /^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md flag' CHANGELOG.md > release-notes.md
# Trim leading blank lines.
sed -i -e '/./,$!d' release-notes.md sed -i -e '/./,$!d' release-notes.md
if [ ! -s release-notes.md ]; then if [ ! -s release-notes.md ]; then
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
fi fi
python3 - "$TAG" "$SHA" <<'PY' > payload.json echo "--- release notes ---"
cat release-notes.md
- name: Create Gitea release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
python3 - "$TAG" <<'PY' > payload.json
import json, sys import json, sys
print(json.dumps({ print(json.dumps({
"tag_name": sys.argv[1], "tag_name": sys.argv[1],
"target_commitish": sys.argv[2],
"name": sys.argv[1], "name": sys.argv[1],
"body": open("release-notes.md").read(), "body": open("release-notes.md").read(),
"draft": False, "draft": False,
"prerelease": False, "prerelease": False,
})) }))
PY PY
# Upsert (re-run safe): PATCH if a release for the tag already exists, # Upsert: the build-and-deploy job may have created a bare release
# else POST a new one (which also creates the tag at target_commitish). # first (to attach the mapping asset), so PATCH the notes if it
# exists, otherwise POST a new one. Both paths are re-run safe.
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true) ID=$(python3 -c "import json,sys; d=json.load(open('existing.json')); print(d.get('id',''))" 2>/dev/null || true)
if [ -n "$ID" ]; then if [ -n "$ID" ]; then
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \ CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
@@ -316,33 +376,6 @@ jobs:
fi fi
cat response.json cat response.json
if [ "$CODE" != "$OK" ]; then if [ "$CODE" != "$OK" ]; then
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2 echo "Release upsert failed with HTTP $CODE (expected $OK)"
exit 1 exit 1
fi fi
echo "Created/updated release $TAG at $SHA"
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
# Attached to the release (it's not an APK, so it fits the no-binaries
# rule). Best-effort: never fail a release over it.
- name: Attach R8 mapping to Gitea release
if: env.IS_RELEASE == 'true'
continue-on-error: true
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
MAP="app/build/outputs/mapping/release/mapping.txt"
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
TAG="v$VERSION"
ASSET="mapping-${VERSION}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
# Replace any prior asset of the same name (re-run safe).
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"

View File

@@ -4,7 +4,11 @@ name: Translations
# only touch values-*/strings.xml) get quick feedback without the full Android # only touch values-*/strings.xml) get quick feedback without the full Android
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation). # build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
on: on:
pull_request: push:
branches:
- '**'
tags-ignore:
- '**'
paths: paths:
- 'app/src/main/res/values*/strings.xml' - 'app/src/main/res/values*/strings.xml'
- 'app/src/main/res/xml/locales_config.xml' - 'app/src/main/res/xml/locales_config.xml'

View File

@@ -7,52 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [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
### Added
- Crash reporting you control. If Calendula closes unexpectedly, it now captures
a technical report and, on the next launch, offers to send it as an issue on
the project's tracker. Nothing is uploaded automatically — the report stays on
your device until you choose to share it, it contains no personal data or
calendar content (only the app, Android and device versions plus the stack
trace), and you see the full text before sending. There's also a "Report a
problem" entry in Settings, and if the app ever fails to start repeatedly, a
minimal recovery screen still lets you send the report.
## [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

View File

@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
<img src="fastlane/metadata/android/en-US/images/icon.png" width="112" alt="Calendula icon"> <img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/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="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="19%" alt="Week view">&nbsp; <img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/01-week.png" width="19%" alt="Week view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">&nbsp; <img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/02-month.png" width="19%" alt="Month view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">&nbsp; <img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/04-detail.png" width="19%" alt="Event detail">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-edit.png" width="19%" alt="Event form">&nbsp; <img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/05-edit.png" width="19%" alt="Event form">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding"> <img src="fdroid-metadata/de.jeanlucmakiola.calendula/en-US/phoneScreenshots/06-onboarding.png" width="19%" alt="Reminder onboarding">
</p> </p>
</div> </div>

View File

@@ -23,13 +23,13 @@ android {
applicationId = "de.jeanlucmakiola.calendula" applicationId = "de.jeanlucmakiola.calendula"
minSdk = 29 minSdk = 29
targetSdk = 36 targetSdk = 36
// These committed values ARE the source of truth for a release: merging // The git tag is the single source of truth for released builds: at
// a bumped versionName into main triggers .gitea/workflows/release.yaml, // release time .gitea/workflows/release.yaml derives both fields from
// which builds this version and then creates the matching vX.Y.Z tag + // the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // (e.g. v2.0.0 -> 20000). These committed values are the dev/local
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. // default; keep them matching the latest released tag. See docs/RELEASING.md.
versionCode = 20705 versionCode = 20700
versionName = "2.7.5" versionName = "2.7.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -47,11 +47,6 @@ 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(
@@ -66,21 +61,6 @@ 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 {
@@ -92,16 +72,6 @@ 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}"

View File

@@ -15,14 +15,3 @@
# 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>(...); }

View File

@@ -68,15 +68,6 @@
android:resource="@xml/shortcuts" /> android:resource="@xml/shortcuts" />
</activity> </activity>
<!-- Standalone surface for a captured crash report. MainActivity routes
here on a startup crash-loop, so it stays clear of the app's Hilt
graph and Compose content. Not exported: launched only by us. -->
<activity
android:name=".ui.crash.CrashReportActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTask" />
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts <!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
no notification itself — a calendar app must (v1.4, Etar model). no notification itself — a calendar app must (v1.4, Etar model).
Exported: the broadcast arrives from the provider's process. --> Exported: the broadcast arrives from the provider's process. -->

View File

@@ -2,19 +2,10 @@ package de.jeanlucmakiola.calendula
import android.app.Application import android.app.Application
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
/** /**
* Application entry point. Registered as android:name=".CalendulaApp" * Application entry point. Registered as android:name=".CalendulaApp"
* in AndroidManifest.xml. Hilt initializes its component graph here. * in AndroidManifest.xml. Hilt initializes its component graph here.
*/ */
@HiltAndroidApp @HiltAndroidApp
class CalendulaApp : Application() { class CalendulaApp : Application()
override fun onCreate() {
super.onCreate()
// Install first thing so startup crashes are captured too (privacy-
// respecting, on-device; the user submits the report by hand).
CrashReporter.install(this)
}
}

View File

@@ -18,13 +18,9 @@ import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.ui.RootScreen import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -45,31 +41,12 @@ class MainActivity : AppCompatActivity() {
// by CalendarHost's import flow. // by CalendarHost's import flow.
private var requestedImportUri by mutableStateOf<Uri?>(null) private var requestedImportUri by mutableStateOf<Uri?>(null)
// A captured crash report awaiting the user's decision, surfaced as a dialog
// over the calendar on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below.
private var pendingCrashReport by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// If the app keeps crashing as it starts, the main UI can't be trusted
// to come up — route to the standalone report screen instead of
// re-entering the crashing graph.
if (CrashReporter.isCrashLoop(this)) {
startActivity(
Intent(this, CrashReportActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
)
finish()
return
}
enableEdgeToEdge() enableEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull() requestedDetailKey = intent.detailKeyOrNull()
requestedNav = intent.navRequestOrNull() requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull() requestedImportUri = intent.importUriOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent { setContent {
// One activity-scoped SettingsViewModel drives both the theme here // One activity-scoped SettingsViewModel drives both the theme here
// and the Settings screen, so a theme change applies app-wide at once. // and the Settings screen, so a theme change applies app-wide at once.
@@ -93,32 +70,10 @@ class MainActivity : AppCompatActivity() {
requestedImportUri = requestedImportUri, requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null }, onImportConsumed = { requestedImportUri = null },
) )
pendingCrashReport?.let { report ->
CrashReportDialog(
report = report,
onSend = {
submitCrashReport(this@MainActivity, report)
CrashReporter.clearReport(this@MainActivity)
pendingCrashReport = null
},
onDismiss = {
// Keep the report (Settings can still reach it); just
// stop it popping on every launch.
CrashReporter.dismissPrompt(this@MainActivity)
pendingCrashReport = null
},
)
}
} }
} }
} }
override fun onResume() {
super.onResume()
// Reaching a running UI means startup succeeded; reset the loop trail.
CrashReporter.markHealthy(this)
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
intent.detailKeyOrNull()?.let { requestedDetailKey = it } intent.detailKeyOrNull()?.let { requestedDetailKey = it }

View File

@@ -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
@@ -245,8 +204,7 @@ class AndroidCalendarDataSource @Inject constructor(
putDescription(description) putDescription(description)
} }
val uri = resolver.insert(localCalendarsUri(), values) val uri = resolver.insert(localCalendarsUri(), values)
// No calendar name in the message — it can reach a crash report. ?: throw WriteFailedException("create local calendar '$name'")
?: throw WriteFailedException("create local calendar")
return ContentUris.parseId(uri) return ContentUris.parseId(uri)
} }
@@ -284,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)
@@ -728,8 +685,7 @@ class AndroidCalendarDataSource @Inject constructor(
is String -> cv.put(column, value) is String -> cv.put(column, value)
is Long -> cv.put(column, value) is Long -> cv.put(column, value)
is Int -> cv.put(column, value) is Int -> cv.put(column, value)
// Only the type, never the value — a cell value can be event content. else -> error("Unsupported value for $column: $value")
else -> error("Unsupported value type for column '$column': ${value::class.simpleName}")
} }
} }
} }
@@ -762,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(

View File

@@ -1,188 +0,0 @@
package de.jeanlucmakiola.calendula.data.crash
import android.content.Context
import android.content.pm.PackageInfo
import android.os.Build
import androidx.core.content.pm.PackageInfoCompat
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
* Privacy-respecting crash capture (prod-readiness item 10). On an uncaught
* exception it writes a self-contained report to the app's private storage and
* then chains to the platform's default handler, so the process still dies
* normally (and the OS shows its own "stopped" dialog). Nothing is uploaded —
* the app holds no `INTERNET` permission. The user submits the report later,
* by hand, as a Gitea issue (see the ui/crash surfaces).
*
* The report is built from a fixed [CrashContext] allowlist — app/Android/device
* version, locale, time, and the stack trace — and **nothing else**: no device
* identifiers, no account names, no calendar/event content, no logcat. The user
* is always shown the full text before it leaves the device.
*/
object CrashReporter {
/**
* Install the handler. Called first thing in `CalendulaApp.onCreate()` so it
* also catches crashes during startup. The handler swallows nothing — it
* persists, then delegates to the previously-registered handler.
*/
fun install(context: Context) {
val appContext = context.applicationContext
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
// Capturing must never mask the original crash, so guard every step.
runCatching {
val now = System.currentTimeMillis()
writeReport(appContext, buildCrashReport(CrashContext.from(appContext), throwable, now))
recordCrashTime(appContext, now)
}
previous?.uncaughtException(thread, throwable)
}
}
/** The persisted report from the last crash, or null if there is none. */
fun pendingReport(context: Context): String? {
val file = reportFile(context)
return if (file.exists()) runCatching { file.readText() }.getOrNull()?.takeIf { it.isNotBlank() } else null
}
/**
* Whether to surface the report unprompted (on the next launch): a report
* exists and the user hasn't already waved this one away. Settings reaches
* the report via [pendingReport] regardless, so "Not now" only stops the
* auto-prompt — it doesn't discard the report.
*/
fun shouldPrompt(context: Context): Boolean =
reportFile(context).exists() && !dismissedFile(context).exists()
/** Stop auto-prompting for the current report without discarding it. */
fun dismissPrompt(context: Context) {
runCatching { dismissedFile(context).apply { parentFile?.mkdirs() }.writeText("") }
}
/** Drop the persisted report once the user has reported it (or from Settings). */
fun clearReport(context: Context) {
runCatching { reportFile(context).delete() }
runCatching { dismissedFile(context).delete() }
}
/**
* Whether the app appears to be in a startup crash-loop: at least
* [LOOP_THRESHOLD] crashes inside [LOOP_WINDOW_MS]. In that case the main UI
* can't be trusted to start, so the caller routes straight to the standalone
* report screen instead of re-entering the crashing graph.
*/
fun isCrashLoop(context: Context): Boolean {
val times = readCrashTimes(context)
if (times.size < LOOP_THRESHOLD) return false
val recent = times.sortedDescending()
return recent[0] - recent[LOOP_THRESHOLD - 1] <= LOOP_WINDOW_MS
}
/**
* Mark the app as having started successfully, resetting the loop counter so
* an ordinary single crash much later never trips loop detection. The
* pending report itself is kept — only the timing trail is cleared.
*/
fun markHealthy(context: Context) {
runCatching { timesFile(context).delete() }
}
// --- persistence -------------------------------------------------------
private fun writeReport(context: Context, report: String) {
val file = reportFile(context).apply { parentFile?.mkdirs() }
file.writeText(report.take(MAX_REPORT_CHARS))
// A fresh crash should prompt again, even if the previous one was waved away.
runCatching { dismissedFile(context).delete() }
}
private fun recordCrashTime(context: Context, nowMillis: Long) {
val kept = (readCrashTimes(context) + nowMillis).takeLast(MAX_TIMES)
timesFile(context).apply { parentFile?.mkdirs() }
.writeText(kept.joinToString("\n"))
}
private fun readCrashTimes(context: Context): List<Long> {
val file = timesFile(context)
if (!file.exists()) return emptyList()
return runCatching { file.readLines().mapNotNull { it.trim().toLongOrNull() } }.getOrDefault(emptyList())
}
private fun crashDir(context: Context) = File(context.filesDir, CRASH_DIR)
private fun reportFile(context: Context) = File(crashDir(context), REPORT_FILE)
private fun timesFile(context: Context) = File(crashDir(context), TIMES_FILE)
private fun dismissedFile(context: Context) = File(crashDir(context), DISMISSED_FILE)
private const val CRASH_DIR = "crash"
private const val REPORT_FILE = "last_crash.txt"
private const val TIMES_FILE = "crash_times.txt"
private const val DISMISSED_FILE = "dismissed"
private const val MAX_TIMES = 5
private const val MAX_REPORT_CHARS = 64 * 1024
private const val LOOP_THRESHOLD = 2
private const val LOOP_WINDOW_MS = 10_000L
}
/**
* The allowlist of non-personal facts that go into a crash report. Built from
* [Build] and the app's own [PackageInfo]; deliberately holds no identifiers.
*/
data class CrashContext(
val appVersionName: String,
val appVersionCode: Long,
val sdkInt: Int,
val androidRelease: String,
val manufacturer: String,
val model: String,
val locale: String,
) {
companion object {
fun from(context: Context): CrashContext {
val pkg = runCatching {
context.packageManager.getPackageInfo(context.packageName, 0)
}.getOrNull()
return CrashContext(
appVersionName = pkg?.versionName ?: "?",
appVersionCode = pkg?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L,
sdkInt = Build.VERSION.SDK_INT,
androidRelease = Build.VERSION.RELEASE ?: "?",
manufacturer = Build.MANUFACTURER ?: "?",
model = Build.MODEL ?: "?",
locale = Locale.getDefault().toLanguageTag(),
)
}
}
}
/**
* Render a crash report from the [ctx] allowlist, the [throwable]'s full stack
* trace, and the crash [nowMillis]. Pure (no Android, no I/O) so it is unit
* tested. The leading marker doubles as the file's sanity check in
* [CrashReporter.pendingReport].
*/
fun buildCrashReport(ctx: CrashContext, throwable: Throwable, nowMillis: Long): String {
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
val time = runCatching {
Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).format(TIME_FORMAT)
}.getOrDefault(nowMillis.toString())
return buildString {
appendLine("Calendula crash report")
appendLine("App version: ${ctx.appVersionName} (${ctx.appVersionCode})")
appendLine("Android: ${ctx.androidRelease} (API ${ctx.sdkInt})")
appendLine("Device: ${ctx.manufacturer} ${ctx.model}")
appendLine("Locale: ${ctx.locale}")
appendLine("Time: $time")
appendLine()
appendLine("Stack trace:")
append(trace)
}
}
private val TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

View File

@@ -24,8 +24,7 @@ class IcsExporter @Inject constructor(
fun writeDocument(uri: Uri, content: String) { fun writeDocument(uri: Uri, content: String) {
context.contentResolver.openOutputStream(uri)?.use { out -> context.contentResolver.openOutputStream(uri)?.use { out ->
out.write(content.toByteArray(Charsets.UTF_8)) out.write(content.toByteArray(Charsets.UTF_8))
// Only the scheme — the full Uri can embed the user's chosen filename. } ?: throw IOException("Could not open $uri for writing")
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
} }
/** /**

View File

@@ -1,59 +0,0 @@
package de.jeanlucmakiola.calendula.ui.crash
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
/**
* A deliberately minimal, standalone surface for a captured crash report.
* `MainActivity` routes here when it detects a startup crash-loop (see
* [CrashReporter.isCrashLoop]): the main UI can't be trusted to start, so this
* screen stays clear of the app's Hilt graph, DataStore-backed theme and
* Compose content — it only reads the report file and shows the report dialog.
* Plain [CalendulaTheme] defaults (follow-system, dynamic colour) avoid touching
* anything that might be the cause of the crash.
*/
class CrashReportActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val report = CrashReporter.pendingReport(this)
if (report == null) {
finish()
return
}
enableEdgeToEdge()
setContent {
CalendulaTheme {
// Opaque backdrop so the dialog doesn't float over a bare task.
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
CrashReportDialog(
report = report,
onSend = {
submitCrashReport(this, report)
CrashReporter.clearReport(this)
finish()
},
onDismiss = {
CrashReporter.clearReport(this)
finish()
},
)
}
}
}
override fun onResume() {
super.onResume()
// Reaching this screen breaks the loop; reset the timing trail so a
// later ordinary crash isn't mistaken for a loop.
CrashReporter.markHealthy(this)
}
}

View File

@@ -1,75 +0,0 @@
package de.jeanlucmakiola.calendula.ui.crash
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/**
* Asks the user to send a captured crash report as an issue. The full report is
* shown verbatim in a scrollable panel — the user sees exactly what will leave
* the device before choosing to share it (the privacy backstop). [onSend] hands
* off to [submitCrashReport]; [onDismiss] declines.
*/
@Composable
fun CrashReportDialog(
report: String,
onSend: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.BugReport, contentDescription = null) },
title = { Text(stringResource(R.string.crash_dialog_title)) },
text = {
Column {
Text(
text = stringResource(R.string.crash_dialog_message),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(12.dp))
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = report,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.heightIn(max = 220.dp)
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
},
confirmButton = {
TextButton(onClick = onSend) { Text(stringResource(R.string.crash_dialog_report)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.crash_dialog_dismiss)) }
},
)
}

View File

@@ -1,61 +0,0 @@
package de.jeanlucmakiola.calendula.ui.crash
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.core.net.toUri
import de.jeanlucmakiola.calendula.R
/**
* Hand the captured crash report off to the user's chosen channel: the report
* is copied to the clipboard (the reliable path for a full stack trace) and the
* project's Gitea "new issue" page is opened with the body prefilled. Nothing is
* sent automatically — the app has no network access; the user reviews and
* submits the issue themselves.
*/
fun submitCrashReport(context: Context, report: String) {
copyReportToClipboard(context, report)
val opened = runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, report)))
}.isSuccess
val message = if (opened) R.string.crash_report_copied else R.string.crash_report_open_failed
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
/** Open the issue tracker's template chooser for a manual (non-crash) report. */
fun openIssueTracker(context: Context) {
val uri = context.getString(R.string.report_issue_choose_url).toUri()
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) }
}
private fun copyReportToClipboard(context: Context, report: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
val label = context.getString(R.string.crash_report_clip_label)
clipboard.setPrimaryClip(ClipData.newPlainText(label, report))
}
/**
* The Gitea `issues/new` URL with `title` and `body` prefilled. A full report
* can blow past URL-length limits, so an over-long one is left out of the link
* (with a "paste from clipboard" placeholder) — the clipboard copy is the
* source of truth in that case.
*/
private fun buildIssueUri(context: Context, report: String) =
context.getString(R.string.report_issue_url).toUri().buildUpon()
.appendQueryParameter("title", context.getString(R.string.crash_report_issue_title))
.appendQueryParameter("body", buildIssueBody(context, report))
.build()
private fun buildIssueBody(context: Context, report: String): String {
val block = if (report.length > MAX_URL_REPORT_CHARS) {
context.getString(R.string.crash_report_body_paste)
} else {
"```\n$report\n```"
}
return context.getString(R.string.crash_report_body_template, block)
}
/** Keep the prefilled body comfortably under common URL-length ceilings. */
private const val MAX_URL_REPORT_CHARS = 6_000

View File

@@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
@@ -74,14 +73,10 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
@@ -201,48 +196,12 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) }, leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
onClick = onManageCalendars, onClick = onManageCalendars,
) )
LanguageRow(position = Position.Middle) LanguageRow(position = Position.Bottom)
ReportProblemRow(position = Position.Bottom)
AppVersionText() AppVersionText()
} }
} }
/**
* Opens the project's issue tracker to report a problem. If a crash report was
* captured (and not yet sent), it surfaces that report first via the same
* dialog the next-launch prompt uses; otherwise it opens the issue template
* chooser. No data leaves the device until the user submits the issue.
*/
@Composable
private fun ReportProblemRow(position: Position) {
val context = LocalContext.current
var report by remember { mutableStateOf<String?>(null) }
GroupedRow(
title = stringResource(R.string.settings_report_problem),
summary = stringResource(R.string.settings_report_problem_hint),
position = position,
leading = { CategoryIcon(Icons.Default.BugReport, ChipAccent.Neutral) },
onClick = {
val pending = CrashReporter.pendingReport(context)
if (pending != null) report = pending else openIssueTracker(context)
},
)
report?.let { pending ->
CrashReportDialog(
report = pending,
onSend = {
submitCrashReport(context, pending)
CrashReporter.clearReport(context)
report = null
},
onDismiss = { report = null },
)
}
}
@Composable @Composable
private fun LanguageRow(position: Position) { private fun LanguageRow(position: Position) {
val context = LocalContext.current val context = LocalContext.current

View File

@@ -282,8 +282,6 @@
<string name="settings_about_source">Quellcode</string> <string name="settings_about_source">Quellcode</string>
<string name="settings_about_version">Version %1$s</string> <string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Calendula-App-Symbol</string> <string name="settings_about_logo_desc">Calendula-App-Symbol</string>
<string name="settings_report_problem">Problem melden</string>
<string name="settings_report_problem_hint">Absturzbericht senden oder Issue-Tracker öffnen</string>
<!-- Calendar manager --> <!-- Calendar manager -->
<string name="calendars_title">Kalender</string> <string name="calendars_title">Kalender</string>
@@ -339,16 +337,4 @@
<item quantity="one">%d bereits in diesem Kalender übersprungen.</item> <item quantity="one">%d bereits in diesem Kalender übersprungen.</item>
<item quantity="other">%d bereits in diesem Kalender übersprungen.</item> <item quantity="other">%d bereits in diesem Kalender übersprungen.</item>
</plurals> </plurals>
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
<string name="crash_dialog_title">Calendula ist abgestürzt</string>
<string name="crash_dialog_message">Calendula wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>
<string name="crash_dialog_report">Melden</string>
<string name="crash_dialog_dismiss">Nicht jetzt</string>
<string name="crash_report_issue_title">Absturzbericht</string>
<string name="crash_report_clip_label">Calendula-Absturzbericht</string>
<string name="crash_report_copied">Bericht in die Zwischenablage kopiert</string>
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
<string name="crash_report_body_template">Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n</string>
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
</resources> </resources>

View File

@@ -279,8 +279,6 @@
<string name="settings_about_source">Source</string> <string name="settings_about_source">Source</string>
<string name="settings_about_version">Version %1$s</string> <string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Calendula app icon</string> <string name="settings_about_logo_desc">Calendula app icon</string>
<string name="settings_report_problem">Report a problem</string>
<string name="settings_report_problem_hint">Send a crash report or open the issue tracker</string>
<!-- Calendar manager --> <!-- Calendar manager -->
<string name="calendars_title">Calendars</string> <string name="calendars_title">Calendars</string>
@@ -342,19 +340,4 @@
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string> <string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string> <string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
<!-- Crash reporting: a captured report the user can submit, by hand, as a
Gitea issue (the app sends nothing automatically). -->
<string name="crash_dialog_title">Calendula crashed</string>
<string name="crash_dialog_message">Calendula closed unexpectedly last time. You can help fix it by sending this report as an issue. It stays on your device until you choose to share it, and includes no personal data or calendar content — only the technical details below.</string>
<string name="crash_dialog_report">Report</string>
<string name="crash_dialog_dismiss">Not now</string>
<string name="crash_report_issue_title">Crash report</string>
<string name="crash_report_clip_label">Calendula crash report</string>
<string name="crash_report_copied">Report copied to 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_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_choose_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues/new/choose</string>
</resources> </resources>

View File

@@ -1,56 +0,0 @@
package de.jeanlucmakiola.calendula.data.crash
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class CrashReportBuilderTest {
private val context = CrashContext(
appVersionName = "2.7.0",
appVersionCode = 20700,
sdkInt = 34,
androidRelease = "14",
manufacturer = "Google",
model = "Pixel 7",
locale = "en-DE",
)
@Test
fun `report carries the allowlisted facts and the stack trace`() {
val report = buildCrashReport(context, IllegalStateException("boom"), nowMillis = 0L)
assertThat(report).startsWith("Calendula crash report")
assertThat(report).contains("App version: 2.7.0 (20700)")
assertThat(report).contains("Android: 14 (API 34)")
assertThat(report).contains("Device: Google Pixel 7")
assertThat(report).contains("Locale: en-DE")
// The exception type + message and a frame from this test are present.
assertThat(report).contains("IllegalStateException")
assertThat(report).contains("boom")
assertThat(report).contains("CrashReportBuilderTest")
}
@Test
fun `nested causes are included`() {
val cause = NullPointerException("inner")
val report = buildCrashReport(context, RuntimeException("outer", cause), nowMillis = 0L)
assertThat(report).contains("outer")
assertThat(report).contains("Caused by")
assertThat(report).contains("inner")
}
@Test
fun `report holds only the allowlisted lines before the stack trace`() {
val report = buildCrashReport(context, Exception("x"), nowMillis = 0L)
val header = report.substringBefore("Stack trace:").trim().lines()
// No identifiers, accounts, or extra fields ever creep into the header:
// it is exactly the six allowlisted lines plus the title.
assertThat(header).hasSize(6)
assertThat(header.first()).isEqualTo("Calendula crash report")
assertThat(header.map { it.substringBefore(":") }).containsExactly(
"Calendula crash report", "App version", "Android", "Device", "Locale", "Time",
).inOrder()
}
}

View File

@@ -143,6 +143,5 @@ JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:
`CalendarDataSource` is faked (`FakeCalendarDataSource` records writes), `CalendarDataSource` is faked (`FakeCalendarDataSource` records writes),
mappers parse `ColumnReader`/plain maps instead of cursors, domain logic mappers parse `ColumnReader`/plain maps instead of cursors, domain logic
(recurrence, validation, snapshots, write-value building) is pure. CI (recurrence, validation, snapshots, write-value building) is pure. CI
(Gitea Actions) runs `lint test assembleDebug` once per pull request; merging a (Gitea Actions) runs `lint test assembleDebug` on every push; release tags
bumped `versionName` to `main` builds, signs, and publishes to the self-hosted additionally build, sign, and publish to the self-hosted F-Droid repo.
F-Droid repo and then mints the `vX.Y.Z` tag + release. See docs/RELEASING.md.

View File

@@ -12,9 +12,7 @@ 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 |
| [`../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/) | F-Droid/fastlane store metadata: descriptions, icon, screenshots (DE + EN) |
| [`../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

View File

@@ -1,98 +1,61 @@
# Releasing Calendula # Releasing Calendula
Calendula is distributed through a self-hosted F-Droid repository. A release is Calendula is distributed through a self-hosted F-Droid repository. Every
built, signed, and published automatically by `.gitea/workflows/release.yaml` release is built, signed, and published automatically by
when a **bumped `versionName` reaches `main`** — the pipeline then creates the `.gitea/workflows/release.yaml` when a version tag is pushed.
matching `vX.Y.Z` tag and Gitea release itself.
## Versioning — the committed version is the source of truth ## Versioning — the git tag is the single source of truth
A release is defined by the `versionName`/`versionCode` committed in A release is defined by its tag, `vMAJOR.MINOR.PATCH` (e.g. `v2.1.0`). At
`app/build.gradle.kts`: release time the workflow derives both Gradle fields from the tag:
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `2.1.0`) - `versionName` = the tag without the leading `v` (`2.1.0`)
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`2.1.0``20100`) - `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`2.1.0``20100`)
So `MINOR` and `PATCH` each have room for 099. The release pipeline reads So `MINOR` and `PATCH` each have room for 099. The values committed in
`versionName`, pins `versionCode` to the derived value, builds, and — once the `app/build.gradle.kts` are only the dev/local default — CI overwrites them
APK is published — creates the tag `v<versionName>` at that commit. The tag is from the tag. Keep the committed `versionCode`/`versionName` matching the
an **output** of a successful release, not its trigger, so a tag always marks a **latest released tag** so local builds are sanely versioned; the published
fully-shipped version (and a failure before publish leaves no tag, so re-running value always comes from the tag.
the workflow safely retries).
Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`→20000. Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`→20000.
## Cutting a release ## Cutting a release
1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the 1. Move the `## [Unreleased]` section of `CHANGELOG.md` under a new
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.
3. Bump the committed `versionName` (and `versionCode`) in 2. Optionally bump the committed `versionCode`/`versionName` in
`app/build.gradle.kts` to the new version. **This bump is what triggers the `app/build.gradle.kts` to match the new version (keeps local builds tidy).
release** when the branch merges to `main`. Then run 3. Commit, then tag and push:
```bash ```bash
scripts/sync_changelog_to_fastlane.sh git tag vX.Y.Z
git push origin vX.Y.Z
``` ```
and commit the generated 4. The push triggers the release workflow. **Hold UI releases for on-device
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`. This is what review and explicit go-ahead before tagging.**
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
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`.** That's it — no manual tagging. The
merge triggers `release.yaml`, which detects the new version, builds, signs,
publishes to F-Droid, and creates the `vX.Y.Z` tag + Gitea release. **Hold UI
releases for on-device review and explicit go-ahead before merging.**
> The `releaseTest` build type exists only for step 4 — it is never published.
> The pipeline always builds and signs the real `release` variant.
## What the pipeline does ## What the pipeline does
CI and release are split so a change is built once on its PR and only does `release.yaml` has three jobs:
release work when a merge actually cuts a release:
- **`ci.yaml`** (on `pull_request`) — lint + unit tests + a debug assemble (and - **ci** — unit tests + a debug assemble (sanity).
a Trivy scan), once per PR. Docs/metadata-only PRs skip the Android build but - **build-and-deploy** — derives the version, builds & signs the release APK
still report a green `CI` check. with the app key, copies it into the F-Droid repo, generates the per-version
- **`release.yaml`** (on push to `main`, plus `workflow_dispatch`) — a cheap changelog, re-signs the F-Droid index with the **repo key**, uploads
`detect` job reads `versionName` and checks whether a tag for it already `repo/` + `metadata/` to the box, and attaches the R8 `mapping.txt` to the
exists. Only when it doesn't does the `release` job run: unit tests on the Gitea release (best-effort).
merged commit, build & sign the release APK with the **app key**, copy it into - **gitea-release** — creates/updates the Gitea release carrying the tag's
the F-Droid repo, generate the per-version changelog, re-sign the index with CHANGELOG section as notes. Gated on `ci` only (not the deploy) so notes
the **repo key**, upload `repo/` + `metadata/`, then create the `vX.Y.Z` tag + publish even if the F-Droid upload hiccups.
Gitea release (CHANGELOG section as notes) and attach the R8 `mapping.txt`
(best-effort). Ordinary merges with no version bump fall through `detect` and
do nothing.
### Manual re-sign / recovery ### Manual re-sign / recovery
A manual `workflow_dispatch` of the release workflow runs a **re-sign-only** A manual `workflow_dispatch` of the release workflow **from a branch** (not a
path: `detect` reports it's not a release, so the `release` job skips the APK tag) runs a **re-sign-only** path: it skips the APK build and just re-signs
build, the version bump, and tag/release creation, and just re-signs the the existing F-Droid index with the configured repo key and re-uploads. Use
existing F-Droid index with the configured repo key and re-uploads. Use this this for key rotation or repo recovery without publishing a new app version.
for key rotation or repo recovery without publishing a new app version.
## Secrets (Gitea → repo Settings → Actions → Secrets) ## Secrets (Gitea → repo Settings → Actions → Secrets)

View File

@@ -1,94 +0,0 @@
# 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.

View File

@@ -1,65 +0,0 @@
# ---------------------------------------------------------------------------
# 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

View File

@@ -1 +0,0 @@
Calendula

View File

@@ -1,8 +0,0 @@
### 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.

View File

@@ -1,6 +0,0 @@
### 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.

View File

@@ -1,6 +0,0 @@
### 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.

View File

@@ -1,6 +0,0 @@
### 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.

View File

@@ -1 +0,0 @@
Calendula

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,6 +1,6 @@
[versions] [versions]
agp = "9.2.1" agp = "9.2.1"
kotlin = "2.3.21" kotlin = "2.4.0"
ksp = "2.3.9" ksp = "2.3.9"
hilt = "2.59.2" hilt = "2.59.2"
coreKtx = "1.19.0" coreKtx = "1.19.0"

View File

@@ -1,23 +0,0 @@
#!/usr/bin/env bash
# Reproducibility guard for the official F-Droid repo.
#
# F-Droid only republishes OUR signed binary if a from-source build reproduces
# it byte-for-byte. AGP's VCS-info injection writes
# META-INF/version-control-info.textproto with build-environment git metadata
# (commit hash / path) — env-dependent, and the one thing that broke
# reproducibility before we disabled it. It MUST stay off on the release build,
# or new versions silently stop publishing to the official repo (fails safe, but
# you'd be stuck on an old version without noticing). Fail loudly if it regresses.
set -euo pipefail
F="app/build.gradle.kts"
# Match `vcsInfo { ... include = false ... }` within the block, tolerant of
# whitespace/newlines (-z: whole file as one record; [^}] stays inside the block).
if grep -Pzoq 'vcsInfo\s*\{[^}]*include\s*=\s*false' "$F"; then
echo "OK: release build disables AGP VCS-info — stays reproducible for F-Droid."
else
echo "ERROR: '$F' release build is missing 'vcsInfo { include = false }'." >&2
echo "AGP would embed env-dependent git metadata (version-control-info.textproto)," >&2
echo "breaking F-Droid reproducible builds. Restore it. See docs/fdroid-official/." >&2
exit 1
fi

View File

@@ -1,48 +0,0 @@
#!/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'"

View File

@@ -1,41 +0,0 @@
#!/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

View File

@@ -1,43 +0,0 @@
#!/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 merge the release branch to main once all of the above pass on a device"
echo "(the merge is what publishes the release — see docs/RELEASING.md)."

View File

@@ -11,6 +11,9 @@ 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)