diff --git a/.github/workflows/android-notification-contract.yml b/.github/workflows/android-notification-contract.yml
new file mode 100644
index 00000000..2fd55cef
--- /dev/null
+++ b/.github/workflows/android-notification-contract.yml
@@ -0,0 +1,96 @@
+name: Android Notification Contract
+
+on:
+ workflow_dispatch:
+ pull_request:
+ branches: [main, 'release/**', 'hotfix/**']
+ push:
+ branches: [main, 'release/**', 'hotfix/**']
+
+permissions:
+ contents: read
+
+concurrency:
+ group: android-notification-contract-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ release-manifest:
+ runs-on: ubuntu-latest
+ timeout-minutes: 35
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+ - uses: subosito/flutter-action@v2
+ with:
+ flutter-version: '3.44.4'
+ channel: stable
+ cache: true
+ - name: Verify source receiver contract and negative fixtures
+ run: python3 -B -m unittest discover -s test/tool -p 'test_*.py' -v
+ - name: Install packages and generate local outputs
+ run: |
+ flutter pub get
+ dart run build_runner build --delete-conflicting-outputs
+ dart run tool/check_generated_dart_policy.dart
+ dart run tool/check_local_only_boundary.dart
+ - name: Create disposable validation signing key
+ run: |
+ keytool -genkeypair -noprompt \
+ -keystore "$RUNNER_TEMP/notification-validation.jks" \
+ -alias notification-validation -keyalg RSA -keysize 2048 -validity 2 \
+ -dname 'CN=OnTime CI Validation Only' \
+ -storepass validation-only -keypass validation-only
+ echo "ANDROID_KEYSTORE_PATH=$RUNNER_TEMP/notification-validation.jks" >> "$GITHUB_ENV"
+ - name: Build release configuration for validation only
+ env:
+ ANDROID_KEYSTORE_PASSWORD: validation-only
+ ANDROID_KEY_ALIAS: notification-validation
+ ANDROID_KEY_PASSWORD: validation-only
+ run: flutter build apk --release
+ - name: Inspect manifest packaged inside APK
+ run: |
+ mkdir -p artifacts/android-notification-contract
+ "$ANDROID_HOME/cmdline-tools/latest/bin/apkanalyzer" manifest print \
+ build/app/outputs/flutter-apk/app-release.apk \
+ > artifacts/android-notification-contract/apk-manifest.xml
+ python3 tool/check_android_notification_manifest.py \
+ artifacts/android-notification-contract/apk-manifest.xml
+ - name: Verify merged manifests and record artifact identity
+ run: |
+ python3 - <<'PY'
+ import hashlib, json, os, shutil, subprocess
+ from pathlib import Path
+ paths = sorted(Path('build/app/intermediates').glob('merged_manifest*/release/**/AndroidManifest.xml'))
+ if not paths:
+ raise SystemExit('No release merged manifest was produced')
+ subprocess.run(['python3', 'tool/check_android_notification_manifest.py', *map(str, paths)], check=True)
+ evidence = Path('artifacts/android-notification-contract')
+ for index, path in enumerate(paths):
+ shutil.copyfile(path, evidence / f'merged-manifest-{index}.xml')
+ apk = Path('build/app/outputs/flutter-apk/app-release.apk')
+ record = {
+ 'commit': os.environ['GITHUB_SHA'],
+ 'artifact': str(apk),
+ 'sha256': hashlib.sha256(apk.read_bytes()).hexdigest(),
+ 'signing': 'disposable CI validation key; not a store release',
+ 'merged_manifests': [str(path) for path in paths],
+ 'device_delivery_verified': False,
+ }
+ (evidence / 'identity.json').write_text(json.dumps(record, indent=2) + '\n')
+ print(json.dumps(record, indent=2))
+ PY
+ - name: Upload validation evidence
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: android-notification-contract-${{ github.sha }}
+ path: artifacts/android-notification-contract/
+ if-no-files-found: warn
+ retention-days: 30
+ - name: Remove disposable key
+ if: always()
+ run: rm -f "$RUNNER_TEMP/notification-validation.jks"
diff --git a/.github/workflows/flutter_test.yml b/.github/workflows/flutter_test.yml
index c1ccf182..4ebd3b3a 100644
--- a/.github/workflows/flutter_test.yml
+++ b/.github/workflows/flutter_test.yml
@@ -16,6 +16,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ - name: Verify Android notification manifest contract
+ run: |
+ python3 -B -m unittest discover -s test/tool -p 'test_*.py' -v
+ python3 tool/check_android_notification_manifest.py android/app/src/main/AndroidManifest.xml
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.44.4"
diff --git a/CONTEXT.md b/CONTEXT.md
index c8acedb1..d327cabd 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -21,6 +21,10 @@ _Avoid_: Server result, analytics event, transient completion screen
Past and completed Schedule details retained locally until the user explicitly deletes the Schedule.
_Avoid_: Server archive, analytics history, automatic retention window, score aggregate
+**Nearest Upcoming Schedule**:
+The unfinished Schedule whose resolved occurrence instant is the earliest at or after the current instant.
+_Avoid_: Today's Schedule, active Schedule Preparation Session
+
**Local Punctuality Score**:
The percentage of eligible Schedule Outcomes completed On Time since the latest Punctuality Score Reset.
_Avoid_: Server score, lifetime score, reward points, zero before first result
@@ -324,7 +328,7 @@ _Avoid_: Loaded range, stream range, cached range
- Backup cryptography uses a reviewed library implementation and never a custom cipher or password-key-derivation construction.
- A **Backup Restore** validates the complete OnTime Backup before changing active local data.
- A successful **Backup Restore** replaces rather than merges the current Local Profile data.
-- A failed **Backup Restore** leaves the current Local Profile data unchanged.
+- A **Backup Restore** that fails before durable replacement preserves the current Local Profile data. Failure of cleanup after a committed replacement keeps the restored data active and exposes pending recovery; it does not claim rollback.
- An **OnTime Backup** contains all **Durable OnTime Data**, including the Local Profile, onboarding state, Schedules, Places, Preparations, retained outcomes, and app preferences.
- An **OnTime Backup** excludes an active Preparation Run, Early Start Session, device identifier, scheduled-notification registry, operating-system permission, cache, and log.
- A successful **Backup Restore** recalculates Schedule Notifications from restored future Schedules instead of restoring device-specific registrations.
@@ -378,7 +382,8 @@ _Avoid_: Loaded range, stream range, cached range
- **Platform-Managed Data Transfer** is not a supported OnTime backup or recovery path.
- OnTime excludes its active data from **Platform-Managed Data Transfer** wherever the Supported Product Platform exposes such control.
- OnTime does not promise that every operating system or device manufacturer will honor the requested exclusion.
-- Each app installation has exactly one **Installation Data Key** stored only in device-bound secure storage.
+- Each app installation has exactly one active **Installation Data Key** stored only in device-bound secure storage.
+- A verified Backup Restore may temporarily retain inactive recovery key material; it does not represent another active Installation Data Key and is removed after safe recovery cleanup.
- The **Installation Data Key** is not synchronized, backed up, exported, or included in an OnTime Backup.
- OnTime uses the **Installation Data Key** without requiring a Backup Password or biometric prompt during normal app use.
- Losing the **Installation Data Key** makes active Durable OnTime Data unreadable; recovery requires a readable OnTime Backup or a destructive local-data reset.
@@ -407,7 +412,7 @@ _Avoid_: Loaded range, stream range, cached range
- An **Ambiguous Schedule Time** requires the user to choose one of the two represented offsets before saving.
- A Schedule and its OnTime Backup preserve the user's chosen occurrence of an **Ambiguous Schedule Time**.
- Time-zone rules are updated only through an OnTime app release, not through a runtime network request.
-- After a time-zone rule update, a future Schedule keeps its intended civil date, time, and Schedule Time Zone while OnTime recalculates its absolute instant and Schedule Notification.
+- After a time-zone rule update, a future Schedule keeps its intended civil date, time, and Schedule Time Zone; a changed occurrence is proposed for explicit confirmation before its saved commitment and Schedule Notification are changed.
- OnTime identifies future Schedules whose absolute notification time changed because of a time-zone rule update; completed and past Schedules remain unchanged.
- The **Local Data Store** is the only authoritative persistence boundary for Durable OnTime Data.
- All durable preferences belong to the Local Data Store together with the Local Profile and user content.
@@ -425,7 +430,7 @@ _Avoid_: Loaded range, stream range, cached range
- A Punctuality Score Reset does not delete Schedules or Schedule Outcomes; Local Data Reset removes the complete score history.
- An OnTime Backup preserves the Local Punctuality Score aggregation basis and its latest reset boundary.
- **Schedule History** has no age-based or storage-based automatic expiration.
-- Deleting a Schedule removes its name, Place, note, Preparation, Schedule Outcome detail, delivery registrations, and appearance in current backup data.
+- Deleting a Schedule removes its details and its exclusively owned content; content still owned or referenced by another Schedule, Recurring Schedule, default Preparation, or Preparation template is retained for that independent purpose. The deleted Schedule is absent from later OnTime Backups. Only minimal content-free delivery ownership may remain while cancellation is unresolved, and it is removed after cancellation is confirmed.
- After a completed Schedule is deleted, only its non-identifying On Time or Late aggregate contribution may remain for Local Punctuality Score continuity.
- Restoring an OnTime Backup may reintroduce a Schedule deleted after that backup's Backup Cutoff, and Restore Preview warns about that replacement effect.
- A **Schedule** has one effective **Preparation** for calculating preparation timing.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index cecb5844..c5324e06 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -62,6 +62,20 @@
android:name=".NativeAlarmReceiver"
android:enabled="true"
android:exported="false" />
+
+
+
+
+
+
+
+
+
+