From 84bb1116ecf4b1a16f6950cf23b44a27c48b0c55 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 6 Oct 2026 18:34:33 +0200 Subject: [PATCH] feat: offline flavor without sync or network access (#39) --- .forgejo/PULL_REQUEST_TEMPLATE.md | 4 +- .forgejo/workflows/ci.yaml | 8 +- .forgejo/workflows/translations.yaml | 2 +- .gitea/workflows/beta.yaml | 11 +- .gitea/workflows/release.yaml | 20 +-- CHANGELOG.md | 2 + CONTRIBUTING.md | 6 +- README.md | 12 ++ app/build.gradle.kts | 56 +++++++- .../agendula/data/sync/CredentialStoreTest.kt | 0 .../agendula/data/sync/SyncContractTest.kt | 0 app/src/full/AndroidManifest.xml | 88 ++++++++++++ .../agendula/data/di/SyncModule.kt | 131 ++++++++++++++++++ .../agendula/data/di/SyncQualifiers.kt | 34 +++++ .../agendula/data/sync/AccountRepository.kt | 0 .../agendula/data/sync/AccountStateStore.kt | 0 .../agendula/data/sync/CalDavAccounts.kt | 0 .../agendula/data/sync/CalDavGateway.kt | 0 .../data/sync/CollectionSupportStore.kt | 0 .../agendula/data/sync/CollectionSyncer.kt | 0 .../agendula/data/sync/CredentialStore.kt | 0 .../data/sync/PendingLoginFlowStore.kt | 0 .../agendula/data/sync/QuarantineStore.kt | 0 .../data/sync/RemoteListRepository.kt | 42 +----- .../agendula/data/sync/SyncAdapterService.kt | 0 .../agendula/data/sync/SyncAuthenticator.kt | 0 .../agendula/data/sync/SyncAvailability.kt | 0 .../agendula/data/sync/SyncCadenceStore.kt | 0 .../agendula/data/sync/SyncEngine.kt | 0 .../agendula/data/sync/SyncFailure.kt | 0 .../agendula/data/sync/SyncNoticeNotifier.kt | 10 +- .../agendula/data/sync/SyncNoticeStore.kt | 0 .../agendula/data/sync/SyncOnEdit.kt | 0 .../agendula/data/sync/SyncPushWorker.kt | 0 .../agendula/data/sync/SyncReport.kt | 0 .../agendula/data/sync/SyncStore.kt | 0 .../agendula/data/sync/SyncStubProvider.kt | 0 .../agendula/data/sync/SyncTrigger.kt | 6 +- .../agendula/data/sync/SyncWorker.kt | 0 .../data/sync/push/AgendulaPushService.kt | 0 .../data/sync/push/PushDistributors.kt | 0 .../data/sync/push/PushMessageHandler.kt | 0 .../agendula/data/sync/push/PushRegistrar.kt | 0 .../data/sync/push/PushRenewalWorker.kt | 0 .../agendula/data/sync/push/PushStore.kt | 0 .../ui/accounts/AccountDetailScreen.kt | 0 .../agendula/ui/accounts/AccountIdentity.kt | 0 .../agendula/ui/accounts/AccountsScreen.kt | 0 .../agendula/ui/accounts/AccountsViewModel.kt | 0 .../ui/accounts/add/AddAccountMessage.kt | 0 .../ui/accounts/add/AddAccountParts.kt | 0 .../ui/accounts/add/AddAccountScreen.kt | 0 .../ui/accounts/add/AddAccountText.kt | 0 .../ui/accounts/add/AddAccountViewModel.kt | 0 .../agendula/ui/accounts/add/AddressStep.kt | 0 .../agendula/ui/accounts/add/ListsStep.kt | 0 .../agendula/ui/accounts/add/ProviderStep.kt | 0 .../agendula/ui/accounts/add/SetupStep.kt | 0 .../agendula/ui/accounts/add/SignInStep.kt | 0 .../agendula/ui/accounts/add/SummaryStep.kt | 0 .../agendula/ui/onboarding/AccountStep.kt | 35 +++++ .../agendula/ui/settings/AccountSections.kt | 78 +++++++++++ .../{main => full}/res/xml/authenticator.xml | 0 .../res/xml/network_security_config.xml | 0 .../{main => full}/res/xml/sync_adapter.xml | 0 app/src/main/AndroidManifest.xml | 78 ----------- .../de/jeanlucmakiola/agendula/AgendulaApp.kt | 6 +- .../jeanlucmakiola/agendula/MainActivity.kt | 53 ++----- .../agendula/data/di/ChannelRefresher.kt | 22 +++ .../agendula/data/di/DataModule.kt | 62 +-------- .../agendula/data/di/LaunchHook.kt | 5 +- .../agendula/data/di/Qualifiers.kt | 31 ----- .../agendula/data/sync/RemoteLists.kt | 52 +++++++ .../agendula/data/sync/SyncRequests.kt | 6 + .../agendula/data/tasks/room/Entities.kt | 2 +- .../data/tasks/room/RoomTasksDataSource.kt | 2 +- .../agendula/ui/imports/ImportViewModel.kt | 6 +- .../ui/licences/OpenSourceLicences.kt | 11 +- .../agendula/ui/lists/ListsViewModel.kt | 22 +-- .../agendula/ui/onboarding/OnboardingFlow.kt | 21 +-- .../ui/onboarding/OnboardingViewModel.kt | 27 ++-- .../agendula/ui/onboarding/WelcomeStep.kt | 20 ++- .../agendula/ui/settings/SettingsScreen.kt | 88 +++--------- .../agendula/ui/tasklist/TaskListViewModel.kt | 8 +- app/src/main/res/values/strings.xml | 2 + app/src/offline/AndroidManifest.xml | 10 ++ .../agendula/data/di/SyncModule.kt | 36 +++++ .../agendula/ui/onboarding/AccountStep.kt | 10 ++ .../agendula/ui/settings/AccountSections.kt | 12 ++ .../data/sync/AccountStateStoreTest.kt | 0 .../data/sync/CollectionChangeTest.kt | 0 .../data/sync/CollectionSupportStoreTest.kt | 0 .../data/sync/CollectionSyncerTest.kt | 0 .../agendula/data/sync/IncrementalSyncTest.kt | 0 .../agendula/data/sync/QuarantineRetryTest.kt | 0 .../agendula/data/sync/SyncFailureTest.kt | 0 .../agendula/data/sync/SyncFakes.kt | 0 .../agendula/data/sync/SyncNoticeStoreTest.kt | 0 .../agendula/data/sync/push/PushStoreTest.kt | 0 .../accounts/add/AddAccountViewModelTest.kt | 0 dav/PROVENANCE.md | 4 +- docs/RELEASING.md | 17 ++- docs/fdroid-official/README.md | 6 + fastlane/Fastfile | 2 +- scripts/publish_codeberg_release.sh | 16 ++- scripts/publish_gitea_release.sh | 14 +- scripts/verify-release.sh | 16 ++- 107 files changed, 779 insertions(+), 433 deletions(-) rename app/src/{androidTest => androidTestFull}/java/de/jeanlucmakiola/agendula/data/sync/CredentialStoreTest.kt (100%) rename app/src/{androidTest => androidTestFull}/java/de/jeanlucmakiola/agendula/data/sync/SyncContractTest.kt (100%) create mode 100644 app/src/full/AndroidManifest.xml create mode 100644 app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt create mode 100644 app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncQualifiers.kt rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStore.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/CalDavAccounts.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStore.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/CredentialStore.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/QuarantineStore.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt (89%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/SyncAdapterService.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/SyncAuthenticator.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/SyncAvailability.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/SyncCadenceStore.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeNotifier.kt (96%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStore.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/SyncOnEdit.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/SyncPushWorker.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/SyncReport.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/SyncStubProvider.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt (98%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/push/AgendulaPushService.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/push/PushDistributors.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/push/PushMessageHandler.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/push/PushRenewalWorker.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/data/sync/push/PushStore.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountMessage.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountParts.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddressStep.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/ui/accounts/add/ListsStep.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/ui/accounts/add/ProviderStep.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/ui/accounts/add/SetupStep.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/ui/accounts/add/SignInStep.kt (100%) rename app/src/{main => full}/java/de/jeanlucmakiola/agendula/ui/accounts/add/SummaryStep.kt (100%) create mode 100644 app/src/full/java/de/jeanlucmakiola/agendula/ui/onboarding/AccountStep.kt create mode 100644 app/src/full/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt rename app/src/{main => full}/res/xml/authenticator.xml (100%) rename app/src/{main => full}/res/xml/network_security_config.xml (100%) rename app/src/{main => full}/res/xml/sync_adapter.xml (100%) create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/di/ChannelRefresher.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/sync/RemoteLists.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt create mode 100644 app/src/offline/AndroidManifest.xml create mode 100644 app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt create mode 100644 app/src/offline/java/de/jeanlucmakiola/agendula/ui/onboarding/AccountStep.kt create mode 100644 app/src/offline/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt rename app/src/{test => testFull}/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStoreTest.kt (100%) rename app/src/{test => testFull}/java/de/jeanlucmakiola/agendula/data/sync/CollectionChangeTest.kt (100%) rename app/src/{test => testFull}/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStoreTest.kt (100%) rename app/src/{test => testFull}/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt (100%) rename app/src/{test => testFull}/java/de/jeanlucmakiola/agendula/data/sync/IncrementalSyncTest.kt (100%) rename app/src/{test => testFull}/java/de/jeanlucmakiola/agendula/data/sync/QuarantineRetryTest.kt (100%) rename app/src/{test => testFull}/java/de/jeanlucmakiola/agendula/data/sync/SyncFailureTest.kt (100%) rename app/src/{test => testFull}/java/de/jeanlucmakiola/agendula/data/sync/SyncFakes.kt (100%) rename app/src/{test => testFull}/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStoreTest.kt (100%) rename app/src/{test => testFull}/java/de/jeanlucmakiola/agendula/data/sync/push/PushStoreTest.kt (100%) rename app/src/{test => testFull}/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt (100%) diff --git a/.forgejo/PULL_REQUEST_TEMPLATE.md b/.forgejo/PULL_REQUEST_TEMPLATE.md index 94555d0..de65dca 100644 --- a/.forgejo/PULL_REQUEST_TEMPLATE.md +++ b/.forgejo/PULL_REQUEST_TEMPLATE.md @@ -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 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 --> ### 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/` - [ ] Provider details stay inside `data/tasks/` - [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine) diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index 6e85308..c9ce12e 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -178,18 +178,18 @@ jobs: # No --no-daemon: the daemon lives only as long as this job container # 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' - run: ./gradlew lintDebug + run: ./gradlew lintFullDebug lintOfflineDebug # :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 # the whole safety argument in dav/PROVENANCE.md. - name: Unit tests 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' run: ./gradlew assembleDebug diff --git a/.forgejo/workflows/translations.yaml b/.forgejo/workflows/translations.yaml index c621258..2fb81d0 100644 --- a/.forgejo/workflows/translations.yaml +++ b/.forgejo/workflows/translations.yaml @@ -2,7 +2,7 @@ name: Translations # Fast, SDK-free parity check for translation resources, so Weblate PRs (which # 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" # status is always reported — like the `ci` job. A path-filtered workflow is diff --git a/.gitea/workflows/beta.yaml b/.gitea/workflows/beta.yaml index 9c87e6e..cbd1114 100644 --- a/.gitea/workflows/beta.yaml +++ b/.gitea/workflows/beta.yaml @@ -156,7 +156,7 @@ jobs: grep -E 'versionName|versionCode' app/build.gradle.kts - name: Unit tests - run: ./gradlew testDebugUnitTest + run: ./gradlew testFullDebugUnitTest # 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. @@ -175,7 +175,8 @@ jobs: storeFile=upload-keystore.jks EOF - - name: Build release APK + # Both flavors; the offline one goes to Codeberg only (issue #39). + - name: Build release APKs run: ./gradlew assembleRelease # Notes = a `## [X.Y.Z-beta.N]` section if there is one, else @@ -190,7 +191,8 @@ jobs: bash scripts/release_notes.sh "$VERSION" > release-notes.md cat release-notes.md TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \ - MAPPING=app/build/outputs/mapping/release/mapping.txt \ + MAPPING=app/build/outputs/mapping/fullRelease/mapping.txt \ + MAPPING_OFFLINE=app/build/outputs/mapping/offlineRelease/mapping.txt \ bash scripts/publish_gitea_release.sh # The point of the whole workflow, so NOT continue-on-error. @@ -202,5 +204,6 @@ jobs: run: | set -e 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 diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 7ac3c97..7cbc323 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -212,7 +212,7 @@ jobs: # Test the exact commit being shipped (only on a real release). - name: Unit tests if: env.IS_RELEASE == 'true' - run: ./gradlew testDebugUnitTest + run: ./gradlew testFullDebugUnitTest - name: Setup Android Keystore if: env.IS_RELEASE == 'true' @@ -230,7 +230,9 @@ jobs: storeFile=upload-keystore.jks 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' run: ./gradlew assembleRelease @@ -286,7 +288,7 @@ jobs: run: | set -e 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 # fastlane tree (committed at release-cut time for the official repo; this @@ -348,7 +350,8 @@ jobs: set -e bash scripts/release_notes.sh "$VERSION" > 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 # Publish the release on Codeberg, which is canonical for tags and @@ -371,7 +374,8 @@ jobs: set -e [ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > 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 # Play takes an App Bundle, not the APK: a second artifact from the same @@ -386,7 +390,7 @@ jobs: - name: Build release AAB if: env.IS_RELEASE == 'true' continue-on-error: true - run: ./gradlew bundleRelease + run: ./gradlew bundleFullRelease # NOT actions/upload-artifact@v4: its client refuses any non-github.com # server as unsupported GHES (go-gitea/gitea#36024). This fork drops that @@ -398,7 +402,7 @@ jobs: uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4 with: 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 retention-days: 14 @@ -492,7 +496,7 @@ jobs: run: | set -euo pipefail # 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; } bundle exec fastlane deploy \ aab:"$AAB" \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bdba4c..fe323db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 list. - 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 - The Tasks widget has agenda-style rows, an optional row colour and an "All diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 68700b3..d855c0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,9 +28,9 @@ layer. ## Build, test, lint ```sh -./gradlew :app:assembleDebug # build the debug APK -./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine) -./gradlew lintDebug # Android lint (CI runs this on every PR) +./gradlew :app:assembleDebug # build the debug APKs (full + offline) +./gradlew :app:testFullDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine) +./gradlew lintFullDebug lintOfflineDebug # Android lint (CI runs this on every PR) ``` CI (`.forgejo/workflows/ci.yaml`, on Codeberg) runs a reproducible-release invariant check, diff --git a/README.md b/README.md index 2756ed3..5661e9a 100644 --- a/README.md +++ b/README.md @@ -119,9 +119,21 @@ APK and a `.sha256` checksum attached — the same APK the F-Droid repository serves. For automatic updates from there, use **[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)**. +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 them, switch on *Include prereleases* for Agendula in Obtainium. +### Offline version + +Each Codeberg release also carries `agendula-offline_v.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 ```sh diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f554e58..7538320 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,4 @@ +import com.android.build.api.artifact.SingleArtifact import java.util.Properties import java.io.FileInputStream @@ -46,6 +47,20 @@ android { 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 { if (keystorePropertiesFile.exists()) { create("release") { @@ -208,26 +223,26 @@ dependencies { // @HiltWorker plumbing. implementation(libs.androidx.work.runtime.ktx) // 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) ksp(libs.androidx.hilt.compiler) // 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. implementation(libs.dmfs.lib.recur) // Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md. - implementation(project(":dav")) + "fullImplementation"(project(":dav")) // 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 // 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 // isReturnDefaultValues — so anything touching XmlUtils would NPE without a // real implementation here. - testImplementation(libs.xpp3) + "testFullImplementation"(libs.xpp3) implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) @@ -266,3 +281,34 @@ dependencies { androidTestImplementation(platform(libs.androidx.compose.bom)) 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("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) } + } +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/CredentialStoreTest.kt b/app/src/androidTestFull/java/de/jeanlucmakiola/agendula/data/sync/CredentialStoreTest.kt similarity index 100% rename from app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/CredentialStoreTest.kt rename to app/src/androidTestFull/java/de/jeanlucmakiola/agendula/data/sync/CredentialStoreTest.kt diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/SyncContractTest.kt b/app/src/androidTestFull/java/de/jeanlucmakiola/agendula/data/sync/SyncContractTest.kt similarity index 100% rename from app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/SyncContractTest.kt rename to app/src/androidTestFull/java/de/jeanlucmakiola/agendula/data/sync/SyncContractTest.kt diff --git a/app/src/full/AndroidManifest.xml b/app/src/full/AndroidManifest.xml new file mode 100644 index 0000000..d6a1f62 --- /dev/null +++ b/app/src/full/AndroidManifest.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt new file mode 100644 index 0000000..ac27212 --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt @@ -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 by preferencesDataStore( + name = CREDENTIALS_DATASTORE, + corruptionHandler = replaceCorrupted(), +) + +/** See [SyncStateDataStore] for why this is a separate file. */ +private val Context.syncStateDataStore: DataStore 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 = + context.credentialsDataStore + + @Provides + @Singleton + @SyncStateDataStore + fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore = + 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, + syncTrigger: Provider, + pendingLoginFlows: Provider, + push: Provider, + ): 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() } + } +} diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncQualifiers.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncQualifiers.kt new file mode 100644 index 0000000..0da660d --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncQualifiers.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CalDavAccounts.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CalDavAccounts.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CalDavAccounts.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CalDavAccounts.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CredentialStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CredentialStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CredentialStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CredentialStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/QuarantineStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/QuarantineStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/QuarantineStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/QuarantineStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt similarity index 89% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt index ea0bfc4..3fb8f29 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.agendula.data.sync 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.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.StorageMode @@ -53,38 +54,7 @@ class RemoteListRepository @Inject constructor( private val resolver: ProviderResolver, private val push: PushStore, @IoDispatcher private val io: CoroutineDispatcher, -) { - - /** 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 - } +) : RemoteLists { /** * 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 * is looking at the picker. */ - suspend fun creatableAccounts(): List = withContext(io) { + override suspend fun creatableAccounts(): List = withContext(io) { // ⚠️ Empty in External mode, whatever the accounts table holds. The // lists on screen then come from a third-party provider, so a row // 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. */ - suspend fun create( + override suspend fun create( accountId: Long, name: String, 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 * 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 if (list.isReadOnly) return@withContext Outcome.ReadOnly 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 * 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 if (list.isReadOnly) return@withContext Outcome.ReadOnly val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAdapterService.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncAdapterService.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAdapterService.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncAdapterService.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAuthenticator.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncAuthenticator.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAuthenticator.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncAuthenticator.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAvailability.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncAvailability.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAvailability.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncAvailability.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncCadenceStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncCadenceStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncCadenceStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncCadenceStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeNotifier.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeNotifier.kt similarity index 96% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeNotifier.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeNotifier.kt index d10e7b5..64130e9 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeNotifier.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeNotifier.kt @@ -14,6 +14,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.agendula.MainActivity +import de.jeanlucmakiola.agendula.data.di.ChannelRefresher import de.jeanlucmakiola.agendula.R import javax.inject.Inject import javax.inject.Singleton @@ -33,7 +34,7 @@ import javax.inject.Singleton @Singleton class SyncNoticeNotifier @Inject constructor( @ApplicationContext private val context: Context, -) { +) : ChannelRefresher { fun canPost(): Boolean { 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. */ 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() { 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. */ - fun refreshChannel() { + override fun refreshChannel() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val manager = context.getSystemService(NotificationManager::class.java) if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel() } 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 NOTIFICATION_ID = 2 private const val SIGN_IN_CHANNEL_ID = "account_sign_in" diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncOnEdit.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncOnEdit.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncOnEdit.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncOnEdit.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncPushWorker.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncPushWorker.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncPushWorker.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncPushWorker.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncReport.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncReport.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncReport.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncReport.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncStubProvider.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncStubProvider.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncStubProvider.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncStubProvider.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt similarity index 98% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt index fc796f2..9818c7b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt @@ -35,7 +35,11 @@ import kotlin.time.Duration.Companion.seconds @Singleton class SyncTrigger @Inject constructor( @ApplicationContext private val context: Context, -) { +) : SyncRequests { + + override fun syncNow(accountName: String) { + enqueue(accountName) + } /** * Starts a sync now. diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/AgendulaPushService.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/AgendulaPushService.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/AgendulaPushService.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/AgendulaPushService.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushDistributors.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushDistributors.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushDistributors.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushDistributors.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushMessageHandler.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushMessageHandler.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushMessageHandler.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushMessageHandler.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushRenewalWorker.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushRenewalWorker.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushRenewalWorker.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushRenewalWorker.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushStore.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushStore.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/data/sync/push/PushStore.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountMessage.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountMessage.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountMessage.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountMessage.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountParts.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountParts.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountParts.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountParts.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddressStep.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddressStep.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddressStep.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddressStep.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/ListsStep.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/ListsStep.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/ListsStep.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/ListsStep.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/ProviderStep.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/ProviderStep.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/ProviderStep.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/ProviderStep.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SetupStep.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/SetupStep.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SetupStep.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/SetupStep.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SignInStep.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/SignInStep.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SignInStep.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/SignInStep.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SummaryStep.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/SummaryStep.kt similarity index 100% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SummaryStep.kt rename to app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/add/SummaryStep.kt diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/onboarding/AccountStep.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/onboarding/AccountStep.kt new file mode 100644 index 0000000..e26f6a9 --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/onboarding/AccountStep.kt @@ -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, + ) +} diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt new file mode 100644 index 0000000..e2bb836 --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt @@ -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, + ) + } +} diff --git a/app/src/main/res/xml/authenticator.xml b/app/src/full/res/xml/authenticator.xml similarity index 100% rename from app/src/main/res/xml/authenticator.xml rename to app/src/full/res/xml/authenticator.xml diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/full/res/xml/network_security_config.xml similarity index 100% rename from app/src/main/res/xml/network_security_config.xml rename to app/src/full/res/xml/network_security_config.xml diff --git a/app/src/main/res/xml/sync_adapter.xml b/app/src/full/res/xml/sync_adapter.xml similarity index 100% rename from app/src/main/res/xml/sync_adapter.xml rename to app/src/full/res/xml/sync_adapter.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2a0182f..c2fc549 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,29 +16,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt index 544ccea..08bb3e4 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt @@ -10,10 +10,10 @@ import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.HiltAndroidApp import dagger.hilt.components.SingletonComponent 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.ReminderScheduler 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.StartupGate 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) runCatching { entryPoint.taskNotifier().refreshChannel() - entryPoint.syncNoticeNotifier().refreshChannel() + entryPoint.channelRefreshers().forEach { it.refreshChannel() } } } @@ -120,7 +120,7 @@ class AgendulaApp : Application(), Configuration.Provider { fun applicationScope(): CoroutineScope fun databaseCheckpoint(): DatabaseCheckpoint fun taskNotifier(): TaskNotifier - fun syncNoticeNotifier(): SyncNoticeNotifier + fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher> fun taskWidgetUpdater(): TaskWidgetUpdater } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt index cfe4b20..2ecc8a0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt @@ -25,7 +25,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.agendula.data.di.LaunchHook -import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.ui.imports.importIntentUri 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.crash.CrashReportActivity 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.theme.AgendulaTheme import de.jeanlucmakiola.floret.crash.CrashReportDialog @@ -55,14 +50,6 @@ class MainActivity : ComponentActivity() { @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 // over the app on the next launch (the single-crash path). A startup // 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. if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) - // 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. - // - // ⚠️ 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. + // Each hook on its own, so one waiting on the network holds up no other. if (savedInstanceState == null) { - lifecycleScope.launch { - 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) } } } + launchHooks.forEach { hook -> lifecycleScope.launch { runCatching { hook.onLaunch(intent) } } } } setContent { 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_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START" 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_TODAY = "de.jeanlucmakiola.agendula.action.TODAY" 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? { if (intent == null) return null - intent.getLongExtra(SyncNoticeNotifier.EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L } - ?.let { return NavRequest.OpenAccount(it) } - if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts + if (BuildConfig.SYNC_ENABLED) { + intent.getLongExtra(EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L } + ?.let { return NavRequest.OpenAccount(it) } + if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts + } when (intent.action) { ACTION_NEW_TASK -> return NavRequest.NewTask( listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L }, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/ChannelRefresher.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/ChannelRefresher.kt new file mode 100644 index 0000000..e4125cc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/ChannelRefresher.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt index 38a327c..e36b813 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt @@ -12,13 +12,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext 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.AndroidTasksDataSource 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.TasksRepository 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.TasksDatabase 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 * 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 by preferencesDataStore( name = "agendula_prefs", corruptionHandler = replaceCorrupted(), ) -/** See [CredentialsDataStore] for why this is a separate file. */ -private val Context.credentialsDataStore: DataStore by preferencesDataStore( - name = CREDENTIALS_DATASTORE, - corruptionHandler = replaceCorrupted(), -) - -/** See [SyncStateDataStore] for why this is a separate file. */ -private val Context.syncStateDataStore: DataStore 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 @InstallIn(SingletonComponent::class) abstract class DataBindModule { @@ -94,22 +62,6 @@ abstract class DataBindModule { @Singleton 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 // store itself, for the one caller that has to read it while another store is // the active one. @@ -128,18 +80,6 @@ object DataProvideModule { fun provideDataStore(@ApplicationContext context: Context): DataStore = context.agendulaDataStore - @Provides - @Singleton - @CredentialsDataStore - fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore = - context.credentialsDataStore - - @Provides - @Singleton - @SyncStateDataStore - fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore = - context.syncStateDataStore - @Provides @Singleton fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase = diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/LaunchHook.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/LaunchHook.kt index 2fc4e52..c35bead 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/LaunchHook.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/LaunchHook.kt @@ -7,9 +7,8 @@ import dagger.hilt.components.SingletonComponent import dagger.multibindings.Multibinds /** - * Something a build variant wants to run when the app is launched with [Intent]. - * Release builds contribute none; the debug source set adds the demo seeder, so - * that class never ships. + * Something a build variant runs on a genuine app open (not on a configuration + * change): sync in the `full` flavor, the demo seeder in debug builds. */ fun interface LaunchHook { suspend fun onLaunch(intent: Intent) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt index 2ee0ee1..aa51f70 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt @@ -17,37 +17,6 @@ annotation class IoDispatcher @Retention(AnnotationRetention.BINARY) 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 * .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/RemoteLists.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/RemoteLists.kt new file mode 100644 index 0000000..aeb76b2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/RemoteLists.kt @@ -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 + + /** 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt new file mode 100644 index 0000000..a95da71 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt @@ -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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt index 972018d..ed2c206 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt @@ -71,7 +71,7 @@ data class TaskListEntity( * * ⚠️ It was set by a rename and read by nobody. A collection's name and * 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 * the server" while nothing ever pays it is worse than no flag at all. */ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt index 205cfa6..b759ea2 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt @@ -520,7 +520,7 @@ class RoomTasksDataSource @Inject constructor( /** * ⚠️ 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. * * This used to set `is_dirty` for an account list instead, on the theory diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt index 5939e6f..4c03174 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt @@ -4,7 +4,7 @@ import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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.StorageMode import de.jeanlucmakiola.agendula.data.tasks.TasksRepository @@ -76,7 +76,7 @@ class ImportViewModel @Inject constructor( repository: TasksRepository, private val resolver: ProviderResolver, private val importer: IcsImport, - private val syncTrigger: SyncTrigger, + private val syncRequests: SyncRequests, ) : ViewModel() { private val ownStore = MutableStateFlow(resolver.mode() == StorageMode.OWN) @@ -161,7 +161,7 @@ class ImportViewModel @Inject constructor( val result = importer.import(ready.parsed, target) (chosen as? ImportChoice.Existing)?.list ?.takeIf { it.accountId != null && result.imported > 0 } - ?.let { runCatching { syncTrigger.enqueue(it.accountName) } } + ?.let { runCatching { syncRequests.syncNow(it.accountName) } } ImportStep.Done( listName = listName, imported = result.imported, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/OpenSourceLicences.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/OpenSourceLicences.kt index b699e81..efee7a1 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/OpenSourceLicences.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/OpenSourceLicences.kt @@ -1,5 +1,7 @@ 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. * @@ -30,6 +32,8 @@ data class Attribution( val licence: Licence, /** Where the source can actually be obtained — MPL-2.0 §3.2(a)'s requirement. */ 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) { @@ -54,30 +58,35 @@ object OpenSourceLicences { copyright = "© bitfire web engineering (Ricki Hirner, Bernhard Stockmann)", licence = Licence.MPL_2, sourceUrl = "https://github.com/bitfireAT/dav4jvm", + syncOnly = true, ), Attribution( name = "dnsjava", copyright = "© Brian Wellington and the dnsjava contributors", licence = Licence.BSD_3, sourceUrl = "https://github.com/dnsjava/dnsjava", + syncOnly = true, ), Attribution( name = "UnifiedPush Android connector", copyright = "© UnifiedPush contributors", licence = Licence.APACHE_2, sourceUrl = "https://codeberg.org/UnifiedPush/android-connector", + syncOnly = true, ), Attribution( name = "Tink", copyright = "© Google LLC", licence = Licence.APACHE_2, sourceUrl = "https://github.com/tink-crypto/tink-java", + syncOnly = true, ), Attribution( name = "OkHttp", copyright = "© Square, Inc.", licence = Licence.APACHE_2, sourceUrl = "https://github.com/square/okhttp", + syncOnly = true, ), Attribution( name = "Okio", @@ -121,5 +130,5 @@ object OpenSourceLicences { licence = Licence.MIT, sourceUrl = "https://codeberg.org/jlmakiola/floret-kit", ), - ) + ).filter { BuildConfig.SYNC_ENABLED || !it.syncOnly } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt index 223f440..1016ee0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel 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.recoveringFromProviderFailure 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. */ -internal fun RemoteListRepository.Outcome.asFailure(local: ListWriteFailure): ListWriteFailure? = +internal fun RemoteLists.Outcome.asFailure(local: ListWriteFailure): ListWriteFailure? = when (this) { - RemoteListRepository.Outcome.Done -> null - is RemoteListRepository.Outcome.Refused -> ListWriteFailure.SERVER_REFUSED - RemoteListRepository.Outcome.Unreachable -> ListWriteFailure.OFFLINE - RemoteListRepository.Outcome.Unsupported -> ListWriteFailure.UNSUPPORTED - RemoteListRepository.Outcome.ReadOnly -> ListWriteFailure.READ_ONLY + RemoteLists.Outcome.Done -> null + is RemoteLists.Outcome.Refused -> ListWriteFailure.SERVER_REFUSED + RemoteLists.Outcome.Unreachable -> ListWriteFailure.OFFLINE + RemoteLists.Outcome.Unsupported -> ListWriteFailure.UNSUPPORTED + RemoteLists.Outcome.ReadOnly -> ListWriteFailure.READ_ONLY // The account is gone, stopped or undecryptable, or the server said // something this call cannot place. Nothing specific to say beyond // "that did not save", which is what [local] is — and specifically not // "you are offline", which neither of them is a claim about. - RemoteListRepository.Outcome.NoAccount, - RemoteListRepository.Outcome.Unexpected, + RemoteLists.Outcome.NoAccount, + RemoteLists.Outcome.Unexpected, -> local } @@ -96,7 +96,7 @@ private const val UPCOMING_PREVIEW = 3 @HiltViewModel class ListsViewModel @Inject constructor( private val repository: TasksRepository, - private val remoteLists: RemoteListRepository, + private val remoteLists: RemoteLists, settingsPrefs: SettingsPrefs, ) : ViewModel() { @@ -215,7 +215,7 @@ class ListsViewModel @Inject constructor( // ⚠️ 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. val outcome = runCatching { remoteLists.create(accountId, name.trim(), color) } - .getOrElse { RemoteListRepository.Outcome.Unreachable } + .getOrElse { RemoteLists.Outcome.Unreachable } _writeFailure.value = outcome.asFailure(ListWriteFailure.SAVE) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingFlow.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingFlow.kt index 6afa7fc..1e100ea 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingFlow.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingFlow.kt @@ -13,7 +13,6 @@ import androidx.compose.material3.Text import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier 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.lifecycle.compose.collectAsStateWithLifecycle 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.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingSpace @@ -47,23 +44,7 @@ fun OnboardingFlow( val state by viewModel.state.collectAsStateWithLifecycle() if (state.step == OnboardingStep.Account) { - // 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, - ) + AccountStep(state, viewModel) return } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingViewModel.kt index 91f7a33..7757d30 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingViewModel.kt @@ -5,10 +5,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.agendula.BuildConfig import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.tasks.TasksRepository 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 kotlinx.coroutines.flow.MutableStateFlow 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 * 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) { accountSlots.value = count @@ -145,11 +145,11 @@ class OnboardingViewModel @Inject constructor( viewModelScope.launch { prefs.setRemindersEnabled(enabled) 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) { connectsAccount.value = connect @@ -192,11 +192,9 @@ class OnboardingViewModel @Inject constructor( step.value = when (step.value) { OnboardingStep.Reminders -> OnboardingStep.Welcome OnboardingStep.ExactAlarms -> OnboardingStep.Reminders - // Once granted there is nothing left to ask, so back skips the step. - OnboardingStep.SyncOffer -> - if (needsExactAlarms.value && !context.canScheduleExactAlarms()) OnboardingStep.ExactAlarms - else OnboardingStep.Reminders - OnboardingStep.FirstList -> OnboardingStep.SyncOffer + OnboardingStep.SyncOffer -> beforeSyncOffer() + OnboardingStep.FirstList -> + if (BuildConfig.SYNC_ENABLED) OnboardingStep.SyncOffer else beforeSyncOffer() 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() { step.value = stepAfterFirstList() } @@ -241,7 +248,7 @@ class OnboardingViewModel @Inject constructor( add(OnboardingStep.Welcome) add(OnboardingStep.Reminders) if (needsExact) add(OnboardingStep.ExactAlarms) - add(OnboardingStep.SyncOffer) + if (BuildConfig.SYNC_ENABLED) add(OnboardingStep.SyncOffer) if (sync) add(OnboardingStep.Account) if (needsList) add(OnboardingStep.FirstList) if (!sync) add(OnboardingStep.Backup) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/WelcomeStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/WelcomeStep.kt index 4c74bc7..3caf328 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/WelcomeStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/WelcomeStep.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material.icons.Icons 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.NotificationsActive import androidx.compose.material3.Button @@ -14,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.BuildConfig import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.floret.components.BenefitRow 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), ) Spacer(Modifier.height(OnboardingSpace.sm)) - BenefitRow( - icon = Icons.Rounded.CloudSync, - title = stringResource(R.string.onboarding_welcome_sync_title), - body = stringResource(R.string.onboarding_welcome_sync_body), - ) + if (BuildConfig.SYNC_ENABLED) { + BenefitRow( + icon = Icons.Rounded.CloudSync, + 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), + ) + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt index 1bbdf46..601b115 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt @@ -40,13 +40,8 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.BuildConfig 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.imports.ImportScreen import de.jeanlucmakiola.agendula.ui.licences.LicencesScreen @@ -64,7 +59,7 @@ import de.jeanlucmakiola.floret.crash.submitCrashReport import de.jeanlucmakiola.floret.locale.AppLanguage /** The settings sub-screens reached from the hub's category rows. */ -private enum class SettingsSection { +internal enum class SettingsSection { Appearance, TaskForm, Reminders, @@ -111,16 +106,6 @@ fun SettingsScreen( 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 // rather than popping the whole Settings destination to the lists overview. @@ -159,58 +144,19 @@ fun SettingsScreen( SlideInSection(visible = section == SettingsSection.Import) { 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) { LicencesScreen(onBack = { section = null }) } - SlideInSection(visible = section == SettingsSection.Account) { - openAccount?.let { id -> - AccountDetailScreen( - accountId = id, - 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, - ) - } + AccountSections( + section = section, + initialAccountId = initialAccountId, + onSection = { section = it }, + ) } } @Composable -private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) { +internal fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) { AnimatedVisibility( visible = visible, enter = slideInHorizontally { it } + fadeIn(), @@ -267,17 +213,19 @@ private fun SettingsHub( Spacer(Modifier.height(8.dp)) GroupedSectionHeader(stringResource(R.string.settings_group_data)) - GroupedRow( - title = stringResource(R.string.settings_section_accounts), - summary = stringResource(R.string.settings_accounts_subtitle), - position = Position.Top, - leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Tertiary) }, - onClick = { onOpenSection(SettingsSection.Accounts) }, - ) + if (BuildConfig.SYNC_ENABLED) { + GroupedRow( + title = stringResource(R.string.settings_section_accounts), + summary = stringResource(R.string.settings_accounts_subtitle), + position = Position.Top, + leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Tertiary) }, + onClick = { onOpenSection(SettingsSection.Accounts) }, + ) + } GroupedRow( title = stringResource(R.string.settings_section_storage), 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) }, onClick = { onOpenSection(SettingsSection.Storage) }, ) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt index 954efbd..f8873fd 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel 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.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.domain.Task @@ -77,7 +77,7 @@ private const val UNDO_WINDOW_MS = 4_000L class TaskListViewModel @Inject constructor( private val repository: TasksRepository, private val settingsPrefs: SettingsPrefs, - private val remoteLists: RemoteListRepository, + private val remoteLists: RemoteLists, @ApplicationScope private val appScope: CoroutineScope, ) : ViewModel() { @@ -300,7 +300,7 @@ class TaskListViewModel @Inject constructor( return@launch } val outcome = runCatching { remoteLists.rename(listId, name.trim(), color) } - .getOrElse { RemoteListRepository.Outcome.Unreachable } + .getOrElse { RemoteLists.Outcome.Unreachable } _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 // re-adding the whole account. val outcome = runCatching { remoteLists.delete(listId) } - .getOrElse { RemoteListRepository.Outcome.Unreachable } + .getOrElse { RemoteLists.Outcome.Unreachable } val failure = outcome.asFailure(ListWriteFailure.DELETE) if (failure == null) _listDeleted.value = true else _listWriteFailure.value = failure } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 891c4d0..99586cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -305,6 +305,8 @@ A notification when something is due, on your schedule. Your server, or none at all Sync over CalDAV with a server you choose, or keep everything on this device. + No internet, by design + This version of Agendula has no network access, so it cannot sync or connect to anything itself. Sync with your own server? diff --git a/app/src/offline/AndroidManifest.xml b/app/src/offline/AndroidManifest.xml new file mode 100644 index 0000000..1382de1 --- /dev/null +++ b/app/src/offline/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt b/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt new file mode 100644 index 0000000..1a3d7fc --- /dev/null +++ b/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt @@ -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 = 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 +} diff --git a/app/src/offline/java/de/jeanlucmakiola/agendula/ui/onboarding/AccountStep.kt b/app/src/offline/java/de/jeanlucmakiola/agendula/ui/onboarding/AccountStep.kt new file mode 100644 index 0000000..5613966 --- /dev/null +++ b/app/src/offline/java/de/jeanlucmakiola/agendula/ui/onboarding/AccountStep.kt @@ -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 diff --git a/app/src/offline/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt b/app/src/offline/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt new file mode 100644 index 0000000..bf4acd8 --- /dev/null +++ b/app/src/offline/java/de/jeanlucmakiola/agendula/ui/settings/AccountSections.kt @@ -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 diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStoreTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStoreTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStoreTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStoreTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionChangeTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionChangeTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionChangeTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionChangeTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStoreTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStoreTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStoreTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStoreTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/IncrementalSyncTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/IncrementalSyncTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/IncrementalSyncTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/IncrementalSyncTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/QuarantineRetryTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/QuarantineRetryTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/QuarantineRetryTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/QuarantineRetryTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncFailureTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/SyncFailureTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncFailureTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/SyncFailureTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncFakes.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/SyncFakes.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncFakes.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/SyncFakes.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStoreTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStoreTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStoreTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStoreTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/push/PushStoreTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/push/PushStoreTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/data/sync/push/PushStoreTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/data/sync/push/PushStoreTest.kt diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt b/app/src/testFull/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt similarity index 100% rename from app/src/test/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt rename to app/src/testFull/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt diff --git a/dav/PROVENANCE.md b/dav/PROVENANCE.md index ac6224b..8de12d5 100644 --- a/dav/PROVENANCE.md +++ b/dav/PROVENANCE.md @@ -144,11 +144,11 @@ app answered every registration with a 500. `parseDate` already used ## Build integration -- `:dav`'s tests are a **plain JVM `test` task**. CI runs `testDebugUnitTest`, +- `:dav`'s tests are a **plain JVM `test` task**. CI runs `testFullDebugUnitTest`, which exists only on Android variants, so `.forgejo/workflows/ci.yaml` names `:dav:test` explicitly. Without that the vendored suite is compiled by nobody and run by nobody, and the safety argument above is void. -- `:app` declares `testImplementation(libs.xpp3)`. `compileOnly` is not +- `:app` declares `testFullImplementation(libs.xpp3)`. `compileOnly` is not transitive, `:app`'s unit tests run on a plain JVM with no framework, and `isReturnDefaultValues = true` makes android.jar's stub factory return `null` — so anything touching `XmlUtils` would fail with an unrelated-looking NPE. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 876c83e..b84ca5a 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -11,6 +11,12 @@ carries no APK assets. Codeberg is the canonical forge; Gitea is build infrastructure. See [Two forges, one repo](#two-forges-one-repo) for how the two are wired. +The app has two product flavors. **`full`** is Agendula as described everywhere +in this document. **`offline`** (`de.jeanlucmakiola.agendula.offline`, issue +#39) has no CalDAV sync, no push and no network permission; every release and +beta builds and signs it too, but it goes **only to Codeberg**, as an extra +`agendula-offline_v.apk` asset. It is not on F-Droid or Play. + Before a stable release you can ship **betas** (`X.Y.Z-beta.N`) from the release branch. They go to **Codeberg only**, flagged as pre-releases; F-Droid and Play never see them. See [Cutting a beta](#cutting-a-beta). @@ -77,7 +83,8 @@ retries). committed `en-US` changelog. 4. **Verify the release build on a real device** — the mandatory gate: ```sh - scripts/verify-release.sh + scripts/verify-release.sh # full + scripts/verify-release.sh offline # when the release touches the offline flavor ``` It builds the `releaseTest` variant (same R8 config as `release`, debug-signed with a `.releasetest` suffix so it installs alongside the real app) and resets @@ -193,7 +200,11 @@ Releases aren't git objects and don't sync with the push mirror in either direction, so the pipeline pushes the `vX.Y.Z` tag straight to Codeberg, creates the release over the Codeberg API, and attaches `agendula_v.apk` + its `.sha256`. It's the same APK the F-Droid repo serves (same **app key**), so it -adds no trust surface. The logic lives in `scripts/publish_codeberg_release.sh`, +adds no trust surface. The offline flavor rides along as +`agendula-offline_v.apk` + `.sha256`; the full APK keeps its old name +because Obtainium filters point at it. +Obtainium users who see both offered can pin one with *Filter APKs by regular +expression* (`^agendula_v` or `^agendula-offline_v`). The logic lives in `scripts/publish_codeberg_release.sh`, which `beta.yaml` uses too (with the pre-release flag set). It skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset, but it is **not** `continue-on-error`: through 0.2.1–0.3.2 this step reported green while never once publishing, which is how a crash-fix release reached @@ -202,7 +213,7 @@ fails the release loudly. ### Google Play channel -**Artifact.** Play gets an **App Bundle** (`bundleRelease`), never the APK. It is +**Artifact.** Play gets an **App Bundle** (`bundleFullRelease`), never the APK. It is built from the same source and signing config at the very end of the `release` job, `continue-on-error`, and handed to the `play` job as a workflow artifact (via a commit-pinned fork of `upload-artifact`/`download-artifact`; the official diff --git a/docs/fdroid-official/README.md b/docs/fdroid-official/README.md index e3dc61b..2f308cb 100644 --- a/docs/fdroid-official/README.md +++ b/docs/fdroid-official/README.md @@ -48,5 +48,11 @@ off the official repo; keep that pattern if the recipe ever changes. Only a chan recipe itself (a new submodule, a build flag) needs an MR, pinned to a full commit hash, never a tag. +⚠️ **Product flavors (from 1.1.0).** The app now has `full` and `offline` +flavors, and `gradle: - yes` would build both. The first build entry for a +flavored version must say `gradle: - full`, and `AutoUpdateMode` copies the +last entry forward, so it is a one-time recipe MR before that tag lands. The +offline flavor is not submitted to F-Droid. + The recipe says `License: MIT`; `:dav` is vendored MPL-2.0 (`dav/PROVENANCE.md`). If the reviewer asks, change it to `MIT AND MPL-2.0`. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 644facd..59043d7 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -22,7 +22,7 @@ IMAGE_LOCALE = "en-US" platform :android do desc "Upload an already-built, already-signed AAB to Play" lane :deploy do |options| - aab = File.expand_path(options[:aab] || "app/build/outputs/bundle/release/app-release.aab", ROOT) + aab = File.expand_path(options[:aab] || "app/build/outputs/bundle/fullRelease/app-full-release.aab", ROOT) UI.user_error!("AAB not found at #{aab}") unless File.exist?(aab) supply( diff --git a/scripts/publish_codeberg_release.sh b/scripts/publish_codeberg_release.sh index 2154102..62a4b11 100755 --- a/scripts/publish_codeberg_release.sh +++ b/scripts/publish_codeberg_release.sh @@ -15,7 +15,8 @@ # TAG, SHA vX.Y.Z[-beta.N] and the commit it marks # PRERELEASE true | false # NOTES_FILE release notes (scripts/release_notes.sh) -# APK the signed release APK +# APK the signed release APK (full flavor) +# APK_OFFLINE optional: the signed offline-flavor APK (issue #39) set -euo pipefail if [ -z "${TOKEN:-}" ]; then echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish." @@ -27,10 +28,19 @@ if [ ! -f "$APK" ]; then echo "No release APK at $APK." >&2; exit 1; fi WORK=$(mktemp -d) trap 'rm -rf "$WORK"' EXIT +# The full APK keeps its historical name: Obtainium filters point at it. ASSET_APK="agendula_${TAG}.apk" ASSET_SUM="${ASSET_APK}.sha256" cp "$APK" "$WORK/$ASSET_APK" ( cd "$WORK" && sha256sum "$ASSET_APK" > "$ASSET_SUM" ) +ASSETS=("$ASSET_APK" "$ASSET_SUM") +if [ -n "${APK_OFFLINE:-}" ]; then + if [ ! -f "$APK_OFFLINE" ]; then echo "No offline APK at $APK_OFFLINE." >&2; exit 1; fi + ASSET_OFFLINE="agendula-offline_${TAG}.apk" + cp "$APK_OFFLINE" "$WORK/$ASSET_OFFLINE" + ( cd "$WORK" && sha256sum "$ASSET_OFFLINE" > "$ASSET_OFFLINE.sha256" ) + ASSETS+=("$ASSET_OFFLINE" "$ASSET_OFFLINE.sha256") +fi # Push the tag to Codeberg ourselves. Under Codeberg-canonical the mirror runs # Codeberg -> Gitea, so waiting for a tag to arrive from Gitea (what 0.3.2 did) @@ -84,8 +94,8 @@ for attempt in 1 2 3 4 5 6; do done if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi -# Attach APK + checksum, replacing any prior asset of the same name. -for A in "$ASSET_APK" "$ASSET_SUM"; do +# Attach APKs + checksums, replacing any prior asset of the same name. +for A in "${ASSETS[@]}"; do OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \ | jq -r --arg n "$A" '.[] | select(.name==$n) | .id') [ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true diff --git a/scripts/publish_gitea_release.sh b/scripts/publish_gitea_release.sh index c5e948f..eaee858 100755 --- a/scripts/publish_gitea_release.sh +++ b/scripts/publish_gitea_release.sh @@ -11,7 +11,8 @@ # TAG, SHA vX.Y.Z[-beta.N] and the commit it marks # PRERELEASE true | false # NOTES_FILE release notes (scripts/release_notes.sh) -# MAPPING optional path to R8's mapping.txt +# MAPPING optional path to R8's mapping.txt (full flavor) +# MAPPING_OFFLINE optional path to the offline flavor's mapping.txt set -euo pipefail : "${TOKEN:?}" "${API:?}" "${TAG:?}" "${SHA:?}" "${PRERELEASE:?}" "${NOTES_FILE:?}" @@ -57,8 +58,8 @@ echo "Created/updated Gitea release $TAG at $SHA" # so it fits the no-binaries rule for this release. Best-effort: never fail a # release over it. attach_mapping() { - local asset="mapping-${TAG#v}.txt.gz" old - gzip -c "$MAPPING" > "$WORK/$asset" + local mapping=$1 asset=$2 old + gzip -c "$mapping" > "$WORK/$asset" # Replace any prior asset of the same name (re-run safe). old=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \ | jq -r --arg n "$asset" '.[] | select(.name==$n) | .id') @@ -72,5 +73,10 @@ attach_mapping() { if [ -z "${MAPPING:-}" ] || [ ! -f "$MAPPING" ]; then echo "No mapping.txt (R8 off?) — skipping." else - attach_mapping || echo "warning: could not attach the R8 mapping to $TAG" >&2 + attach_mapping "$MAPPING" "mapping-${TAG#v}.txt.gz" \ + || echo "warning: could not attach the R8 mapping to $TAG" >&2 +fi +if [ -n "${MAPPING_OFFLINE:-}" ] && [ -f "$MAPPING_OFFLINE" ]; then + attach_mapping "$MAPPING_OFFLINE" "mapping-offline-${TAG#v}.txt.gz" \ + || echo "warning: could not attach the offline R8 mapping to $TAG" >&2 fi diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index 81b4894..a0d9413 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -11,16 +11,22 @@ # unminified debug build — or a device that already holds the permission — # silently hides. # -# Usage: scripts/verify-release.sh +# Usage: scripts/verify-release.sh [full|offline] (default: full) set -euo pipefail cd "$(dirname "$0")/.." -PKG="de.jeanlucmakiola.agendula.releasetest" -APK="app/build/outputs/apk/releaseTest/app-releaseTest.apk" +FLAVOR="${1:-full}" +case "$FLAVOR" in + full) PKG="de.jeanlucmakiola.agendula.releasetest" ;; + offline) PKG="de.jeanlucmakiola.agendula.offline.releasetest" ;; + *) echo "Unknown flavor '$FLAVOR' (full|offline)." >&2; exit 2 ;; +esac +TASK="assemble${FLAVOR^}ReleaseTest" +APK="app/build/outputs/apk/$FLAVOR/releaseTest/app-$FLAVOR-releaseTest.apk" -echo "==> Building release-candidate APK (releaseTest, R8 minified)…" -./gradlew :app:assembleReleaseTest +echo "==> Building release-candidate APK ($FLAVOR releaseTest, R8 minified)…" +./gradlew ":app:$TASK" echo "==> Installing $PKG …" adb install -r "$APK"