Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89775e44a7 | ||
|
|
c0eb03e5df | ||
|
|
7bfda26de9 | ||
|
|
f65aed4fe2 | ||
|
|
84bb1116ec |
@@ -26,14 +26,14 @@ Which of these ran green, and anything you exercised by hand. On-device notes
|
|||||||
are especially useful for UI changes, and for anything touching the provider
|
are especially useful for UI changes, and for anything touching the provider
|
||||||
read/write paths (OpenTasks / tasks.org installed).
|
read/write paths (OpenTasks / tasks.org installed).
|
||||||
|
|
||||||
./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug
|
./gradlew lintFullDebug lintOfflineDebug :app:testFullDebugUnitTest :app:testOfflineDebugUnitTest :app:assembleDebug
|
||||||
python3 scripts/check_translations.py
|
python3 scripts/check_translations.py
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|
||||||
### Checklist
|
### Checklist
|
||||||
|
|
||||||
- [ ] `./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug` passes locally
|
- [ ] `./gradlew lintFullDebug lintOfflineDebug :app:testFullDebugUnitTest :app:testOfflineDebugUnitTest :app:assembleDebug` passes locally
|
||||||
- [ ] New domain logic comes with JVM unit tests under `app/src/test/`
|
- [ ] New domain logic comes with JVM unit tests under `app/src/test/`
|
||||||
- [ ] Provider details stay inside `data/tasks/`
|
- [ ] Provider details stay inside `data/tasks/`
|
||||||
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
|
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
|
||||||
|
|||||||
@@ -127,10 +127,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
|
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
|
||||||
|
# here keeps Gradle from downloading it via foojay.
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: 'jetbrains'
|
||||||
java-version: '17'
|
java-version: '21'
|
||||||
|
|
||||||
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
|
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
|
||||||
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
|
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
|
||||||
@@ -178,18 +180,18 @@ jobs:
|
|||||||
|
|
||||||
# No --no-daemon: the daemon lives only as long as this job container
|
# No --no-daemon: the daemon lives only as long as this job container
|
||||||
# and lets the following steps skip JVM startup + reconfiguration.
|
# and lets the following steps skip JVM startup + reconfiguration.
|
||||||
- name: Lint (debug variant only)
|
- name: Lint (debug variants only)
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: ./gradlew lintDebug
|
run: ./gradlew lintFullDebug lintOfflineDebug
|
||||||
|
|
||||||
# :dav is a plain JVM module, so it has no testDebugUnitTest — naming only
|
# :dav is a plain JVM module, so it has no testDebugUnitTest — naming only
|
||||||
# that task would compile the vendored suite and run none of it, which is
|
# that task would compile the vendored suite and run none of it, which is
|
||||||
# the whole safety argument in dav/PROVENANCE.md.
|
# the whole safety argument in dav/PROVENANCE.md.
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: ./gradlew testDebugUnitTest :dav:test :caldav:test
|
run: ./gradlew testFullDebugUnitTest testOfflineDebugUnitTest :dav:test :caldav:test
|
||||||
|
|
||||||
- name: Assemble debug APK
|
- name: Assemble debug APKs
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: ./gradlew assembleDebug
|
run: ./gradlew assembleDebug
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Translations
|
|||||||
|
|
||||||
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||||
# only touch values-*/strings.xml) get quick feedback without the full Android
|
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||||
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
# build. The deeper checks still run in CI via lintFullDebug (ExtraTranslation).
|
||||||
#
|
#
|
||||||
# Runs on every PR (no path filter) so the required "Translations / check"
|
# Runs on every PR (no path filter) so the required "Translations / check"
|
||||||
# status is always reported — like the `ci` job. A path-filtered workflow is
|
# status is always reported — like the `ci` job. A path-filtered workflow is
|
||||||
|
|||||||
+20
-45
@@ -3,7 +3,7 @@ name: Beta — Codeberg pre-release
|
|||||||
# A beta is cut by pushing a release branch whose committed versionName is
|
# A beta is cut by pushing a release branch whose committed versionName is
|
||||||
# X.Y.Z-beta.N (see docs/RELEASING.md). Same model as release.yaml: the
|
# X.Y.Z-beta.N (see docs/RELEASING.md). Same model as release.yaml: the
|
||||||
# committed version is the trigger and the vX.Y.Z-beta.N tag is an output. If
|
# committed version is the trigger and the vX.Y.Z-beta.N tag is an output. If
|
||||||
# no tag exists for that version yet, this runs the unit tests, builds and
|
# its Codeberg pre-release doesn't carry the APK yet, this runs the unit tests, builds and
|
||||||
# signs the APK with the app key, records a Gitea pre-release (with the R8
|
# signs the APK with the app key, records a Gitea pre-release (with the R8
|
||||||
# mapping) and publishes a Codeberg pre-release with the APK + SHA-256.
|
# mapping) and publishes a Codeberg pre-release with the APK + SHA-256.
|
||||||
#
|
#
|
||||||
@@ -36,53 +36,23 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Resolve version and whether it is a new beta
|
- name: Resolve version and whether it still needs publishing
|
||||||
id: v
|
id: v
|
||||||
env:
|
|
||||||
# Codeberg, not Gitea: see the same lookup in release.yaml.
|
|
||||||
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
INFO=$(bash scripts/version_info.sh)
|
INFO=$(bash scripts/version_info.sh)
|
||||||
echo "$INFO"
|
echo "$INFO"
|
||||||
echo "$INFO" >> "$GITHUB_OUTPUT"
|
echo "$INFO" >> "$GITHUB_OUTPUT"
|
||||||
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
|
if [ "$(bash scripts/version_info.sh channel)" != beta ]; then
|
||||||
BASE=$(echo "$INFO" | sed -n 's/^base_version=//p')
|
echo "Not a beta — nothing to do."
|
||||||
if [ "$(echo "$INFO" | sed -n 's/^channel=//p')" != "beta" ]; then
|
|
||||||
echo "versionName $VERSION is not a beta — nothing to do."
|
|
||||||
echo "is_beta=false" >> "$GITHUB_OUTPUT"
|
echo "is_beta=false" >> "$GITHUB_OUTPUT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
# Fatal on anything but a clean 200/404, as in release.yaml: guessing
|
# Done only once its Codeberg pre-release carries the APK, so a failed
|
||||||
# "no tag" during an outage would re-cut a published beta.
|
# publish is redone by the next push; refused unless newer than the
|
||||||
tag_status() {
|
# latest stable release.
|
||||||
curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/$1" || echo 000
|
GATE=$(bash scripts/release_gate.sh)
|
||||||
}
|
echo "is_beta=${GATE#cut=}" >> "$GITHUB_OUTPUT"
|
||||||
# A beta of a version that already shipped stable would carry a lower
|
|
||||||
# versionCode than the stable one: nobody could install it over it.
|
|
||||||
case "$(tag_status "v$BASE")" in
|
|
||||||
200)
|
|
||||||
echo "v$BASE already shipped as stable; a beta of it is pointless. Bump the version." >&2
|
|
||||||
exit 1 ;;
|
|
||||||
404) ;;
|
|
||||||
*) echo "Codeberg tag lookup for v$BASE failed — refusing to guess." >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
STATUS=$(tag_status "v$VERSION")
|
|
||||||
case "$STATUS" in
|
|
||||||
200)
|
|
||||||
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
|
||||||
echo "is_beta=false" >> "$GITHUB_OUTPUT"
|
|
||||||
;;
|
|
||||||
404)
|
|
||||||
echo "No tag for v$VERSION on Codeberg yet — cutting the beta."
|
|
||||||
echo "is_beta=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 published beta." >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
beta:
|
beta:
|
||||||
needs: detect
|
needs: detect
|
||||||
@@ -100,10 +70,12 @@ jobs:
|
|||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
|
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
|
||||||
|
# here keeps Gradle from downloading it via foojay.
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: 'jetbrains'
|
||||||
java-version: '17'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
@@ -156,7 +128,7 @@ jobs:
|
|||||||
grep -E 'versionName|versionCode' app/build.gradle.kts
|
grep -E 'versionName|versionCode' app/build.gradle.kts
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: ./gradlew testDebugUnitTest
|
run: ./gradlew testFullDebugUnitTest
|
||||||
|
|
||||||
# The real app key, same as a stable release: a beta has to update in
|
# The real app key, same as a stable release: a beta has to update in
|
||||||
# place to the next beta and to the stable version.
|
# place to the next beta and to the stable version.
|
||||||
@@ -175,7 +147,8 @@ jobs:
|
|||||||
storeFile=upload-keystore.jks
|
storeFile=upload-keystore.jks
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Build release APK
|
# Both flavors; the offline one goes to Codeberg only (issue #39).
|
||||||
|
- name: Build release APKs
|
||||||
run: ./gradlew assembleRelease
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
# Notes = a `## [X.Y.Z-beta.N]` section if there is one, else
|
# Notes = a `## [X.Y.Z-beta.N]` section if there is one, else
|
||||||
@@ -190,7 +163,8 @@ jobs:
|
|||||||
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||||
cat release-notes.md
|
cat release-notes.md
|
||||||
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
|
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
|
||||||
MAPPING=app/build/outputs/mapping/release/mapping.txt \
|
MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
|
||||||
|
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
|
||||||
bash scripts/publish_gitea_release.sh
|
bash scripts/publish_gitea_release.sh
|
||||||
|
|
||||||
# The point of the whole workflow, so NOT continue-on-error.
|
# The point of the whole workflow, so NOT continue-on-error.
|
||||||
@@ -202,5 +176,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
|
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
|
||||||
APK=app/build/outputs/apk/release/app-release.apk \
|
APK=app/build/outputs/apk/full/release/app-full-release.apk \
|
||||||
|
APK_OFFLINE=app/build/outputs/apk/offline/release/app-offline-release.apk \
|
||||||
bash scripts/publish_codeberg_release.sh
|
bash scripts/publish_codeberg_release.sh
|
||||||
|
|||||||
@@ -62,17 +62,6 @@ 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/agendula
|
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
# versionName -> versionCode, channel and the pre-release flag (set
|
# versionName -> versionCode, channel and the pre-release flag (set
|
||||||
@@ -94,30 +83,11 @@ jobs:
|
|||||||
echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
|
echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# A tag for this version already existing means the release shipped on
|
# Tags are read from Codeberg, the canonical forge, by exact name: its
|
||||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
# git/refs/tags API matches by prefix, so v1.1.0-beta.1 would read as
|
||||||
#
|
# v1.1.0. A lookup error is fatal rather than read as "no tag".
|
||||||
# Anything other than a clean 200/404 is treated as fatal rather than
|
GATE=$(bash scripts/release_gate.sh)
|
||||||
# as "no tag". A Codeberg outage or a network blip would otherwise
|
echo "is_release=${GATE#cut=}" >> "$GITHUB_OUTPUT"
|
||||||
# read as absent and re-cut a release that has already shipped —
|
|
||||||
# republishing to F-Droid. Failing here is recoverable; a duplicate
|
|
||||||
# release is not.
|
|
||||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
|
|
||||||
case "$STATUS" in
|
|
||||||
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
|
|
||||||
|
|
||||||
# Before a single Gradle task runs: F-Droid truncates the in-client
|
# Before a single Gradle task runs: F-Droid truncates the in-client
|
||||||
# changelog, so an over-long one would reach users cut off mid-sentence.
|
# changelog, so an over-long one would reach users cut off mid-sentence.
|
||||||
@@ -150,10 +120,12 @@ jobs:
|
|||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
|
# JetBrains 21 is what gradle-daemon-jvm.properties asks for; installing it
|
||||||
|
# here keeps Gradle from downloading it via foojay.
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: 'jetbrains'
|
||||||
java-version: '17'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
@@ -212,7 +184,7 @@ jobs:
|
|||||||
# 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
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
run: ./gradlew testDebugUnitTest
|
run: ./gradlew testFullDebugUnitTest
|
||||||
|
|
||||||
- name: Setup Android Keystore
|
- name: Setup Android Keystore
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
@@ -230,7 +202,9 @@ jobs:
|
|||||||
storeFile=upload-keystore.jks
|
storeFile=upload-keystore.jks
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Build release APK
|
# Both flavors. Only full goes to F-Droid and Play; the offline one
|
||||||
|
# (issue #39) is a Codeberg release asset and nothing else.
|
||||||
|
- name: Build release APKs
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
run: ./gradlew assembleRelease
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
@@ -286,7 +260,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
mkdir -p fdroid/repo
|
mkdir -p fdroid/repo
|
||||||
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
|
cp app/build/outputs/apk/full/release/app-full-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
|
||||||
|
|
||||||
# Per-version "What's New": ensure this version's changelog exists in the
|
# Per-version "What's New": ensure this version's changelog exists in the
|
||||||
# fastlane tree (committed at release-cut time for the official repo; this
|
# fastlane tree (committed at release-cut time for the official repo; this
|
||||||
@@ -348,7 +322,8 @@ jobs:
|
|||||||
set -e
|
set -e
|
||||||
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||||
TAG="v$VERSION" NOTES_FILE=release-notes.md \
|
TAG="v$VERSION" NOTES_FILE=release-notes.md \
|
||||||
MAPPING=app/build/outputs/mapping/release/mapping.txt \
|
MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \
|
||||||
|
MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \
|
||||||
bash scripts/publish_gitea_release.sh
|
bash scripts/publish_gitea_release.sh
|
||||||
|
|
||||||
# Publish the release on Codeberg, which is canonical for tags and
|
# Publish the release on Codeberg, which is canonical for tags and
|
||||||
@@ -371,7 +346,8 @@ jobs:
|
|||||||
set -e
|
set -e
|
||||||
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||||
TAG="v$VERSION" NOTES_FILE=release-notes.md \
|
TAG="v$VERSION" NOTES_FILE=release-notes.md \
|
||||||
APK=app/build/outputs/apk/release/app-release.apk \
|
APK=app/build/outputs/apk/full/release/app-full-release.apk \
|
||||||
|
APK_OFFLINE=app/build/outputs/apk/offline/release/app-offline-release.apk \
|
||||||
bash scripts/publish_codeberg_release.sh
|
bash scripts/publish_codeberg_release.sh
|
||||||
|
|
||||||
# Play takes an App Bundle, not the APK: a second artifact from the same
|
# Play takes an App Bundle, not the APK: a second artifact from the same
|
||||||
@@ -386,7 +362,7 @@ jobs:
|
|||||||
- name: Build release AAB
|
- name: Build release AAB
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: ./gradlew bundleRelease
|
run: ./gradlew bundleFullRelease
|
||||||
|
|
||||||
# NOT actions/upload-artifact@v4: its client refuses any non-github.com
|
# NOT actions/upload-artifact@v4: its client refuses any non-github.com
|
||||||
# server as unsupported GHES (go-gitea/gitea#36024). This fork drops that
|
# server as unsupported GHES (go-gitea/gitea#36024). This fork drops that
|
||||||
@@ -398,7 +374,7 @@ jobs:
|
|||||||
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
|
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
|
||||||
with:
|
with:
|
||||||
name: release-aab-${{ needs.detect.outputs.version }}
|
name: release-aab-${{ needs.detect.outputs.version }}
|
||||||
path: app/build/outputs/bundle/release/app-release.aab
|
path: app/build/outputs/bundle/fullRelease/app-full-release.aab
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
@@ -492,7 +468,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Absolute: a lane body runs from fastlane/, not the workspace root.
|
# Absolute: a lane body runs from fastlane/, not the workspace root.
|
||||||
AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
|
AAB="$GITHUB_WORKSPACE/dist/app-full-release.aab"
|
||||||
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
|
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
|
||||||
bundle exec fastlane deploy \
|
bundle exec fastlane deploy \
|
||||||
aab:"$AAB" \
|
aab:"$AAB" \
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ All notable changes to this project are documented here. The format follows
|
|||||||
- A widget can show tasks from several lists at once, optionally grouped by
|
- A widget can show tasks from several lists at once, optionally grouped by
|
||||||
list.
|
list.
|
||||||
- A setting under Settings → Task form pre-fills a new task's start with today.
|
- A setting under Settings → Task form pre-fills a new task's start with today.
|
||||||
|
- An offline version of Agendula with no sync and no network access at all,
|
||||||
|
published as a separate APK on Codeberg (#39).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- The Tasks widget has agenda-style rows, an optional row colour and an "All
|
- The Tasks widget has agenda-style rows, an optional row colour and an "All
|
||||||
|
|||||||
+3
-3
@@ -28,9 +28,9 @@ layer.
|
|||||||
## Build, test, lint
|
## Build, test, lint
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./gradlew :app:assembleDebug # build the debug APK
|
./gradlew :app:assembleDebug # build the debug APKs (full + offline)
|
||||||
./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
|
./gradlew :app:testFullDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
|
||||||
./gradlew lintDebug # Android lint (CI runs this on every PR)
|
./gradlew lintFullDebug lintOfflineDebug # Android lint (CI runs this on every PR)
|
||||||
```
|
```
|
||||||
|
|
||||||
CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
|
CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check,
|
||||||
|
|||||||
@@ -119,9 +119,21 @@ APK and a `.sha256` checksum attached — the same APK the F-Droid repository
|
|||||||
serves. For automatic updates from there, use
|
serves. For automatic updates from there, use
|
||||||
**[Obtainium](https://github.com/ImranR98/Obtainium)** and
|
**[Obtainium](https://github.com/ImranR98/Obtainium)** and
|
||||||
**[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**.
|
**[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**.
|
||||||
|
Each release carries two APKs (see [Offline version](#offline-version)), so set
|
||||||
|
*Filter APKs by regular expression* to `^agendula_v` to always get the regular
|
||||||
|
app.
|
||||||
Betas of upcoming versions are published there too, as pre-releases; to test
|
Betas of upcoming versions are published there too, as pre-releases; to test
|
||||||
them, switch on *Include prereleases* for Agendula in Obtainium.
|
them, switch on *Include prereleases* for Agendula in Obtainium.
|
||||||
|
|
||||||
|
### Offline version
|
||||||
|
|
||||||
|
Each Codeberg release also carries `agendula-offline_v<version>.apk`: Agendula
|
||||||
|
without CalDAV sync and **without the network permission**, so it cannot
|
||||||
|
connect to anything. It installs alongside the regular app rather than replacing
|
||||||
|
it; move tasks between the two with Settings → Storage → Export / Import. In
|
||||||
|
Obtainium, pick it with *Filter APKs by regular expression* set to
|
||||||
|
`^agendula-offline_v`.
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+53
-7
@@ -1,3 +1,4 @@
|
|||||||
|
import com.android.build.api.artifact.SingleArtifact
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
|
|
||||||
@@ -32,8 +33,8 @@ android {
|
|||||||
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
|
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
|
||||||
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
|
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
|
||||||
// See docs/RELEASING.md.
|
// See docs/RELEASING.md.
|
||||||
versionCode = 1010001
|
versionCode = 1010002
|
||||||
versionName = "1.1.0-beta.1"
|
versionName = "1.1.0-beta.2"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
@@ -46,6 +47,20 @@ android {
|
|||||||
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.sync")
|
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.sync")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `offline` (#39): no sync code, network libraries or network permission.
|
||||||
|
flavorDimensions += "network"
|
||||||
|
productFlavors {
|
||||||
|
create("full") {
|
||||||
|
dimension = "network"
|
||||||
|
buildConfigField("boolean", "SYNC_ENABLED", "true")
|
||||||
|
}
|
||||||
|
create("offline") {
|
||||||
|
dimension = "network"
|
||||||
|
applicationIdSuffix = ".offline"
|
||||||
|
buildConfigField("boolean", "SYNC_ENABLED", "false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
create("release") {
|
create("release") {
|
||||||
@@ -208,26 +223,26 @@ dependencies {
|
|||||||
// @HiltWorker plumbing.
|
// @HiltWorker plumbing.
|
||||||
implementation(libs.androidx.work.runtime.ktx)
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
// Custom Tabs: the Nextcloud login flow hands the browser an approval page.
|
// Custom Tabs: the Nextcloud login flow hands the browser an approval page.
|
||||||
implementation(libs.androidx.browser)
|
"fullImplementation"(libs.androidx.browser)
|
||||||
implementation(libs.androidx.hilt.work)
|
implementation(libs.androidx.hilt.work)
|
||||||
ksp(libs.androidx.hilt.compiler)
|
ksp(libs.androidx.hilt.compiler)
|
||||||
|
|
||||||
// Push sync: a UnifiedPush distributor delivers the server's WebDAV-Push messages.
|
// Push sync: a UnifiedPush distributor delivers the server's WebDAV-Push messages.
|
||||||
implementation(libs.unifiedpush.connector)
|
"fullImplementation"(libs.unifiedpush.connector)
|
||||||
|
|
||||||
// RFC 5545 recurrence expansion, in-process; see the catalog for the pin.
|
// RFC 5545 recurrence expansion, in-process; see the catalog for the pin.
|
||||||
implementation(libs.dmfs.lib.recur)
|
implementation(libs.dmfs.lib.recur)
|
||||||
|
|
||||||
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
|
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
|
||||||
implementation(project(":dav"))
|
"fullImplementation"(project(":dav"))
|
||||||
// Discovery, auth and Nextcloud Login Flow v2.
|
// Discovery, auth and Nextcloud Login Flow v2.
|
||||||
implementation(project(":caldav"))
|
"fullImplementation"(project(":caldav"))
|
||||||
// :dav gets org.xmlpull.v1 from the Android framework at runtime and declares
|
// :dav gets org.xmlpull.v1 from the Android framework at runtime and declares
|
||||||
// xpp3 compileOnly, which is not transitive. Unit tests run on a plain JVM
|
// xpp3 compileOnly, which is not transitive. Unit tests run on a plain JVM
|
||||||
// with no framework, and android.jar's stub factory returns null under
|
// with no framework, and android.jar's stub factory returns null under
|
||||||
// isReturnDefaultValues — so anything touching XmlUtils would NPE without a
|
// isReturnDefaultValues — so anything touching XmlUtils would NPE without a
|
||||||
// real implementation here.
|
// real implementation here.
|
||||||
testImplementation(libs.xpp3)
|
"testFullImplementation"(libs.xpp3)
|
||||||
|
|
||||||
implementation(libs.androidx.room.runtime)
|
implementation(libs.androidx.room.runtime)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
@@ -266,3 +281,34 @@ dependencies {
|
|||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fails the build if the offline flavor's merged manifest asks for the network. */
|
||||||
|
abstract class VerifyNoNetworkPermissions : DefaultTask() {
|
||||||
|
@get:InputFile
|
||||||
|
@get:PathSensitive(PathSensitivity.NONE)
|
||||||
|
abstract val manifest: RegularFileProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun verify() {
|
||||||
|
val text = manifest.get().asFile.readText()
|
||||||
|
val found = listOf("INTERNET", "ACCESS_NETWORK_STATE", "ACCESS_WIFI_STATE", "CHANGE_NETWORK_STATE")
|
||||||
|
.filter { "\"android.permission.$it\"" in text }
|
||||||
|
if (found.isNotEmpty()) {
|
||||||
|
throw GradleException(
|
||||||
|
"The offline flavor's merged manifest declares ${found.joinToString()}. " +
|
||||||
|
"Remove it in app/src/offline/AndroidManifest.xml with tools:node=\"remove\".",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidComponents {
|
||||||
|
onVariants(selector().withFlavor("network" to "offline")) { variant ->
|
||||||
|
val name = variant.name.replaceFirstChar { it.uppercase() }
|
||||||
|
val verify = tasks.register<VerifyNoNetworkPermissions>("verify${name}Manifest") {
|
||||||
|
manifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST))
|
||||||
|
}
|
||||||
|
tasks.matching { it.name in setOf("package$name", "package${name}Bundle", "check") }
|
||||||
|
.configureEach { dependsOn(verify) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- CalDAV sync and UnifiedPush; everything here needs the network. -->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
|
||||||
|
but it shows in F-Droid's permission diff, so declare it deliberately
|
||||||
|
rather than letting it appear from nowhere.
|
||||||
|
|
||||||
|
READ_SYNC_SETTINGS / WRITE_SYNC_SETTINGS are what the ContentResolver
|
||||||
|
sync APIs need. No FOREGROUND_SERVICE: sync is a plain worker, and the
|
||||||
|
dataSync FGS type would bring the Android 15 six-hours-per-24 budget
|
||||||
|
(whose failure mode is a fatal RemoteServiceException) and a Play
|
||||||
|
requirement for a video demo.
|
||||||
|
|
||||||
|
Two more permissions appear in the merged manifest without being
|
||||||
|
declared here, and both come from work-runtime: WAKE_LOCK, and
|
||||||
|
FOREGROUND_SERVICE. The latter is not us taking the FGS route — below
|
||||||
|
API 31 WorkManager implements expedited work with a foreground service,
|
||||||
|
and minSdk is 29, so it is load-bearing for the "Sync now" button.
|
||||||
|
Removing it with tools:node="remove" would break expedited work on
|
||||||
|
exactly the older devices that need it most. Noted because it shows in
|
||||||
|
F-Droid's permission diff and would otherwise look unexplained. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<!-- Custom Tabs provider detection. Without this entry it silently finds
|
||||||
|
nothing on API 30+, and the Nextcloud login flow falls back to an
|
||||||
|
external browser for no visible reason. -->
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
|
<application android:networkSecurityConfig="@xml/network_security_config">
|
||||||
|
|
||||||
|
<!-- Sync plumbing. The stub provider exists only to give the sync
|
||||||
|
adapter an authority to register against: Agendula publishes no real
|
||||||
|
ContentProvider since :provider was deleted, and without an authority
|
||||||
|
ContentService.hasAuthorityAccess() makes every ContentResolver sync
|
||||||
|
call a silent no-op at targetSdk >= 34. -->
|
||||||
|
<provider
|
||||||
|
android:name=".data.sync.SyncStubProvider"
|
||||||
|
android:authorities="${applicationId}.sync"
|
||||||
|
android:exported="false"
|
||||||
|
android:syncable="true" />
|
||||||
|
|
||||||
|
<!-- Exported and guarded by ACCOUNT_MANAGER. Note that
|
||||||
|
android.permission.ACCOUNT_AUTHENTICATOR does not exist. -->
|
||||||
|
<service
|
||||||
|
android:name=".data.sync.AuthenticatorService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.ACCOUNT_MANAGER">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.accounts.AccountAuthenticator" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.accounts.AccountAuthenticator"
|
||||||
|
android:resource="@xml/authenticator" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".data.sync.SyncAdapterService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_SYNC_ADAPTER">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.content.SyncAdapter" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.content.SyncAdapter"
|
||||||
|
android:resource="@xml/sync_adapter" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- UnifiedPush: the connector binds this to deliver endpoints and
|
||||||
|
WebDAV-Push messages. Not exported; the connector's own receiver is
|
||||||
|
what distributors talk to. -->
|
||||||
|
<service
|
||||||
|
android:name=".data.sync.push.AgendulaPushService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dagger.multibindings.IntoSet
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.SyncRequests
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
|
||||||
|
import javax.inject.Provider
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** See [CredentialsDataStore] for why this is a separate file. */
|
||||||
|
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
|
name = CREDENTIALS_DATASTORE,
|
||||||
|
corruptionHandler = replaceCorrupted(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** See [SyncStateDataStore] for why this is a separate file. */
|
||||||
|
private val Context.syncStateDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
|
name = SYNC_STATE_DATASTORE,
|
||||||
|
corruptionHandler = replaceCorrupted(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
||||||
|
* exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name.
|
||||||
|
*/
|
||||||
|
const val CREDENTIALS_DATASTORE = "agendula_credentials"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
||||||
|
* exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
|
||||||
|
*/
|
||||||
|
const val SYNC_STATE_DATASTORE = "agendula_sync_state"
|
||||||
|
|
||||||
|
/** CalDAV sync: the `full` flavor's side of the seams `main` declares. */
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
abstract class SyncBindModule {
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindLocalWriteListener(impl: SyncOnEdit): LocalWriteListener
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindRemoteLists(impl: RemoteListRepository): RemoteLists
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@IntoSet
|
||||||
|
abstract fun bindSyncNoticeChannel(impl: SyncNoticeNotifier): ChannelRefresher
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object SyncProvideModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@CredentialsDataStore
|
||||||
|
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||||
|
context.credentialsDataStore
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@SyncStateDataStore
|
||||||
|
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||||
|
context.syncStateDataStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync hard on app open: the periodic worker's interval is a floor, and in
|
||||||
|
* the `rare` and `restricted` App Standby buckets it may not have run at
|
||||||
|
* all. `KEEP` makes rescheduling idempotent, so this also repairs a schedule
|
||||||
|
* lost to "clear app data" or to a restore.
|
||||||
|
*/
|
||||||
|
@Provides
|
||||||
|
@IntoSet
|
||||||
|
fun syncOnOpenHook(
|
||||||
|
accounts: Provider<AccountRepository>,
|
||||||
|
syncTrigger: Provider<SyncTrigger>,
|
||||||
|
pendingLoginFlows: Provider<PendingLoginFlowStore>,
|
||||||
|
push: Provider<PushRegistrar>,
|
||||||
|
): LaunchHook = LaunchHook {
|
||||||
|
runCatching {
|
||||||
|
accounts.get().rescheduleAll()
|
||||||
|
accounts.get().syncable().forEach { syncTrigger.get().enqueue(it.displayName) }
|
||||||
|
// A login flow the previous process died in the middle of.
|
||||||
|
// Its password, if the user approved, exists nowhere else.
|
||||||
|
pendingLoginFlows.get().reclaim()
|
||||||
|
}
|
||||||
|
// Last and on its own: it waits on the network, and must not hold up
|
||||||
|
// the reclaim above. Re-registering on open is what the connector
|
||||||
|
// recommends.
|
||||||
|
runCatching { push.get().updateAll() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.di
|
||||||
|
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the DataStore holding **only** the Keystore-encrypted app passwords.
|
||||||
|
*
|
||||||
|
* A separate file from `agendula_prefs` on purpose: Auto Backup includes
|
||||||
|
* `datastore/`, and a restored ciphertext is permanently undecryptable because
|
||||||
|
* Keystore keys are non-exportable. Its own file is what lets the backup rules
|
||||||
|
* exclude the credentials and nothing else — excluding the whole database or
|
||||||
|
* all of DataStore would trade a latent bug for a live one.
|
||||||
|
*/
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class CredentialsDataStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
|
||||||
|
* counters and the full-reconciliation clock.
|
||||||
|
*
|
||||||
|
* Its own file for the same reason the credentials have one: Auto Backup
|
||||||
|
* includes `datastore/`, and every value in here is a statement about *this*
|
||||||
|
* device's conversation with a server. Restored onto a new install they are all
|
||||||
|
* lies, and two of them are dangerous — a restored "reconciled recently" makes
|
||||||
|
* the engine trust a sync token for another day, which is precisely the silently
|
||||||
|
* pruned change log the full path exists to catch, and a restored quarantine
|
||||||
|
* count silently skips resources that were never tried here.
|
||||||
|
*
|
||||||
|
* Not user data, so nothing is lost by excluding it.
|
||||||
|
*/
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class SyncStateDataStore
|
||||||
+6
-36
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.sync
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome
|
||||||
import de.jeanlucmakiola.agendula.data.sync.push.PushStore
|
import de.jeanlucmakiola.agendula.data.sync.push.PushStore
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
@@ -53,38 +54,7 @@ class RemoteListRepository @Inject constructor(
|
|||||||
private val resolver: ProviderResolver,
|
private val resolver: ProviderResolver,
|
||||||
private val push: PushStore,
|
private val push: PushStore,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) {
|
) : RemoteLists {
|
||||||
|
|
||||||
/** Why a collection write did not happen, in a form the UI can translate. */
|
|
||||||
sealed interface Outcome {
|
|
||||||
data object Done : Outcome
|
|
||||||
|
|
||||||
/** The server said no, and will say no again. */
|
|
||||||
data class Refused(val code: Int) : Outcome
|
|
||||||
|
|
||||||
/** The server could not be reached. Worth another try. */
|
|
||||||
data object Unreachable : Outcome
|
|
||||||
|
|
||||||
/** This account cannot make collections at all — iCloud, Posteo, Google. */
|
|
||||||
data object Unsupported : Outcome
|
|
||||||
|
|
||||||
/** Ours is a read-only share; the write belongs to whoever owns it. */
|
|
||||||
data object ReadOnly : Outcome
|
|
||||||
|
|
||||||
/** The account is gone, stopped, or has no credential we can decrypt. */
|
|
||||||
data object NoAccount : Outcome
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The server answered something this call cannot make sense of.
|
|
||||||
*
|
|
||||||
* ⚠️ Not [Unreachable]. `CollectionOutcome` is one type across create,
|
|
||||||
* update and delete, so each of them has branches the other's method
|
|
||||||
* can return and its own cannot — and mapping those to [Unreachable]
|
|
||||||
* told someone sitting on wifi that they were offline. Unreachable is a
|
|
||||||
* claim about the network, and this is not one.
|
|
||||||
*/
|
|
||||||
data object Unexpected : Outcome
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The accounts a new list may be created on, freshest answer first.
|
* The accounts a new list may be created on, freshest answer first.
|
||||||
@@ -95,7 +65,7 @@ class RemoteListRepository @Inject constructor(
|
|||||||
* able to say so, and the only moment that costs nothing is while the user
|
* able to say so, and the only moment that costs nothing is while the user
|
||||||
* is looking at the picker.
|
* is looking at the picker.
|
||||||
*/
|
*/
|
||||||
suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) {
|
override suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) {
|
||||||
// ⚠️ Empty in External mode, whatever the accounts table holds. The
|
// ⚠️ Empty in External mode, whatever the accounts table holds. The
|
||||||
// lists on screen then come from a third-party provider, so a row
|
// lists on screen then come from a third-party provider, so a row
|
||||||
// inserted into ours would exist, sync, and be visible to nobody.
|
// inserted into ours would exist, sync, and be visible to nobody.
|
||||||
@@ -120,7 +90,7 @@ class RemoteListRepository @Inject constructor(
|
|||||||
*
|
*
|
||||||
* @return the new list's local id, or why there is none.
|
* @return the new list's local id, or why there is none.
|
||||||
*/
|
*/
|
||||||
suspend fun create(
|
override suspend fun create(
|
||||||
accountId: Long,
|
accountId: Long,
|
||||||
name: String,
|
name: String,
|
||||||
color: Int,
|
color: Int,
|
||||||
@@ -189,7 +159,7 @@ class RemoteListRepository @Inject constructor(
|
|||||||
* a row that has already been renamed locally by then reads as a rename that
|
* a row that has already been renamed locally by then reads as a rename that
|
||||||
* worked and then quietly reverted on the next sync.
|
* worked and then quietly reverted on the next sync.
|
||||||
*/
|
*/
|
||||||
suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) {
|
override suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) {
|
||||||
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.NoAccount
|
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.NoAccount
|
||||||
if (list.isReadOnly) return@withContext Outcome.ReadOnly
|
if (list.isReadOnly) return@withContext Outcome.ReadOnly
|
||||||
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
|
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
|
||||||
@@ -227,7 +197,7 @@ class RemoteListRepository @Inject constructor(
|
|||||||
* grades 404 and 410 that way — because otherwise a collection someone
|
* grades 404 and 410 that way — because otherwise a collection someone
|
||||||
* removed from another client leaves a row here that nothing can get rid of.
|
* removed from another client leaves a row here that nothing can get rid of.
|
||||||
*/
|
*/
|
||||||
suspend fun delete(listId: Long): Outcome = withContext(io) {
|
override suspend fun delete(listId: Long): Outcome = withContext(io) {
|
||||||
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.Done
|
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.Done
|
||||||
if (list.isReadOnly) return@withContext Outcome.ReadOnly
|
if (list.isReadOnly) return@withContext Outcome.ReadOnly
|
||||||
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
|
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
|
||||||
+4
-6
@@ -14,6 +14,7 @@ import androidx.core.app.NotificationManagerCompat
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.agendula.MainActivity
|
import de.jeanlucmakiola.agendula.MainActivity
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -33,7 +34,7 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class SyncNoticeNotifier @Inject constructor(
|
class SyncNoticeNotifier @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
) {
|
) : ChannelRefresher {
|
||||||
|
|
||||||
fun canPost(): Boolean {
|
fun canPost(): Boolean {
|
||||||
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
@@ -123,7 +124,7 @@ class SyncNoticeNotifier @Inject constructor(
|
|||||||
* extra names the account for routing to Settings → Accounts → it.
|
* extra names the account for routing to Settings → Accounts → it.
|
||||||
*/
|
*/
|
||||||
private fun signInIntent(accountId: Long): Intent =
|
private fun signInIntent(accountId: Long): Intent =
|
||||||
MainActivity.openIntent(context).putExtra(EXTRA_SIGN_IN_ACCOUNT_ID, accountId)
|
MainActivity.openIntent(context).putExtra(MainActivity.EXTRA_SIGN_IN_ACCOUNT_ID, accountId)
|
||||||
|
|
||||||
private fun ensureSignInChannel() {
|
private fun ensureSignInChannel() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
@@ -170,16 +171,13 @@ class SyncNoticeNotifier @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Re-create the channel, if it exists, in the current language. */
|
/** Re-create the channel, if it exists, in the current language. */
|
||||||
fun refreshChannel() {
|
override fun refreshChannel() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val manager = context.getSystemService(NotificationManager::class.java)
|
val manager = context.getSystemService(NotificationManager::class.java)
|
||||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel()
|
if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The account a sign-in notification is about, on the intent it opens. */
|
|
||||||
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
|
|
||||||
|
|
||||||
private const val CHANNEL_ID = "sync_notices"
|
private const val CHANNEL_ID = "sync_notices"
|
||||||
private const val NOTIFICATION_ID = 2
|
private const val NOTIFICATION_ID = 2
|
||||||
private const val SIGN_IN_CHANNEL_ID = "account_sign_in"
|
private const val SIGN_IN_CHANNEL_ID = "account_sign_in"
|
||||||
+5
-1
@@ -35,7 +35,11 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
@Singleton
|
@Singleton
|
||||||
class SyncTrigger @Inject constructor(
|
class SyncTrigger @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
) {
|
) : SyncRequests {
|
||||||
|
|
||||||
|
override fun syncNow(accountName: String) {
|
||||||
|
enqueue(accountName)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a sync now.
|
* Starts a sync now.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.onboarding
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.agendula.ui.accounts.add.ADD_ACCOUNT_STEPS
|
||||||
|
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
|
||||||
|
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
|
||||||
|
|
||||||
|
/** The inline wizard's shortest length, before a provider is picked. */
|
||||||
|
internal const val ACCOUNT_STEP_SLOTS = ADD_ACCOUNT_STEPS
|
||||||
|
|
||||||
|
/** [OnboardingStep.Account]: the add-account wizard, given the whole screen. */
|
||||||
|
@Composable
|
||||||
|
internal fun AccountStep(state: OnboardingUiState, viewModel: OnboardingViewModel) {
|
||||||
|
// The same instance the wizard resolves for itself, so its length can be
|
||||||
|
// read here without keeping a second copy of its state.
|
||||||
|
val account: AddAccountViewModel = hiltViewModel()
|
||||||
|
val accountState by account.state.collectAsStateWithLifecycle()
|
||||||
|
// ⚠️ The wizard grows a step for a provider that needs an app password
|
||||||
|
// minted first, and the outer bar has to grow with it — otherwise first
|
||||||
|
// run counts one flow while the screen inside it counts another.
|
||||||
|
LaunchedEffect(accountState.totalSteps) {
|
||||||
|
viewModel.onAccountStepsChanged(accountState.totalSteps)
|
||||||
|
}
|
||||||
|
AddAccountScreen(
|
||||||
|
onDone = { viewModel.onAccountFinished(added = true) },
|
||||||
|
onBack = { viewModel.onAccountFinished(added = false) },
|
||||||
|
stepOffset = state.position - 1,
|
||||||
|
totalSteps = state.total,
|
||||||
|
viewModel = account,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||||
|
import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen
|
||||||
|
import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen
|
||||||
|
import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel
|
||||||
|
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
|
||||||
|
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
|
||||||
|
|
||||||
|
/** Settings → Accounts and the screens under it, layered over the hub. */
|
||||||
|
@Composable
|
||||||
|
internal fun AccountSections(
|
||||||
|
section: SettingsSection?,
|
||||||
|
initialAccountId: Long?,
|
||||||
|
onSection: (SettingsSection?) -> Unit,
|
||||||
|
) {
|
||||||
|
// Hoisted so the add flow can refresh the list it returns to.
|
||||||
|
val accountsViewModel: AccountsViewModel = hiltViewModel()
|
||||||
|
// Shared with the add flow's own lookup, so "sign in again" can prefill it.
|
||||||
|
val addAccountViewModel: AddAccountViewModel = hiltViewModel()
|
||||||
|
val signInAgain: (AccountEntity) -> Unit = { account ->
|
||||||
|
addAccountViewModel.startReauthentication(account.id, account.principalUrl, account.username)
|
||||||
|
onSection(SettingsSection.AddAccount)
|
||||||
|
}
|
||||||
|
// Which account the detail screen is showing; the section alone cannot say.
|
||||||
|
var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) }
|
||||||
|
|
||||||
|
// Accounts stays composed under Add account, for the same reason Storage
|
||||||
|
// stays composed under Export: the deeper screen slides over it.
|
||||||
|
val accountsOpen = section == SettingsSection.Accounts ||
|
||||||
|
section?.parent == SettingsSection.Accounts
|
||||||
|
SlideInSection(visible = accountsOpen) {
|
||||||
|
AccountsScreen(
|
||||||
|
onAddAccount = {
|
||||||
|
// A sign-in abandoned half-way must not turn this into one.
|
||||||
|
if (addAccountViewModel.state.value.reauthenticating) addAccountViewModel.onStartOver()
|
||||||
|
onSection(SettingsSection.AddAccount)
|
||||||
|
},
|
||||||
|
onSignInAgain = signInAgain,
|
||||||
|
onOpenAccount = {
|
||||||
|
openAccount = it
|
||||||
|
onSection(SettingsSection.Account)
|
||||||
|
},
|
||||||
|
onOpenStorage = { onSection(SettingsSection.Storage) },
|
||||||
|
onBack = { onSection(null) },
|
||||||
|
viewModel = accountsViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SlideInSection(visible = section == SettingsSection.Account) {
|
||||||
|
openAccount?.let { id ->
|
||||||
|
AccountDetailScreen(
|
||||||
|
accountId = id,
|
||||||
|
onBack = { onSection(SettingsSection.Accounts) },
|
||||||
|
onRemoved = { onSection(SettingsSection.Accounts) },
|
||||||
|
onSignInAgain = signInAgain,
|
||||||
|
onOpenStorage = { onSection(SettingsSection.Storage) },
|
||||||
|
viewModel = accountsViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SlideInSection(visible = section == SettingsSection.AddAccount) {
|
||||||
|
AddAccountScreen(
|
||||||
|
// The list stays composed underneath, so nothing re-runs its
|
||||||
|
// init and OnResume never fires on a section change. It no longer
|
||||||
|
// needs to: the accounts come from an observed query, so a new
|
||||||
|
// account — and every later sync — arrives on its own.
|
||||||
|
onDone = { onSection(SettingsSection.Accounts) },
|
||||||
|
onBack = { onSection(SettingsSection.Accounts) },
|
||||||
|
viewModel = addAccountViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,29 +16,6 @@
|
|||||||
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
|
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
|
||||||
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
|
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
|
||||||
|
|
||||||
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
|
|
||||||
but it shows in F-Droid's permission diff, so declare it deliberately
|
|
||||||
rather than letting it appear from nowhere.
|
|
||||||
|
|
||||||
READ_SYNC_SETTINGS / WRITE_SYNC_SETTINGS are what the ContentResolver
|
|
||||||
sync APIs need. No FOREGROUND_SERVICE: sync is a plain worker, and the
|
|
||||||
dataSync FGS type would bring the Android 15 six-hours-per-24 budget
|
|
||||||
(whose failure mode is a fatal RemoteServiceException) and a Play
|
|
||||||
requirement for a video demo.
|
|
||||||
|
|
||||||
Two more permissions appear in the merged manifest without being
|
|
||||||
declared here, and both come from work-runtime: WAKE_LOCK, and
|
|
||||||
FOREGROUND_SERVICE. The latter is not us taking the FGS route — below
|
|
||||||
API 31 WorkManager implements expedited work with a foreground service,
|
|
||||||
and minSdk is 29, so it is load-bearing for the "Sync now" button.
|
|
||||||
Removing it with tools:node="remove" would break expedited work on
|
|
||||||
exactly the older devices that need it most. Noted because it shows in
|
|
||||||
F-Droid's permission diff and would otherwise look unexplained. -->
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
|
||||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<!-- Exact due-time reminders. User-granted: USE_EXACT_ALARM is reserved for
|
<!-- Exact due-time reminders. User-granted: USE_EXACT_ALARM is reserved for
|
||||||
@@ -55,12 +32,6 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent>
|
</intent>
|
||||||
<!-- Custom Tabs provider detection. Without this entry it silently finds
|
|
||||||
nothing on API 30+, and the Nextcloud login flow falls back to an
|
|
||||||
external browser for no visible reason. -->
|
|
||||||
<intent>
|
|
||||||
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
|
||||||
</intent>
|
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
@@ -71,7 +42,6 @@
|
|||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:localeConfig="@xml/locales_config"
|
android:localeConfig="@xml/locales_config"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Agendula"
|
android:theme="@style/Theme.Agendula"
|
||||||
@@ -263,54 +233,6 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- Sync plumbing. The stub provider exists only to give the sync
|
|
||||||
adapter an authority to register against: Agendula publishes no real
|
|
||||||
ContentProvider since :provider was deleted, and without an authority
|
|
||||||
ContentService.hasAuthorityAccess() makes every ContentResolver sync
|
|
||||||
call a silent no-op at targetSdk >= 34. -->
|
|
||||||
<provider
|
|
||||||
android:name=".data.sync.SyncStubProvider"
|
|
||||||
android:authorities="${applicationId}.sync"
|
|
||||||
android:exported="false"
|
|
||||||
android:syncable="true" />
|
|
||||||
|
|
||||||
<!-- Exported and guarded by ACCOUNT_MANAGER. Note that
|
|
||||||
android.permission.ACCOUNT_AUTHENTICATOR does not exist. -->
|
|
||||||
<service
|
|
||||||
android:name=".data.sync.AuthenticatorService"
|
|
||||||
android:exported="true"
|
|
||||||
android:permission="android.permission.ACCOUNT_MANAGER">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.accounts.AccountAuthenticator" />
|
|
||||||
</intent-filter>
|
|
||||||
<meta-data
|
|
||||||
android:name="android.accounts.AccountAuthenticator"
|
|
||||||
android:resource="@xml/authenticator" />
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".data.sync.SyncAdapterService"
|
|
||||||
android:exported="true"
|
|
||||||
android:permission="android.permission.BIND_SYNC_ADAPTER">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.content.SyncAdapter" />
|
|
||||||
</intent-filter>
|
|
||||||
<meta-data
|
|
||||||
android:name="android.content.SyncAdapter"
|
|
||||||
android:resource="@xml/sync_adapter" />
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<!-- UnifiedPush: the connector binds this to deliver endpoints and
|
|
||||||
WebDAV-Push messages. Not exported; the connector's own receiver is
|
|
||||||
what distributors talk to. -->
|
|
||||||
<service
|
|
||||||
android:name=".data.sync.push.AgendulaPushService"
|
|
||||||
android:exported="false">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<!-- WorkManager's on-demand initialisation. Removing the default
|
<!-- WorkManager's on-demand initialisation. Removing the default
|
||||||
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
|
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
|
||||||
@HiltWorker workers can take injected dependencies. -->
|
@HiltWorker workers can take injected dependencies. -->
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import dagger.hilt.android.EntryPointAccessors
|
|||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
|
||||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
|
||||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||||
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
|
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
|
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
|
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
|
||||||
@@ -105,7 +105,7 @@ class AgendulaApp : Application(), Configuration.Provider {
|
|||||||
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
|
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
|
||||||
runCatching {
|
runCatching {
|
||||||
entryPoint.taskNotifier().refreshChannel()
|
entryPoint.taskNotifier().refreshChannel()
|
||||||
entryPoint.syncNoticeNotifier().refreshChannel()
|
entryPoint.channelRefreshers().forEach { it.refreshChannel() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ class AgendulaApp : Application(), Configuration.Provider {
|
|||||||
fun applicationScope(): CoroutineScope
|
fun applicationScope(): CoroutineScope
|
||||||
fun databaseCheckpoint(): DatabaseCheckpoint
|
fun databaseCheckpoint(): DatabaseCheckpoint
|
||||||
fun taskNotifier(): TaskNotifier
|
fun taskNotifier(): TaskNotifier
|
||||||
fun syncNoticeNotifier(): SyncNoticeNotifier
|
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
|
||||||
fun taskWidgetUpdater(): TaskWidgetUpdater
|
fun taskWidgetUpdater(): TaskWidgetUpdater
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.agendula.data.di.LaunchHook
|
import de.jeanlucmakiola.agendula.data.di.LaunchHook
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
|
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
|
||||||
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
|
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
|
||||||
@@ -33,10 +32,6 @@ import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
|||||||
import de.jeanlucmakiola.agendula.ui.RootScreen
|
import de.jeanlucmakiola.agendula.ui.RootScreen
|
||||||
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
|
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
|
||||||
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
|
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
|
||||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
|
|
||||||
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
|
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
|
||||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
@@ -55,14 +50,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
|
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
|
||||||
|
|
||||||
@Inject lateinit var accounts: AccountRepository
|
|
||||||
|
|
||||||
@Inject lateinit var syncTrigger: SyncTrigger
|
|
||||||
|
|
||||||
@Inject lateinit var pendingLoginFlows: PendingLoginFlowStore
|
|
||||||
|
|
||||||
@Inject lateinit var push: PushRegistrar
|
|
||||||
|
|
||||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||||
// over the app on the next launch (the single-crash path). A startup
|
// over the app on the next launch (the single-crash path). A startup
|
||||||
// crash-loop is handled out of band, before setContent — see below.
|
// crash-loop is handled out of band, before setContent — see below.
|
||||||
@@ -94,34 +81,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
// Surface a single captured crash as a dialog on the next launch.
|
// Surface a single captured crash as a dialog on the next launch.
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
|
|
||||||
// Sync hard on app open: the periodic worker's interval is a floor, and
|
// Each hook on its own, so one waiting on the network holds up no other.
|
||||||
// in the `rare` and `restricted` App Standby buckets it may not have run
|
|
||||||
// at all. `KEEP` makes rescheduling idempotent, so this also repairs a
|
|
||||||
// schedule lost to "clear app data" or to a restore.
|
|
||||||
//
|
|
||||||
// ⚠️ Only on a genuine open. This activity declares no `configChanges`,
|
|
||||||
// so onCreate runs again on every rotation, theme switch, locale change
|
|
||||||
// and font-scale change — each of which would otherwise start a fresh
|
|
||||||
// network sync the moment the previous one finished.
|
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
lifecycleScope.launch {
|
launchHooks.forEach { hook -> lifecycleScope.launch { runCatching { hook.onLaunch(intent) } } }
|
||||||
runCatching {
|
|
||||||
accounts.rescheduleAll()
|
|
||||||
accounts.syncable().forEach { syncTrigger.enqueue(it.displayName) }
|
|
||||||
// A login flow the previous process died in the middle of.
|
|
||||||
// Its password, if the user approved, exists nowhere else.
|
|
||||||
pendingLoginFlows.reclaim()
|
|
||||||
}
|
|
||||||
// Last and on its own: it waits on the network, and must not
|
|
||||||
// hold up the reclaim above. Re-registering on open is what
|
|
||||||
// the connector recommends.
|
|
||||||
runCatching { push.updateAll() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Variant hooks: the demo seeder in debug builds, nothing in release.
|
|
||||||
if (savedInstanceState == null) {
|
|
||||||
lifecycleScope.launch { launchHooks.forEach { runCatching { it.onLaunch(intent) } } }
|
|
||||||
}
|
}
|
||||||
setContent {
|
setContent {
|
||||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||||
@@ -180,6 +142,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
||||||
const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
|
const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
|
||||||
private const val EXTRA_OPEN_ACCOUNTS = "de.jeanlucmakiola.agendula.extra.OPEN_ACCOUNTS"
|
private const val EXTRA_OPEN_ACCOUNTS = "de.jeanlucmakiola.agendula.extra.OPEN_ACCOUNTS"
|
||||||
|
|
||||||
|
/** The account a sign-in notification is about, on the intent it opens. */
|
||||||
|
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
|
||||||
const val ACTION_NEW_TASK = "de.jeanlucmakiola.agendula.action.NEW_TASK"
|
const val ACTION_NEW_TASK = "de.jeanlucmakiola.agendula.action.NEW_TASK"
|
||||||
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
|
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
|
||||||
private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
|
private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
|
||||||
@@ -235,9 +200,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
internal fun navRequestOf(intent: Intent?): NavRequest? {
|
internal fun navRequestOf(intent: Intent?): NavRequest? {
|
||||||
if (intent == null) return null
|
if (intent == null) return null
|
||||||
intent.getLongExtra(SyncNoticeNotifier.EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
|
if (BuildConfig.SYNC_ENABLED) {
|
||||||
?.let { return NavRequest.OpenAccount(it) }
|
intent.getLongExtra(EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
|
||||||
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
|
?.let { return NavRequest.OpenAccount(it) }
|
||||||
|
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
|
||||||
|
}
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
ACTION_NEW_TASK -> return NavRequest.NewTask(
|
ACTION_NEW_TASK -> return NavRequest.NewTask(
|
||||||
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
|
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.di
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dagger.multibindings.Multibinds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A notification channel a build variant adds, re-created in the current
|
||||||
|
* language on start and on a configuration change. The `full` flavor
|
||||||
|
* contributes the sync notices; `offline` has none.
|
||||||
|
*/
|
||||||
|
fun interface ChannelRefresher {
|
||||||
|
fun refreshChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
abstract class ChannelRefresherModule {
|
||||||
|
@Multibinds
|
||||||
|
abstract fun channelRefreshers(): Set<ChannelRefresher>
|
||||||
|
}
|
||||||
@@ -12,13 +12,6 @@ import dagger.Provides
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
|
|
||||||
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
|
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
|
||||||
@@ -27,7 +20,6 @@ import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
|||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||||
@@ -51,37 +43,13 @@ import javax.inject.Singleton
|
|||||||
* reconciled", and the credential store to accounts that ask to be signed in
|
* reconciled", and the credential store to accounts that ask to be signed in
|
||||||
* again — all states the app already knows how to be in, unlike a launch loop.
|
* again — all states the app already knows how to be in, unlike a launch loop.
|
||||||
*/
|
*/
|
||||||
private fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() }
|
internal fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() }
|
||||||
|
|
||||||
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
name = "agendula_prefs",
|
name = "agendula_prefs",
|
||||||
corruptionHandler = replaceCorrupted(),
|
corruptionHandler = replaceCorrupted(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** See [CredentialsDataStore] for why this is a separate file. */
|
|
||||||
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
|
|
||||||
name = CREDENTIALS_DATASTORE,
|
|
||||||
corruptionHandler = replaceCorrupted(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** See [SyncStateDataStore] for why this is a separate file. */
|
|
||||||
private val Context.syncStateDataStore: DataStore<Preferences> by preferencesDataStore(
|
|
||||||
name = SYNC_STATE_DATASTORE,
|
|
||||||
corruptionHandler = replaceCorrupted(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
|
||||||
* exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name.
|
|
||||||
*/
|
|
||||||
const val CREDENTIALS_DATASTORE = "agendula_credentials"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
|
||||||
* exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
|
|
||||||
*/
|
|
||||||
const val SYNC_STATE_DATASTORE = "agendula_sync_state"
|
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
abstract class DataBindModule {
|
abstract class DataBindModule {
|
||||||
@@ -94,22 +62,6 @@ abstract class DataBindModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
|
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
|
||||||
|
|
||||||
@Binds
|
|
||||||
@Singleton
|
|
||||||
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
|
|
||||||
|
|
||||||
@Binds
|
|
||||||
@Singleton
|
|
||||||
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
|
|
||||||
|
|
||||||
@Binds
|
|
||||||
@Singleton
|
|
||||||
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
|
|
||||||
|
|
||||||
@Binds
|
|
||||||
@Singleton
|
|
||||||
abstract fun bindLocalWriteListener(impl: SyncOnEdit): LocalWriteListener
|
|
||||||
|
|
||||||
// Deliberately unqualified-free of the routing above: this is the external
|
// Deliberately unqualified-free of the routing above: this is the external
|
||||||
// store itself, for the one caller that has to read it while another store is
|
// store itself, for the one caller that has to read it while another store is
|
||||||
// the active one.
|
// the active one.
|
||||||
@@ -128,18 +80,6 @@ object DataProvideModule {
|
|||||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||||
context.agendulaDataStore
|
context.agendulaDataStore
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
@CredentialsDataStore
|
|
||||||
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
|
||||||
context.credentialsDataStore
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
@SyncStateDataStore
|
|
||||||
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
|
||||||
context.syncStateDataStore
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
|
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import dagger.hilt.components.SingletonComponent
|
|||||||
import dagger.multibindings.Multibinds
|
import dagger.multibindings.Multibinds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Something a build variant wants to run when the app is launched with [Intent].
|
* Something a build variant runs on a genuine app open (not on a configuration
|
||||||
* Release builds contribute none; the debug source set adds the demo seeder, so
|
* change): sync in the `full` flavor, the demo seeder in debug builds.
|
||||||
* that class never ships.
|
|
||||||
*/
|
*/
|
||||||
fun interface LaunchHook {
|
fun interface LaunchHook {
|
||||||
suspend fun onLaunch(intent: Intent)
|
suspend fun onLaunch(intent: Intent)
|
||||||
|
|||||||
@@ -17,37 +17,6 @@ annotation class IoDispatcher
|
|||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class ApplicationScope
|
annotation class ApplicationScope
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks the DataStore holding **only** the Keystore-encrypted app passwords.
|
|
||||||
*
|
|
||||||
* A separate file from `agendula_prefs` on purpose: Auto Backup includes
|
|
||||||
* `datastore/`, and a restored ciphertext is permanently undecryptable because
|
|
||||||
* Keystore keys are non-exportable. Its own file is what lets the backup rules
|
|
||||||
* exclude the credentials and nothing else — excluding the whole database or
|
|
||||||
* all of DataStore would trade a latent bug for a live one.
|
|
||||||
*/
|
|
||||||
@Qualifier
|
|
||||||
@Retention(AnnotationRetention.BINARY)
|
|
||||||
annotation class CredentialsDataStore
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
|
|
||||||
* counters and the full-reconciliation clock.
|
|
||||||
*
|
|
||||||
* Its own file for the same reason the credentials have one: Auto Backup
|
|
||||||
* includes `datastore/`, and every value in here is a statement about *this*
|
|
||||||
* device's conversation with a server. Restored onto a new install they are all
|
|
||||||
* lies, and two of them are dangerous — a restored "reconciled recently" makes
|
|
||||||
* the engine trust a sync token for another day, which is precisely the silently
|
|
||||||
* pruned change log the full path exists to catch, and a restored quarantine
|
|
||||||
* count silently skips resources that were never tried here.
|
|
||||||
*
|
|
||||||
* Not user data, so nothing is lost by excluding it.
|
|
||||||
*/
|
|
||||||
@Qualifier
|
|
||||||
@Retention(AnnotationRetention.BINARY)
|
|
||||||
annotation class SyncStateDataStore
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks
|
* Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks
|
||||||
* .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than
|
* .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task lists that live on a server: making, renaming, recolouring and deleting
|
||||||
|
* them. The `full` flavor talks CalDAV; the `offline` one has no accounts, so
|
||||||
|
* every list is device-only and none of this is ever reached with a real one.
|
||||||
|
*/
|
||||||
|
interface RemoteLists {
|
||||||
|
|
||||||
|
/** Why a collection write did not happen, in a form the UI can translate. */
|
||||||
|
sealed interface Outcome {
|
||||||
|
data object Done : Outcome
|
||||||
|
|
||||||
|
/** The server said no, and will say no again. */
|
||||||
|
data class Refused(val code: Int) : Outcome
|
||||||
|
|
||||||
|
/** The server could not be reached. Worth another try. */
|
||||||
|
data object Unreachable : Outcome
|
||||||
|
|
||||||
|
/** This account cannot make collections at all — iCloud, Posteo, Google. */
|
||||||
|
data object Unsupported : Outcome
|
||||||
|
|
||||||
|
/** Ours is a read-only share; the write belongs to whoever owns it. */
|
||||||
|
data object ReadOnly : Outcome
|
||||||
|
|
||||||
|
/** The account is gone, stopped, or has no credential we can decrypt. */
|
||||||
|
data object NoAccount : Outcome
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server answered something this call cannot make sense of.
|
||||||
|
*
|
||||||
|
* ⚠️ Not [Unreachable]. `CollectionOutcome` is one type across create,
|
||||||
|
* update and delete, so each of them has branches the other's method
|
||||||
|
* can return and its own cannot — and mapping those to [Unreachable]
|
||||||
|
* told someone sitting on wifi that they were offline. Unreachable is a
|
||||||
|
* claim about the network, and this is not one.
|
||||||
|
*/
|
||||||
|
data object Unexpected : Outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The accounts a new list may be created on. */
|
||||||
|
suspend fun creatableAccounts(): List<AccountEntity>
|
||||||
|
|
||||||
|
/** Makes a collection on [accountId]'s home set and a row pointing at it. */
|
||||||
|
suspend fun create(accountId: Long, name: String, color: Int): Outcome
|
||||||
|
|
||||||
|
suspend fun rename(listId: Long, name: String, color: Int): Outcome
|
||||||
|
|
||||||
|
suspend fun delete(listId: Long): Outcome
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
/** Asks for an account to be synced now. A no-op in the `offline` flavor. */
|
||||||
|
fun interface SyncRequests {
|
||||||
|
fun syncNow(accountName: String)
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ data class TaskListEntity(
|
|||||||
*
|
*
|
||||||
* ⚠️ It was set by a rename and read by nobody. A collection's name and
|
* ⚠️ It was set by a rename and read by nobody. A collection's name and
|
||||||
* colour are now written server-first by
|
* colour are now written server-first by
|
||||||
* [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], so there is
|
* [de.jeanlucmakiola.agendula.data.sync.RemoteLists], so there is
|
||||||
* no local edit left waiting to be pushed — and a flag that means "owed to
|
* no local edit left waiting to be pushed — and a flag that means "owed to
|
||||||
* the server" while nothing ever pays it is worse than no flag at all.
|
* the server" while nothing ever pays it is worse than no flag at all.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -520,7 +520,7 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* ⚠️ Device-only lists. An account-backed collection is renamed through
|
* ⚠️ Device-only lists. An account-backed collection is renamed through
|
||||||
* [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], which sends
|
* [de.jeanlucmakiola.agendula.data.sync.RemoteLists], which sends
|
||||||
* the PROPPATCH first and writes here only once the server has taken it.
|
* the PROPPATCH first and writes here only once the server has taken it.
|
||||||
*
|
*
|
||||||
* This used to set `is_dirty` for an account list instead, on the theory
|
* This used to set `is_dirty` for an account list instead, on the theory
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import android.net.Uri
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
import de.jeanlucmakiola.agendula.data.sync.SyncRequests
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
@@ -76,7 +76,7 @@ class ImportViewModel @Inject constructor(
|
|||||||
repository: TasksRepository,
|
repository: TasksRepository,
|
||||||
private val resolver: ProviderResolver,
|
private val resolver: ProviderResolver,
|
||||||
private val importer: IcsImport,
|
private val importer: IcsImport,
|
||||||
private val syncTrigger: SyncTrigger,
|
private val syncRequests: SyncRequests,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val ownStore = MutableStateFlow(resolver.mode() == StorageMode.OWN)
|
private val ownStore = MutableStateFlow(resolver.mode() == StorageMode.OWN)
|
||||||
@@ -161,7 +161,7 @@ class ImportViewModel @Inject constructor(
|
|||||||
val result = importer.import(ready.parsed, target)
|
val result = importer.import(ready.parsed, target)
|
||||||
(chosen as? ImportChoice.Existing)?.list
|
(chosen as? ImportChoice.Existing)?.list
|
||||||
?.takeIf { it.accountId != null && result.imported > 0 }
|
?.takeIf { it.accountId != null && result.imported > 0 }
|
||||||
?.let { runCatching { syncTrigger.enqueue(it.accountName) } }
|
?.let { runCatching { syncRequests.syncNow(it.accountName) } }
|
||||||
ImportStep.Done(
|
ImportStep.Done(
|
||||||
listName = listName,
|
listName = listName,
|
||||||
imported = result.imported,
|
imported = result.imported,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.licences
|
package de.jeanlucmakiola.agendula.ui.licences
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.BuildConfig
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The third-party code Agendula ships, and what each licence obliges us to say.
|
* The third-party code Agendula ships, and what each licence obliges us to say.
|
||||||
*
|
*
|
||||||
@@ -30,6 +32,8 @@ data class Attribution(
|
|||||||
val licence: Licence,
|
val licence: Licence,
|
||||||
/** Where the source can actually be obtained — MPL-2.0 §3.2(a)'s requirement. */
|
/** Where the source can actually be obtained — MPL-2.0 §3.2(a)'s requirement. */
|
||||||
val sourceUrl: String,
|
val sourceUrl: String,
|
||||||
|
/** Only in the `full` flavor, which carries CalDAV sync and push. */
|
||||||
|
val syncOnly: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class Licence(val spdxId: String, val url: String) {
|
enum class Licence(val spdxId: String, val url: String) {
|
||||||
@@ -54,30 +58,35 @@ object OpenSourceLicences {
|
|||||||
copyright = "© bitfire web engineering (Ricki Hirner, Bernhard Stockmann)",
|
copyright = "© bitfire web engineering (Ricki Hirner, Bernhard Stockmann)",
|
||||||
licence = Licence.MPL_2,
|
licence = Licence.MPL_2,
|
||||||
sourceUrl = "https://github.com/bitfireAT/dav4jvm",
|
sourceUrl = "https://github.com/bitfireAT/dav4jvm",
|
||||||
|
syncOnly = true,
|
||||||
),
|
),
|
||||||
Attribution(
|
Attribution(
|
||||||
name = "dnsjava",
|
name = "dnsjava",
|
||||||
copyright = "© Brian Wellington and the dnsjava contributors",
|
copyright = "© Brian Wellington and the dnsjava contributors",
|
||||||
licence = Licence.BSD_3,
|
licence = Licence.BSD_3,
|
||||||
sourceUrl = "https://github.com/dnsjava/dnsjava",
|
sourceUrl = "https://github.com/dnsjava/dnsjava",
|
||||||
|
syncOnly = true,
|
||||||
),
|
),
|
||||||
Attribution(
|
Attribution(
|
||||||
name = "UnifiedPush Android connector",
|
name = "UnifiedPush Android connector",
|
||||||
copyright = "© UnifiedPush contributors",
|
copyright = "© UnifiedPush contributors",
|
||||||
licence = Licence.APACHE_2,
|
licence = Licence.APACHE_2,
|
||||||
sourceUrl = "https://codeberg.org/UnifiedPush/android-connector",
|
sourceUrl = "https://codeberg.org/UnifiedPush/android-connector",
|
||||||
|
syncOnly = true,
|
||||||
),
|
),
|
||||||
Attribution(
|
Attribution(
|
||||||
name = "Tink",
|
name = "Tink",
|
||||||
copyright = "© Google LLC",
|
copyright = "© Google LLC",
|
||||||
licence = Licence.APACHE_2,
|
licence = Licence.APACHE_2,
|
||||||
sourceUrl = "https://github.com/tink-crypto/tink-java",
|
sourceUrl = "https://github.com/tink-crypto/tink-java",
|
||||||
|
syncOnly = true,
|
||||||
),
|
),
|
||||||
Attribution(
|
Attribution(
|
||||||
name = "OkHttp",
|
name = "OkHttp",
|
||||||
copyright = "© Square, Inc.",
|
copyright = "© Square, Inc.",
|
||||||
licence = Licence.APACHE_2,
|
licence = Licence.APACHE_2,
|
||||||
sourceUrl = "https://github.com/square/okhttp",
|
sourceUrl = "https://github.com/square/okhttp",
|
||||||
|
syncOnly = true,
|
||||||
),
|
),
|
||||||
Attribution(
|
Attribution(
|
||||||
name = "Okio",
|
name = "Okio",
|
||||||
@@ -121,5 +130,5 @@ object OpenSourceLicences {
|
|||||||
licence = Licence.MIT,
|
licence = Licence.MIT,
|
||||||
sourceUrl = "https://codeberg.org/jlmakiola/floret-kit",
|
sourceUrl = "https://codeberg.org/jlmakiola/floret-kit",
|
||||||
),
|
),
|
||||||
)
|
).filter { BuildConfig.SYNC_ENABLED || !it.syncOnly }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
|
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.floret.time.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
@@ -54,19 +54,19 @@ enum class ListWriteFailure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Maps a remote outcome onto the wording the screens already know how to show. */
|
/** Maps a remote outcome onto the wording the screens already know how to show. */
|
||||||
internal fun RemoteListRepository.Outcome.asFailure(local: ListWriteFailure): ListWriteFailure? =
|
internal fun RemoteLists.Outcome.asFailure(local: ListWriteFailure): ListWriteFailure? =
|
||||||
when (this) {
|
when (this) {
|
||||||
RemoteListRepository.Outcome.Done -> null
|
RemoteLists.Outcome.Done -> null
|
||||||
is RemoteListRepository.Outcome.Refused -> ListWriteFailure.SERVER_REFUSED
|
is RemoteLists.Outcome.Refused -> ListWriteFailure.SERVER_REFUSED
|
||||||
RemoteListRepository.Outcome.Unreachable -> ListWriteFailure.OFFLINE
|
RemoteLists.Outcome.Unreachable -> ListWriteFailure.OFFLINE
|
||||||
RemoteListRepository.Outcome.Unsupported -> ListWriteFailure.UNSUPPORTED
|
RemoteLists.Outcome.Unsupported -> ListWriteFailure.UNSUPPORTED
|
||||||
RemoteListRepository.Outcome.ReadOnly -> ListWriteFailure.READ_ONLY
|
RemoteLists.Outcome.ReadOnly -> ListWriteFailure.READ_ONLY
|
||||||
// The account is gone, stopped or undecryptable, or the server said
|
// The account is gone, stopped or undecryptable, or the server said
|
||||||
// something this call cannot place. Nothing specific to say beyond
|
// something this call cannot place. Nothing specific to say beyond
|
||||||
// "that did not save", which is what [local] is — and specifically not
|
// "that did not save", which is what [local] is — and specifically not
|
||||||
// "you are offline", which neither of them is a claim about.
|
// "you are offline", which neither of them is a claim about.
|
||||||
RemoteListRepository.Outcome.NoAccount,
|
RemoteLists.Outcome.NoAccount,
|
||||||
RemoteListRepository.Outcome.Unexpected,
|
RemoteLists.Outcome.Unexpected,
|
||||||
-> local
|
-> local
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ private const val UPCOMING_PREVIEW = 3
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ListsViewModel @Inject constructor(
|
class ListsViewModel @Inject constructor(
|
||||||
private val repository: TasksRepository,
|
private val repository: TasksRepository,
|
||||||
private val remoteLists: RemoteListRepository,
|
private val remoteLists: RemoteLists,
|
||||||
settingsPrefs: SettingsPrefs,
|
settingsPrefs: SettingsPrefs,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -215,7 +215,7 @@ class ListsViewModel @Inject constructor(
|
|||||||
// ⚠️ Server first. A row written before the MKCALENDAR would be a list
|
// ⚠️ Server first. A row written before the MKCALENDAR would be a list
|
||||||
// that exists on the phone and nowhere else, with nothing to say so.
|
// that exists on the phone and nowhere else, with nothing to say so.
|
||||||
val outcome = runCatching { remoteLists.create(accountId, name.trim(), color) }
|
val outcome = runCatching { remoteLists.create(accountId, name.trim(), color) }
|
||||||
.getOrElse { RemoteListRepository.Outcome.Unreachable }
|
.getOrElse { RemoteLists.Outcome.Unreachable }
|
||||||
_writeFailure.value = outcome.asFailure(ListWriteFailure.SAVE)
|
_writeFailure.value = outcome.asFailure(ListWriteFailure.SAVE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -24,8 +23,6 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
|
|
||||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
|
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingProgress
|
import de.jeanlucmakiola.floret.components.OnboardingProgress
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
@@ -47,23 +44,7 @@ fun OnboardingFlow(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
if (state.step == OnboardingStep.Account) {
|
if (state.step == OnboardingStep.Account) {
|
||||||
// The same instance the wizard resolves for itself, so its length can be
|
AccountStep(state, viewModel)
|
||||||
// read here without keeping a second copy of its state.
|
|
||||||
val account: AddAccountViewModel = hiltViewModel()
|
|
||||||
val accountState by account.state.collectAsStateWithLifecycle()
|
|
||||||
// ⚠️ The wizard grows a step for a provider that needs an app password
|
|
||||||
// minted first, and the outer bar has to grow with it — otherwise first
|
|
||||||
// run counts one flow while the screen inside it counts another.
|
|
||||||
LaunchedEffect(accountState.totalSteps) {
|
|
||||||
viewModel.onAccountStepsChanged(accountState.totalSteps)
|
|
||||||
}
|
|
||||||
AddAccountScreen(
|
|
||||||
onDone = { viewModel.onAccountFinished(added = true) },
|
|
||||||
onBack = { viewModel.onAccountFinished(added = false) },
|
|
||||||
stepOffset = state.position - 1,
|
|
||||||
totalSteps = state.total,
|
|
||||||
viewModel = account,
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.agendula.BuildConfig
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.agendula.ui.accounts.add.ADD_ACCOUNT_STEPS
|
|
||||||
import de.jeanlucmakiola.agendula.ui.common.canScheduleExactAlarms
|
import de.jeanlucmakiola.agendula.ui.common.canScheduleExactAlarms
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -93,7 +93,7 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
* provider is chosen, so the bar is honest from the first frame and stays
|
* provider is chosen, so the bar is honest from the first frame and stays
|
||||||
* honest when the choice makes the flow longer.
|
* honest when the choice makes the flow longer.
|
||||||
*/
|
*/
|
||||||
private val accountSlots = MutableStateFlow(ADD_ACCOUNT_STEPS)
|
private val accountSlots = MutableStateFlow(ACCOUNT_STEP_SLOTS)
|
||||||
|
|
||||||
fun onAccountStepsChanged(count: Int) {
|
fun onAccountStepsChanged(count: Int) {
|
||||||
accountSlots.value = count
|
accountSlots.value = count
|
||||||
@@ -145,11 +145,11 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setRemindersEnabled(enabled)
|
prefs.setRemindersEnabled(enabled)
|
||||||
needsExactAlarms.value = enabled && !context.canScheduleExactAlarms()
|
needsExactAlarms.value = enabled && !context.canScheduleExactAlarms()
|
||||||
advanceTo(if (needsExactAlarms.value) OnboardingStep.ExactAlarms else OnboardingStep.SyncOffer)
|
advanceTo(if (needsExactAlarms.value) OnboardingStep.ExactAlarms else afterReminders())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onExactAlarmsDone() = advanceTo(OnboardingStep.SyncOffer)
|
fun onExactAlarmsDone() = advanceTo(afterReminders())
|
||||||
|
|
||||||
fun onSyncAnswered(connect: Boolean) {
|
fun onSyncAnswered(connect: Boolean) {
|
||||||
connectsAccount.value = connect
|
connectsAccount.value = connect
|
||||||
@@ -192,11 +192,9 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
step.value = when (step.value) {
|
step.value = when (step.value) {
|
||||||
OnboardingStep.Reminders -> OnboardingStep.Welcome
|
OnboardingStep.Reminders -> OnboardingStep.Welcome
|
||||||
OnboardingStep.ExactAlarms -> OnboardingStep.Reminders
|
OnboardingStep.ExactAlarms -> OnboardingStep.Reminders
|
||||||
// Once granted there is nothing left to ask, so back skips the step.
|
OnboardingStep.SyncOffer -> beforeSyncOffer()
|
||||||
OnboardingStep.SyncOffer ->
|
OnboardingStep.FirstList ->
|
||||||
if (needsExactAlarms.value && !context.canScheduleExactAlarms()) OnboardingStep.ExactAlarms
|
if (BuildConfig.SYNC_ENABLED) OnboardingStep.SyncOffer else beforeSyncOffer()
|
||||||
else OnboardingStep.Reminders
|
|
||||||
OnboardingStep.FirstList -> OnboardingStep.SyncOffer
|
|
||||||
else -> return
|
else -> return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,6 +228,15 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Without sync built in there is nothing to offer, and reminders lead straight on. */
|
||||||
|
private fun afterReminders(): OnboardingStep =
|
||||||
|
if (BuildConfig.SYNC_ENABLED) OnboardingStep.SyncOffer else OnboardingStep.FirstList
|
||||||
|
|
||||||
|
/** Once granted there is nothing left to ask, so back skips the exact-alarm step. */
|
||||||
|
private fun beforeSyncOffer(): OnboardingStep =
|
||||||
|
if (needsExactAlarms.value && !context.canScheduleExactAlarms()) OnboardingStep.ExactAlarms
|
||||||
|
else OnboardingStep.Reminders
|
||||||
|
|
||||||
private fun afterFirstList() {
|
private fun afterFirstList() {
|
||||||
step.value = stepAfterFirstList()
|
step.value = stepAfterFirstList()
|
||||||
}
|
}
|
||||||
@@ -241,7 +248,7 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
add(OnboardingStep.Welcome)
|
add(OnboardingStep.Welcome)
|
||||||
add(OnboardingStep.Reminders)
|
add(OnboardingStep.Reminders)
|
||||||
if (needsExact) add(OnboardingStep.ExactAlarms)
|
if (needsExact) add(OnboardingStep.ExactAlarms)
|
||||||
add(OnboardingStep.SyncOffer)
|
if (BuildConfig.SYNC_ENABLED) add(OnboardingStep.SyncOffer)
|
||||||
if (sync) add(OnboardingStep.Account)
|
if (sync) add(OnboardingStep.Account)
|
||||||
if (needsList) add(OnboardingStep.FirstList)
|
if (needsList) add(OnboardingStep.FirstList)
|
||||||
if (!sync) add(OnboardingStep.Backup)
|
if (!sync) add(OnboardingStep.Backup)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.Checklist
|
import androidx.compose.material.icons.rounded.Checklist
|
||||||
|
import androidx.compose.material.icons.rounded.CloudOff
|
||||||
import androidx.compose.material.icons.rounded.CloudSync
|
import androidx.compose.material.icons.rounded.CloudSync
|
||||||
import androidx.compose.material.icons.rounded.NotificationsActive
|
import androidx.compose.material.icons.rounded.NotificationsActive
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -14,6 +15,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.agendula.BuildConfig
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
@@ -56,10 +58,18 @@ internal fun WelcomeStep(chrome: StepChrome, onContinue: () -> Unit) {
|
|||||||
body = stringResource(R.string.onboarding_welcome_reminders_body),
|
body = stringResource(R.string.onboarding_welcome_reminders_body),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
BenefitRow(
|
if (BuildConfig.SYNC_ENABLED) {
|
||||||
icon = Icons.Rounded.CloudSync,
|
BenefitRow(
|
||||||
title = stringResource(R.string.onboarding_welcome_sync_title),
|
icon = Icons.Rounded.CloudSync,
|
||||||
body = stringResource(R.string.onboarding_welcome_sync_body),
|
title = stringResource(R.string.onboarding_welcome_sync_title),
|
||||||
)
|
body = stringResource(R.string.onboarding_welcome_sync_body),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Rounded.CloudOff,
|
||||||
|
title = stringResource(R.string.onboarding_welcome_offline_title),
|
||||||
|
body = stringResource(R.string.onboarding_welcome_offline_body),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,13 +40,8 @@ import androidx.compose.ui.res.vectorResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.agendula.BuildConfig
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen
|
|
||||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen
|
|
||||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
|
||||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
|
|
||||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
|
|
||||||
import de.jeanlucmakiola.agendula.ui.export.ExportScreen
|
import de.jeanlucmakiola.agendula.ui.export.ExportScreen
|
||||||
import de.jeanlucmakiola.agendula.ui.imports.ImportScreen
|
import de.jeanlucmakiola.agendula.ui.imports.ImportScreen
|
||||||
import de.jeanlucmakiola.agendula.ui.licences.LicencesScreen
|
import de.jeanlucmakiola.agendula.ui.licences.LicencesScreen
|
||||||
@@ -64,7 +59,7 @@ import de.jeanlucmakiola.floret.crash.submitCrashReport
|
|||||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
private enum class SettingsSection {
|
internal enum class SettingsSection {
|
||||||
Appearance,
|
Appearance,
|
||||||
TaskForm,
|
TaskForm,
|
||||||
Reminders,
|
Reminders,
|
||||||
@@ -111,16 +106,6 @@ fun SettingsScreen(
|
|||||||
else SettingsSection.entries.firstOrNull { it.name == initialSection },
|
else SettingsSection.entries.firstOrNull { it.name == initialSection },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Hoisted so the add flow can refresh the list it returns to.
|
|
||||||
val accountsViewModel: AccountsViewModel = hiltViewModel()
|
|
||||||
// Shared with the add flow's own lookup, so "sign in again" can prefill it.
|
|
||||||
val addAccountViewModel: AddAccountViewModel = hiltViewModel()
|
|
||||||
val signInAgain: (AccountEntity) -> Unit = { account ->
|
|
||||||
addAccountViewModel.startReauthentication(account.id, account.principalUrl, account.username)
|
|
||||||
section = SettingsSection.AddAccount
|
|
||||||
}
|
|
||||||
// Which account the detail screen is showing; the section alone cannot say.
|
|
||||||
var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) }
|
|
||||||
|
|
||||||
// Inside a sub-screen, system back (button or gesture) returns to the hub
|
// Inside a sub-screen, system back (button or gesture) returns to the hub
|
||||||
// rather than popping the whole Settings destination to the lists overview.
|
// rather than popping the whole Settings destination to the lists overview.
|
||||||
@@ -159,58 +144,19 @@ fun SettingsScreen(
|
|||||||
SlideInSection(visible = section == SettingsSection.Import) {
|
SlideInSection(visible = section == SettingsSection.Import) {
|
||||||
ImportScreen(onBack = { section = SettingsSection.Storage })
|
ImportScreen(onBack = { section = SettingsSection.Storage })
|
||||||
}
|
}
|
||||||
// Accounts stays composed under Add account, for the same reason Storage
|
|
||||||
// stays composed under Export: the deeper screen slides over it.
|
|
||||||
val accountsOpen = section == SettingsSection.Accounts ||
|
|
||||||
section?.parent == SettingsSection.Accounts
|
|
||||||
SlideInSection(visible = accountsOpen) {
|
|
||||||
AccountsScreen(
|
|
||||||
onAddAccount = {
|
|
||||||
// A sign-in abandoned half-way must not turn this into one.
|
|
||||||
if (addAccountViewModel.state.value.reauthenticating) addAccountViewModel.onStartOver()
|
|
||||||
section = SettingsSection.AddAccount
|
|
||||||
},
|
|
||||||
onSignInAgain = signInAgain,
|
|
||||||
onOpenAccount = {
|
|
||||||
openAccount = it
|
|
||||||
section = SettingsSection.Account
|
|
||||||
},
|
|
||||||
onOpenStorage = { section = SettingsSection.Storage },
|
|
||||||
onBack = { section = null },
|
|
||||||
viewModel = accountsViewModel,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
SlideInSection(visible = section == SettingsSection.Licences) {
|
SlideInSection(visible = section == SettingsSection.Licences) {
|
||||||
LicencesScreen(onBack = { section = null })
|
LicencesScreen(onBack = { section = null })
|
||||||
}
|
}
|
||||||
SlideInSection(visible = section == SettingsSection.Account) {
|
AccountSections(
|
||||||
openAccount?.let { id ->
|
section = section,
|
||||||
AccountDetailScreen(
|
initialAccountId = initialAccountId,
|
||||||
accountId = id,
|
onSection = { section = it },
|
||||||
onBack = { section = SettingsSection.Accounts },
|
)
|
||||||
onRemoved = { section = SettingsSection.Accounts },
|
|
||||||
onSignInAgain = signInAgain,
|
|
||||||
onOpenStorage = { section = SettingsSection.Storage },
|
|
||||||
viewModel = accountsViewModel,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SlideInSection(visible = section == SettingsSection.AddAccount) {
|
|
||||||
AddAccountScreen(
|
|
||||||
// The list stays composed underneath, so nothing re-runs its
|
|
||||||
// init and OnResume never fires on a section change. It no longer
|
|
||||||
// needs to: the accounts come from an observed query, so a new
|
|
||||||
// account — and every later sync — arrives on its own.
|
|
||||||
onDone = { section = SettingsSection.Accounts },
|
|
||||||
onBack = { section = SettingsSection.Accounts },
|
|
||||||
viewModel = addAccountViewModel,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
|
internal fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = slideInHorizontally { it } + fadeIn(),
|
enter = slideInHorizontally { it } + fadeIn(),
|
||||||
@@ -267,17 +213,19 @@ private fun SettingsHub(
|
|||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
GroupedSectionHeader(stringResource(R.string.settings_group_data))
|
GroupedSectionHeader(stringResource(R.string.settings_group_data))
|
||||||
GroupedRow(
|
if (BuildConfig.SYNC_ENABLED) {
|
||||||
title = stringResource(R.string.settings_section_accounts),
|
GroupedRow(
|
||||||
summary = stringResource(R.string.settings_accounts_subtitle),
|
title = stringResource(R.string.settings_section_accounts),
|
||||||
position = Position.Top,
|
summary = stringResource(R.string.settings_accounts_subtitle),
|
||||||
leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Tertiary) },
|
position = Position.Top,
|
||||||
onClick = { onOpenSection(SettingsSection.Accounts) },
|
leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Tertiary) },
|
||||||
)
|
onClick = { onOpenSection(SettingsSection.Accounts) },
|
||||||
|
)
|
||||||
|
}
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_section_storage),
|
title = stringResource(R.string.settings_section_storage),
|
||||||
summary = stringResource(R.string.settings_storage_subtitle),
|
summary = stringResource(R.string.settings_storage_subtitle),
|
||||||
position = Position.Bottom,
|
position = if (BuildConfig.SYNC_ENABLED) Position.Bottom else Position.Alone,
|
||||||
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Secondary) },
|
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Secondary) },
|
||||||
onClick = { onOpenSection(SettingsSection.Storage) },
|
onClick = { onOpenSection(SettingsSection.Storage) },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
|
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
@@ -77,7 +77,7 @@ private const val UNDO_WINDOW_MS = 4_000L
|
|||||||
class TaskListViewModel @Inject constructor(
|
class TaskListViewModel @Inject constructor(
|
||||||
private val repository: TasksRepository,
|
private val repository: TasksRepository,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
private val remoteLists: RemoteListRepository,
|
private val remoteLists: RemoteLists,
|
||||||
@ApplicationScope private val appScope: CoroutineScope,
|
@ApplicationScope private val appScope: CoroutineScope,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -300,7 +300,7 @@ class TaskListViewModel @Inject constructor(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val outcome = runCatching { remoteLists.rename(listId, name.trim(), color) }
|
val outcome = runCatching { remoteLists.rename(listId, name.trim(), color) }
|
||||||
.getOrElse { RemoteListRepository.Outcome.Unreachable }
|
.getOrElse { RemoteLists.Outcome.Unreachable }
|
||||||
_listWriteFailure.value = outcome.asFailure(ListWriteFailure.SAVE)
|
_listWriteFailure.value = outcome.asFailure(ListWriteFailure.SAVE)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +322,7 @@ class TaskListViewModel @Inject constructor(
|
|||||||
// existing for every other client, and could only be got back by
|
// existing for every other client, and could only be got back by
|
||||||
// re-adding the whole account.
|
// re-adding the whole account.
|
||||||
val outcome = runCatching { remoteLists.delete(listId) }
|
val outcome = runCatching { remoteLists.delete(listId) }
|
||||||
.getOrElse { RemoteListRepository.Outcome.Unreachable }
|
.getOrElse { RemoteLists.Outcome.Unreachable }
|
||||||
val failure = outcome.asFailure(ListWriteFailure.DELETE)
|
val failure = outcome.asFailure(ListWriteFailure.DELETE)
|
||||||
if (failure == null) _listDeleted.value = true else _listWriteFailure.value = failure
|
if (failure == null) _listDeleted.value = true else _listWriteFailure.value = failure
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,6 +305,8 @@
|
|||||||
<string name="onboarding_welcome_reminders_body">A notification when something is due, on your schedule.</string>
|
<string name="onboarding_welcome_reminders_body">A notification when something is due, on your schedule.</string>
|
||||||
<string name="onboarding_welcome_sync_title">Your server, or none at all</string>
|
<string name="onboarding_welcome_sync_title">Your server, or none at all</string>
|
||||||
<string name="onboarding_welcome_sync_body">Sync over CalDAV with a server you choose, or keep everything on this device.</string>
|
<string name="onboarding_welcome_sync_body">Sync over CalDAV with a server you choose, or keep everything on this device.</string>
|
||||||
|
<string name="onboarding_welcome_offline_title">No internet, by design</string>
|
||||||
|
<string name="onboarding_welcome_offline_body">This version of Agendula has no network access, so it cannot sync or connect to anything itself.</string>
|
||||||
|
|
||||||
<!-- First run: sync offer -->
|
<!-- First run: sync offer -->
|
||||||
<string name="onboarding_sync_title">Sync with your own server?</string>
|
<string name="onboarding_sync_title">Sync with your own server?</string>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Libraries merge these in (work-runtime brings ACCESS_NETWORK_STATE). -->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" tools:node="remove" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" tools:node="remove" />
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.di
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.SyncRequests
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `offline` flavor has no CalDAV, no accounts and no network permission, so
|
||||||
|
* every list is device-only and the seams `main` declares are inert.
|
||||||
|
*/
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object SyncModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideLocalWriteListener(): LocalWriteListener = LocalWriteListener.NONE
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideSyncRequests(): SyncRequests = SyncRequests { }
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideRemoteLists(): RemoteLists = NoRemoteLists
|
||||||
|
}
|
||||||
|
|
||||||
|
private object NoRemoteLists : RemoteLists {
|
||||||
|
override suspend fun creatableAccounts(): List<AccountEntity> = emptyList()
|
||||||
|
override suspend fun create(accountId: Long, name: String, color: Int): Outcome = Outcome.NoAccount
|
||||||
|
override suspend fun rename(listId: Long, name: String, color: Int): Outcome = Outcome.NoAccount
|
||||||
|
override suspend fun delete(listId: Long): Outcome = Outcome.NoAccount
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.onboarding
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
internal const val ACCOUNT_STEP_SLOTS = 0
|
||||||
|
|
||||||
|
/** Never reached: without sync the flow has no [OnboardingStep.SyncOffer] to lead here. */
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
|
@Composable
|
||||||
|
internal fun AccountStep(state: OnboardingUiState, viewModel: OnboardingViewModel) = Unit
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
/** No accounts in the `offline` flavor; the hub hides the row that leads here. */
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
|
@Composable
|
||||||
|
internal fun AccountSections(
|
||||||
|
section: SettingsSection?,
|
||||||
|
initialAccountId: Long?,
|
||||||
|
onSection: (SettingsSection?) -> Unit,
|
||||||
|
) = Unit
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user