Skip to content

[CI verification only — do not merge] prove Rust-from-source CI works - #3

Closed
BrawlerXull wants to merge 38 commits into
mainfrom
integration/d1-d2-d5
Closed

[CI verification only — do not merge] prove Rust-from-source CI works#3
BrawlerXull wants to merge 38 commits into
mainfrom
integration/d1-d2-d5

Conversation

@BrawlerXull

Copy link
Copy Markdown
Owner

Throwaway PR opened only to execute the workflows in this branch on a runner.

Fork PRs to CCExtractor run the base branch's workflow definitions, so the new
Rust/NDK setup and APK verification steps cannot run on upstream PR CCExtractor#652. This is
a same-repo PR, so they do run here.

Watching for:

  • Flutter CI — now installs Rust + cargo-ndk and compiles libtc_helper.so for
    arm64-v8a / armeabi-v7a / x86_64 during the Gradle build, then asserts the APK
    actually contains all three.
  • Build tc_helper (compile from source) — has never run on CI before.

Once both are green this PR gets closed. It is the prerequisite for purging the
committed .so binaries from the repo.

Removes the deprecated HTTP sync path so all synchronization flows through
the native TaskChampion Rust FFI bridge, matching the single-path target
architecture.

- Delete lib/app/v3/net/{fetch,add_task,complete,delete,modify,origin}.dart
  and lib/app/v3/db/update.dart (the HTTP push-sync helper).
- Rewrite saveCredentials() to validate credentials via the native sync_()
  FFI call instead of an HTTP GET /tasks probe; drop the taskReplica branch.
- Remove the taskc-HTTP sync call sites (home_controller.refreshTasks and its
  callers, show_tasks, taskc_details, home_page_app_bar) while preserving all
  local SQLite operations.
- Rebrand the 8 ccsync* localization keys to syncServer* and reword copy from
  "CCSync" to "TaskChampion sync server" across all 9 language files; collapse
  the mode-branched sync-URL label to the single path.
- Drop the obsolete HTTP fetchTasks test group and its generated mocks; realign
  the localization snapshot tests to the reworded strings.

http stays in pubspec (still used by pushNotification_service.dart).
flutter analyze: 0 errors. No test regressions vs main (pre-existing headless
plugin/MethodChannel failures unchanged).
Follow-up hardening after an adversarial review of the CCSync retirement:

- saveCredentials(): validate the entered credentials with sync_() BEFORE
  persisting them via setTaskcCreds(), so invalid credentials are never
  written to the active profile when validation fails. (The prior order —
  which mirrored the proposal's example — persisted first, leaving bad creds
  on disk while telling the user the check failed.) Validation is a live sync,
  so saving now requires connectivity to the sync server.
- Remove the orphaned taskchampionBackendUrl localization getter from the
  abstract Sentences class and all 8 locale implementations; it became dead
  when the credentials view's mode-branched URL label collapsed to the single
  syncServerBackendUrl path.
- Fix a stray non-Urdu token ("używaj") left in syncServerEasySyncTitle on a
  line the rebrand already touched.

flutter analyze: 0 errors. Full suite unchanged at +317 -18 (no new failures).
…attributes

Overhauls the Rust FFI bridge (Deliverable 2):

Build config
- Pin Cargo.toml to edition 2021 for broader Rust/flutter_rust_bridge
  compatibility; add the thiserror dependency.
- Add rust/build.rs: rerun-on-source-change tracking, target-platform
  detection (exposed via the TC_HELPER_PLATFORM compile-time env), and an
  opt-in (FRB_CODEGEN=1) binding-regeneration trigger.

Modularization & typed errors
- Extract the duplicated storage-open boilerplate into storage.rs, the task
  serializer into serialize.rs, and a thiserror-based TcHelperError into
  utils/error.rs. api.rs is now a thin FFI layer of delegators.
- Remove every .unwrap() panic; each FFI entry point returns Result<_, String>
  so flutter_rust_bridge surfaces failures to Dart as catchable exceptions with
  a real message, instead of relying on panics.

Surfaced task attributes
- The serializer now emits annotations, dependencies (depends), is_blocked,
  is_blocking, and recur, which the Dart TaskForReplica model previously
  anticipated but never received. TaskForReplica gains those fields + parsing
  (reusing the existing Annotation model).
- urgency is intentionally NOT surfaced: TaskChampion 2.0.3 does not compute or
  store an urgency value on Task, so there is nothing authoritative to expose.

Regenerated the flutter_rust_bridge bindings for the new signatures
(getAllTasksJson stays Future<String>; the mutating calls are now Future<void>,
throwing on error — all existing Dart callers ignored the old int return).

Deferred (needs mentor coordination + CI cross-compilation, not doable here):
purging the tracked android jniLibs *.so binaries. There is currently no
Android cargo/cargo-ndk integration, so Gradle bundles the committed .so
directly; removing them before automated cross-compilation exists would break
Android builds.

cargo build + cargo test: pass. flutter analyze: 0 errors. Full test suite
unchanged at +317 -20 (pre-existing headless failures only). Runtime FFI
behaviour still needs validation via an on-device build.
Adds a self-contained Hugo site and the CI to publish it (Deliverable 5).

