Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
883e8fd101 | ||
|
|
11d1147931 | ||
|
|
836817cd12 | ||
|
|
9c35712573 | ||
|
|
4342c6ab6d | ||
|
|
57bad5c74c | ||
|
|
ebf833d0bd | ||
|
|
2f24a9878c | ||
|
|
2313671965 | ||
|
|
e1b80d9f89 | ||
|
|
99bb6adf80 |
@@ -37,9 +37,24 @@ jobs:
|
|||||||
- name: Reproducible-release invariant
|
- name: Reproducible-release invariant
|
||||||
run: bash scripts/check_reproducible_release.sh
|
run: bash scripts/check_reproducible_release.sh
|
||||||
|
|
||||||
|
# versionCode must match versionName (the official F-Droid repo builds the
|
||||||
|
# tag as committed), and a beta must never reach main.
|
||||||
|
- name: Committed version is well-formed
|
||||||
|
env:
|
||||||
|
BASE: ${{ github.base_ref }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
bash scripts/version_info.sh --check
|
||||||
|
if [ "${BASE#refs/heads/}" = "main" ] && [ "$(bash scripts/version_info.sh channel)" = "beta" ]; then
|
||||||
|
echo "ERROR: versionName $(bash scripts/version_info.sh version) is a beta." >&2
|
||||||
|
echo "Set the stable version (and its versionCode) before merging into main." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Play rejects a "What's New" over 500 characters, which would fail the
|
# Play rejects a "What's New" over 500 characters, which would fail the
|
||||||
# upload after the release had already shipped everywhere else. Cheap, so
|
# upload after the release had already shipped everywhere else. Cheap, so
|
||||||
# it runs on every PR rather than only on the release merge.
|
# it runs on every PR rather than only on the release merge. A beta ships
|
||||||
|
# no What's New, so only the older files are checked for one.
|
||||||
- name: Changelog length invariant
|
- name: Changelog length invariant
|
||||||
run: bash scripts/check_changelog_lengths.sh
|
run: bash scripts/check_changelog_lengths.sh
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: Android build environment
|
||||||
|
description: JDK 17, Android SDK + build tools, Gradle cache and jq for the release and beta pipelines.
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'zulu'
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- name: Setup Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
packages: ''
|
||||||
|
|
||||||
|
- name: Setup Android SDK cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /opt/android-sdk
|
||||||
|
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
||||||
|
|
||||||
|
- name: Install Android SDK packages
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
yes | sdkmanager --licenses >/dev/null || true
|
||||||
|
sdkmanager \
|
||||||
|
"platform-tools" \
|
||||||
|
"platforms;android-37.0" \
|
||||||
|
"build-tools;36.0.0"
|
||||||
|
|
||||||
|
- name: Setup Gradle cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
|
- name: Install jq
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
SUDO=""
|
||||||
|
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y jq
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
$SUDO apk add --no-cache jq
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Grant execute permission for gradlew
|
||||||
|
shell: bash
|
||||||
|
run: chmod +x ./gradlew
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
name: Beta — Codeberg pre-release
|
||||||
|
|
||||||
|
# Pushing a release/* branch whose versionName is X.Y.Z-beta.N publishes a
|
||||||
|
# Codeberg pre-release (APK + SHA-256) and a Gitea pre-release (R8 mapping).
|
||||||
|
# Betas never reach F-Droid or Play. See docs/RELEASING.md.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['release/**']
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: beta
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
detect:
|
||||||
|
# Gitea only; see the same guard in release.yaml.
|
||||||
|
if: github.repository_owner == 'makiolaj'
|
||||||
|
runs-on: docker
|
||||||
|
outputs:
|
||||||
|
is_beta: ${{ steps.v.outputs.is_beta }}
|
||||||
|
version: ${{ steps.v.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Resolve version and whether it still needs publishing
|
||||||
|
id: v
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
INFO=$(bash scripts/version_info.sh)
|
||||||
|
echo "$INFO"
|
||||||
|
echo "$INFO" >> "$GITHUB_OUTPUT"
|
||||||
|
if [ "$(bash scripts/version_info.sh channel)" != beta ]; then
|
||||||
|
echo "Not a beta — nothing to do."
|
||||||
|
echo "is_beta=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
GATE=$(bash scripts/release_gate.sh)
|
||||||
|
echo "is_beta=${GATE#cut=}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
beta:
|
||||||
|
needs: detect
|
||||||
|
if: needs.detect.outputs.is_beta == 'true'
|
||||||
|
runs-on: docker
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: /opt/android-sdk
|
||||||
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
|
VERSION: ${{ needs.detect.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Android build environment
|
||||||
|
uses: ./.gitea/actions/android-env
|
||||||
|
|
||||||
|
- name: Pin versionCode to versionName
|
||||||
|
run: bash scripts/version_info.sh --pin
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
|
# The real app key, so a beta updates in place to later betas and stable.
|
||||||
|
- name: Setup Android Keystore
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
run: bash scripts/write_keystore.sh
|
||||||
|
|
||||||
|
- name: Build release APK
|
||||||
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
|
- name: Create tag + Gitea pre-release
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||||
|
cat release-notes.md
|
||||||
|
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
|
||||||
|
MAPPING=app/build/outputs/mapping/release/mapping.txt \
|
||||||
|
bash scripts/publish_gitea_release.sh
|
||||||
|
|
||||||
|
- name: Publish pre-release to Codeberg
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
||||||
|
API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
|
||||||
|
APK=app/build/outputs/apk/release/app-release.apk \
|
||||||
|
bash scripts/publish_codeberg_release.sh
|
||||||
+32
-255
@@ -7,7 +7,9 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play
|
|||||||
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
|
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
|
||||||
# direct-download channel — the tag is an output of the pipeline, not its
|
# direct-download channel — the tag is an output of the pipeline, not its
|
||||||
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
||||||
# nothing.
|
# nothing. Betas (X.Y.Z-beta.N) never come through here: beta.yaml cuts them
|
||||||
|
# from release/* branches as Codeberg-only pre-releases, and `detect` refuses
|
||||||
|
# one that reaches main.
|
||||||
#
|
#
|
||||||
# A trailing `play` job then uploads the App Bundle to Google Play. It is last
|
# A trailing `play` job then uploads the App Bundle to Google Play. It is last
|
||||||
# and separate because Play is the only channel that can reject a good build for
|
# and separate because Play is the only channel that can reject a good build for
|
||||||
@@ -54,56 +56,28 @@ jobs:
|
|||||||
|
|
||||||
- name: Resolve version and whether it is a new release
|
- name: Resolve version and whether it is a new release
|
||||||
id: v
|
id: v
|
||||||
env:
|
|
||||||
# Tags are read from Codeberg, which is canonical — deliberately NOT
|
|
||||||
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
|
|
||||||
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
|
|
||||||
# source does not have. A tag minted here on Gitea is therefore wiped
|
|
||||||
# by the next sync (Codeberg does not have it yet) and only reappears
|
|
||||||
# once the tag push at the end of this workflow propagates back.
|
|
||||||
# Asking Gitea inside that window would report "no tag" for a release
|
|
||||||
# that already shipped, and cut it a second time.
|
|
||||||
# Public repo, so this read needs no token.
|
|
||||||
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
|
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
INFO=$(bash scripts/version_info.sh)
|
||||||
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
|
echo "$INFO"
|
||||||
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
|
echo "$INFO" >> "$GITHUB_OUTPUT"
|
||||||
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
|
||||||
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p')
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Resolved version $VERSION (code $VERSION_CODE)"
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
echo "Manual dispatch — re-sign path, not a release."
|
echo "Manual dispatch — re-sign path, not a release."
|
||||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
# A tag for this version already existing means the release shipped on
|
# Backstop for CI's guard: betas ship from release/* via beta.yaml.
|
||||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
if [ "$CHANNEL" != "stable" ]; then
|
||||||
#
|
echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
|
||||||
# Anything other than a clean 200/404 is treated as fatal rather than
|
exit 1
|
||||||
# as "no tag". A Codeberg outage or a network blip would otherwise
|
fi
|
||||||
# read as absent and re-cut a release that has already shipped —
|
# Tags are read from Codeberg, the canonical forge: a tag minted here is
|
||||||
# republishing to F-Droid and Play. Failing here is recoverable; a
|
# wiped by the next mirror sync until Codeberg has it. A lookup error is
|
||||||
# duplicate release is not.
|
# fatal, since guessing "no tag" would re-cut a shipped release.
|
||||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
|
GATE=$(bash scripts/release_gate.sh)
|
||||||
case "$STATUS" in
|
echo "is_release=${GATE#cut=}" >> "$GITHUB_OUTPUT"
|
||||||
200)
|
|
||||||
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
|
||||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
|
||||||
;;
|
|
||||||
404)
|
|
||||||
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
|
|
||||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
|
|
||||||
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
||||||
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
||||||
@@ -124,65 +98,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Android build environment
|
||||||
uses: actions/setup-java@v4
|
uses: ./.gitea/actions/android-env
|
||||||
with:
|
|
||||||
distribution: 'zulu'
|
|
||||||
java-version: '17'
|
|
||||||
|
|
||||||
- name: Setup Android SDK
|
# The committed versionName is the source of truth; pin the derived code.
|
||||||
uses: android-actions/setup-android@v3
|
|
||||||
with:
|
|
||||||
packages: ''
|
|
||||||
|
|
||||||
- name: Setup Android SDK cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: /opt/android-sdk
|
|
||||||
key: ${{ runner.os }}-android-sdk-37-36.0.0
|
|
||||||
|
|
||||||
- name: Install Android SDK packages
|
|
||||||
run: |
|
|
||||||
yes | sdkmanager --licenses >/dev/null || true
|
|
||||||
sdkmanager \
|
|
||||||
"platform-tools" \
|
|
||||||
"platforms;android-37.0" \
|
|
||||||
"build-tools;36.0.0"
|
|
||||||
|
|
||||||
- name: Setup Gradle cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.gradle/caches
|
|
||||||
~/.gradle/wrapper
|
|
||||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-gradle-
|
|
||||||
|
|
||||||
- name: Install jq
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
SUDO=""
|
|
||||||
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
|
||||||
$SUDO apt-get update
|
|
||||||
$SUDO apt-get install -y jq
|
|
||||||
elif command -v apk >/dev/null 2>&1; then
|
|
||||||
$SUDO apk add --no-cache jq
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
|
||||||
run: chmod +x ./gradlew
|
|
||||||
|
|
||||||
# The committed versionName is the source of truth. Pin versionCode to the
|
|
||||||
# value derived from it so the published APK's code is always
|
|
||||||
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
|
|
||||||
- name: Pin versionCode to versionName
|
- name: Pin versionCode to versionName
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
run: |
|
run: bash scripts/version_info.sh --pin
|
||||||
set -e
|
|
||||||
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
|
|
||||||
grep -E 'versionName|versionCode' app/build.gradle.kts
|
|
||||||
|
|
||||||
# Test the exact commit being shipped (only on a real release).
|
# Test the exact commit being shipped (only on a real release).
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
@@ -195,15 +117,7 @@ jobs:
|
|||||||
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
run: |
|
run: bash scripts/write_keystore.sh
|
||||||
mkdir -p app
|
|
||||||
echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks
|
|
||||||
cat > key.properties <<EOF
|
|
||||||
storePassword=$KEY_PASSWORD
|
|
||||||
keyPassword=$KEY_PASSWORD
|
|
||||||
keyAlias=$KEY_ALIAS
|
|
||||||
storeFile=upload-keystore.jks
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Build release APK
|
- name: Build release APK
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
@@ -312,6 +226,7 @@ jobs:
|
|||||||
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
|
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
|
||||||
# this commit, so the tag only ever marks a fully-shipped release (and a
|
# this commit, so the tag only ever marks a fully-shipped release (and a
|
||||||
# failure before here leaves no tag, so re-running the workflow retries).
|
# failure before here leaves no tag, so re-running the workflow retries).
|
||||||
|
# Also attaches the R8 mapping, best-effort.
|
||||||
- name: Create tag + Gitea release
|
- name: Create tag + Gitea release
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
env:
|
env:
|
||||||
@@ -320,74 +235,10 @@ jobs:
|
|||||||
SHA: ${{ github.sha }}
|
SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
TAG="v$VERSION"
|
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||||
# Notes = this version's CHANGELOG section.
|
TAG="v$VERSION" PRERELEASE=false NOTES_FILE=release-notes.md \
|
||||||
awk -v ver="$VERSION" '
|
MAPPING=app/build/outputs/mapping/release/mapping.txt \
|
||||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
bash scripts/publish_gitea_release.sh
|
||||||
/^## \[/ { flag = 0 }
|
|
||||||
flag' CHANGELOG.md > release-notes.md
|
|
||||||
sed -i -e '/./,$!d' release-notes.md
|
|
||||||
if [ ! -s release-notes.md ]; then
|
|
||||||
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
|
|
||||||
fi
|
|
||||||
python3 - "$TAG" "$SHA" <<'PY' > payload.json
|
|
||||||
import json, sys
|
|
||||||
print(json.dumps({
|
|
||||||
"tag_name": sys.argv[1],
|
|
||||||
"target_commitish": sys.argv[2],
|
|
||||||
"name": sys.argv[1],
|
|
||||||
"body": open("release-notes.md").read(),
|
|
||||||
"draft": False,
|
|
||||||
"prerelease": False,
|
|
||||||
}))
|
|
||||||
PY
|
|
||||||
# Upsert (re-run safe): PATCH if a release for the tag already exists,
|
|
||||||
# else POST a new one (which also creates the tag at target_commitish).
|
|
||||||
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
|
|
||||||
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
|
|
||||||
if [ -n "$ID" ]; then
|
|
||||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d @payload.json "$API/releases/$ID")
|
|
||||||
OK=200
|
|
||||||
else
|
|
||||||
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d @payload.json "$API/releases")
|
|
||||||
OK=201
|
|
||||||
fi
|
|
||||||
cat response.json
|
|
||||||
if [ "$CODE" != "$OK" ]; then
|
|
||||||
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
|
|
||||||
exit 1
|
|
||||||
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"
|
|
||||||
|
|
||||||
# Mirror the release to the Codeberg mirror as a direct-download channel
|
# Mirror the release to the Codeberg mirror as a direct-download channel
|
||||||
# for users who don't want F-Droid. Gitea already push-mirrors branches +
|
# for users who don't want F-Droid. Gitea already push-mirrors branches +
|
||||||
@@ -406,84 +257,10 @@ jobs:
|
|||||||
SHA: ${{ github.sha }}
|
SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
if [ -z "${TOKEN:-}" ]; then
|
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||||
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
|
TAG="v$VERSION" PRERELEASE=false NOTES_FILE=release-notes.md \
|
||||||
exit 0
|
APK=app/build/outputs/apk/release/app-release.apk \
|
||||||
fi
|
bash scripts/publish_codeberg_release.sh
|
||||||
TAG="v$VERSION"
|
|
||||||
APK="app/build/outputs/apk/release/app-release.apk"
|
|
||||||
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
|
|
||||||
ASSET_APK="calendula_v${VERSION}.apk"
|
|
||||||
ASSET_SUM="${ASSET_APK}.sha256"
|
|
||||||
cp "$APK" "/tmp/$ASSET_APK"
|
|
||||||
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
|
|
||||||
|
|
||||||
# Release notes: reuse the section extracted for the Gitea release,
|
|
||||||
# fall back to the CHANGELOG entry if that step's file is gone.
|
|
||||||
if [ ! -s release-notes.md ]; then
|
|
||||||
awk -v ver="$VERSION" '
|
|
||||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
|
||||||
/^## \[/ { flag = 0 }
|
|
||||||
flag' CHANGELOG.md > release-notes.md
|
|
||||||
sed -i -e '/./,$!d' release-notes.md
|
|
||||||
fi
|
|
||||||
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
|
||||||
# The pipeline creates the tag via the Gitea API, which the push mirror
|
|
||||||
# (sync_on_commit only fires on real git pushes) doesn't propagate
|
|
||||||
# promptly — so a release POST that carries a target_commitish can
|
|
||||||
# outrun the mirror and 500 on a commit/tag Codeberg hasn't received.
|
|
||||||
# Push the tag straight to Codeberg so it's guaranteed present, then
|
|
||||||
# attach the release to that existing tag with NO target_commitish
|
|
||||||
# (which is what triggered the 500).
|
|
||||||
git tag -f "$TAG" "$SHA"
|
|
||||||
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/calendula.git" \
|
|
||||||
"refs/tags/$TAG"
|
|
||||||
python3 - "$TAG" <<'PY' > cb-payload.json
|
|
||||||
import json, sys
|
|
||||||
print(json.dumps({
|
|
||||||
"tag_name": sys.argv[1],
|
|
||||||
"name": sys.argv[1],
|
|
||||||
"body": open("release-notes.md").read(),
|
|
||||||
"draft": False,
|
|
||||||
"prerelease": False,
|
|
||||||
}))
|
|
||||||
PY
|
|
||||||
# Create (or update) the release. Codeberg 500s on a POST/GET against a
|
|
||||||
# tag it has only just received — the release request outruns the
|
|
||||||
# indexing of the ref we pushed a moment ago — so a single attempt kept
|
|
||||||
# failing and skipping the mirror even though the very same call
|
|
||||||
# succeeds seconds later. Retry with backoff, and PATCH in place if a
|
|
||||||
# release already exists (re-run safe). A 5xx body still exits curl 0,
|
|
||||||
# so the loop, not `set -e`, controls the flow.
|
|
||||||
ID=""
|
|
||||||
for attempt in 1 2 3 4 5 6; do
|
|
||||||
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
|
|
||||||
if [ -n "$EXIST" ]; then
|
|
||||||
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d @cb-payload.json "$API/releases/$EXIST"
|
|
||||||
ID="$EXIST"; break
|
|
||||||
fi
|
|
||||||
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d @cb-payload.json "$API/releases")
|
|
||||||
echo "release POST attempt $attempt HTTP $CODE"
|
|
||||||
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
|
|
||||||
[ -n "$ID" ] && break
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
|
|
||||||
|
|
||||||
# Attach APK + checksum, replacing any prior asset of the same name.
|
|
||||||
for A in "$ASSET_APK" "$ASSET_SUM"; do
|
|
||||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
|
||||||
| jq -r --arg n "$A" '.[] | 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/$A" \
|
|
||||||
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
|
|
||||||
done
|
|
||||||
echo "Published $TAG to Codeberg."
|
|
||||||
|
|
||||||
# Play takes an App Bundle, not the APK, so it is a second artifact from
|
# Play takes an App Bundle, not the APK, so it is a second artifact from
|
||||||
# the same source and the same signing config — not a repackage of the
|
# the same source and the same signing config — not a repackage of the
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
name: Renovate
|
name: Renovate
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
|
# Every merge to main. Mirror syncs from Codeberg fire push events here (the
|
||||||
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
|
# same trigger release.yaml relies on), so a merged Renovate PR is followed
|
||||||
|
# within minutes by a run that rebases the sibling PRs it just conflicted —
|
||||||
|
# most bumps touch gradle/libs.versions.toml. Outside renovate.json5's
|
||||||
|
# `schedule` window such a run only maintains existing branches
|
||||||
|
# (updateNotScheduled), it never opens new PRs. Renovate's own rebases push
|
||||||
|
# to renovate/* branches, not main, so this cannot loop.
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# Weekly sweep for new updates. Mondays 05:00 UTC, inside the schedule window
|
||||||
|
# in renovate.json5 — keep the two in step.
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 5 * * 1'
|
- cron: '0 5 * * 1'
|
||||||
# Manual run for an on-demand sweep from the Actions tab.
|
# Manual run for an on-demand sweep from the Actions tab.
|
||||||
|
|||||||
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Edits and deletions show up straight away on every phone.** On some phones, Samsung among them, an edited, moved or deleted event stayed where it was until the app was restarted, so a second edit could act on an event that had already changed. Calendula now refreshes its views itself after every change, and again when you come back to the app ([#364]).
|
||||||
|
- **The home-screen widgets do less work per update.** The month widget loads the months around the one it shows instead of more than two years of events, and the agenda widget only its chosen range. This should help widgets that stayed on "loading" on slower phones ([#363]).
|
||||||
|
|
||||||
|
## [2.22.3] — 2026-10-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The store listing is called "Calendula: Calendar" again,** with "Calendar" in each store language. Since 2.22.0 the Play listing comes from the repository, whose title was only "Calendula". The app is unchanged ([#361]).
|
||||||
|
|
||||||
|
## [2.22.2] — 2026-10-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **2.22 reaches Google Play.** The Dutch store listing used the locale code `nl`, which Play rejects, so the Play uploads of 2.22.0 and 2.22.1 failed. It now uses `nl-NL`, and CI checks every store locale against the codes Play accepts. The app is unchanged from 2.22.1 ([#359]).
|
||||||
|
|
||||||
## [2.22.1] — 2026-10-02
|
## [2.22.1] — 2026-10-02
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -1823,3 +1837,7 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#331]: https://codeberg.org/jlmakiola/calendula/issues/331
|
[#331]: https://codeberg.org/jlmakiola/calendula/issues/331
|
||||||
[#336]: https://codeberg.org/jlmakiola/calendula/issues/336
|
[#336]: https://codeberg.org/jlmakiola/calendula/issues/336
|
||||||
[#357]: https://codeberg.org/jlmakiola/calendula/issues/357
|
[#357]: https://codeberg.org/jlmakiola/calendula/issues/357
|
||||||
|
[#359]: https://codeberg.org/jlmakiola/calendula/issues/359
|
||||||
|
[#361]: https://codeberg.org/jlmakiola/calendula/issues/361
|
||||||
|
[#363]: https://codeberg.org/jlmakiola/calendula/issues/363
|
||||||
|
[#364]: https://codeberg.org/jlmakiola/calendula/issues/364
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ For automatic updates from Codeberg, use
|
|||||||
[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula),
|
[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula),
|
||||||
or add it by hand: *Add App*, paste `https://codeberg.org/jlmakiola/calendula`,
|
or add it by hand: *Add App*, paste `https://codeberg.org/jlmakiola/calendula`,
|
||||||
then *Add*. Obtainium then watches the releases and tells you when a new one
|
then *Add*. Obtainium then watches the releases and tells you when a new one
|
||||||
is out.
|
is out. Betas of upcoming versions are published there too, as pre-releases; to
|
||||||
|
test them, switch on *Include prereleases* for Calendula in Obtainium.
|
||||||
|
|
||||||
### Google Play
|
### Google Play
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,14 @@ android {
|
|||||||
// These committed values ARE the source of truth for a release: merging
|
// These committed values ARE the source of truth for a release: merging
|
||||||
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself. A versionName of X.Y.Z-beta.N pushed to a release/*
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// branch instead cuts a Codeberg-only pre-release (beta.yaml).
|
||||||
versionCode = 22201
|
// versionCode is derived from versionName by scripts/version_info.sh
|
||||||
versionName = "2.22.1"
|
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
|
||||||
|
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
|
||||||
|
// See docs/RELEASING.md.
|
||||||
|
versionCode = 2220401
|
||||||
|
versionName = "2.22.4-beta.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ 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.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
@@ -56,6 +57,7 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
/** A prefilled create form from an external launch, with the source it came from. */
|
/** A prefilled create form from an external launch, with the source it came from. */
|
||||||
private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
||||||
@@ -63,6 +65,8 @@ private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
|||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
@Inject lateinit var calendarRepository: CalendarRepository
|
||||||
|
|
||||||
// Which of light/dark the system bars are drawn for. The styles installed
|
// Which of light/dark the system bars are drawn for. The styles installed
|
||||||
// in onCreate read this field live, so androidx's config-change replay
|
// in onCreate read this field live, so androidx's config-change replay
|
||||||
// picks up the in-app override instead of the night resource qualifier.
|
// picks up the in-app override instead of the night resource qualifier.
|
||||||
@@ -245,6 +249,12 @@ class MainActivity : AppCompatActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
// Changes made while we were away may never have notified us (#364).
|
||||||
|
calendarRepository.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
// Reaching a running UI means startup succeeded; reset the loop trail.
|
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||||
|
|||||||
@@ -610,8 +610,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
// cancelled exception for the one instance (#47). A NULL status is a
|
// cancelled exception for the one instance (#47). A NULL status is a
|
||||||
// normal, un-cancelled event, so it must survive the filter — a bare
|
// normal, un-cancelled event, so it must survive the filter — a bare
|
||||||
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
||||||
"${CalendarContract.Instances.STATUS} IS NULL OR " +
|
// DELETED = 0: a provider that soft-deletes may keep the row's
|
||||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
|
// instances until a purge (#364).
|
||||||
|
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||||
|
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0",
|
||||||
null,
|
null,
|
||||||
CalendarContract.Instances.BEGIN + " ASC",
|
CalendarContract.Instances.BEGIN + " ASC",
|
||||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import kotlin.time.Instant
|
|||||||
interface CalendarRepository {
|
interface CalendarRepository {
|
||||||
fun calendars(): Flow<List<CalendarSource>>
|
fun calendars(): Flow<List<CalendarSource>>
|
||||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||||
|
|
||||||
|
/** Re-query every open [calendars] / [instances] flow. */
|
||||||
|
fun refresh()
|
||||||
suspend fun eventDetail(eventId: Long): EventDetail
|
suspend fun eventDetail(eventId: Long): EventDetail
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+34
-17
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
|||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
@@ -51,21 +52,37 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
private suspend fun allDayReminderTimeMinutes(): Int =
|
private suspend fun allDayReminderTimeMinutes(): Int =
|
||||||
settingsPrefs.allDayReminderTimeMinutes.first()
|
settingsPrefs.allDayReminderTimeMinutes.first()
|
||||||
|
|
||||||
|
// DROP_OLDEST: tryEmit never fails, so a tick that lands while a subscriber
|
||||||
|
// still holds the previous one is never lost (#364).
|
||||||
private val ticks = MutableSharedFlow<Unit>(
|
private val ticks = MutableSharedFlow<Unit>(
|
||||||
replay = 0,
|
replay = 0,
|
||||||
extraBufferCapacity = 1,
|
extraBufferCapacity = 1,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bumped on every provider notification, so one tick's calendar read can be
|
* Bumped on every [refresh], so one tick's calendar read can be
|
||||||
* shared by everything that needs it (see [calendarsSnapshot]).
|
* shared by everything that needs it (see [calendarsSnapshot]).
|
||||||
*/
|
*/
|
||||||
private val generation = AtomicLong(0L)
|
private val generation = AtomicLong(0L)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
dataSource.registerChangeListener {
|
dataSource.registerChangeListener(::refresh)
|
||||||
generation.incrementAndGet()
|
}
|
||||||
ticks.tryEmit(Unit)
|
|
||||||
|
// Writes re-query on their own too: some providers never notify the
|
||||||
|
// observer, which left every view stale until a restart (#364).
|
||||||
|
override fun refresh() {
|
||||||
|
generation.incrementAndGet()
|
||||||
|
ticks.tryEmit(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a provider write on [io], then re-query whether or not it threw. */
|
||||||
|
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
} finally {
|
||||||
|
refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +198,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
displayName: String,
|
displayName: String,
|
||||||
color: Int,
|
color: Int,
|
||||||
description: String?,
|
description: String?,
|
||||||
): Long = withContext(io) {
|
): Long = write {
|
||||||
dataSource.createLocalCalendar(displayName, color, description)
|
dataSource.createLocalCalendar(displayName, color, description)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,13 +207,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
displayName: String,
|
displayName: String,
|
||||||
color: Int,
|
color: Int,
|
||||||
description: String?,
|
description: String?,
|
||||||
) = withContext(io) { dataSource.updateCalendar(id, displayName, color, description) }
|
) = write { dataSource.updateCalendar(id, displayName, color, description) }
|
||||||
|
|
||||||
override suspend fun deleteCalendar(id: Long) =
|
override suspend fun deleteCalendar(id: Long) =
|
||||||
withContext(io) { dataSource.deleteCalendar(id) }
|
write { dataSource.deleteCalendar(id) }
|
||||||
|
|
||||||
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
|
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
|
||||||
withContext(io) {
|
write {
|
||||||
if (dataSource.canWriteCalendars()) {
|
if (dataSource.canWriteCalendars()) {
|
||||||
ids.forEach { dataSource.setCalendarVisible(it, visible) }
|
ids.forEach { dataSource.setCalendarVisible(it, visible) }
|
||||||
// Nothing of ours is left waiting for the provider once the
|
// Nothing of ours is left waiting for the provider once the
|
||||||
@@ -217,7 +234,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun importEvents(
|
override suspend fun importEvents(
|
||||||
targetCalendarId: Long,
|
targetCalendarId: Long,
|
||||||
events: List<ParsedIcsEvent>,
|
events: List<ParsedIcsEvent>,
|
||||||
): IcsImportSummary = withContext(io) {
|
): IcsImportSummary = write {
|
||||||
val existing = dataSource.existingUids(targetCalendarId)
|
val existing = dataSource.existingUids(targetCalendarId)
|
||||||
// Both are per-calendar, not per-event: looking them up once keeps a
|
// Both are per-calendar, not per-event: looking them up once keeps a
|
||||||
// thousand-event restore to two extra queries. The palette is the
|
// thousand-event restore to two extra queries. The palette is the
|
||||||
@@ -268,7 +285,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
override suspend fun createEvent(form: EventForm): Long = write {
|
||||||
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,11 +293,11 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
eventId: Long,
|
eventId: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
) = withContext(io) {
|
) = write {
|
||||||
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
|
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
override suspend fun deleteEvent(eventId: Long) = write {
|
||||||
dataSource.deleteEvent(eventId)
|
dataSource.deleteEvent(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +306,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
targetCalendarId: Long,
|
targetCalendarId: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = write {
|
||||||
dataSource.moveEvent(
|
dataSource.moveEvent(
|
||||||
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
|
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
|
||||||
)
|
)
|
||||||
@@ -300,7 +317,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = write {
|
||||||
dataSource.updateOccurrence(
|
dataSource.updateOccurrence(
|
||||||
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
|
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
|
||||||
)
|
)
|
||||||
@@ -311,20 +328,20 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = write {
|
||||||
dataSource.updateEventFromOccurrence(
|
dataSource.updateEventFromOccurrence(
|
||||||
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
|
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write {
|
||||||
dataSource.deleteOccurrence(eventId, beginMillis)
|
dataSource.deleteOccurrence(eventId, beginMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteEventFromOccurrence(
|
override suspend fun deleteEventFromOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
) = withContext(io) {
|
) = write {
|
||||||
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
@@ -28,11 +29,12 @@ import kotlin.time.Clock
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far either side of today the month widget pre-loads. The displayed month
|
* Months either side of the displayed one the month widget loads, so a few arrow
|
||||||
* is chosen reactively in the composition, so one wide read covers ~13 months of
|
* taps page without a re-query. Kept small: every load expands recurrences across
|
||||||
* prev/next navigation without re-querying on every arrow tap.
|
* the whole window, and on slow devices a wide one held the widget on "loading"
|
||||||
|
* (#363).
|
||||||
*/
|
*/
|
||||||
private const val MONTH_WIDGET_RANGE_DAYS = 400
|
private const val MONTH_WIDGET_PAD_MONTHS = 3
|
||||||
|
|
||||||
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
|
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
@@ -76,6 +78,8 @@ sealed interface AgendaWidgetData {
|
|||||||
val savedShowToday: Boolean,
|
val savedShowToday: Boolean,
|
||||||
/** Saved widget size step (#103) — the fallback before Glance state is set. */
|
/** Saved widget size step (#103) — the fallback before Glance state is set. */
|
||||||
val savedWidgetSize: WidgetSize,
|
val savedWidgetSize: WidgetSize,
|
||||||
|
/** Last day [days] covers; a wider range picked later needs a reload. */
|
||||||
|
val loadedThrough: LocalDate,
|
||||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||||
val now: Instant,
|
val now: Instant,
|
||||||
) : AgendaWidgetData
|
) : AgendaWidgetData
|
||||||
@@ -94,14 +98,15 @@ sealed interface MonthWidgetSource {
|
|||||||
val today: LocalDate,
|
val today: LocalDate,
|
||||||
val weekStart: DayOfWeek,
|
val weekStart: DayOfWeek,
|
||||||
val instances: List<EventInstance>,
|
val instances: List<EventInstance>,
|
||||||
|
/** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */
|
||||||
|
val months: IntRange,
|
||||||
) : MonthWidgetSource
|
) : MonthWidgetSource
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process-lived cache of the wide month window. Month navigation re-runs
|
* Process-lived cache of the month window. Month navigation can re-run
|
||||||
* `provideGlance` (via `updateAll`), and re-querying ~13 months of instances on
|
* `provideGlance`, and re-querying on every arrow tap is what made paging feel
|
||||||
* every arrow tap is what made paging feel sluggish — so we load once and reuse
|
* sluggish — so we load once and reuse the same snapshot for every nearby month. Invalidated by
|
||||||
* the same snapshot for every nearby month. Invalidated by
|
|
||||||
* [invalidateMonthWidgetCache] when calendar data changes (the freshness
|
* [invalidateMonthWidgetCache] when calendar data changes (the freshness
|
||||||
* receiver), and automatically when the day rolls over (the `today` guard).
|
* receiver), and automatically when the day rolls over (the `today` guard).
|
||||||
*/
|
*/
|
||||||
@@ -118,8 +123,11 @@ internal fun invalidateMonthWidgetCache() {
|
|||||||
* One-shot read of the upcoming agenda for the widget. Reuses the app's
|
* One-shot read of the upcoming agenda for the widget. Reuses the app's
|
||||||
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
|
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
|
||||||
* [first]-emitted snapshot already has hidden calendars filtered out.
|
* [first]-emitted snapshot already has hidden calendars filtered out.
|
||||||
|
*
|
||||||
|
* Loads [range] (the widget's own, or the saved pref when null) rather than the
|
||||||
|
* widest selectable window (#363).
|
||||||
*/
|
*/
|
||||||
internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
internal suspend fun Context.loadAgendaWidgetData(range: AgendaRange? = null): AgendaWidgetData {
|
||||||
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val anchor = today(zone)
|
val anchor = today(zone)
|
||||||
@@ -129,11 +137,11 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
val savedPastDisplay = prefs.pastEventDisplay.first()
|
val savedPastDisplay = prefs.pastEventDisplay.first()
|
||||||
val showToday = prefs.agendaShowToday.first()
|
val showToday = prefs.agendaShowToday.first()
|
||||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||||
// Load the widest selectable window once; the displayed range is sliced in
|
// The displayed range is still sliced in the composition from Glance state;
|
||||||
// the composition from Glance state, so changing the range is a plain
|
// a wider one picked later reloads there (see loadedThrough).
|
||||||
// recomposition and never depends on the widget session restarting.
|
val dayCount = (range ?: savedRange).dayCount(anchor, weekStart)
|
||||||
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
|
val window = agendaRange(anchor, dayCount - 1, zone)
|
||||||
val windowEnd = anchor.plus(AgendaRange.MAX_CUSTOM_DAYS - 1, DateTimeUnit.DAY)
|
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
|
||||||
val instances = ep.calendarRepository().instances(window).first()
|
val instances = ep.calendarRepository().instances(window).first()
|
||||||
val is24Hour = prefs.timeFormat.first()
|
val is24Hour = prefs.timeFormat.first()
|
||||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
||||||
@@ -148,22 +156,39 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
savedPastDisplay = savedPastDisplay,
|
savedPastDisplay = savedPastDisplay,
|
||||||
savedShowToday = showToday,
|
savedShowToday = showToday,
|
||||||
savedWidgetSize = prefs.widgetSize.first(),
|
savedWidgetSize = prefs.widgetSize.first(),
|
||||||
|
loadedThrough = windowEnd,
|
||||||
now = Clock.System.now(),
|
now = Clock.System.now(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One-shot wide read backing the month widget's grid for any nearby month. */
|
internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.ordinal
|
||||||
internal suspend fun Context.loadMonthWidgetSource(): MonthWidgetSource {
|
|
||||||
|
private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1)
|
||||||
|
|
||||||
|
/** Read backing the month widget's grid for [centerIndex] and the months around it. */
|
||||||
|
internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): MonthWidgetSource {
|
||||||
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
|
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val anchor = today(zone)
|
val anchor = today(zone)
|
||||||
|
val center = centerIndex ?: monthIndexOf(anchor)
|
||||||
// Reuse the cached window unless the day changed (then it's stale for "today").
|
// Reuse the cached window unless the day changed (then it's stale for "today").
|
||||||
MonthWidgetCache.data?.let { if (it.today == anchor) return it }
|
MonthWidgetCache.data?.let { if (it.today == anchor && center in it.months) return it }
|
||||||
val ep = widgetEntryPoint()
|
val ep = widgetEntryPoint()
|
||||||
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
|
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||||
val from = anchor.minus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
val months = (center - MONTH_WIDGET_PAD_MONTHS)..(center + MONTH_WIDGET_PAD_MONTHS)
|
||||||
val to = anchor.plus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
// Padded by the grid's spill into the neighbouring months.
|
||||||
|
val from = firstDayOfMonthIndex(months.first)
|
||||||
|
.minus(1, DateTimeUnit.WEEK)
|
||||||
|
.atStartOfDayIn(zone)
|
||||||
|
val to = firstDayOfMonthIndex(months.last + 1)
|
||||||
|
.plus(2, DateTimeUnit.WEEK)
|
||||||
|
.atTime(23, 59, 59)
|
||||||
|
.toInstant(zone)
|
||||||
val instances = ep.calendarRepository().instances(from..to).first()
|
val instances = ep.calendarRepository().instances(from..to).first()
|
||||||
return MonthWidgetSource.Ready(today = anchor, weekStart = weekStart, instances = instances)
|
return MonthWidgetSource.Ready(
|
||||||
.also { MonthWidgetCache.data = it }
|
today = anchor,
|
||||||
|
weekStart = weekStart,
|
||||||
|
instances = instances,
|
||||||
|
months = months,
|
||||||
|
).also { MonthWidgetCache.data = it }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.glance.ColorFilter
|
import androidx.glance.ColorFilter
|
||||||
@@ -28,6 +34,7 @@ import androidx.glance.appwidget.lazy.items
|
|||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
import androidx.glance.background
|
import androidx.glance.background
|
||||||
import androidx.glance.currentState
|
import androidx.glance.currentState
|
||||||
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
import androidx.glance.layout.Alignment
|
import androidx.glance.layout.Alignment
|
||||||
import androidx.glance.layout.Box
|
import androidx.glance.layout.Box
|
||||||
@@ -125,7 +132,8 @@ class AgendaWidget : GlanceAppWidget() {
|
|||||||
override val sizeMode = SizeMode.Single
|
override val sizeMode = SizeMode.Single
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val data = context.loadAgendaWidgetData()
|
val stored = getAppWidgetState<Preferences>(context, id)[AGENDA_RANGE_KEY]
|
||||||
|
val data = context.loadAgendaWidgetData(stored?.let { parseAgendaRange(it, AgendaRange.Month) })
|
||||||
val dark = (context.resources.configuration.uiMode and
|
val dark = (context.resources.configuration.uiMode and
|
||||||
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
provideContent {
|
provideContent {
|
||||||
@@ -190,71 +198,95 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
||||||
is AgendaWidgetData.Ready -> {
|
is AgendaWidgetData.Ready -> {
|
||||||
// Range read reactively from per-instance Glance state (falls back
|
// Range read reactively from per-instance Glance state (falls back
|
||||||
// to the saved pref for a freshly placed widget), then the wide
|
// to the saved pref for a freshly placed widget), then the loaded
|
||||||
// pre-loaded window is sliced to it — pure recomposition.
|
// window is sliced to it — pure recomposition.
|
||||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||||
val pastDisplay =
|
val context = androidx.glance.LocalContext.current
|
||||||
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
var loaded by remember(data) { mutableStateOf<AgendaWidgetData.Ready>(data) }
|
||||||
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
|
// A range wider than what was loaded reloads in a live session,
|
||||||
val rangeEnd = data.today.plus(
|
// where provideGlance doesn't run again (#363).
|
||||||
range.dayCount(data.today, data.weekStart) - 1,
|
LaunchedEffect(range) {
|
||||||
DateTimeUnit.DAY,
|
val end = loaded.today.plus(
|
||||||
)
|
range.dayCount(loaded.today, loaded.weekStart) - 1,
|
||||||
// Slice to the chosen range, then drop finished events (and any day
|
DateTimeUnit.DAY,
|
||||||
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
)
|
||||||
// Finally re-anchor today (as an empty placeholder day) when the
|
if (end > loaded.loadedThrough) {
|
||||||
// "always show today" pref is on and today has no events left (#35).
|
(context.loadAgendaWidgetData(range) as? AgendaWidgetData.Ready)
|
||||||
val visibleDays = anchorTodayIfMissing(
|
?.let { loaded = it }
|
||||||
days = data.days
|
}
|
||||||
.filter { it.date <= rangeEnd }
|
}
|
||||||
.let { days ->
|
AgendaRows(data = loaded, range = range, dark = dark, metrics = metrics)
|
||||||
if (pastDisplay == PastEventDisplay.HIDE) {
|
}
|
||||||
days.mapNotNull { day ->
|
}
|
||||||
val remaining = day.events.filterNot { it.hasEnded(data.now) }
|
}
|
||||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
}
|
||||||
}
|
|
||||||
} else {
|
@Composable
|
||||||
days
|
private fun AgendaRows(
|
||||||
}
|
data: AgendaWidgetData.Ready,
|
||||||
},
|
range: AgendaRange,
|
||||||
today = data.today,
|
dark: Boolean,
|
||||||
enabled = showToday,
|
metrics: AgendaMetrics,
|
||||||
)
|
) {
|
||||||
if (visibleDays.isEmpty()) {
|
val pastDisplay =
|
||||||
WidgetMessage(R.string.agenda_empty_title, metrics)
|
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
||||||
|
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
|
||||||
|
val rangeEnd = data.today.plus(
|
||||||
|
range.dayCount(data.today, data.weekStart) - 1,
|
||||||
|
DateTimeUnit.DAY,
|
||||||
|
)
|
||||||
|
// Slice to the chosen range, then drop finished events (and any day
|
||||||
|
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
||||||
|
// Finally re-anchor today (as an empty placeholder day) when the
|
||||||
|
// "always show today" pref is on and today has no events left (#35).
|
||||||
|
val visibleDays = anchorTodayIfMissing(
|
||||||
|
days = data.days
|
||||||
|
.filter { it.date <= rangeEnd }
|
||||||
|
.let { days ->
|
||||||
|
if (pastDisplay == PastEventDisplay.HIDE) {
|
||||||
|
days.mapNotNull { day ->
|
||||||
|
val remaining = day.events.filterNot { it.hasEnded(data.now) }
|
||||||
|
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val rows = buildList {
|
days
|
||||||
visibleDays.forEach { day ->
|
}
|
||||||
add(AgendaRow.Header(day.date, data.today))
|
},
|
||||||
if (day.events.isEmpty()) {
|
today = data.today,
|
||||||
add(AgendaRow.Placeholder(day.date))
|
enabled = showToday,
|
||||||
} else {
|
)
|
||||||
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
|
if (visibleDays.isEmpty()) {
|
||||||
}
|
WidgetMessage(R.string.agenda_empty_title, metrics)
|
||||||
}
|
} else {
|
||||||
}
|
val rows = buildList {
|
||||||
// Bound the payload, then drop a day header the cut left
|
visibleDays.forEach { day ->
|
||||||
// stranded with nothing under it.
|
add(AgendaRow.Header(day.date, data.today))
|
||||||
.take(MAX_AGENDA_ROWS)
|
if (day.events.isEmpty()) {
|
||||||
.dropLastWhile { it is AgendaRow.Header }
|
add(AgendaRow.Placeholder(day.date))
|
||||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
} else {
|
||||||
items(rows.size) { index ->
|
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
|
||||||
when (val row = rows[index]) {
|
}
|
||||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
|
}
|
||||||
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
}
|
||||||
is AgendaRow.Event -> EventRow(
|
// Bound the payload, then drop a day header the cut left
|
||||||
event = row.event,
|
// stranded with nothing under it.
|
||||||
day = row.date,
|
.take(MAX_AGENDA_ROWS)
|
||||||
dark = dark,
|
.dropLastWhile { it is AgendaRow.Header }
|
||||||
soften = data.soften,
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
is24Hour = data.is24Hour,
|
items(rows.size) { index ->
|
||||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
when (val row = rows[index]) {
|
||||||
row.event.hasEnded(data.now),
|
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
|
||||||
metrics = metrics,
|
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
||||||
)
|
is AgendaRow.Event -> EventRow(
|
||||||
}
|
event = row.event,
|
||||||
}
|
day = row.date,
|
||||||
}
|
dark = dark,
|
||||||
|
soften = data.soften,
|
||||||
|
is24Hour = data.is24Hour,
|
||||||
|
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||||
|
row.event.hasEnded(data.now),
|
||||||
|
metrics = metrics,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.glance.ColorFilter
|
import androidx.glance.ColorFilter
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
@@ -26,6 +32,7 @@ import androidx.glance.appwidget.action.actionRunCallback
|
|||||||
import androidx.glance.appwidget.action.actionStartActivity
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
import androidx.glance.appwidget.cornerRadius
|
import androidx.glance.appwidget.cornerRadius
|
||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
import androidx.glance.background
|
import androidx.glance.background
|
||||||
import androidx.glance.currentState
|
import androidx.glance.currentState
|
||||||
@@ -59,6 +66,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventInk
|
|||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||||
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
||||||
|
import de.jeanlucmakiola.calendula.widget.monthIndexOf
|
||||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
import de.jeanlucmakiola.calendula.widget.today
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
||||||
@@ -81,10 +89,7 @@ private val LANE_HEIGHT = 14.dp
|
|||||||
private val DAY_NUMBER_HEIGHT = 18.dp
|
private val DAY_NUMBER_HEIGHT = 18.dp
|
||||||
private val GRID_HPADDING = 8.dp
|
private val GRID_HPADDING = 8.dp
|
||||||
|
|
||||||
private fun currentMonthIndex(zone: TimeZone): Int {
|
private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone))
|
||||||
val t = today(zone)
|
|
||||||
return t.year * 12 + t.month.ordinal
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun yearMonthOf(index: Int): YearMonth =
|
private fun yearMonthOf(index: Int): YearMonth =
|
||||||
YearMonth(index / 12, Month(index % 12 + 1))
|
YearMonth(index / 12, Month(index % 12 + 1))
|
||||||
@@ -123,7 +128,8 @@ class MonthWidget : GlanceAppWidget() {
|
|||||||
override val sizeMode = SizeMode.Exact
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val source = context.loadMonthWidgetSource()
|
val shown = getAppWidgetState<Preferences>(context, id)[MONTH_INDEX_KEY]
|
||||||
|
val source = context.loadMonthWidgetSource(shown)
|
||||||
val dark = (context.resources.configuration.uiMode and
|
val dark = (context.resources.configuration.uiMode and
|
||||||
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
// Read fresh (not through the cached source) so toggling the softener
|
// Read fresh (not through the cached source) so toggling the softener
|
||||||
@@ -198,18 +204,28 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
|
|||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
|
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
|
||||||
val ym = yearMonthOf(index)
|
val ym = yearMonthOf(index)
|
||||||
|
// Paging past the loaded months reloads around the new one in a
|
||||||
|
// live session, where provideGlance doesn't run again.
|
||||||
|
val context = LocalContext.current
|
||||||
|
var loaded by remember(source) { mutableStateOf<MonthWidgetSource.Ready>(source) }
|
||||||
|
LaunchedEffect(index) {
|
||||||
|
if (index !in loaded.months) {
|
||||||
|
(context.loadMonthWidgetSource(index) as? MonthWidgetSource.Ready)
|
||||||
|
?.let { loaded = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
// Column width from the live widget size, minus our H padding.
|
// Column width from the live widget size, minus our H padding.
|
||||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
val weeks = layoutMonthWeeks(ym, loaded.weekStart, loaded.instances, zone)
|
||||||
|
|
||||||
MonthHeader(label = monthLabel(ym, source.today.year))
|
MonthHeader(label = monthLabel(ym, loaded.today.year))
|
||||||
Spacer(GlanceModifier.height(2.dp))
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
WeekdayHeader(weekStart = loaded.weekStart, colW = colW)
|
||||||
weeks.forEach { week ->
|
weeks.forEach { week ->
|
||||||
WeekRow(
|
WeekRow(
|
||||||
week = week,
|
week = week,
|
||||||
currentMonth = ym.month,
|
currentMonth = ym.month,
|
||||||
today = source.today,
|
today = loaded.today,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
soften = soften,
|
soften = soften,
|
||||||
colW = colW,
|
colW = colW,
|
||||||
|
|||||||
+39
@@ -95,6 +95,45 @@ class CalendarRepositoryImplTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instances re-emit after a write the provider never notified about`(@TempDir tempDir: Path) = runTest {
|
||||||
|
var current = listOf(makeEvent(10L))
|
||||||
|
val fake = FakeCalendarDataSource().apply { instancesResult = { _, _ -> current } }
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
|
repo.instances(range).test {
|
||||||
|
assertThat(awaitItem().map { it.eventId }).containsExactly(10L)
|
||||||
|
|
||||||
|
current = emptyList()
|
||||||
|
repo.deleteEvent(10L)
|
||||||
|
|
||||||
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instances re-emit after a write that failed`(@TempDir tempDir: Path) = runTest {
|
||||||
|
var current = listOf(makeEvent(10L))
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
instancesResult = { _, _ -> current }
|
||||||
|
writeError = WriteFailedException("boom")
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
|
repo.instances(range).test {
|
||||||
|
awaitItem()
|
||||||
|
|
||||||
|
current = emptyList()
|
||||||
|
assertThrows<WriteFailedException> { repo.deleteOccurrence(10L, 1_000L) }
|
||||||
|
|
||||||
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `instances forwards epoch-millis bounds to data source`(@TempDir tempDir: Path) = runTest {
|
fun `instances forwards epoch-millis bounds to data source`(@TempDir tempDir: Path) = runTest {
|
||||||
var observedBegin: Long? = null
|
var observedBegin: Long? = null
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ the package list (recurring writes, save conflicts, reminder delivery).
|
|||||||
straight back to it. Sync is DAVx5's / Google's / the system's job.
|
straight back to it. Sync is DAVx5's / Google's / the system's job.
|
||||||
2. **Observer-driven UI.** A `ContentObserver` on the provider triggers
|
2. **Observer-driven UI.** A `ContentObserver` on the provider triggers
|
||||||
re-queries; every screen recomposes from fresh provider state. After a
|
re-queries; every screen recomposes from fresh provider state. After a
|
||||||
write, nothing is patched by hand — the provider notifies, the views
|
write, nothing is patched by hand — the views refresh from the provider.
|
||||||
refresh. This also covers external changes (sync) for free.
|
This also covers external changes (sync) for free. Some providers don't
|
||||||
|
reliably notify (#364), so the repository also re-queries after each of
|
||||||
|
its own writes, and `MainActivity` does on every return to the foreground.
|
||||||
3. **JVM-first testing.** Everything between the UI and the
|
3. **JVM-first testing.** Everything between the UI and the
|
||||||
`ContentResolver` is shaped so it runs as a plain JUnit 5 test: pure
|
`ContentResolver` is shaped so it runs as a plain JUnit 5 test: pure
|
||||||
domain logic, cursor-free mappers, a `FakeCalendarDataSource` for
|
domain logic, cursor-free mappers, a `FakeCalendarDataSource` for
|
||||||
|
|||||||
+75
-7
@@ -5,34 +5,56 @@ built, signed, and published automatically by `.gitea/workflows/release.yaml`
|
|||||||
when a **bumped `versionName` reaches `main`** — the pipeline then creates the
|
when a **bumped `versionName` reaches `main`** — the pipeline then creates the
|
||||||
matching `vX.Y.Z` tag and Gitea release itself.
|
matching `vX.Y.Z` tag and Gitea release itself.
|
||||||
|
|
||||||
|
Before a stable release you can ship **betas** (`X.Y.Z-beta.N`) from the
|
||||||
|
release branch. They go to **Codeberg only**, flagged as pre-releases; F-Droid
|
||||||
|
and Play never see them. See [Cutting a beta](#cutting-a-beta).
|
||||||
|
|
||||||
## Versioning — the committed version is the source of truth
|
## Versioning — the committed version is the source of truth
|
||||||
|
|
||||||
A release is defined by the `versionName`/`versionCode` committed in
|
A release is defined by the `versionName`/`versionCode` committed in
|
||||||
`app/build.gradle.kts`:
|
`app/build.gradle.kts`:
|
||||||
|
|
||||||
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `2.1.0`)
|
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `2.22.4`), or
|
||||||
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`2.1.0` → `20100`)
|
`MAJOR.MINOR.PATCH-beta.N` for a beta (e.g. `2.23.0-beta.2`)
|
||||||
|
- `versionCode` is derived from it by `scripts/version_info.sh`:
|
||||||
|
|
||||||
So `MINOR` and `PATCH` each have room for 0–99. The release pipeline reads
|
| `versionName` | `versionCode` | Example |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `X.Y.Z` up to 2.22.3 (legacy) | `X*10000 + Y*100 + Z` | `2.22.3` → `22203` |
|
||||||
|
| `X.Y.Z-beta.N` (N = 1–98) | `X*1000000 + Y*10000 + Z*100 + N` | `2.23.0-beta.2` → `2230002` |
|
||||||
|
| `X.Y.Z` from 2.22.4 | `X*1000000 + Y*10000 + Z*100 + 99` | `2.22.4` → `2220499` |
|
||||||
|
|
||||||
|
A beta's code sits below its own stable release and above everything before
|
||||||
|
it, so a beta install updates in place to the next beta and then to the stable
|
||||||
|
version. Every version up to 2.22.3 keeps the code it already shipped with, and
|
||||||
|
`2.22.4` is the first on the new scheme. `MINOR` and `PATCH` each have room for
|
||||||
|
0–99. Run `scripts/version_info.sh` to see what the committed version resolves
|
||||||
|
to; CI fails a PR whose committed `versionCode` doesn't match, because the
|
||||||
|
official F-Droid repo builds the tag exactly as committed.
|
||||||
|
|
||||||
|
The release pipeline reads
|
||||||
`versionName`, pins `versionCode` to the derived value, builds, and — once the
|
`versionName`, pins `versionCode` to the derived value, builds, and — once the
|
||||||
APK is published — creates the tag `v<versionName>` at that commit. The tag is
|
APK is published — creates the tag `v<versionName>` at that commit. The tag is
|
||||||
an **output** of a successful release, not its trigger, so a tag always marks a
|
an **output** of a successful release, not its trigger, so a tag always marks a
|
||||||
fully-shipped version (and a failure before publish leaves no tag, so re-running
|
fully-shipped version (and a failure before publish leaves no tag, so re-running
|
||||||
the workflow safely retries).
|
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 … `v2.0.0`→20000 …
|
||||||
|
`v2.22.3`→22203, then `v2.22.4`→2220499.
|
||||||
|
|
||||||
## Cutting a release
|
## Cutting a release
|
||||||
|
|
||||||
1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the
|
1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the
|
||||||
feature/fix branches that make up this release into it. This branch is the
|
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`.
|
release candidate — everything below happens on it, before it reaches `main`.
|
||||||
|
To ship betas first, follow [Cutting a beta](#cutting-a-beta) from here and
|
||||||
|
come back to step 2 when going stable.
|
||||||
2. Move the `## [Unreleased]` section of `CHANGELOG.md` under a new
|
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
|
3. Bump the committed `versionName` (and `versionCode`, which
|
||||||
`app/build.gradle.kts` to the new version. **This bump is what triggers the
|
`scripts/version_info.sh` prints) in `app/build.gradle.kts` to the new version. **This bump is what triggers the
|
||||||
release** when the branch merges to `main`. Then write the per-version
|
release** when the branch merges to `main`. Then write the per-version
|
||||||
"What's New" by hand to
|
"What's New" by hand to
|
||||||
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` and commit it.
|
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` and commit it.
|
||||||
@@ -89,6 +111,48 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`
|
|||||||
> The `releaseTest` build type exists only for step 4 — it is never published.
|
> The `releaseTest` build type exists only for step 4 — it is never published.
|
||||||
> The pipeline always builds and signs the real `release` variant.
|
> The pipeline always builds and signs the real `release` variant.
|
||||||
|
|
||||||
|
## Cutting a beta
|
||||||
|
|
||||||
|
A beta is a test build of the next version, published as a **pre-release on
|
||||||
|
Codeberg** and nowhere else. It is signed with the real app key, so testers
|
||||||
|
install it over their stable install and it updates in place to later betas
|
||||||
|
and to the stable release.
|
||||||
|
|
||||||
|
1. **On `release/vX.Y.Z`**, with the features merged in, set
|
||||||
|
`versionName = "X.Y.Z-beta.1"` and the matching `versionCode`
|
||||||
|
(`scripts/version_info.sh` prints it; `2.23.0-beta.1` → `2230001`).
|
||||||
|
No What's New file — betas don't ship to the stores. Notes come from a
|
||||||
|
`## [X.Y.Z-beta.N]` section of `CHANGELOG.md` if you write one, otherwise
|
||||||
|
from `## [Unreleased]`. So keep the entries under `## [Unreleased]` while
|
||||||
|
betas are going out and only move them to `## [X.Y.Z]` when going stable:
|
||||||
|
once they're moved, a beta's notes come out empty.
|
||||||
|
2. **Optionally verify it on a device** with `scripts/verify-release.sh`, as for
|
||||||
|
a stable release.
|
||||||
|
3. **Push the branch to Codeberg.** When it reaches Gitea, `beta.yaml` sees a
|
||||||
|
beta whose Codeberg pre-release doesn't carry its APK yet, runs the unit
|
||||||
|
tests, builds and signs the APK, creates the `vX.Y.Z-beta.1` tag + a Gitea
|
||||||
|
pre-release (with the R8 mapping) and publishes the **Codeberg
|
||||||
|
pre-release** with the APK + `.sha256`. If that publish fails part-way, the
|
||||||
|
next push of the branch redoes it.
|
||||||
|
4. **Next round:** bump to `-beta.2` (and its `versionCode`) and push again.
|
||||||
|
5. **Going stable:** set `versionName = "X.Y.Z"` and its `versionCode`
|
||||||
|
(`2.23.0` → `2230099`), then continue from step 2 of
|
||||||
|
[Cutting a release](#cutting-a-release).
|
||||||
|
|
||||||
|
Who gets a beta:
|
||||||
|
|
||||||
|
- **Obtainium** users only with *Include prereleases* switched on for the app.
|
||||||
|
That is how a tester opts in; everyone else stays on stable.
|
||||||
|
- **F-Droid** (self-hosted and official) never: `beta.yaml` doesn't touch the
|
||||||
|
self-hosted repo, and the official recipe's `UpdateCheckMode: Tags ^v[0-9.]+$`
|
||||||
|
ignores `-beta` tags. Keep that pattern if the recipe ever changes.
|
||||||
|
- **Play** never.
|
||||||
|
|
||||||
|
Guards: a beta version can't reach `main` (CI fails the PR, and `release.yaml`'s
|
||||||
|
`detect` refuses one as a backstop), `beta.yaml` refuses a beta that isn't
|
||||||
|
newer than the latest stable release, and betas start at 2.22.4 (the legacy
|
||||||
|
codes have no room below them).
|
||||||
|
|
||||||
## 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
|
CI and release are split so a change is built once on its PR and only does
|
||||||
@@ -108,6 +172,10 @@ release work when a merge actually cuts a release:
|
|||||||
mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum
|
mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum
|
||||||
(both best-effort). Ordinary merges with no version bump fall through `detect`
|
(both best-effort). Ordinary merges with no version bump fall through `detect`
|
||||||
and do nothing.
|
and do nothing.
|
||||||
|
- **`beta.yaml`** (on push to `release/**`) — when the committed `versionName`
|
||||||
|
is a beta with no tag yet: unit tests, build & sign with the app key, Gitea
|
||||||
|
pre-release with the R8 mapping, Codeberg pre-release with the APK +
|
||||||
|
`.sha256`. Nothing else; see [Cutting a beta](#cutting-a-beta).
|
||||||
- **`play` job** (same workflow, after `release`) — uploads the App Bundle to
|
- **`play` job** (same workflow, after `release`) — uploads the App Bundle to
|
||||||
Google Play. Runs last and separately so a Play rejection can't endanger a
|
Google Play. Runs last and separately so a Play rejection can't endanger a
|
||||||
release that already shipped; skips cleanly until Play is configured.
|
release that already shipped; skips cleanly until Play is configured.
|
||||||
@@ -184,7 +252,7 @@ on every pull request:
|
|||||||
| `images/icon.png` | 512×512, 32-bit PNG |
|
| `images/icon.png` | 512×512, 32-bit PNG |
|
||||||
| `images/featureGraphic.png` | 1024×500, JPEG or 24-bit PNG |
|
| `images/featureGraphic.png` | 1024×500, JPEG or 24-bit PNG |
|
||||||
| `images/*Screenshots/*` | JPEG or 24-bit PNG (no alpha), sides 320–3840, long edge ≤ 2× short, 2–8 per type |
|
| `images/*Screenshots/*` | JPEG or 24-bit PNG (no alpha), sides 320–3840, long edge ≤ 2× short, 2–8 per type |
|
||||||
| locale directories | exactly the ones in `fastlane/store-locales.txt`, which maps every app `values-*` to a Play locale code |
|
| locale directories | exactly the ones in `fastlane/store-locales.txt`, which maps every app `values-*` to a Play locale code; the code must be one Play accepts (`nl-NL`, not `nl`) |
|
||||||
|
|
||||||
A locale without its own images falls back to en-US in both stores, so graphics
|
A locale without its own images falls back to en-US in both stores, so graphics
|
||||||
only need to exist there. Adding an app language means adding its line to
|
only need to exist there. Adding an app language means adding its line to
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
لم يصل الإصداران 2.22.0 و2.22.1 إلى Google Play، لذا هذا أول إصدار 2.22 هناك. التطبيق مطابق للإصدار 2.22.1.
|
||||||
|
|
||||||
|
• يتنقّل اليوم والأسبوع والشهر بمُنزلق يتبع إصبعك.
|
||||||
|
• يعرض العرض متعدد الأيام عدداً قابلاً للتخصيص من الأيام جنباً إلى جنب.
|
||||||
|
• الساعات المرئية: يملأ نطاق ساعات تختاره الشاشة.
|
||||||
|
• يبرز اليوم في شبكة الشهر.
|
||||||
|
• يُفتح الرابط في موقع الحدث في المتصفح.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
عاد اسم صفحة المتجر إلى "Calendula: التقويم". لم يتغير شيء في التطبيق.
|
||||||
@@ -1 +1 @@
|
|||||||
Calendula
|
Calendula: التقويم
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Вэрсіі 2.22.0 і 2.22.1 не трапілі ў Google Play, таму гэта першая вэрсія 2.22 там. Праграма такая ж, як 2.22.1.
|
||||||
|
|
||||||
|
• Дзень, Тыдзень і Месяц перагортваюцца пэйджарам, які сочыць за пальцам.
|
||||||
|
• Шматдзённы выгляд паказвае наладжвальную колькасць дзён побач.
|
||||||
|
• Бачныя гадзіны: абраны дыяпазон гадзін запаўняе экран.
|
||||||
|
• Сёньня вылучаецца ў сетцы месяца.
|
||||||
|
• Спасылка ў месцы падзеі адкрываецца ў браўзеры.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Старонка ў краме зноў называецца «Calendula: Каляндар». Праграма не змянілася.
|
||||||
@@ -1 +1 @@
|
|||||||
Calendula
|
Calendula: Каляндар
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Verze 2.22.0 a 2.22.1 se na Google Play nedostaly, takže tohle je tam první verze 2.22. Aplikace je stejná jako 2.22.1.
|
||||||
|
|
||||||
|
• Den, Týden a Měsíc listují stránkovačem, který sleduje prst.
|
||||||
|
• Vícedenní zobrazení ukazuje nastavitelný počet dní vedle sebe.
|
||||||
|
• Viditelné hodiny: zvolený rozsah hodin vyplní obrazovku.
|
||||||
|
• Dnes vyniká v mřížce měsíce.
|
||||||
|
• Odkaz v místě události se otevře v prohlížeči.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Stránka v obchodě se opět jmenuje „Calendula: Kalendář“. Aplikace se nezměnila.
|
||||||
@@ -1 +1 @@
|
|||||||
Calendula
|
Calendula: Kalendář
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Google Play hat 2.22.0 und 2.22.1 verpasst, deshalb ist das dort die erste 2.22-Version. Die App ist dieselbe wie 2.22.1.
|
||||||
|
|
||||||
|
• Tag, Woche und Monat lassen sich mit einem fingerfolgenden Pager wischen.
|
||||||
|
• Mehrtagesansicht: einstellbare Anzahl Tage nebeneinander.
|
||||||
|
• Sichtbare Stunden: ein gewählter Stundenbereich füllt den Bildschirm.
|
||||||
|
• Heute hebt sich im Monatsraster ab.
|
||||||
|
• Ein Link im Ort eines Termins öffnet sich im Browser.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Der Store-Eintrag heißt wieder „Calendula: Kalender“. An der App hat sich nichts geändert.
|
||||||
@@ -1 +1 @@
|
|||||||
Calendula
|
Calendula: Kalender
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Google Play missed 2.22.0 and 2.22.1, so this is the first 2.22 release there. The app is the same as 2.22.1.
|
||||||
|
|
||||||
|
• Day, Week and Month follow your finger when you swipe between dates.
|
||||||
|
• A multi-day view shows a set number of days side by side.
|
||||||
|
• Visible hours fits the hours you choose to the screen.
|
||||||
|
• Today stands out in the month grid.
|
||||||
|
• A link in an event's location opens in the browser.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
The store listing is called "Calendula: Calendar" again. Nothing in the app changed.
|
||||||
@@ -1 +1 @@
|
|||||||
Calendula
|
Calendula: Calendar
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
2.22.0 y 2.22.1 no llegaron a Google Play, así que esta es la primera versión 2.22 allí. La app es la misma que 2.22.1.
|
||||||
|
|
||||||
|
• Día, Semana y Mes se deslizan con un paginador que sigue tu dedo.
|
||||||
|
• Vista de varios días: muestra un número configurable de días uno junto a otro.
|
||||||
|
• Horas visibles: un rango de horas elegido llena la pantalla.
|
||||||
|
• Hoy destaca en la cuadrícula del mes.
|
||||||
|
• Un enlace en la ubicación de un evento se abre en el navegador.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
La ficha de la tienda vuelve a llamarse «Calendula: Calendario». La app no cambia.
|
||||||
@@ -1 +1 @@
|
|||||||
Calendula
|
Calendula: Calendario
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Les versions 2.22.0 et 2.22.1 ne sont pas arrivées sur Google Play ; voici donc la première version 2.22 sur Play. L'app est identique à la 2.22.1.
|
||||||
|
|
||||||
|
• Jour, Semaine et Mois défilent avec un pager qui suit le doigt.
|
||||||
|
• Vue multi-jours : affiche un nombre de jours réglable, côte à côte.
|
||||||
|
• Heures visibles : une plage d'heures choisie remplit l'écran.
|
||||||
|
• Aujourd'hui ressort dans la grille du mois.
|
||||||
|
• Un lien dans le lieu d'un événement s'ouvre dans le navigateur.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
La fiche du store s'appelle de nouveau « Calendula: Calendrier ». L'app ne change pas.
|
||||||
@@ -1 +1 @@
|
|||||||
Calendula
|
Calendula: Calendrier
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
A 2.22.0 és a 2.22.1 nem jutott el a Google Playre, így ez az első 2.22-es verzió ott. Az alkalmazás ugyanaz, mint a 2.22.1.
|
||||||
|
|
||||||
|
• A Nap, Hét és Hónap nézet ujjkövető lapozóval lapozható.
|
||||||
|
• A többnapos nézet beállítható számú napot jelenít meg egymás mellett.
|
||||||
|
• Látható órák: a kiválasztott óratartomány kitölti a képernyőt.
|
||||||
|
• A mai nap kiemelkedik a havi rácsban.
|
||||||
|
• Az esemény helyszínében lévő link a böngészőben nyílik meg.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Az áruházi oldal neve ismét „Calendula: Naptár”. Az alkalmazás nem változott.
|
||||||
@@ -1 +1 @@
|
|||||||
Calendula
|
Calendula: Naptár
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Le versioni 2.22.0 e 2.22.1 non sono arrivate su Google Play, quindi questa è la prima versione 2.22 lì. L'app è la stessa della 2.22.1.
|
||||||
|
|
||||||
|
• Giorno, Settimana e Mese scorrono con un pager che segue il dito.
|
||||||
|
• Vista multi-giorno: mostra un numero configurabile di giorni affiancati.
|
||||||
|
• Ore visibili: un intervallo di ore scelto riempie lo schermo.
|
||||||
|
• Oggi risalta nella griglia del mese.
|
||||||
|
• Un link nel luogo di un evento si apre nel browser.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
La scheda dello store si chiama di nuovo «Calendula: Calendario». L'app non cambia.
|
||||||
@@ -1 +1 @@
|
|||||||
Calendula
|
Calendula: Calendario
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
2.22.0と2.22.1はGoogle Playに配信されなかったため、これがGoogle Playでの最初の2.22です。アプリの内容は2.22.1と同じです。
|
||||||
|
|
||||||
|
• 日・週・月ビューが指に追従するページャーでめくれます。
|
||||||
|
• マルチデイビューで、設定可能な日数を横に並べて表示します。
|
||||||
|
• 表示時間帯: 選んだ時間の範囲が画面いっぱいに表示されます。
|
||||||
|
• 月表示で今日が目立ちます。
|
||||||
|
• 予定の場所にあるリンクはブラウザで開きます。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ストアの掲載名が再び「Calendula: カレンダー」になりました。アプリ自体に変更はありません。
|
||||||
@@ -1 +1 @@
|
|||||||
Calendula
|
Calendula: カレンダー
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
2.22.0 en 2.22.1 zijn nooit op Google Play verschenen, dus dit is daar de eerste 2.22-versie. De app is gelijk aan 2.22.1.
|
||||||
|
|
||||||
|
• Dag, Week en Maand blader je door met een pager die je vinger volgt.
|
||||||
|
• Een meerdaagse weergave toont een instelbaar aantal dagen naast elkaar.
|
||||||
|
• Zichtbare uren: een gekozen tijdsbereik vult het scherm.
|
||||||
|
• Vandaag valt op in het maandraster.
|
||||||
|
• Een link in de locatie van een afspraak opent in de browser.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
De winkelvermelding heet weer "Calendula: Agenda". Aan de app is niets veranderd.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Calendula: Agenda
|
||||||
@@ -1 +0,0 @@
|
|||||||
Calendula
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Wersje 2.22.0 i 2.22.1 nie trafiły do Google Play, więc to pierwsza wersja 2.22 w tym sklepie. Aplikacja jest taka sama jak 2.22.1.
|
||||||
|
|
||||||
|
• Dzień, Tydzień i Miesiąc przewijają się pagerem, który podąża za palcem.
|
||||||
|
• Widok wielodniowy pokazuje ustawialną liczbę dni obok siebie.
|
||||||
|
• Widoczne godziny: wybrany zakres godzin wypełnia ekran.
|
||||||
|
• Dziś wyróżnia się w siatce miesiąca.
|
||||||
|
• Link w miejscu wydarzenia otwiera się w przeglądarce.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Strona w sklepie znów nazywa się „Calendula: Kalendarz”. Aplikacja się nie zmieniła.
|
||||||
@@ -1 +1 @@
|
|||||||
Calendula
|
Calendula: Kalendarz
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
As versões 2.22.0 e 2.22.1 não chegaram ao Google Play, então esta é a primeira versão 2.22 lá. O app é o mesmo da 2.22.1.
|
||||||
|
|
||||||
|
• Dia, Semana e Mês avançam com um pager que acompanha o dedo.
|
||||||
|
• Visualização de vários dias: mostra um número configurável de dias lado a lado.
|
||||||
|
• Horas visíveis: um intervalo de horas escolhido preenche a tela.
|
||||||
|
• Hoje se destaca na grade do mês.
|
||||||
|
• Um link no local de um evento abre no navegador.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
A página da loja voltou a se chamar "Calendula: Calendário". O app não mudou.
|
||||||
@@ -1 +1 @@
|
|||||||
Calendula
|
Calendula: Calendário
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
As versões 2.22.0 e 2.22.1 não chegaram ao Google Play, por isso esta é a primeira versão 2.22 lá. A app é a mesma da 2.22.1.
|
||||||
|
|
||||||
|
• Dia, Semana e Mês deslizam com um pager que acompanha o dedo.
|
||||||
|
• Vista de vários dias: mostra um número configurável de dias lado a lado.
|
||||||
|
• Horas visíveis: um intervalo de horas escolhido preenche o ecrã.
|
||||||
|
• Hoje destaca-se na grelha do mês.
|
||||||
|
• Uma ligação no local de um evento abre no navegador.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
A página da loja volta a chamar-se "Calendula: Calendário". A app não mudou.
|
||||||
@@ -1 +1 @@
|
|||||||
Calendula
|
Calendula: Calendário
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Версии 2.22.0 и 2.22.1 не попали в Google Play, поэтому это первая версия 2.22 там. Приложение то же, что и 2.22.1.
|
||||||
|
|
||||||
|
• День, Неделя и Месяц листаются пейджером, который следует за пальцем.
|
||||||
|
• Многодневный вид показывает настраиваемое число дней рядом друг с другом.
|
||||||
|
• Видимые часы: выбранный диапазон часов заполняет экран.
|
||||||
|
• Сегодня выделяется в сетке месяца.
|
||||||
|
• Ссылка в месте события открывается в браузере.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Страница в магазине снова называется «Calendula: Календарь». Приложение не изменилось.
|
||||||
@@ -1 +1 @@
|
|||||||
Calendula
|
Calendula: Календарь
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Verzie 2.22.0 a 2.22.1 sa na Google Play nedostali, takže toto je tam prvá verzia 2.22. Aplikácia je rovnaká ako 2.22.1.
|
||||||
|
|
||||||
|
• Deň, Týždeň a Mesiac sa listujú stránkovačom, ktorý sleduje prst.
|
||||||
|
• Viacdňové zobrazenie ukazuje nastaviteľný počet dní vedľa seba.
|
||||||
|
• Viditeľné hodiny: zvolený rozsah hodín vyplní obrazovku.
|
||||||
|
• Dnešok vyniká v mriežke mesiaca.
|
||||||
|
• Odkaz v mieste udalosti sa otvorí v prehliadači.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Stránka v obchode sa opäť volá „Calendula: Kalendár“. Aplikácia sa nezmenila.
|
||||||
@@ -1 +1 @@
|
|||||||
Calendula
|
Calendula: Kalendár
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
2.22.0 和 2.22.1 未能发布到 Google Play,因此这是 Google Play 上的第一个 2.22 版本。应用内容与 2.22.1 相同。
|
||||||
|
|
||||||
|
• 日、周、月视图可以用跟手的分页手势滑动切换。
|
||||||
|
• 多日视图并排显示可自定义天数。
|
||||||
|
• 可见时段:选定的时间范围会铺满整个屏幕。
|
||||||
|
• “今天”在月视图网格中突出显示。
|
||||||
|
• 日程地点中的链接会在浏览器中打开。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
商店页面名称恢复为“Calendula: 日历”。应用本身没有变化。
|
||||||
@@ -1 +1 @@
|
|||||||
Calendula
|
Calendula: 日历
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ fr fr-FR
|
|||||||
hu hu-HU
|
hu hu-HU
|
||||||
it it-IT
|
it it-IT
|
||||||
ja ja-JP
|
ja ja-JP
|
||||||
nl nl
|
nl nl-NL
|
||||||
pl pl-PL
|
pl pl-PL
|
||||||
pt pt-PT
|
pt pt-PT
|
||||||
pt-rBR pt-BR
|
pt-rBR pt-BR
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
#This file is generated by updateDaemonJvm
|
#This file is generated by updateDaemonJvm
|
||||||
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
|
toolchainUrl.FREE_BSD.AARCH64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-aarch64-b1163.116.tar.gz
|
||||||
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
|
toolchainUrl.FREE_BSD.X86_64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-x64-b1163.116.tar.gz
|
||||||
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
|
toolchainUrl.LINUX.AARCH64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-aarch64-b1163.116.tar.gz
|
||||||
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
|
toolchainUrl.LINUX.X86_64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-x64-b1163.116.tar.gz
|
||||||
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
|
toolchainUrl.MAC_OS.AARCH64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-osx-aarch64-b1163.116.tar.gz
|
||||||
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
|
toolchainUrl.MAC_OS.X86_64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-osx-x64-b1163.116.tar.gz
|
||||||
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
|
toolchainUrl.UNIX.AARCH64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-aarch64-b1163.116.tar.gz
|
||||||
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
|
toolchainUrl.UNIX.X86_64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-linux-x64-b1163.116.tar.gz
|
||||||
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
|
toolchainUrl.WINDOWS.AARCH64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-windows-aarch64-b1163.116.tar.gz
|
||||||
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
|
toolchainUrl.WINDOWS.X86_64=https\://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.11-windows-x64-b1163.116.tar.gz
|
||||||
toolchainVendor=JETBRAINS
|
toolchainVendor=JETBRAINS
|
||||||
toolchainVersion=21
|
toolchainVersion=21
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ coreKtx = "1.19.0"
|
|||||||
appcompat = "1.7.1"
|
appcompat = "1.7.1"
|
||||||
lifecycleRuntime = "2.11.0"
|
lifecycleRuntime = "2.11.0"
|
||||||
activityCompose = "1.13.0"
|
activityCompose = "1.13.0"
|
||||||
composeBom = "2026.06.01"
|
composeBom = "2026.09.00"
|
||||||
# Material 3 Expressive APIs currently live only in the 1.5 alpha line.
|
# Material 3 Expressive APIs currently live only in the 1.5 alpha line.
|
||||||
# Pin explicitly to override the BOM (which ships stable 1.4.0).
|
# Pin explicitly to override the BOM (which ships stable 1.4.0).
|
||||||
# Re-evaluate when 1.5.0 stable lands.
|
# Re-evaluate when 1.5.0 stable lands.
|
||||||
material3 = "1.5.0-alpha26"
|
material3 = "1.5.0-alpha29"
|
||||||
datastore = "1.2.1"
|
datastore = "1.2.1"
|
||||||
junit = "6.1.3"
|
junit = "6.1.3"
|
||||||
junitPlatform = "6.1.3"
|
junitPlatform = "6.1.3"
|
||||||
@@ -22,10 +22,10 @@ kotlinxDatetime = "0.7.0"
|
|||||||
kotlinxCoroutines = "1.11.0"
|
kotlinxCoroutines = "1.11.0"
|
||||||
turbine = "1.2.1"
|
turbine = "1.2.1"
|
||||||
hiltNavigationCompose = "1.4.0"
|
hiltNavigationCompose = "1.4.0"
|
||||||
lifecycleCompose = "2.10.0"
|
lifecycleCompose = "2.11.0"
|
||||||
androidxTestRules = "1.7.0"
|
androidxTestRules = "1.7.0"
|
||||||
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
|
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
|
||||||
glance = "1.1.1"
|
glance = "1.2.0"
|
||||||
work = "2.11.2"
|
work = "2.11.2"
|
||||||
documentfile = "1.1.0"
|
documentfile = "1.1.0"
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -52,9 +52,16 @@
|
|||||||
prConcurrentLimit: 5,
|
prConcurrentLimit: 5,
|
||||||
prHourlyLimit: 0,
|
prHourlyLimit: 0,
|
||||||
|
|
||||||
// Cadence is owned by the Gitea Actions cron (.gitea/workflows/renovate.yml,
|
// New branches/PRs only in the Monday window (UTC), which brackets the weekly
|
||||||
// Mondays) — no internal `schedule` here, so the two don't double-gate and
|
// cron in .gitea/workflows/renovate.yml with slack for a late start — keep
|
||||||
// silently skip a run.
|
// the two in step, or the sweep lands outside the window and finds nothing.
|
||||||
|
// The same workflow also runs on every push to main; those runs fall outside
|
||||||
|
// the window and, via the default updateNotScheduled: true, only maintain
|
||||||
|
// existing branches — chiefly rebasing PRs a merge just conflicted.
|
||||||
|
schedule: ["* 4-6 * * 1"],
|
||||||
|
// Rebase only on conflict, not every time main moves (automerge is off, so
|
||||||
|
// this matches "auto"; spelled out because the on-merge trigger relies on it).
|
||||||
|
rebaseWhen: "conflicted",
|
||||||
|
|
||||||
// Gitea Actions workflows live under .gitea/workflows, not .github — extend
|
// Gitea Actions workflows live under .gitea/workflows, not .github — extend
|
||||||
// the github-actions manager (same syntax) to watch them too.
|
// the github-actions manager (same syntax) to watch them too.
|
||||||
|
|||||||
@@ -28,10 +28,11 @@ LIMIT=500
|
|||||||
STRICT=0
|
STRICT=0
|
||||||
[ "${1:-}" = "--strict" ] && STRICT=1
|
[ "${1:-}" = "--strict" ] && STRICT=1
|
||||||
|
|
||||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
VERSION=$(bash scripts/version_info.sh version)
|
||||||
[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; }
|
VERSION_CODE=$(bash scripts/version_info.sh version_code)
|
||||||
MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
|
# A beta ships no What's New, so only the older files are checked.
|
||||||
VERSION_CODE=$(( ${MAJOR:-0} * 10000 + ${MINOR:-0} * 100 + ${PATCH:-0} ))
|
BETA=0
|
||||||
|
[ "$(bash scripts/version_info.sh channel)" = beta ] && BETA=1
|
||||||
|
|
||||||
fail=0
|
fail=0
|
||||||
warned=0
|
warned=0
|
||||||
@@ -65,7 +66,7 @@ for f in fastlane/metadata/android/*/changelogs/*.txt; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ "$current" -eq 0 ]; then
|
if [ "$current" -eq 0 ] && [ "$BETA" -eq 0 ]; then
|
||||||
echo "ERROR: no changelog for version $VERSION (code $VERSION_CODE)." >&2
|
echo "ERROR: no changelog for version $VERSION (code $VERSION_CODE)." >&2
|
||||||
echo " Write fastlane/metadata/android/en-US/changelogs/$VERSION_CODE.txt" >&2
|
echo " Write fastlane/metadata/android/en-US/changelogs/$VERSION_CODE.txt" >&2
|
||||||
echo " before releasing — see docs/RELEASING.md step 3." >&2
|
echo " before releasing — see docs/RELEASING.md step 3." >&2
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ fail the upload after the release has shipped everywhere else.
|
|||||||
|
|
||||||
- every locale directory is listed in fastlane/store-locales.txt, and every
|
- every locale directory is listed in fastlane/store-locales.txt, and every
|
||||||
app language (res/values-*) has an entry there and a directory
|
app language (res/values-*) has an entry there and a directory
|
||||||
|
- every store code is a language Play accepts (supply's ALL_LANGUAGES)
|
||||||
- title / short / full description exist, are non-empty and within Play's caps,
|
- title / short / full description exist, are non-empty and within Play's caps,
|
||||||
and the full description isn't hard-wrapped
|
and the full description isn't hard-wrapped
|
||||||
- icon: 512x512 32-bit PNG; featureGraphic: 1024x500 JPEG or 24-bit PNG
|
- icon: 512x512 32-bit PNG; featureGraphic: 1024x500 JPEG or 24-bit PNG
|
||||||
@@ -29,6 +30,16 @@ SCREENSHOT_DIRS = {"phoneScreenshots", "sevenInchScreenshots", "tenInchScreensho
|
|||||||
"tvScreenshots", "wearScreenshots"}
|
"tvScreenshots", "wearScreenshots"}
|
||||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg"}
|
IMAGE_EXTS = {".png", ".jpg", ".jpeg"}
|
||||||
|
|
||||||
|
# fastlane supply/lib/supply/languages.rb, with `_` back to `-`.
|
||||||
|
PLAY_LOCALES = set("""
|
||||||
|
af am ar az-AZ be bg bn-BD ca cs-CZ da-DK de-DE el-GR en-AU en-CA en-GB en-IN
|
||||||
|
en-SG en-US en-ZA es-419 es-ES es-US et eu-ES fa fi-FI fil fr-CA fr-FR gl-ES
|
||||||
|
hi-IN hr hu-HU hy-AM id is-IS it-IT iw-IL ja-JP ka-GE km-KH kn-IN ko-KR ky-KG
|
||||||
|
lo-LA lt lv mk-MK ml-IN mn-MN mr-IN ms ms-MY my-MM ne-NP nl-NL no-NO pl-PL
|
||||||
|
pt-BR pt-PT rm ro ru-RU si-LK sk sl sr sv-SE sw ta-IN te-IN th tr-TR uk vi
|
||||||
|
zh-CN zh-HK zh-TW zu
|
||||||
|
""".split())
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
|
|
||||||
@@ -80,6 +91,10 @@ def check_mapping():
|
|||||||
for q in sorted(mapping.keys() - app_langs):
|
for q in sorted(mapping.keys() - app_langs):
|
||||||
error(f"{rel(MAPPING)} maps '{q}' but the app has no values-{q}")
|
error(f"{rel(MAPPING)} maps '{q}' but the app has no values-{q}")
|
||||||
|
|
||||||
|
for q, store in sorted(mapping.items()):
|
||||||
|
if store not in PLAY_LOCALES:
|
||||||
|
error(f"{rel(MAPPING)} maps '{q}' to '{store}', which Play doesn't accept as a listing language")
|
||||||
|
|
||||||
stores = set(mapping.values())
|
stores = set(mapping.values())
|
||||||
dirs = {d.name for d in META.iterdir() if d.is_dir()}
|
dirs = {d.name for d in META.iterdir() if d.is_dir()}
|
||||||
for d in sorted(dirs - stores):
|
for d in sorted(dirs - stores):
|
||||||
|
|||||||
Executable
+80
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Publishes TAG on Codeberg with the signed APK and its SHA-256 attached.
|
||||||
|
# Upserts, so re-runs are safe. A missing TOKEN skips a stable release but fails
|
||||||
|
# a pre-release, where Codeberg is the only channel.
|
||||||
|
#
|
||||||
|
# Env: TOKEN, API (.../api/v1/repos/<owner>/<repo>), TAG, SHA, PRERELEASE,
|
||||||
|
# NOTES_FILE, APK
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${TOKEN:-}" ]; then
|
||||||
|
if [ "${PRERELEASE:-}" = true ]; then
|
||||||
|
echo "CODEBERG_RELEASE_TOKEN not set — a pre-release has nowhere else to go." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
: "${API:?}" "${TAG:?}" "${SHA:?}" "${PRERELEASE:?}" "${NOTES_FILE:?}" "${APK:?}"
|
||||||
|
if [ ! -f "$APK" ]; then echo "No release APK at $APK." >&2; exit 1; fi
|
||||||
|
|
||||||
|
WORK=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
|
ASSET_APK="calendula_${TAG}.apk"
|
||||||
|
ASSET_SUM="${ASSET_APK}.sha256"
|
||||||
|
cp "$APK" "$WORK/$ASSET_APK"
|
||||||
|
( cd "$WORK" && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
|
||||||
|
|
||||||
|
# Push the tag first and create the release without target_commitish: a POST
|
||||||
|
# naming a commit Codeberg hasn't received yet 500s.
|
||||||
|
HOST=${API#https://}; HOST=${HOST%%/*}
|
||||||
|
REPO=${API#*/repos/}
|
||||||
|
git tag -f "$TAG" "$SHA"
|
||||||
|
git push -f "https://${REPO%%/*}:${TOKEN}@${HOST}/${REPO}.git" "refs/tags/$TAG"
|
||||||
|
|
||||||
|
python3 - "$TAG" "$PRERELEASE" "$NOTES_FILE" <<'PY' > "$WORK/payload.json"
|
||||||
|
import json, sys
|
||||||
|
tag, pre, notes = sys.argv[1:4]
|
||||||
|
print(json.dumps({
|
||||||
|
"tag_name": tag,
|
||||||
|
"name": tag,
|
||||||
|
"body": open(notes).read(),
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": pre == "true",
|
||||||
|
}))
|
||||||
|
PY
|
||||||
|
|
||||||
|
# Codeberg 500s on a freshly pushed tag for a few seconds, hence the retries.
|
||||||
|
ID=""
|
||||||
|
for attempt in 1 2 3 4 5 6; do
|
||||||
|
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
|
||||||
|
if [ -n "$EXIST" ]; then
|
||||||
|
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X PATCH \
|
||||||
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d @"$WORK/payload.json" "$API/releases/$EXIST" || echo 000)
|
||||||
|
echo "release PATCH HTTP $CODE"
|
||||||
|
if [ "$CODE" = 200 ]; then ID="$EXIST"; break; fi
|
||||||
|
else
|
||||||
|
CODE=$(curl -s -o "$WORK/response.json" -w '%{http_code}' -X POST \
|
||||||
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d @"$WORK/payload.json" "$API/releases" || echo 000)
|
||||||
|
echo "release POST attempt $attempt HTTP $CODE"
|
||||||
|
ID=$(jq -r '.id // empty' "$WORK/response.json" 2>/dev/null || true)
|
||||||
|
[ -n "$ID" ] && break
|
||||||
|
fi
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
if [ -z "$ID" ]; then echo "Could not create or update the Codeberg release." >&2; exit 1; fi
|
||||||
|
|
||||||
|
for A in "$ASSET_APK" "$ASSET_SUM"; do
|
||||||
|
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
||||||
|
| jq -r --arg n "$A" '.[]? | select(.name==$n) | .id' 2>/dev/null || true)
|
||||||
|
for O in $OLD; do
|
||||||
|
curl -s -o /dev/null -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$O" || true
|
||||||
|
done
|
||||||
|
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: token $TOKEN" \
|
||||||
|
-F "attachment=@$WORK/$A" "$API/releases/$ID/assets?name=$A" || echo 000)
|
||||||
|
echo "asset $A HTTP $CODE"
|
||||||
|
if [ "$CODE" != 201 ]; then echo "Uploading $A failed." >&2; exit 1; fi
|
||||||
|
done
|
||||||
|
echo "Published $TAG to Codeberg."
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Upserts the Gitea release for TAG (creating the tag at SHA) and attaches the
|
||||||
|
# R8 mapping, best-effort.
|
||||||
|
#
|
||||||
|
# Env: TOKEN, API (.../api/v1/repos/<owner>/<repo>), TAG, SHA, PRERELEASE,
|
||||||
|
# NOTES_FILE, MAPPING (optional)
|
||||||
|
set -euo pipefail
|
||||||
|
: "${TOKEN:?}" "${API:?}" "${TAG:?}" "${SHA:?}" "${PRERELEASE:?}" "${NOTES_FILE:?}"
|
||||||
|
|
||||||
|
WORK=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
|
python3 - "$TAG" "$SHA" "$PRERELEASE" "$NOTES_FILE" <<'PY' > "$WORK/payload.json"
|
||||||
|
import json, sys
|
||||||
|
tag, sha, pre, notes = sys.argv[1:5]
|
||||||
|
print(json.dumps({
|
||||||
|
"tag_name": tag,
|
||||||
|
"target_commitish": sha,
|
||||||
|
"name": tag,
|
||||||
|
"body": open(notes).read(),
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": pre == "true",
|
||||||
|
}))
|
||||||
|
PY
|
||||||
|
|
||||||
|
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
|
||||||
|
if [ -n "$ID" ]; then
|
||||||
|
CODE=$(curl -s -o "$WORK/response.json" -w '%{http_code}' -X PATCH \
|
||||||
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d @"$WORK/payload.json" "$API/releases/$ID")
|
||||||
|
OK=200
|
||||||
|
else
|
||||||
|
CODE=$(curl -s -o "$WORK/response.json" -w '%{http_code}' -X POST \
|
||||||
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d @"$WORK/payload.json" "$API/releases")
|
||||||
|
OK=201
|
||||||
|
fi
|
||||||
|
cat "$WORK/response.json"; echo
|
||||||
|
if [ "$CODE" != "$OK" ]; then
|
||||||
|
echo "Gitea release upsert failed with HTTP $CODE (expected $OK)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ID=$(jq -r '.id' "$WORK/response.json")
|
||||||
|
echo "Created/updated Gitea release $TAG at $SHA"
|
||||||
|
|
||||||
|
attach_mapping() {
|
||||||
|
local asset="mapping-${TAG#v}.txt.gz" old
|
||||||
|
gzip -c "$MAPPING" > "$WORK/$asset"
|
||||||
|
old=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
||||||
|
| jq -r --arg n "$asset" '.[] | select(.name==$n) | .id')
|
||||||
|
if [ -n "$old" ]; then
|
||||||
|
curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$old" >/dev/null
|
||||||
|
fi
|
||||||
|
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||||
|
-F "attachment=@$WORK/$asset" \
|
||||||
|
"$API/releases/$ID/assets?name=$asset" -o /dev/null -w "asset $asset HTTP %{http_code}\n"
|
||||||
|
}
|
||||||
|
if [ -z "${MAPPING:-}" ] || [ ! -f "$MAPPING" ]; then
|
||||||
|
echo "No mapping.txt (R8 off?) — skipping."
|
||||||
|
else
|
||||||
|
attach_mapping || echo "warning: could not attach the R8 mapping to $TAG" >&2
|
||||||
|
fi
|
||||||
Executable
+59
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Decides whether the committed version still needs publishing; prints cut=true|false.
|
||||||
|
#
|
||||||
|
# stable: no vX.Y.Z tag on Codeberg yet.
|
||||||
|
# beta: its Codeberg pre-release doesn't carry the APK yet, so a failed
|
||||||
|
# publish is redone by the next push. Refused unless it is newer than
|
||||||
|
# every shipped stable release.
|
||||||
|
#
|
||||||
|
# Fails on any lookup error rather than guessing.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.." # repo root
|
||||||
|
|
||||||
|
REPO=${REPO:-jlmakiola/calendula}
|
||||||
|
VERSION=$(bash scripts/version_info.sh version)
|
||||||
|
BASE=$(bash scripts/version_info.sh base_version)
|
||||||
|
CHANNEL=$(bash scripts/version_info.sh channel)
|
||||||
|
|
||||||
|
# Exact tag names. The Codeberg git/refs/tags/<name> API matches by prefix.
|
||||||
|
TAGS=$(git ls-remote --tags --refs "https://codeberg.org/$REPO.git" | sed 's#.*refs/tags/##')
|
||||||
|
|
||||||
|
if [ "$CHANNEL" = stable ]; then
|
||||||
|
if grep -qxF "v$VERSION" <<<"$TAGS"; then
|
||||||
|
echo "Tag v$VERSION already exists on Codeberg — nothing to release." >&2
|
||||||
|
echo "cut=false"
|
||||||
|
else
|
||||||
|
echo "No tag for v$VERSION on Codeberg yet — cutting the release." >&2
|
||||||
|
echo "cut=true"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
LATEST=$(grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' <<<"$TAGS" | sed 's/^v//' | sort -V | tail -n1 || true)
|
||||||
|
if [ -n "$LATEST" ] && [ "$(printf '%s\n%s\n' "$BASE" "$LATEST" | sort -V | tail -n1)" = "$LATEST" ]; then
|
||||||
|
echo "Beta $VERSION is not newer than the shipped stable $LATEST — bump the version." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ASSET="calendula_v${VERSION}.apk"
|
||||||
|
BODY=$(mktemp); trap 'rm -f "$BODY"' EXIT
|
||||||
|
STATUS=$(curl -s -o "$BODY" -w '%{http_code}' "https://codeberg.org/api/v1/repos/$REPO/releases/tags/v$VERSION" || echo 000)
|
||||||
|
case "$STATUS" in
|
||||||
|
200)
|
||||||
|
if grep -qF "\"name\":\"$ASSET\"" "$BODY"; then
|
||||||
|
echo "Pre-release v$VERSION already carries $ASSET — nothing to do." >&2
|
||||||
|
echo "cut=false"
|
||||||
|
else
|
||||||
|
echo "Pre-release v$VERSION exists without $ASSET — publishing it again." >&2
|
||||||
|
echo "cut=true"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
404)
|
||||||
|
echo "No pre-release for v$VERSION yet — cutting the beta." >&2
|
||||||
|
echo "cut=true"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Codeberg release lookup for v$VERSION returned HTTP $STATUS — refusing to guess." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Prints the release notes for VERSION from CHANGELOG.md. A beta without its own
|
||||||
|
# section falls back to [Unreleased], under a line saying what a beta is.
|
||||||
|
#
|
||||||
|
# scripts/release_notes.sh 2.22.4 > release-notes.md
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.." # repo root
|
||||||
|
|
||||||
|
VERSION=${1:?usage: release_notes.sh VERSION}
|
||||||
|
|
||||||
|
section() {
|
||||||
|
awk -v ver="$1" '
|
||||||
|
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||||
|
/^## \[/ { flag = 0 }
|
||||||
|
flag' CHANGELOG.md | sed -e '/./,$!d'
|
||||||
|
}
|
||||||
|
|
||||||
|
NOTES=$(section "$VERSION")
|
||||||
|
if [[ "$VERSION" == *-beta.* ]]; then
|
||||||
|
[ -n "$NOTES" ] || NOTES=$(section Unreleased)
|
||||||
|
printf '%s\n\n' "**Beta of ${VERSION%%-*}, for testing.** It updates in place to later betas and to the stable release. Obtainium only offers betas with *Include prereleases* switched on; F-Droid and Play never get them."
|
||||||
|
fi
|
||||||
|
if [ -n "$NOTES" ]; then
|
||||||
|
printf '%s\n' "$NOTES"
|
||||||
|
else
|
||||||
|
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._"
|
||||||
|
fi
|
||||||
@@ -19,11 +19,15 @@ cd "$(dirname "$0")/.." # repo root
|
|||||||
|
|
||||||
LIMIT=500
|
LIMIT=500
|
||||||
|
|
||||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
VERSION=$(bash scripts/version_info.sh version)
|
||||||
[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; }
|
VERSION_CODE=$(bash scripts/version_info.sh version_code)
|
||||||
MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
|
|
||||||
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
# Betas only ship to Codeberg, whose notes come from CHANGELOG.md. A What's New
|
||||||
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
# file for one would sit in the tree F-Droid and Play read, so none is wanted.
|
||||||
|
if [ "$(bash scripts/version_info.sh channel)" = beta ]; then
|
||||||
|
echo "Beta $VERSION: no store What's New (betas only ship to Codeberg)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
CL_DIR="fastlane/metadata/android/en-US/changelogs"
|
CL_DIR="fastlane/metadata/android/en-US/changelogs"
|
||||||
mkdir -p "$CL_DIR"
|
mkdir -p "$CL_DIR"
|
||||||
|
|||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Derives versionCode, channel and pre-release flag from the committed versionName.
|
||||||
|
#
|
||||||
|
# X.Y.Z (<= 2.22.3) stable X*10000 + Y*100 + Z (legacy)
|
||||||
|
# X.Y.Z-beta.N beta X*1000000 + Y*10000 + Z*100 + N (N = 1..98)
|
||||||
|
# X.Y.Z (>= 2.22.4) stable X*1000000 + Y*10000 + Z*100 + 99
|
||||||
|
#
|
||||||
|
# scripts/version_info.sh all values as key=value lines
|
||||||
|
# scripts/version_info.sh <key> one value
|
||||||
|
# scripts/version_info.sh --check fail unless the committed versionCode matches
|
||||||
|
# scripts/version_info.sh --pin rewrite the committed versionCode to the derived one
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.." # repo root
|
||||||
|
|
||||||
|
GRADLE="app/build.gradle.kts"
|
||||||
|
NAME=$(grep -oP 'versionName\s*=\s*"\K[^"]+' "$GRADLE" || true)
|
||||||
|
COMMITTED=$(grep -oP 'versionCode\s*=\s*\K[0-9]+' "$GRADLE" || true)
|
||||||
|
[ -n "$NAME" ] || { echo "No versionName in $GRADLE" >&2; exit 1; }
|
||||||
|
|
||||||
|
if [[ ! "$NAME" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-beta\.([1-9][0-9]*))?$ ]]; then
|
||||||
|
echo "versionName '$NAME' is neither X.Y.Z nor X.Y.Z-beta.N" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
MAJOR=${BASH_REMATCH[1]}; MINOR=${BASH_REMATCH[2]}; PATCH=${BASH_REMATCH[3]}
|
||||||
|
BETA=${BASH_REMATCH[5]}
|
||||||
|
BASE="$MAJOR.$MINOR.$PATCH"
|
||||||
|
|
||||||
|
if [ "$MINOR" -gt 99 ] || [ "$PATCH" -gt 99 ]; then
|
||||||
|
echo "versionName '$NAME': MINOR and PATCH each have room for 0-99" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
LEGACY=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||||
|
if [ -n "$BETA" ]; then
|
||||||
|
if [ "$LEGACY" -lt 22204 ]; then
|
||||||
|
echo "versionName '$NAME': betas start at 2.22.4; legacy codes have no room for them" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$BETA" -lt 1 ] || [ "$BETA" -gt 98 ]; then
|
||||||
|
echo "versionName '$NAME': beta number must be 1-98 (99 is the stable release)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
CHANNEL=beta
|
||||||
|
CODE=$(( MAJOR * 1000000 + MINOR * 10000 + PATCH * 100 + BETA ))
|
||||||
|
elif [ "$LEGACY" -lt 22204 ]; then
|
||||||
|
CHANNEL=stable
|
||||||
|
CODE=$LEGACY
|
||||||
|
else
|
||||||
|
CHANNEL=stable
|
||||||
|
CODE=$(( MAJOR * 1000000 + MINOR * 10000 + PATCH * 100 + 99 ))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$CHANNEL" = beta ]; then PRERELEASE=true; else PRERELEASE=false; fi
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
"")
|
||||||
|
printf 'version=%s\nversion_code=%s\nbase_version=%s\nchannel=%s\nprerelease=%s\n' \
|
||||||
|
"$NAME" "$CODE" "$BASE" "$CHANNEL" "$PRERELEASE"
|
||||||
|
;;
|
||||||
|
--check)
|
||||||
|
if [ "$COMMITTED" != "$CODE" ]; then
|
||||||
|
echo "ERROR: $GRADLE has versionCode = ${COMMITTED:-<none>}, but versionName '$NAME' needs $CODE." >&2
|
||||||
|
echo "Set versionCode = $CODE (see docs/RELEASING.md for the scheme)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK: versionName $NAME -> versionCode $CODE ($CHANNEL)."
|
||||||
|
;;
|
||||||
|
--pin)
|
||||||
|
sed -i "s/versionCode = .*/versionCode = $CODE/" "$GRADLE"
|
||||||
|
grep -E 'versionName|versionCode' "$GRADLE"
|
||||||
|
;;
|
||||||
|
version) echo "$NAME" ;;
|
||||||
|
version_code) echo "$CODE" ;;
|
||||||
|
base_version) echo "$BASE" ;;
|
||||||
|
channel) echo "$CHANNEL" ;;
|
||||||
|
prerelease) echo "$PRERELEASE" ;;
|
||||||
|
*) echo "Unknown key '$1'" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Writes the app signing keystore and key.properties from CI secrets.
|
||||||
|
#
|
||||||
|
# Env: KEYSTORE_BASE64, KEY_PASSWORD, KEY_ALIAS
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.." # repo root
|
||||||
|
: "${KEYSTORE_BASE64:?}" "${KEY_PASSWORD:?}" "${KEY_ALIAS:?}"
|
||||||
|
|
||||||
|
mkdir -p app
|
||||||
|
echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks
|
||||||
|
cat > key.properties <<PROPS
|
||||||
|
storePassword=$KEY_PASSWORD
|
||||||
|
keyPassword=$KEY_PASSWORD
|
||||||
|
keyAlias=$KEY_ALIAS
|
||||||
|
storeFile=upload-keystore.jks
|
||||||
|
PROPS
|
||||||
Reference in New Issue
Block a user