-
Notifications
You must be signed in to change notification settings - Fork 0
Build out the Where tax tracking app #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a5279aa
e6c7c25
d76b199
64ce431
60d1a5e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| # Where | ||
|
|
||
| There **Where** app is an app designed to track how much time to spend in a given location, usually state by state within the US. | ||
|
|
||
| Any time per day spent in a state counts entirely towards a day in that state. So it's possible to be in two states on a given day. | ||
|
|
||
|
|
||
|
|
||
| ## Build Information | ||
|
|
||
| Repo-wide build, formatting, and agent-sync rules live in the root [`AGENTS.md`](../AGENTS.md). | ||
|
|
||
| ## Layout | ||
|
|
||
| | Piece | Role | Path | | ||
| |-------|------|------| | ||
| | **Where** | iOS app (`com.stuff.where`) | [`Where/`](Where/) — [`Sources/`](Where/Sources/), [`Tests/`](Where/Tests/), [`Resources/`](Where/Resources/) | | ||
| | **WhereCore** | SPM library (domain / non-UI) | [`WhereCore/Sources/`](WhereCore/Sources/), [`Tests/`](WhereCore/Tests/) | | ||
| | **WhereUI** | SPM library (SwiftUI); depends on **WhereCore** | [`WhereUI/Sources/`](WhereUI/Sources/), [`Tests/`](WhereUI/Tests/) | | ||
| | **WhereTesting** | SPM test helpers for Where targets | [`WhereTesting/Sources/`](WhereTesting/Sources/) | | ||
|
|
||
| The app target depends on the **WhereUI** package product only; **WhereUI** pulls in **WhereCore** via SPM. | ||
|
|
||
| ## Xcode / Tuist | ||
|
|
||
| Targets are declared in the root [`Project.swift`](../Project.swift) and [`Package.swift`](../Package.swift): | ||
|
|
||
| - **WhereTests** — tests the app; depends on the **Where** target and **WhereTesting** (no **StuffTestHost**). | ||
| - **WhereCoreTests** and **WhereUITests** — hosted unit tests (see `unitTests` in `Project.swift`): **StuffTestHost** + **WhereTesting** + the corresponding package product. | ||
|
|
||
| Regenerate the Xcode project from the repo root with `./ide`. | ||
|
|
||
| ## Conventions | ||
|
|
||
| Match the rest of the repo: **Swift Testing** only, bundle IDs under `com.stuff.*`, and shared cross-feature code in [`Shared/`](../Shared/) (**StuffCore**, etc.) rather than duplicating it under `Where/`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import BackgroundTasks | ||
| import Foundation | ||
| import WhereData | ||
|
|
||
| @MainActor | ||
| final class AppRefreshCoordinator { | ||
| static let taskIdentifier = "com.stuff.where.app-refresh" | ||
|
|
||
| private let trackingController: BackgroundTrackingController | ||
|
|
||
| init(trackingController: BackgroundTrackingController) { | ||
| self.trackingController = trackingController | ||
| } | ||
|
|
||
| func register() { | ||
| BGTaskScheduler.shared.register( | ||
| forTaskWithIdentifier: Self.taskIdentifier, | ||
| using: nil, | ||
| ) { [weak self] task in | ||
| guard let refreshTask = task as? BGAppRefreshTask else { | ||
| task.setTaskCompleted(success: false) | ||
| return | ||
| } | ||
|
|
||
| self?.handle(refreshTask) | ||
| } | ||
| } | ||
|
|
||
| func schedule() { | ||
| let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier) | ||
| request.earliestBeginDate = Date(timeIntervalSinceNow: 4 * 60 * 60) | ||
| try? BGTaskScheduler.shared.submit(request) | ||
| } | ||
|
|
||
| private func handle(_ task: BGAppRefreshTask) { | ||
| schedule() | ||
| task.expirationHandler = {} | ||
|
|
||
| Task { | ||
| await trackingController.refreshMonitoring() | ||
| task.setTaskCompleted(success: true) | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import CoreLocation | ||
| import Foundation | ||
| import WhereCore | ||
|
|
||
| actor AppleJurisdictionResolver: JurisdictionResolving { | ||
| private let geocoder = CLGeocoder() | ||
|
Check warning on line 6 in Where/Where/Sources/Tracking/AppleJurisdictionResolver.swift
|
||
|
|
||
| func jurisdiction(for event: TrackingWakeEvent) async -> TaxJurisdiction { | ||
| let location = CLLocation( | ||
| latitude: event.latitude, | ||
| longitude: event.longitude, | ||
| ) | ||
|
|
||
| do { | ||
| let placemarks = try await geocoder.reverseGeocodeLocation(location) | ||
| guard let placemark = placemarks.first else { | ||
| return .unknown | ||
| } | ||
|
|
||
| guard placemark.isoCountryCode == "US" else { | ||
| return .unknown | ||
| } | ||
|
|
||
| guard | ||
| let administrativeArea = placemark.administrativeArea, | ||
| let state = USState(rawValue: administrativeArea.uppercased()) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reverse geocoder always returns unknown for US statesHigh Severity
Reviewed by Cursor Bugbot for commit 60d1a5e. Configure here. |
||
| else { | ||
| return .unknown | ||
| } | ||
|
|
||
| return .state(state) | ||
| } catch { | ||
| return .unknown | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| import CoreLocation | ||
| import Foundation | ||
| import WhereCore | ||
| import WhereData | ||
|
|
||
| @MainActor | ||
| final class AppleLocationBridge: NSObject, CLLocationManagerDelegate, LocationAuthorizationProviding, LocationWakeSource { | ||
|
Check warning on line 7 in Where/Where/Sources/Tracking/AppleLocationBridge.swift
|
||
| private let locationManager: CLLocationManager | ||
| private weak var trackingController: BackgroundTrackingController? | ||
| private let regionRadius: CLLocationDistance = 150_000 | ||
|
|
||
| init( | ||
| locationManager: CLLocationManager = CLLocationManager(), | ||
| trackingController: BackgroundTrackingController? = nil, | ||
| ) { | ||
| self.locationManager = locationManager | ||
| self.trackingController = trackingController | ||
| super.init() | ||
| self.locationManager.delegate = self | ||
| self.locationManager.desiredAccuracy = kCLLocationAccuracyKilometer | ||
| self.locationManager.allowsBackgroundLocationUpdates = true | ||
| self.locationManager.pausesLocationUpdatesAutomatically = true | ||
| } | ||
|
|
||
| func attach(trackingController: BackgroundTrackingController) { | ||
| self.trackingController = trackingController | ||
| } | ||
|
|
||
| func currentAuthorizationStatus() async -> TrackingAuthorizationStatus { | ||
| map(locationManager.authorizationStatus) | ||
| } | ||
|
|
||
| func requestAlwaysAuthorization() async { | ||
| locationManager.requestAlwaysAuthorization() | ||
| } | ||
|
|
||
| func startMonitoring(configuration: TrackingMonitoringConfiguration) async { | ||
| if configuration.wantsSignificantLocationChanges { | ||
| locationManager.startMonitoringSignificantLocationChanges() | ||
| } | ||
|
|
||
| if configuration.wantsVisitMonitoring { | ||
| locationManager.startMonitoringVisits() | ||
| } | ||
|
|
||
| refreshRegions(configuration: configuration) | ||
| } | ||
|
|
||
| func refreshRegionMonitoring(configuration: TrackingMonitoringConfiguration) async { | ||
| refreshRegions(configuration: configuration) | ||
| } | ||
|
|
||
| func stopMonitoring() async { | ||
| locationManager.stopMonitoringSignificantLocationChanges() | ||
| locationManager.stopMonitoringVisits() | ||
|
|
||
| for region in locationManager.monitoredRegions { | ||
| locationManager.stopMonitoring(for: region) | ||
| } | ||
| } | ||
|
|
||
| func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { | ||
| guard let trackingController else { return } | ||
| let status = map(manager.authorizationStatus) | ||
| Task { | ||
| await trackingController.handleAuthorizationStatusChange(status) | ||
| } | ||
| } | ||
|
|
||
| func locationManager(_: CLLocationManager, didUpdateLocations locations: [CLLocation]) { | ||
| guard let trackingController, let location = locations.last else { return } | ||
|
|
||
| let event = TrackingWakeEvent( | ||
| timestamp: location.timestamp, | ||
| reason: .significantLocationChange, | ||
| latitude: location.coordinate.latitude, | ||
| longitude: location.coordinate.longitude, | ||
| horizontalAccuracy: location.horizontalAccuracy, | ||
| ) | ||
|
|
||
| Task { | ||
| await trackingController.handleWakeEvent(event) | ||
| } | ||
| } | ||
|
|
||
| func locationManager(_: CLLocationManager, didVisit visit: CLVisit) { | ||
| guard let trackingController else { return } | ||
|
|
||
| let event = TrackingWakeEvent( | ||
| timestamp: visit.arrivalDate == .distantPast ? Date() : visit.arrivalDate, | ||
| reason: .visit, | ||
| latitude: visit.coordinate.latitude, | ||
| longitude: visit.coordinate.longitude, | ||
| horizontalAccuracy: visit.horizontalAccuracy, | ||
| ) | ||
|
|
||
| Task { | ||
| await trackingController.handleWakeEvent(event) | ||
| } | ||
| } | ||
|
|
||
| func locationManager(_: CLLocationManager, didEnterRegion region: CLRegion) { | ||
| handleRegionWake(region: region) | ||
| } | ||
|
|
||
| func locationManager(_: CLLocationManager, didExitRegion region: CLRegion) { | ||
| handleRegionWake(region: region) | ||
| } | ||
|
|
||
| private func refreshRegions(configuration: TrackingMonitoringConfiguration) { | ||
| for region in locationManager.monitoredRegions { | ||
| locationManager.stopMonitoring(for: region) | ||
| } | ||
|
|
||
| for jurisdiction in configuration.jurisdictionRegions.prefix(20) { | ||
| guard let region = circularRegion(for: jurisdiction) else { continue } | ||
| locationManager.startMonitoring(for: region) | ||
| } | ||
| } | ||
|
|
||
| private func circularRegion(for jurisdiction: TaxJurisdiction) -> CLCircularRegion? { | ||
| let coordinate: CLLocationCoordinate2D | ||
|
|
||
| switch jurisdiction { | ||
| case .state(.california): | ||
| coordinate = CLLocationCoordinate2D(latitude: 36.7783, longitude: -119.4179) | ||
| case .state(.newYork): | ||
| coordinate = CLLocationCoordinate2D(latitude: 42.9538, longitude: -75.5268) | ||
| case .state, .unknown: | ||
| return nil | ||
| } | ||
|
|
||
| let region = CLCircularRegion( | ||
| center: coordinate, | ||
| radius: regionRadius, | ||
| identifier: jurisdiction.abbreviation, | ||
| ) | ||
| region.notifyOnEntry = true | ||
| region.notifyOnExit = true | ||
| return region | ||
| } | ||
|
|
||
| private func handleRegionWake(region: CLRegion) { | ||
| guard | ||
| let trackingController, | ||
| let circularRegion = region as? CLCircularRegion | ||
| else { | ||
| return | ||
| } | ||
|
|
||
| let event = TrackingWakeEvent( | ||
| timestamp: Date(), | ||
| reason: .regionBoundary, | ||
| latitude: circularRegion.center.latitude, | ||
| longitude: circularRegion.center.longitude, | ||
| ) | ||
|
|
||
| Task { | ||
| await trackingController.handleWakeEvent(event) | ||
| } | ||
| } | ||
|
|
||
| private func map(_ status: CLAuthorizationStatus) -> TrackingAuthorizationStatus { | ||
| switch status { | ||
| case .notDetermined: | ||
| .notDetermined | ||
| case .restricted: | ||
| .restricted | ||
| case .denied: | ||
| .denied | ||
| case .authorizedWhenInUse: | ||
| .authorizedWhenInUse | ||
| case .authorizedAlways: | ||
| .authorizedAlways | ||
| @unknown default: | ||
| .restricted | ||
| } | ||
| } | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Background task never completed when self is nil
Medium Severity
In the
registerclosure, ifselfisnilwhen the background task fires,self?.handle(refreshTask)silently no-ops without ever callingsetTaskCompletedon the task. The system expects every deliveredBGTaskto be completed; failing to do so can cause the OS to throttle or stop scheduling future background refresh tasks for this app.Reviewed by Cursor Bugbot for commit 60d1a5e. Configure here.