Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
6e17e76
fix: restore Android scheduled notification receivers
jjoonleo Sep 23, 2026
35f4d60
fix: support encrypted backup export on Android and iOS
jjoonleo Sep 23, 2026
8eab02b
fix: provide iOS backup restore UTI and validate picker contract
jjoonleo Sep 23, 2026
c11d970
fix: preserve profile preferences and backup revisions on partial upd…
jjoonleo Sep 23, 2026
e1ac7bc
docs: record APK validation and privacy migration decisions
jjoonleo Sep 23, 2026
0c41589
fix: reconcile notification privacy content and preserve failed cance…
jjoonleo Sep 23, 2026
089afee
fix: remove private preparation content from runtime and alarm payloads
jjoonleo Sep 23, 2026
37fc014
docs: record cold notification launch decisions and audit progress
jjoonleo Sep 23, 2026
712b370
chore: integrate recurring schedules into audit stabilization
jjoonleo Sep 23, 2026
5e4db7e
docs: record A14 decisions and integrated CI evidence
jjoonleo Sep 23, 2026
4c96bb7
docs: define lossless alarm reconciliation and lifecycle ordering
jjoonleo Sep 23, 2026
eb06f71
docs: record cancellation recovery contract and layout regression
jjoonleo Sep 23, 2026
b9f959d
fix: preserve notification launch intent and selected preparation ses…
jjoonleo Sep 23, 2026
0c84ff6
docs: define timer delivery and accessible layout audit contracts
jjoonleo Sep 23, 2026
204b6e6
docs: record verified A12 Flutter and Android release checks
jjoonleo Sep 23, 2026
bde8b2d
fix: size home schedule card from its content
jjoonleo Sep 23, 2026
bca81bc
docs: preserve home regression evidence and preparation start contract
jjoonleo Sep 23, 2026
34e6c36
docs: normalize archived test log whitespace
jjoonleo Sep 23, 2026
59d92bd
fix: honor Android exact timing permission and report actual delivery…
jjoonleo Sep 23, 2026
dac9c5a
docs: link exact timing implementation and validation evidence
jjoonleo Sep 23, 2026
edfff97
fix: preserve notification timing through registry sanitization
jjoonleo Sep 23, 2026
e2fbdf0
docs: record registry persistence fix and atomic workflow contracts
jjoonleo Sep 23, 2026
11b2b10
docs: record recovery contracts and final timing validation
jjoonleo Sep 23, 2026
e733c47
docs: define active store selection for damaged database recovery
jjoonleo Sep 23, 2026
0f76369
docs: publish damaged store recovery decisions and acceptance criteria
jjoonleo Sep 23, 2026
282db21
fix: reconcile notification providers against observed delivery state
jjoonleo Sep 23, 2026
b81259b
docs: record delivery reconciliation evidence and implementation prog…
jjoonleo Sep 23, 2026
bebf2a3
docs: record migration safeguards and delivery CI evidence
jjoonleo Sep 23, 2026
d2ae69d
docs: record bounded backup validation decisions for D05
jjoonleo Sep 23, 2026
ecd0429
fix: serialize alarm reconciliation and data replacement ownership
jjoonleo Sep 23, 2026
818e99c
docs: record A15 delivery and start D03 recovery implementation
jjoonleo Sep 23, 2026
0efdd3a
docs: verify final A15 CI and preserve D03 recovery handoff
jjoonleo Sep 24, 2026
e903db9
docs: specify zoned schedule consistency for audit A10
jjoonleo Sep 24, 2026
5f3adf1
fix: persist alarm ownership and resume verified local reset
jjoonleo Sep 24, 2026
af28e8c
docs: record reset recovery verification and time zone UI issue
jjoonleo Sep 24, 2026
6b5469a
docs: define lifecycle-aware nearest schedule query
jjoonleo Sep 24, 2026
659a5fc
chore: integrate refreshed screens while preserving recovery and deli…
jjoonleo Sep 24, 2026
074d5fd
fix: connect verified reset operation after screen integration
jjoonleo Sep 24, 2026
204804e
docs: specify history deletion and record refreshed screen integration
jjoonleo Sep 24, 2026
d06474d
fix: deliver fresh preparation step transitions from the real timer
jjoonleo Sep 24, 2026
e6c1438
docs: record verified timer delivery and reset recovery CI
jjoonleo Sep 24, 2026
7a6825f
docs: record A06 CI and T03 T04 audit contracts
jjoonleo Sep 24, 2026
c090771
docs: define mobile E2E audit acceptance and workflow handoff
jjoonleo Sep 24, 2026
d73c786
fix: make preparation start atomic and recoverable
jjoonleo Sep 24, 2026
28baf6c
docs: record A07 verification and native QA contracts
jjoonleo Sep 24, 2026
3771615
docs: define required mobile PR validation contract
jjoonleo Sep 24, 2026
271fd88
docs: record A07 remote validation and R04 release contract
jjoonleo Sep 24, 2026
4529d9f
fix: preserve backup restore outcomes and reject stale previews
jjoonleo Sep 24, 2026
aa2fda6
docs: record C01 regression proof and remaining integration work
jjoonleo Sep 24, 2026
60b2c36
docs: define R01 protection policy and A08 persistence decisions
jjoonleo Sep 24, 2026
84f374d
docs: verify C01 remote CI and clarify binary audit scope
jjoonleo Sep 24, 2026
9682c34
docs: define final mobile artifact network verification
jjoonleo Sep 24, 2026
b5c4522
docs: define current local-only documentation and setup verification
jjoonleo Sep 24, 2026
f5a41cc
docs: define privacy and store disclosure audit scope
jjoonleo Sep 24, 2026
da7e6e3
docs: prepare restore runtime integration after atomic saves
jjoonleo Sep 24, 2026
a4dfbe4
docs: define backup reminder age and snooze behavior
jjoonleo Sep 24, 2026
63913cd
docs: record exact readback hashes for existing audit issues
jjoonleo Sep 24, 2026
fb874a4
docs: define template backup timestamp preservation
jjoonleo Sep 24, 2026
3ba8814
docs: define localized result duration correction
jjoonleo Sep 24, 2026
126161d
fix: save schedule aggregates atomically
jjoonleo Sep 24, 2026
ee6c1f8
docs: preserve aggregate save evidence and localization decisions
jjoonleo Sep 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
96 changes: 96 additions & 0 deletions .github/workflows/android-notification-contract.yml
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 4 additions & 0 deletions .github/workflows/flutter_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 9 additions & 4 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@
android:name=".NativeAlarmReceiver"
android:enabled="true"
android:exported="false" />
<!-- flutter_local_notifications owns these scheduled PendingIntents. -->
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"
android:exported="false" />
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package club.devkor.ontime