Site (website/)
- Themeless Hugo site (all layouts in-repo, no submodule/theme fetch): landing
  page, downloads page, and a docs skeleton, with a small dark stylesheet.
- A `nightly-builds` shortcode renders the build log; the downloads page shows
  the most recent nightly APKs, newest first, with per-build status.
- Ships a custom-domain CNAME (taskwarrior.ccextractor.org).

Build log
- scripts/update_build_log.py prepends {ts, sha, msg, status, artifact, build}
  to website/data/nightly_builds.json (keeps the 90 most recent). The file ships
  empty ([]) and is populated by CI.

CI
- build-nightly.yml: daily at 02:00 UTC (+ manual) — builds the signed nightly
  APK, records the result via the collector, and commits the log to main.
- deploy-website.yml: on pushes touching website/** — builds with Hugo and
  deploys to GitHub Pages via actions/deploy-pages.
- nightlydepolyci.yml: ignore website/** and scripts/** so a build-log commit
  does not trigger a redundant APK rebuild.

Verified locally: warning-free `hugo --minify` build (Hugo 0.164.0), and the
collector + shortcode round-trip (entry renders on the downloads page; empty
state renders when the log is []).

Deviation from the proposal, documented in website/README.md: uses the modern
GitHub Actions Pages deployment instead of renaming fdroid-repo -> public-site,
so the site source stays in website/ on main with no dedicated deploy branch.
Manual infra steps (enable Pages, DNS, branch protection) are listed there.
Follow-up to the CCSync retirement: the profile page still surfaced the
"CCSync" brand -- the "changed profile mode to CCSync" snackbar label and a
commented-out "CCSync (v3)" mode radio. Rebrand the label to "Taskchampion"
(matching the retained TW3C mode) and delete the dead commented block.
pubspec used a caret constraint, so `flutter pub get` could resolve the newer
2.12.0 runtime while the committed FFI bindings were generated with codegen
2.11.1. That mismatch crashed the app at RustLib.init() on startup. Pinning the
exact 2.11.1 (matching rust/Cargo.toml's =2.11.1) keeps the Dart package,
generated bindings, and native library in lockstep.
…rust_bridge to exact 2.11.1

Adds test_dependencies_and_annotations_surface asserting depends[]/is_blocked/is_blocking and annotation entry(RFC3339)+description. Pins flutter_rust_bridge (was ^2.11.1) to avoid resolving 2.12.0, which crashes at RustLib.init().
The committed .so predated the serializer overhaul (Feb build vs June source), so the app ran stale native code. Rebuilt via cargo-ndk. Stopgap until CI compiles from source (see build-tc-helper.yml).
- home_page_body: reactively read tasksFromReplica in the Obx so the replica list rebuilds when the async FFI fetch completes (was empty: taskReplica flips true before the list is populated).
- task detail: surface annotations/depends/is_blocked/is_blocking/recur (read-only) for replica tasks.
- safe_tour: guard tutorial_coach_mark.show() against unmounted target keys (fixes recurring 'obtain target position (null)' FormatException); applied to profile + manage-task-server tours.
Regenerates the native lib from rust/ on each run so it can't go stale; builds the APK against it. Unverified on CI. armeabi-v7a left as a documented TODO (needs aws-lc-sys bindgen).
The Taskchampion (replica) home never showed tasks despite the FFI returning them. Four compounding bugs:
- show_tasks_replica wrapped its body in an Obx that read no observable -> ObxError -> blank error widget. Reactivity now lives in the parent (home_page_body) Obx, so this is a plain build.
- home_controller.onInit never initialized pendingFilter/waitingFilter from their persisted values, so pendingFilter defaulted false and the list filtered for completed tasks only, hiding all pending ones.
- the project filter treated an empty-string projectFilter ('') as an active filter, dropping every task whose project is null.
Verified on-device: replica home lists tasks; detail view surfaces annotations/depends/is_blocked/is_blocking/recur.
…gen; rebuild both ABIs

32-bit ARM has no pre-generated aws-lc-sys bindings, so it needs the crate's 'bindgen' feature (libclang). Scoped that feature to ONLY [target.armv7-linux-androideabi] so arm64/iOS/host keep using pre-generated bindings (forcing bindgen globally broke the iOS link). Rebuilt both Android ABIs from source; CI installs libclang + builds both. Host cargo test green.
tc_helper only ever uses the remote TaskChampion sync server (ServerConfig::Remote). taskchampion's default 'sync' feature also pulled server-aws + server-gcp, dragging in the entire AWS + Google-Cloud SDKs and aws-lc-rs/aws-lc-sys. Switched to default-features=false + [server-sync, bundled] (ureq + ring).

Wins:
- iOS xcframework now builds (aws-lc-sys 0.30.0 failed to cross-compile PQ/kyber for iOS); rebuilt device arm64 + fat simulator from the new Rust.
- No more bindgen hack for 32-bit ARM (aws-lc gone); dropped libcrc_fast entirely.
- libtc_helper.so 29M->6.2M (arm64), 20M->4.7M (v7a); release APK 60M->36M.
- Smaller dependency/attack surface; faster builds; simpler CI (no cmake/ninja/libclang).

Verified: host cargo test (2) pass; both Android ABIs + all 3 iOS targets build; real-server sync returns Ok() (ureq+ring); on-device the replica home lists tasks + detail rows render with the new lib, no RustLib version crash.
The search box only fed the local-taskc list (searchedTasks); the replica
view (TaskReplicaViewBuilder) read tasksFromReplica directly and ignored the
query, so typing in search did nothing in TaskChampion mode.

Add a reactive searchQuery (kept in sync by search()/toggleSearch()) and
filter the replica snapshot by task.description in home_page_body's Obx, so
the list narrows on each keystroke and restores when the query is cleared.
Matching is case-insensitive substring, consistent with the taskc path.
Expand the site beyond the initial skeleton:
- New Features page (wired into the nav) covering the full task model,
  offline-first behaviour, native TaskChampion sync, and cross-platform reach.
- New Docs guides that auto-list on /docs/: getting-started, sync-setup,
  architecture (with a data-flow diagram), and an FAQ.
- Home page: three more feature cards + an "Explore all features" link.
- CSS: style markdown tables, code blocks, and blockquotes on content pages.
Saving TaskChampion credentials validated and synced the replica, but the
home task list was only ever populated by an explicit refresh. So right
after configuring sync for the first time the user saw an empty list (until
a manual refresh or an app restart) even though the sync had succeeded —
which reads as "sync is broken".

After a successful save, reload the sync-mode flag and run a sync+refresh on
the HomeController so the freshly-synced tasks appear at once. Guarded with
Get.isRegistered and a try/catch so a transient refresh failure can never
flip an already-successful credential save.

Verified on-device against a live TaskChampion server: configuring a fresh
profile now lands on a populated task list without a manual refresh.
Two cosmetic issues in the profile sync UI:
- The "changed profile mode to<Mode>" snackbar was missing a space before
  the mode name (rendered "...mode toTaskchampion"). Add the separator.
- The per-profile config action was hard-coded to "Configure Taskserver"
  even for Taskchampion (v3) profiles. Label it from the profile's actual
  mode so a v3 profile reads "Configure Taskchampion" (the destination
  screen was already correct; only the label was wrong).

Both verified on-device across TW2<->TW3C mode switches.
The right-drawer Sort By offers 8 columns, but the replica task list only
handled Modified / Due till / Priority — Created, Start Time, and Project fell
through to `default: return 0`, so those buttons did nothing. Priority also
sorted alphabetically ('H' < 'L' < 'M'), which is the wrong severity order.

- Add `entry` to TaskForReplica (the Rust serializer already emits it; the
  model just dropped it) so "Created" can sort.
- Handle Created (entry), Start Time (start), and Project in the replica sort
  switch, alongside the existing Modified / Due till.
- Sort Priority by severity rank (H > M > L > none) instead of alphabetically.
- Drop the redundant pre-sort; unsorted/unsupported columns (Tags, Urgency —
  Urgency isn't surfaced by taskchampion 2.0.3) fall back to newest-modified.

Filters (Status Pending/Completed, Project) were already correct — verified.
Sort verified on-device against a live server: Created- matched the CLI's
entry order exactly; Priority- correctly floated H tasks to the top.
The Sort By chips built their sort value from the *localized* label
(filterDrawerCreated etc.), but every sort switch matches hardcoded English
keys ('Created+', 'Modified-', ...). So in any non-English locale the stored
value (e.g. Hindi "निर्मित+") never matched, and all sorting silently did
nothing — in the local task list too, not just the replica.

Pair each option with a stable English key (used as the sort value and matched
by the switches) plus the localized display label. The chip shows the
translated word; the stored value stays locale-independent.

Verified on-device in Hindi: tapping निर्मित (Created) now sorts by entry —
order matched the CLI's entry ordering exactly.
These were the last two no-op sort options in Taskchampion mode.

- Tags: sort by the task's tags sorted-and-joined, so tasks sharing tags group
  together and untagged tasks fall to one end.
- Urgency: TaskChampion 2.0.3 doesn't compute or store urgency (it's a
  Taskwarrior-CLI concept), so add TaskForReplica.computeUrgency() — a faithful
  port of Taskwarrior's default urgency algorithm and coefficients (priority
  6/3.9/1.8, due 12, next-tag 15, active 4, age 2 over 365d, annotations/tags/
  project 1, blocking 8, blocked -5, waiting -3). `scheduled` and UDAs are
  omitted (not surfaced by the serializer / not present).

Verified: 14 unit tests cover the formula term-by-term; on-device, Urgency-
matched the `task` CLI's own urgency ranking (React Redux 24.80 on top), and
Tags- surfaced exactly the tagged tasks grouped by tag.
Brings Taskwarrior's report system to the app — named bundles of
filter + sort + columns, instead of hand-building a filter/sort each time.

- ReportDefinition / ColumnSpec / SortCriterion models (+ Taskwarrior
  `report.*.sort`/`.columns` string parsing).
- VirtualFilterEngine: the 8 virtual tags (+ACTIVE/+READY/+BLOCKED/+BLOCKING/
  +OVERDUE/+WAITING/+PENDING/+COMPLETED/+DELETED), attribute filters
  (status:/project:/priority:), negation (-TAG), and compound AND expressions,
  evaluated over TaskForReplica (the active TaskChampion path).
- ReportService: the 9 core default reports (next/active/ready/blocked/waiting/
  completed/recurring/overdue/all) + an executor (filter -> multi-key sort,
  reusing computeUrgency). Custom reports sort above defaults; same-named custom
  overrides a default.
- TaskrcParser: extracts user-defined reports (those with a .sort) from a
  .taskrc, marked isCustom.

Note: under TaskChampion a "waiting" task is pending with a future wait (no
distinct status), so the waiting report uses the +WAITING virtual tag.

22 unit tests cover filters, executor, report merge, and .taskrc parsing.
Wires the report data layer into a screen and the app:
- ReportEngineController: loads the report catalogue (custom .taskrc reports
  first, then defaults) and runs the selected report over a fresh replica
  snapshot.
- ReportEngineView: a report picker (custom grouped above defaults, each with
  its description + filter expression) that opens a filtered+sorted task list
  with a count, pull-to-refresh, and tap-through to task detail.
- TaskrcService: loads user-defined reports from an optional .taskrc in the app
  documents dir (path surfaced in the UI); silently no-ops if absent.
- New /report-engine route + binding; a Reports icon in the home app bar.

Verified on-device against the live server: the picker lists all 9 default
reports; "next" filtered to status:pending; "overdue" applied the +OVERDUE
virtual filter (35 tasks, matching the CLI's overdue set) sorted due+.
…ng engine

- +OVERDUE matched non-pending tasks. Taskwarrior's OVERDUE virtual tag only
  ever applies to pending tasks; a completed/deleted task with a past due date
  is never "overdue". Confirmed on the live server: 13 completed tasks have a
  past due date and would have wrongly matched a bare +OVERDUE filter (e.g. in
  a future custom .taskrc report not ANDed with status:pending). Fixed by
  requiring status == 'pending' in evaluateTag's OVERDUE case.

- project: used a raw prefix match (startsWith), so `project:work` would also
  match an unrelated project like `workshop`. Taskwarrior's project filter is
  hierarchical: a match requires an exact match or a dot boundary
  (`work`/`work.sub`, not `workshop`). Fixed _matchAttribute accordingly.

- Hardware/gesture back button on the report engine screen was inconsistent
  with the in-app back arrow: from report results, tapping the AppBar arrow
  correctly returns to the picker first, but the hardware back button popped
  straight to Home, skipping the picker. Confirmed on-device. Fixed by wrapping
  the screen in PopScope and routing both back paths through one
  _handleBack() so they can't drift apart again.

2 new regression tests added (+OVERDUE excludes non-pending; project: dot
boundary) — full suite 38/38 passing. Verified on-device: hardware back now
correctly does results -> picker -> home; existing reports (overdue: 36 tasks,
all pending) show no regression.
…mpty state

runReport() swallowed any exception from Replica.getAllTasksFromReplica() and
just cleared the results list, so a genuine failure (e.g. replica not
configured, FFI error) rendered identically to "this report legitimately has
zero matches" — the user had no way to tell a broken sync from an empty report.

Add a hasError flag, set on catch, and show a distinct message in that case
("Couldn't load tasks for this report...") with pull-to-refresh to retry,
instead of the generic "No tasks match this report."

Verified on-device: normal report runs are unaffected (hasError stays false,
same task counts as before).
Audited profile management, home controller, and the add-task flow (areas not
yet reviewed this session) via parallel code review, then verified each
candidate by reading the exact source and tracing the real mechanism before
fixing — two initially-flagged "silent data loss" claims about
saveTask()/copyWith() turned out to be false positives (Rust's update_task_impl
does a targeted merge of only the supplied keys, so annotations/depends/recur
are never touched by a save) and were correctly discarded.

Confirmed and fixed:

- profiles.dart deleteDatabase(): checked whether a profile's .db file exists,
  then deleted the *unrelated* `current-profile` marker file instead of the db
  file it just confirmed existed (copy-paste error). Now deletes the db file
  itself. The old code could silently corrupt which profile is "current" when
  deleting an unrelated profile, and never actually freed the db file's space.

- splash_controller.dart changeModeTo(): unconditionally called
  selectProfile(currentProfile.value) after changing ANY profile's mode.
  selectProfile() unconditionally clears HomeController.tasks as a side
  effect, so changing an inactive profile's sync mode silently emptied the
  *active* profile's visible task list (TW2 mode) until a manual refresh —
  even though nothing about the active profile changed. Now only refreshes
  live state when the profile being modified is the one actually active.

- home_controller.dart _profileSet(): the waiting-filter reload logic read the
  persisted value, and if it was true, called toggleWaitingFilter() (flipping
  it to false) before reading it back — so a profile with the waiting filter
  enabled had it silently disabled every time HomeController initialized
  (i.e. every app cold start). Simplified to a direct read, matching the
  already-correct pendingFilter pattern on the line above it.

- add_task_bottom_sheet_new.dart: neither save handler (TW2 or TW3C/replica)
  reset `selectedDates` after adding a task, unlike every other field
  (description, project, tags, priority). A due/wait date picked for one task
  silently carried over and got applied to the next task added, in both modes.

Verified: flutter analyze clean (0 errors, same pre-existing infos/warnings as
baseline); full test suite shows the same failure count before and after
(confirmed by diffing test/utils/taskfunctions/profiles_test.dart specifically
against a stash of the pre-fix state) — no regressions. On-device live
verification for the profile/task-list-wipe and date-carryover fixes is
pending device reconnection (adb lost the connection mid-session).
…hing)

Continued the audit into task_database.dart (legacy TW2 SQLite layer),
TaskForC (lib/app/v3/models/task.dart), and profile/directory switching in
home_controller.dart. Each candidate verified against the actual source before
fixing.

- task_database.dart updateTask(): `taskDepends` was populated from
  `task.tags` instead of `task.depends` (copy-paste error). setDependsForTask()
  does a full delete-then-insert, so every legacy-mode task update silently
  destroyed the task's real dependency relationships and replaced them with
  its own tags. Now reads task.depends.

- task.dart TaskForC.fromJson(): annotations were hardcoded to an empty list
  regardless of the JSON payload (the debugPrint right above it even checked
  whether json['annotations'] was null, suggesting awareness the field could
  be present) — every legacy-mode task load silently dropped its annotations.
  Now parses them via Annotation.fromJson(). Also: `urgency` is declared
  nullable but fromJson() force-called `.toDouble()` on it with no null
  check, a crash risk on any task with no urgency value. Now null-safe.

- task_database.dart: openForProfile() didn't await _open(), and 6 methods
  (get/setTagsForTask, get/setDependsForTask, get/setAnnotationsForTask)
  called ensureDatabaseIsOpen() without awaiting it, unlike every other call
  site in the file. Narrow race window (only matters before the db has ever
  been opened), but a real, fixable inconsistency.

- home_controller.dart refreshTaskWithNewProfile() / changeInDirectory(): both
  re-pointed `storage` to the new profile/directory but then just called
  _refreshTasks() with whatever filter/sort/tag state happened to be in
  memory from the PREVIOUS profile — never reloading the new profile's own
  persisted Query/storage.tabs state. So switching profiles (or changing the
  base directory) silently carried over the old profile's project/tag/sort
  filters onto the new profile's tasks. Both now call _profileSet() to fully
  reload state from the profile actually being switched to.

- home_controller.dart _profileSet(): selectedTags.addAll(...) only ever
  accumulated tag filters. Since _profileSet() now also runs on every profile
  switch (not just app boot), a plain addAll would leak the previous
  profile's tag filters into the new one. Changed to assignAll() (a proper
  replace, confirmed via RxSet source: clear() + addAll()).

Ruled out as false positives during verification (documented for the record,
not fixed): TaskForC "silent annotation loss" claims in save/complete paths
turned out fine in an earlier round (Rust does a targeted merge); this round,
`SplashController.baseDirectory()` "called as a method" is GetX's Rx.call()
idiom (sugar for .value), used identically in 5+ files across the app — not a
bug.

Verified: flutter analyze clean (0 new errors/warnings beyond pre-existing
baseline); 4 new unit tests for TaskForC.fromJson (annotations + nullable
urgency) all pass; full test suite shows the same pre-existing failure count
as before (headless-environment sqflite/plugin unavailability, unrelated).
On-device live click-through still pending — device has not reconnected.
Addresses the "compile automatically on every build" half of the Rust
deliverable (Issue-tracker item 2b): instead of relying on a pre-built .so,
`flutter run` / `flutter build` now compile the tc_helper native library from
rust/ so the packaged binary always matches the current Rust source.

- Adds a `cargoBuildTcHelper` Gradle task wired into `preBuild` that runs
  cargo-ndk for arm64-v8a + armeabi-v7a, emitting libtc_helper.so straight into
  src/main/jniLibs/<abi>/ (single jniLibs source — avoids the "Duplicate
  resources" sourceSet-merge error you get from a second jniLibs dir).
- Resolves the Android NDK automatically (android.ndkDirectory, else the newest
  under $SDK/ndk) and adds the Android std targets on demand, so a fresh Rust
  toolchain self-heals.
- If cargo/cargo-ndk aren't on PATH the task skips with a warning and the build
  falls back to the committed .so, so a checkout without a Rust toolchain (and
  today's CI, which has none yet) still builds. When the toolchain IS present, a
  real compile error fails the build so problems surface loudly.

Verified locally: with the committed jniLibs removed, a clean
`flutter build apk --flavor production` compiled both ABIs from source and
packaged lib/{arm64-v8a,armeabi-v7a}/libtc_helper.so into the APK; with them
present, the build still succeeds (the fresh compile overwrites them in place).

Follow-up (item 2b, remainder): once the Android-building CI workflows install
the Rust toolchain, the committed jniLibs/*.so can be git-ignored, removed from
the tree, and purged from history; iOS/desktop can get analogous compile hooks.
…pion

The app had two scattered TaskChampion utility folders — utils/taskc/ (the
sync protocol payload/message/response/codec types plus the reporting engine's
virtual_filter_engine + taskrc_parser) and utils/taskchampion/ (credentials
storage). Merge the former into the latter so all TaskChampion utilities live
under one directory, following the existing lib/app/utils/<area>/ convention.

Pure relocation: files moved via git mv, all import paths rewritten
(utils/taskc/ -> utils/taskchampion/), and the mirrored test files moved to
test/utils/taskchampion/. No logic changed.

Verified: flutter analyze 0 errors; the moved protocol tests (codec/message/
payload/response) all pass; on-device the app builds (Rust auto-compiled),
launches, and the reporting engine runs a report end-to-end (exercising the
relocated virtual_filter_engine at runtime).
The app carried two parallel task models — TaskForC (local/Taskserver, which
stores entry/modified as strings) and TaskForReplica (TaskChampion, which
stores them as epoch seconds). Shared logic had to be hard-typed to one of
them, so filtering, sorting, reports and urgency only ever worked for a single
sync mode.

Introduce TaskLike: the canonical read contract both models now implement.
Most attributes already had compatible types, so implementing it required no
field changes; the two attributes whose representations genuinely differ are
exposed as normalized accessors (entryDate/modifiedDate), letting each model
keep its own storage format.

- lib/app/models/task_like.dart — the contract, plus shared date
  normalization (parseTaskDate/epochToDate).
- lib/app/models/task_urgency.dart — Taskwarrior's urgency algorithm, lifted
  off TaskForReplica and retyped to the contract so every model gets the same
  ranking. TaskForReplica.computeUrgency() now delegates, so its public API
  and behaviour are unchanged.
- VirtualFilterEngine and ReportService are now generic over
  <T extends TaskLike>, so they work for both models while preserving each
  caller's concrete element type (no List<TaskLike> leaking into callers).
- ReportService sorts entry/modified via the normalized accessors, with
  explicit null-ordering (missing dates sort first ascending) and a urgency
  cache that no longer collides when uuid is null.

Net effect: the local/Taskserver path gains reports, virtual-tag filtering and
urgency for free, and the reports/burndown view unification is now unblocked.

Two real bugs were caught by the new edge-case tests while writing this, both
in the shared date parser, and both from trying DateTime.tryParse before the
specific formats (which made those branches dead code):
- a Taskwarrior compact stamp without a trailing Z was read as *local* time
  and then shifted by the device's timezone — the same data would resolve
  differently per user;
- epoch seconds given as a string were read as a year ('1717243200' became
  the year 171726). Specific formats are now matched first, with a
  minimum-digit guard so a short numeric value is never silently read as a
  1970s timestamp.

Verified: flutter analyze 0 errors; 29 new edge-case tests covering date
formats, cross-model equivalence (identical tasks must produce identical
urgency/filter/report results through either model), sorting null-ordering,
null-uuid caching, empty lists and all-null/garbage-date tasks; the existing
urgency and reporting suites still pass unchanged; the full suite sits at the
pre-existing -18 baseline, so no regressions. A release APK builds, compiling
all 25 consumer files of both models.

On-device runtime pass is still pending — the test phone has been dropping off
adb; the previous modularization step was verified on it end-to-end.
Deleting a task in the app destroyed it outright. delete_task_impl called
TaskData::delete(), which purges the record from the replica — the `task purge`
equivalent — so the task vanished everywhere: unrecoverable, no audit trail,
and invisible to any "deleted" view on any client. Confirmed against the CLI:
after deleting in the app, the task could not be found at all, not even as
status:deleted.

Taskwarrior's own `task delete` is a *soft* delete. Match it:

- rust: delete_task_impl now sets Status::Deleted via replica.get_task()
  instead of purging via get_task_data()/TaskData::delete(). The record
  survives, still syncs, and can be restored.

- rust: update_task_impl no longer forces Status::Pending before applying the
  caller's map. That unconditional reset meant any update without an explicit
  "status" silently resurrected a completed or deleted task — which would also
  have quietly undone the soft delete above. Status is now only written when
  the caller actually supplies it.

With deleted tasks preserved, they need somewhere to be seen, so the status
filter becomes three-way instead of a pending/completed boolean:

- Query gains a string status filter (pending/completed/deleted) that migrates
  from the persisted boolean on first read, so an existing profile keeps
  whatever it was showing. The legacy getPendingFilter() is kept in step for
  the call sites still reading it.
- cycleStatusFilter(includeDeleted:) advances the cycle; "deleted" is only
  offered on the TaskChampion path, the only one that retains deleted tasks,
  so other sync modes keep the original two-way toggle.
- HomeController exposes statusFilter; the replica list filters on it directly
  rather than on the boolean; the drawer renders all three labels.
- New filterDrawerDeleted string added across all 9 language files.

Verified end-to-end on-device against a live server: deleting a task in the app
now leaves status=deleted on the server (previously the record was gone), the
task is restorable, the drawer cycles Pending -> Completed -> Deleted, and the
Deleted view lists it. 4 Rust tests (2 new: soft-delete semantics, and status
preservation across an update), flutter analyze 0 errors, Dart suite at the
pre-existing -18 baseline.
The burndown feature carried one widget per (period x sync mode):
burn_down_{daily,weekly,monthly} x {base,taskc,replica}. The nine files were
near-identical — same tooltip, same stacked pending/completed series, same
axes, same legend — and differed only in three things: where they fetched
tasks from, which date field they bucketed by, and their caption text.

Replace them with:

- burn_down_data.dart — BurnDownEntry (a task reduced to just date + status)
  and bucketBurnDown(), the daily/weekly/monthly grouping. Keeping the chart
  input this narrow is what lets one implementation serve every mode: the app
  has three task models (built-value Task, TaskForC, TaskForReplica) and each
  caller maps its own into this shape.
- views/burn_down_chart.dart — the single chart widget, parameterised by
  period plus caption/axis suffixes.

The three report screens now map the tasks they had *already fetched* and
render all three tabs from the shared widget. That also removes a double
fetch: each screen fetched tasks for its empty-state check, then every chart
re-fetched the same list.

Behaviour is deliberately preserved, including each mode's existing choice of
date field (replica buckets by `modified`, taskc/base by `entry`) and the
existing captions, so the charts render exactly as before.

Also documented a PRE-EXISTING quirk found while writing the tests rather than
silently changing it: Utils.getWeekNumbertoInt is ceil(daysSinceJan1 / 7), so
weekly buckets are fixed 7-day windows from Jan 1, not calendar weeks — two
days in the same Mon-Sun week can land in different buckets. Asserted in a
test so it is visible; making the charts follow real calendar weeks would be a
deliberate behaviour change in Utils, not part of this refactor.

13 files -> 5, 2033 lines -> 820. 12 new unit tests for the bucketing.
flutter analyze 0 errors; suite at the pre-existing -18 baseline. Verified
on-device in replica mode: Daily, Weekly and Monthly all render with the same
captions, axes and stacked series as before, and the totals roll up
consistently across the three periods.

Follow-up left alone: ReportsController still holds the now-unused
dailyInfo/weeklyInfo/monthlyInfo maps, sortBurnDown* methods and tooltip
fields. They are interleaved with the live code that populates `allData`
(which the base screen still needs), so removing them is a separate surgical
change best done when the local path can be runtime-tested.
…oller

Follow-up to the burndown unification. With the nine per-mode chart widgets
collapsed into one, the controller's own copy of that logic had no callers
left. Removed:

  - sortBurnDownDaily / sortBurnDownWeekLy / sortBurnDownMonthly
  - dailyInfo / weeklyInfo / monthlyInfo
  - the three Burndown tooltip behaviours
  - initDailyReports / initWeeklyReports / initMonthlyReports
  - the `storageWidget` field, which was assigned in all three init methods
    and never read (every other `storageWidget` in the codebase is an
    unrelated local)

plus six imports that went unused with them.

Each init method also re-populated `allData`, but onInit() already does that
in its own Future.delayed, so the base screen keeps its data from a single
remaining source instead of four competing ones. Dropping them also removes
three `Get.find<HomeController>()` calls that would have thrown had
ReportsController ever initialised before HomeController was registered.

Only the externally-referenced members are left: allData, the daily/weekly/
monthly tour keys, initReportsTour, showReportsTour and tabController.

451 lines -> 133. flutter analyze 0 errors; suite at the pre-existing -18
baseline. Verified on-device: the burndown screen renders exactly as before
(same buckets, captions and axes) with no LateInitializationError, which is
what a wrongly-removed field would have produced.

Not touched, as they predate this work and are outside the burndown cleanup:
taskDatabase / isSaved / selectedIndex / fetchTasks also appear unreferenced
outside the controller.
… too

Completes the auto-compile work started for Android, so the native library is
rebuilt from rust/ on every platform's build and can never drift from its
source. This is also the prerequisite for eventually dropping the checked-in
binaries.

- scripts/build_tc_helper_apple.sh — builds for Apple targets. `macos` runs a
  plain `cargo build --release`, which is exactly where the desktop loader
  looks (ioDirectory in frb_generated.dart). `ios` builds the device and
  simulator slices, lipos the simulator archs together and reassembles
  ios/tc_helper.xcframework.
- ios/Podfile, macos/Podfile — a `script_phase` running the above before
  compile. Hooking CocoaPods rather than editing Runner.xcodeproj/project.pbxproj
  deliberately: the pbxproj is a fragile ID-keyed format, and a bad edit there
  breaks every iOS build.
- linux/CMakeLists.txt, windows/CMakeLists.txt — a `tc_helper_rust` custom
  target that runs `cargo build --release`, wired as a dependency of the app
  target.

Every hook is non-fatal by design. If cargo, rustup or full Xcode is missing,
the hook warns and the build continues with whatever library is already
present. A contributor without a Rust toolchain must still be able to build
the app; these only upgrade the build when the toolchain exists. The iOS path
also assembles the new xcframework in a temporary location and swaps it in
only once it is complete, so a failure part-way through cannot leave the
project without a linkable framework.

Verified what this machine allows:
  * macOS path run for real — produced rust/target/release/libtc_helper.dylib
    at the exact path the frb desktop loader reads.
  * The iOS path's guard exercised: with only Xcode CommandLineTools installed
    it warns, exits 0, and leaves the existing xcframework untouched.
  * Both Podfiles pass `ruby -c` (a malformed Podfile would break pod install).
  * The CMake block was configured in an isolated harness, both with cargo
    present (creates and wires the tc_helper_rust target) and with it absent
    (warns, and configure still succeeds).
  * Android rebuilt and packaged successfully — no regression to the platform
    that was already working.

Not verified: a full iOS/Linux/Windows app build. This machine has only Xcode
CommandLineTools, so xcodebuild refuses to run and neither iOS nor macOS
Flutter builds are possible here, and Linux/Windows are the wrong OS. The
non-fatal design is what keeps that acceptable: the worst case for an
unverified hook is that it no-ops.
Flutter builds three ABIs (arm64-v8a, armeabi-v7a, x86_64) but cargo-ndk was
only invoked for the two ARM ones. The x86_64 APK therefore shipped
libflutter.so and libapp.so without libtc_helper.so: it built successfully,
installed successfully, and then crashed at RustLib.init(). A build that fails
loudly is safe; one that silently emits a broken artifact is not — and this one
would have been published the moment split APKs were attached to a release.

x86_64 is what the standard Android Studio emulator runs, so this also unblocks
emulator-based development for contributors.

- build.gradle: add -t x86_64 to cargoBuildTcHelper and the matching rustup target
- build-tc-helper.yml: same ABI list, plus a step that fails the run if any ABI
  is missing a library, so the two lists can't silently drift apart again
- commit the x86_64 .so alongside the existing two, keeping the no-Rust fallback
  complete (all three are removed together by the pending jniLibs purge)

Verified: all three split APKs and the universal APK now contain libtc_helper.so.
…he result

Only build-tc-helper.yml had a Rust toolchain; the three workflows that actually
build shippable APKs (flutterci, build-nightly, nightlydepolyci) had none. They
passed only because prebuilt .so files are committed — so CI has never once
compiled the Rust library it ships, and deleting those binaries would have made
all three emit APKs with no libtc_helper.so at all. The Gradle hook is
intentionally non-fatal, so nothing would have gone red: they would simply have
published the x86_64 bug on every ABI.

This is the prerequisite for purging the committed binaries from the repo.

- .github/actions/setup-rust-android: one composite action (NDK, Rust, the three
  Android targets, cargo-ndk, plus a cargo cache) used by all four workflows, so
  the target list has a single definition instead of four that can drift
- scripts/verify_apk_native_libs.sh: asserts a built APK contains libtc_helper.so
  for every ABI it should carry, inferring the expected set from the filename so
  it handles universal and split APKs. Checks the APK rather than jniLibs/, which
  catches "cargo never ran" and "Gradle didn't package it" alike
- every workflow that builds an APK now runs that check, before publishing:
  build-nightly records a dud as a FAILED nightly instead of a good one, and
  nightlydepolyci refuses to deploy it to F-Droid
- build-tc-helper.yml refactored onto the shared action

Verified locally: the checker passes the universal APK and all three splits, and
fails an APK with lib/x86_64/libtc_helper.so deliberately removed. The CI steps
themselves are NOT yet proven on a runner — fork PRs execute the base branch's
workflows, so these cannot run on PR CCExtractor#652.
…comment

The first CI run of the new shared action failed, in the one step that existed
only to print diagnostics: `cargo-ndk --version` exits 1 with "This binary may
only be called via `cargo ndk`". Everything load-bearing had already succeeded —
NDK installed, ANDROID_NDK_HOME resolved, all three Android targets added,
cargo-ndk built in 34s — so the toolchain setup was fine and a reporting step
took the build down with it.

- invoke it as `cargo ndk --version` (both call sites); `command -v cargo-ndk`
  stays, since testing for the file is not invoking it
- guard the diagnostics with || true so a future CLI change there can never fail
  a build; the APK verification is the real gate. (continue-on-error is not
  available on composite-action steps, hence || true.)
- build-tc-helper.yml: drop the "has not yet been run on CI" note. It has run
  green on ubuntu-latest, so the NDK version and cargo-ndk invocation were
  already runner-verified, not merely transcribed from a local build.
@BrawlerXull

Copy link
Copy Markdown
Owner Author

Both workflows green on 2b06bf1Flutter CI and Build tc_helper. taskchampion compiled from source for all three Android targets and the APK verification confirmed libtc_helper.so present for arm64-v8a, armeabi-v7a and x86_64. Purpose served; closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant