The app's only encryption is HTTPS/TLS (accessed via iOS) plus trivial
local XOR obfuscation, so set ITSAppUsesNonExemptEncryption to false.
This is the correct export-compliance classification and stops App Store
Connect from prompting on every upload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codemagic build VMs are ephemeral, so fetch-signing-files without a
persistent private key creates a distribution certificate that can't be
reused on later builds ("Cannot save Signing Certificates without
certificate private key"). Pass a CERTIFICATE_PRIVATE_KEY secret so the
certificate is created/reused with a key Codemagic owns.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
get-latest-testflight-build-number errors when the app has no builds
yet, which broke the first CI run. Fall back to 0 so the initial build
number is 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Set up automated cloud iOS builds and TestFlight distribution without a
local Mac:
- Add codemagic.yaml: iOS->TestFlight workflow with automatic signing,
build-number auto-increment, triggered by the vX.Y.Z release tags.
- Set the iOS bundle identifier to net.oott-security.app (was the
placeholder com.example.frontend).
- Set the iOS display name to OOTT.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The events.rs file mixed three domains: device-event recording, change
detection, and the entire notification pipeline (rendering + delivery +
sending). This made it long, gave functions side effects beyond their
stated goal (classify_* silently recorded events), and intertwined the
events and notifications logic.
Split along domain boundaries:
- model::device_events now owns DeviceChange, the shared contract.
- events records device events only (record_new_device/record_known_device);
events/detection.rs holds pure change detection.
- new notifications module owns rendering, delivery, and sending
(notifications.rs + delivery.rs + render.rs); pushover/error moved here.
Data now flows one way: events produces DeviceChange, notifications
consumes it, both depend only on model. Scanners/pipeline/main orchestrate.
classify_* renamed to record_* so the write is the stated goal; send and
send_notification collapsed into persist_and_deliver.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record what happened on each known-device sighting instead of only
"seen": a baseline DeviceSeen (history heartbeat, no notification) plus
DeviceChanged and DeviceBackOnline events, each deduplicated
independently so a recent routine sighting no longer suppresses a
genuine change or return notification. The frontend chart now trusts the
event type for its marker and tooltip rather than comparing each event's
snapshot against the device's current state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gate notification persistence and delivery on the device-event
deduplication window, not just the device_events table: record_event now
reports whether it recorded, and classify_new_device/classify_existing_device
suppress their changes when the sighting is a duplicate.
Treat an empty->value IP fill as not a change via a new ip_changed helper
(mirroring vendor_changed), so a device gaining its first address raises no
"changed" notification.
Key deduplication on (mac, scanner, event_type) instead of (mac, ip,
scanner), so repeated sightings of the same kind collapse regardless of the
reported address.
Also delete stale oott.db-wal/-shm in run_tests.sh to avoid init_db panics.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The API docs nav entry uses the origin-relative path "/api/docs", which
Uri.parse left scheme- and host-less, so canLaunchUrl/launchUrl could not
open it (e.g. in Docker). Resolve external links against the current page
so they carry a scheme and host; absolute URLs pass through unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New devices are never registered, so the status line only ever read
"Not registered" and added no information. Remove it from the single
new-device notification body (the consolidated summary never had one).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an "API Docs" navigation entry that opens /api/docs in a new tab.
It is shown only in the wide-mode navigation rail (not the compact
bottom bar) and sits just before About.
Generalises the navigation destination model so an entry can be an
in-app route or an external link, and can be restricted to wide layouts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The root URI had no content of its own and only showed a pointer
message; send visitors straight to the front-end instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backend: notifications now show a plain "-" for an absent name, vendor, or
device type (was empty string / "(unknown)" / "Unknown"), via a single
UNKNOWN_PLACEHOLDER constant.
Frontend:
- Empty/unknown values render as an em dash everywhere, centralised in a new
Placeholders.emptyValue constant (replaces inline '—' and '(unknown)').
- Route paths moved to a new Routes class, used by the router and every
navigation call site.
- Device event type modelled as a DeviceEventType enum mirroring the backend
(NewDevice/DeviceSeen) instead of bare string comparisons.
- Hardcoded EdgeInsets/SizedBox spacing replaced with existing Insets tokens.
Tests and formatting updated; all backend and frontend tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Flutter web bundle was built with the default base href of "/" but is
served under "/web/", so index.html loaded while every asset reference
resolved to the site root and 404'd, leaving a blank page. Build the bundle
with --base-href=/web/ so asset URLs match the mount point.
Also correct the root guidance text (the API explorer is at /api/docs, not
/api) and add an /api -> /api/docs redirect for convenience.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Trim the storage considerations down to assumptions, per-scenario
estimates, and the levers to control DB size. Add an authentication
and access-control section noting OOTT has no built-in user management
and warning not to gate /api behind an external auth layer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Active scanners (ARP, SNMP) now accumulate every change across a whole
scan and emit one notification per type via events::notify: a single
device produces the usual single-device notification (carrying its MAC),
while two or more produce one consolidated summary with an empty
mac_address. Device events are still recorded per device.
Notification bodies no longer include MAC or IP addresses; the title
MAC fallback is masked to the last two octets. Summaries list up to
three devices then "…and N more devices".
Split sighting handling so record_sighting persists + records the event
and returns Vec<DeviceChange>; passive listeners (mDNS, SSDP, DHCP) use
record_and_notify since they see one device per event.
Also fixes NotificationType::from_str never mapping "DeviceChanged",
which made those notifications round-trip from the DB as Other.
Frontend: the card already hides the device link when mac_address is
null; added widget tests for the present/absent link cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Let users collapse the wide-mode NavigationRail to an icons-only compact
view to reclaim horizontal space. A bottom-pinned double-chevron toggle
sits centred when compact and slides to the rail's right side when
extended, animating in sync with the rail. The choice is persisted via
the nav_rail_extended preference (defaults to extended).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enabling WAL per pooled connection raced several concurrent journal-mode
switches when r2d2 eagerly opens the pool at startup, producing transient
"disk I/O error" and "database is locked" failures. WAL is a persistent
property of the database file, so enable it once in init_db (before the
scanners and web server start) and keep only the genuinely per-connection
pragmas (busy_timeout, synchronous, foreign_keys) in the pool initializer.
Also remove the -wal/-shm sidecars in run.sh: deleting oott.db but leaving
a stale write-ahead log behind makes SQLite open the fresh database against
an orphaned log and fail with "disk I/O error".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tune SQLite and remove blocking calls from async/scan paths:
- Enable WAL + synchronous=NORMAL + busy_timeout + foreign_keys on each
pooled connection, so the five scanners, web server, and retention no
longer contend on the default rollback journal / FULL fsync.
- Run DB work in axum handlers via spawn_blocking (db::run_blocking) so
synchronous rusqlite calls no longer block tokio worker threads.
- Deliver notifications on a dedicated task fed by a bounded channel; the
blocking Pushover HTTP call runs in spawn_blocking, so a slow or
unreachable Pushover can never stall device discovery.
- Make get_db_connection() return Result instead of panicking, so pool
exhaustion surfaces as a 500 rather than crashing the process.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add ActiveStatusCell/PassiveStatusCell wrappers in the scanners common
module so the five per-scanner status.rs files reduce to a single static;
replace parse_parameter_bool/int/string with one generic parse_parameter
over FromStr; extract the shared LIMIT/OFFSET paging clause into
db::apply_paging; and drop a no-op for-loop in the ARP sender.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Collapse the five scanner status models into two shared shapes,
ActiveScannerStatus and PassiveScannerStatus, mirroring the backend's
active/passive vocabulary. Replace the five near-identical per-scanner
detail card files with a single scanner_status_cards.dart (two shared
resolvers plus a config list), and rebuild the combined home card to
iterate a list of scanners with two shape resolvers instead of five
copy-pasted resolve methods.
Extract two reusable mixins:
- PeriodicRebuild: the shared once-a-second "rebuild to refresh elapsed
text" timer used by the scanner cards and the stale indicator.
- PaginatedListState: the shared pagination state, page-size/page-count
getters, cancel-token-aware fetch orchestration, and disposal used by
the device and notification lists.
No behaviour change; ~900 lines removed. Tests and analyzer pass.
The ARP and SNMP scanners reported every online sighting, so a device
seen on multiple IPs or via duplicate ARP replies was counted more than
once. Fold the dedup into ActiveStatus::record_scan, which now takes the
device slice and reports the number of distinct MAC addresses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The list endpoints now return a total count alongside the page so the
front-end can show how many pages exist and offer a last-page jump.
Backend: add count(is_new) and count_devices(...) (sharing a WHERE-builder
with list_devices so page and count can't drift), wrap both list responses
in {items, total_count} structs, and register them with utoipa.
Front-end: parse the wrapper shape (dropping the fetch-one-extra trick),
add a Last-page button and a responsive "Page X of Y" / "X / Y" label to
the shared PaginationBar, and track the total in both lists. Notifications
re-sync the count on every fetch and decrement it locally on mark-read/
unread removals so the count stays accurate without a re-fetch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wide layout makes the leading device-type icon column header tappable to
sort; narrow layout gains a "Device Type" option in the sort sheet. The
backend already whitelisted device_type as a sort column.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The device detail chart's time-range selector (Today / Last week / ...)
rendered as a SegmentedButton that overflowed on narrow phone layouts.
Reuse the responsive FilterSelector widget so the same control is used
for both the list filters and the chart: segmented pills on wide layouts,
a compact dropdown combo box on phones.
To keep the two controls consistent, FilterSelector now renders a
SegmentedButton (instead of ChoiceChips) on wide layouts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the notifications list's wholesale redraw with a SliverAnimatedList
driven by a GlobalKey, keeping `_items` in lockstep with the animated state.
- Background refreshes (poll, pull-to-refresh, resume, route pop, mark-all)
reconcile against the fetched page: departed rows slide out, newly fetched
rows slide in at the top with a theme-coloured arrival highlight, and
surviving rows stay put (with in-place read-state recolouring under "All").
- Filter/page changes and the initial load reset the list (fresh key) so the
new dataset appears instantly without per-row animation.
- Read/unread removals are owned by the list: buttons play a slide/fade exit,
while swipes let Dismissible animate and then reconcile, avoiding double
animation and the disposed-widget race.
Add the arrival highlight overlay to NotificationCard and cover the new
behaviour with widget tests (swipe-out, flash-in, external removal, in-place
"All" mark, filter reset).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Changing pages in the notifications or devices list gave no cue that
the next page was loading. Render an indeterminate progress bar at the
shell level, pinned flush against the bottom of the page body (above
the nav bar on phones, the screen bottom on wide layouts), driven by a
shared paginationLoading notifier the lists set while fetching. The
pagination bar keeps disabling its buttons during the fetch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wrap compact (phone) device rows in a Card instead of a bare
Material+Divider so they get the same rounded, spaced look as the
notifications list. Drop the row divider on narrow layouts and reduce
the phone page size to 5 to account for the taller card rows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On narrow layouts the devices list's filter chips (Not registered /
Registered / All) competed for horizontal space with the Sort and Filter
icon buttons and overlapped. Introduce a reusable FilterSelector that
keeps the chips on wide layouts but collapses to a compact dropdown
button on phones, and use it for both the devices and notifications
lists for consistency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On phone-width layouts (< Breakpoints.medium) the notification and device
lists now request fewer items per page so the list and its pagination bar
fit on screen together on common current phones. Notifications use 4 items
and devices 6 on phones; wider layouts keep 5 and 10 respectively. The
initial fetch is deferred to didChangeDependencies so the page size can read
the screen width from MediaQuery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The devices list already supported pull-to-refresh; mirror that on the
notifications list by wrapping its CustomScrollView in a RefreshIndicator
with AlwaysScrollableScrollPhysics. Keep the existing list visible during
a refresh (_isLoading = _items.isEmpty) instead of flashing the skeleton,
matching the devices list behaviour.
Add widget tests covering pull-to-refresh for both lists.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
run_android_emulator.sh already boots the emulator and launches the app; it
delegated the boot step to run_android.sh. Inline that logic as a
boot_emulator() helper and drop the now-redundant script.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The pushover config section is now only required when notifications.method
is "pushover". Validation at startup rejects the missing-section case so a
misconfiguration fails fast instead of erroring on every notification.
Updates the sample TOML, README and nix module for consistency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the _goToPage wrapper with an optional scrollToTop flag on
_fetchPage, so there is a single fetch entry point. The pagination bar
passes scrollToTop: true; all other callers keep the current behaviour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>