/** A launch is only a hint to open the current DB-backed confirmation screen. */
object AlarmLaunchPayload {
fun sanitize(source: Map<*, *>?): Map<String, String>? {
val id = source?.get("scheduleId") as? String ?: return null
if (id.isBlank() || id.length > 512 || id.any { it.code < 32 || it.code == 127 }) {
return null
}
return mapOf(
"type" to "schedule_alarm",
"scheduleId" to id,
"alarmLaunchPayloadVersion" to "9",
"promptVariant" to "alarm",
)
}

/** Provider display fields are never forwarded to Flutter as a route. */
fun deliveryExtras(source: Map<*, *>?): Map<String, String> {
val result = sanitize(source)?.toMutableMap() ?: mutableMapOf()
for (key in listOf("nativeAlarmId", "alarmTime", "preparationStartTime")) {
val value = source?.get(key)
val number = when (value) {
is String -> value.toLongOrNull()
is Byte, is Short, is Int, is Long -> (value as Number).toLong()
else -> null
}
if (number != null) result[key] = number.toString()
}
// Do not show arbitrary content for a malformed/orphan launch identity.
if (result.containsKey("scheduleId")) {
for (key in listOf("title", "body")) {
(source?.get(key) as? String)?.let { result[key] = it }
}
}
return result
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,10 @@ class AlarmRingingActivity : Activity() {
}

private fun capturePayload(intent: Intent?) {
val extras = mutableMapOf<String, String>()
val rawExtras = intent?.extras
if (rawExtras != null) {
for (key in rawExtras.keySet()) {
rawExtras.get(key)?.let { extras[key] = it.toString() }
}
}
extras["type"] = "schedule_alarm"
extras["promptVariant"] = "alarm"
val extras = AlarmLaunchPayload.deliveryExtras(
rawExtras?.keySet()?.associateWith { key -> rawExtras.get(key) },
)
requestCode = extras["nativeAlarmId"]?.toIntOrNull()
?: extras["scheduleId"]?.hashCode()
?: 1
Expand Down Expand Up @@ -368,9 +363,7 @@ class AlarmRingingActivity : Activity() {
private fun startPreparing() {
stopRinging(showStoppedState = false)
NativeAlarmReceiver.cancelAlarmNotification(this, requestCode)
val launchPayload = payload.toMutableMap().apply {
put("alarmLaunchAction", "startPreparation")
}
val launchPayload = AlarmLaunchPayload.sanitize(payload) ?: emptyMap()
NativeLog.d(
TAG,
"AlarmRingingActivity start preparing handoff requestCode=$requestCode " +
Expand Down
Loading
Loading