diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 221af5dcc..e65c9aacb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,6 +161,9 @@ jobs: - name: Build info run: bash tool/check/build_info.sh + - name: Widget township resources + run: bash tool/check/widget_township_resources.sh + - name: Codegen is up to date run: | bash tool/dev/codegen.sh diff --git a/ios/DPIPWidgetIntentsExtension/IntentHandler.swift b/ios/DPIPWidgetIntentsExtension/IntentHandler.swift index e35aebd9f..61270f1a4 100644 --- a/ios/DPIPWidgetIntentsExtension/IntentHandler.swift +++ b/ios/DPIPWidgetIntentsExtension/IntentHandler.swift @@ -7,6 +7,12 @@ final class IntentHandler: INExtension, return self } + func defaultLocation( + for intent: WeatherWidgetConfigurationIntent + ) -> WidgetLocation? { + makeCurrentWidgetLocation() + } + func provideLocationOptionsCollection( for intent: WeatherWidgetConfigurationIntent, with completion: @escaping ( @@ -15,11 +21,6 @@ final class IntentHandler: INExtension, ) -> Void ) { let catalog = WidgetLocationCatalogStore().load() - let currentLocationDisplayString = String( - localized: "intent.current_location", - bundle: .main, - comment: "Current-location option in weather widget configuration." - ) let locations = makeWidgetLocationOptions( from: catalog, @@ -36,4 +37,22 @@ final class IntentHandler: INExtension, nil ) } + + private func makeCurrentWidgetLocation() -> WidgetLocation { + let option = makeCurrentWidgetLocationOption( + displayString: currentLocationDisplayString + ) + return WidgetLocation( + identifier: option.identifier, + display: option.displayString + ) + } + + private var currentLocationDisplayString: String { + String( + localized: "intent.current_location", + bundle: .main, + comment: "Current-location option in weather widget configuration." + ) + } } diff --git a/ios/DPIPWidgetIntentsExtension/WidgetLocationCatalog.swift b/ios/DPIPWidgetIntentsExtension/WidgetLocationCatalog.swift deleted file mode 100644 index 7bee14cae..000000000 --- a/ios/DPIPWidgetIntentsExtension/WidgetLocationCatalog.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation - -struct WidgetLocationCatalog: Decodable { - let schemaVersion: Int - let locations: [WidgetLocationCatalogLocation] -} - -struct WidgetLocationCatalogLocation: Decodable { - let regionCode: String - let displayName: String - let administrativeAreaName: String - let latitude: Double - let longitude: Double -} - -struct WidgetLocationCatalogStore { - private let appGroupIdentifier = - "group.com.exptech.dpip.dpip.widgets" - - func load() -> WidgetLocationCatalog? { - guard let containerURL = - FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: appGroupIdentifier - ) - else { - return nil - } - - let url = containerURL - .appendingPathComponent("WidgetSnapshots") - .appendingPathComponent("location-catalog.json") - - guard let data = try? Data(contentsOf: url) else { - return nil - } - - return Self.decode(data) - } - - static func decode(_ data: Data) -> WidgetLocationCatalog? { - guard let catalog = try? JSONDecoder().decode( - WidgetLocationCatalog.self, - from: data - ) else { - return nil - } - - guard catalog.schemaVersion == 1 else { - return nil - } - - return catalog - } -} diff --git a/ios/DPIPWidgetIntentsExtension/WidgetLocationOptions.swift b/ios/DPIPWidgetIntentsExtension/WidgetLocationOptions.swift index 74ffbdf5f..28ba24315 100644 --- a/ios/DPIPWidgetIntentsExtension/WidgetLocationOptions.swift +++ b/ios/DPIPWidgetIntentsExtension/WidgetLocationOptions.swift @@ -5,13 +5,21 @@ struct WidgetLocationOption: Equatable { let displayString: String } +func makeCurrentWidgetLocationOption( + displayString: String +) -> WidgetLocationOption { + WidgetLocationOption( + identifier: "current-location", + displayString: displayString + ) +} + func makeWidgetLocationOptions( from catalog: WidgetLocationCatalog?, currentLocationDisplayString: String ) -> [WidgetLocationOption] { var options = [ - WidgetLocationOption( - identifier: "current-location", + makeCurrentWidgetLocationOption( displayString: currentLocationDisplayString ) ] diff --git a/ios/DPIPWidgets/CurrentLocationCurrentWeatherWidgetRefreshService.swift b/ios/DPIPWidgets/CurrentLocationCurrentWeatherWidgetRefreshService.swift new file mode 100644 index 000000000..20ae98dde --- /dev/null +++ b/ios/DPIPWidgets/CurrentLocationCurrentWeatherWidgetRefreshService.swift @@ -0,0 +1,100 @@ +struct CurrentLocationCurrentWeatherWidgetRefreshService: Sendable { + typealias AcquireLocation = @MainActor @Sendable () async + -> WidgetCurrentLocationResult + typealias ResolveTownship = @Sendable ( + WidgetCurrentLocation + ) -> WidgetResolvedWeatherLocation? + typealias BeginWrite = @Sendable ( + CurrentWeatherSnapshotAddress + ) throws -> CurrentWeatherSnapshotWriteToken + + private let acquireLocation: AcquireLocation + private let resolveTownship: ResolveTownship + private let beginWrite: BeginWrite + private let pipeline: CurrentWeatherWidgetRefreshPipeline + + init( + acquireLocation: @escaping AcquireLocation, + resolver: WidgetTownshipResolver, + weatherClient: CurrentWeatherClient, + clock: WidgetServerClock, + writer: CurrentWeatherWidgetSnapshotWriter + ) { + self.init( + acquireLocation: acquireLocation, + resolveTownship: { currentLocation in + resolver.resolve(currentLocation) + }, + beginWrite: { address in + try writer.beginWrite(for: address) + }, + pipeline: CurrentWeatherWidgetRefreshPipeline( + weatherClient: weatherClient, + clock: clock, + writer: writer + ) + ) + } + + init( + acquireLocation: @escaping AcquireLocation, + resolveTownship: @escaping ResolveTownship, + beginWrite: @escaping BeginWrite, + pipeline: CurrentWeatherWidgetRefreshPipeline + ) { + self.acquireLocation = acquireLocation + self.resolveTownship = resolveTownship + self.beginWrite = beginWrite + self.pipeline = pipeline + } + + func refresh() async -> CurrentWeatherWidgetRefreshResult { + let writeToken: CurrentWeatherSnapshotWriteToken + do { + writeToken = try beginWrite(.currentLocation) + } catch { + return .failed + } + + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "current location acquisition started" + ) + #endif + + let currentLocationResult = await acquireLocation() + let currentLocation: WidgetCurrentLocation + switch currentLocationResult { + case .acquired(let acquiredLocation): + currentLocation = acquiredLocation + case .unavailable, .timedOut: + return .unavailable + case .failed: + return .failed + } + + #if DEBUG + WidgetWeatherRefreshDiagnostics.log("township resolution started") + #endif + guard let location = resolveTownship(currentLocation), + location.address == .currentLocation + else { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "township resolution unavailable" + ) + #endif + return .unavailable + } + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "township resolved regionCode=\(location.regionCode)" + ) + #endif + + return await pipeline.refresh( + location: location, + writeToken: writeToken + ) + } +} diff --git a/ios/DPIPWidgets/CurrentWeatherClient.swift b/ios/DPIPWidgets/CurrentWeatherClient.swift new file mode 100644 index 000000000..21107d93d --- /dev/null +++ b/ios/DPIPWidgets/CurrentWeatherClient.swift @@ -0,0 +1,87 @@ +import Foundation + +enum CurrentWeatherClientError: Error, Equatable { + case invalidCoordinate + case invalidURL + case invalidResponse + case httpStatus(Int) + case responseTooLarge +} + +struct CurrentWeatherClient: Sendable { + private static let scheme = "https" + private static let host = "api.core-tnn1.exptech.dev" + private static let realtimePath = "/api/v5/meteor/weather/realtime" + + private static let requestTimeout: TimeInterval = 6 + private static let maximumResponseSize = 128 * 1024 + + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func makeURL(latitude: Double, longitude: Double) throws -> URL { + guard latitude.isFinite, + longitude.isFinite, + (-90.0...90.0).contains(latitude), + (-180.0...180.0).contains(longitude) + else { + throw CurrentWeatherClientError.invalidCoordinate + } + + var components = URLComponents() + components.scheme = Self.scheme + components.host = Self.host + components.path = "\(Self.realtimePath)/\(latitude),\(longitude)" + + guard let url = components.url else { + throw CurrentWeatherClientError.invalidURL + } + + return url + } + + func fetch( + latitude: Double, + longitude: Double + ) async throws -> CurrentWeatherRemoteDTO? { + let url = try makeURL( + latitude: latitude, + longitude: longitude + ) + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = Self.requestTimeout + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw CurrentWeatherClientError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + throw CurrentWeatherClientError.httpStatus( + httpResponse.statusCode + ) + } + + guard data.count <= Self.maximumResponseSize else { + throw CurrentWeatherClientError.responseTooLarge + } + + let jsonObject = try JSONSerialization.jsonObject(with: data) + + if let object = jsonObject as? [String: Any], + object.isEmpty { + return nil + } + + return try JSONDecoder().decode( + CurrentWeatherRemoteDTO.self, + from: data + ) + } +} diff --git a/ios/DPIPWidgets/CurrentWeatherRemoteDTO.swift b/ios/DPIPWidgets/CurrentWeatherRemoteDTO.swift new file mode 100644 index 000000000..1584f8ed9 --- /dev/null +++ b/ios/DPIPWidgets/CurrentWeatherRemoteDTO.swift @@ -0,0 +1,137 @@ +import Foundation + +/// The subset of `/api/v5/meteor/weather/realtime/:coords` used by the +/// current-weather widget. +struct CurrentWeatherRemoteDTO: Decodable, Sendable { + let stationName: String + let time: Int + let weather: String + let weatherCode: Int + let temperature: Double? + let humidity: Int? + let rain: Double? + + var condition: CurrentWeatherWidgetCondition { + currentWeatherWidgetCondition(for: weatherCode) + } + + private enum CodingKeys: String, CodingKey { + case station + case time + case data + } + + private struct Station: Decodable, Sendable { + let name: String + } + + private struct WeatherData: Decodable, Sendable { + let weather: String + let weatherCode: Int + let temperature: Double? + let humidity: Int? + let rain: Double? + + private enum CodingKeys: String, CodingKey { + case weather + case weatherCode + case temperature + case humidity + case rain + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + weather = try container.decode(String.self, forKey: .weather) + weatherCode = try container.decode(Int.self, forKey: .weatherCode) + temperature = try Self.decodeNullableDouble( + from: container, + forKey: .temperature + ) + humidity = try Self.decodeNullableInt( + from: container, + forKey: .humidity + ) + rain = try Self.decodeNullableDouble( + from: container, + forKey: .rain + ) + } + + private static func decodeNullableDouble( + from container: KeyedDecodingContainer, + forKey key: CodingKeys + ) throws -> Double? { + guard let value = try container.decodeIfPresent( + Double.self, + forKey: key + ) else { + return nil + } + + return value == -99 ? nil : value + } + + private static func decodeNullableInt( + from container: KeyedDecodingContainer, + forKey key: CodingKeys + ) throws -> Int? { + guard let value = try container.decodeIfPresent( + Int.self, + forKey: key + ) else { + return nil + } + + return value == -99 ? nil : value + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let station = try container.decode(Station.self, forKey: .station) + let data = try container.decode(WeatherData.self, forKey: .data) + + stationName = station.name + time = try container.decode(Int.self, forKey: .time) + weather = data.weather + weatherCode = data.weatherCode + temperature = data.temperature + humidity = data.humidity + rain = data.rain + } +} + +/// Mirrors Dart's authoritative `weatherConditionForCode()` classification. +func currentWeatherWidgetCondition( + for weatherCode: Int +) -> CurrentWeatherWidgetCondition { + guard weatherCode > 0 else { + return .unknown + } + + switch weatherCode % 100 { + case 1, 2, 5: + return .fog + case 3, 4, 14, 15, 16, 17, 18, 19: + return .thunderstorm + case 6, 7, 11, 13: + return .rain + case 8, 9, 10, 12: + return .snow + default: + break + } + + switch weatherCode / 100 { + case 1: + return .clear + case 2: + return .cloudy + case 3: + return .overcast + default: + return .unknown + } +} diff --git a/ios/DPIPWidgets/CurrentWeatherSnapshotTime.swift b/ios/DPIPWidgets/CurrentWeatherSnapshotTime.swift new file mode 100644 index 000000000..e489b77b5 --- /dev/null +++ b/ios/DPIPWidgets/CurrentWeatherSnapshotTime.swift @@ -0,0 +1,4 @@ +struct CurrentWeatherSnapshotTime: Equatable, Sendable { + let calibratedNowUnixMilliseconds: Int64 + let calibratedTimeOffsetMilliseconds: Int +} diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetRefreshPipeline.swift b/ios/DPIPWidgets/CurrentWeatherWidgetRefreshPipeline.swift new file mode 100644 index 000000000..8b7c0a715 --- /dev/null +++ b/ios/DPIPWidgets/CurrentWeatherWidgetRefreshPipeline.swift @@ -0,0 +1,172 @@ +enum CurrentWeatherWidgetRefreshResult: Equatable, Sendable { + case refreshed + case superseded + case noObservation + case unavailable + case failed +} + +struct CurrentWeatherWidgetRefreshPipeline: Sendable { + typealias FetchWeather = @Sendable ( + Double, + Double + ) async throws -> CurrentWeatherRemoteDTO? + typealias SynchronizeClock = @Sendable () async + -> CurrentWeatherSnapshotTime? + typealias CommitSnapshot = @Sendable ( + CurrentWeatherWidgetSnapshot, + CurrentWeatherSnapshotWriteToken + ) throws -> CurrentWeatherSnapshotWriteResult + + private let fetchWeather: FetchWeather + private let synchronizeClock: SynchronizeClock + private let commitSnapshot: CommitSnapshot + + init( + weatherClient: CurrentWeatherClient, + clock: WidgetServerClock, + writer: CurrentWeatherWidgetSnapshotWriter + ) { + self.init( + fetchWeather: { latitude, longitude in + try await weatherClient.fetch( + latitude: latitude, + longitude: longitude + ) + }, + synchronizeClock: { + await Self.synchronizedSnapshotTime(clock: clock) + }, + commitSnapshot: { snapshot, token in + try writer.write(snapshot, using: token) + } + ) + } + + init( + fetchWeather: @escaping FetchWeather, + synchronizeClock: @escaping SynchronizeClock, + commitSnapshot: @escaping CommitSnapshot + ) { + self.fetchWeather = fetchWeather + self.synchronizeClock = synchronizeClock + self.commitSnapshot = commitSnapshot + } + + func refresh( + location: WidgetResolvedWeatherLocation, + writeToken: CurrentWeatherSnapshotWriteToken + ) async -> CurrentWeatherWidgetRefreshResult { + #if DEBUG + async let observation = fetchWeatherWithDiagnostics(location) + #else + async let observation = fetchWeather( + location.latitude, + location.longitude + ) + #endif + async let snapshotTime = synchronizeClock() + + let resolvedObservation: CurrentWeatherRemoteDTO? + let resolvedSnapshotTime: CurrentWeatherSnapshotTime? + do { + (resolvedObservation, resolvedSnapshotTime) = try await ( + observation, + snapshotTime + ) + } catch { + return .failed + } + + guard let resolvedObservation else { + return .noObservation + } + guard let resolvedSnapshotTime else { + return .failed + } + + let snapshot = CurrentWeatherWidgetSnapshotFactory.make( + observation: resolvedObservation, + location: location, + time: resolvedSnapshotTime + ) + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "snapshot write attempted sourceIdentifier=" + + (snapshot.sourceIdentifier ?? "none") + + " regionCode=\(snapshot.regionCode) " + + "observationTime=\(snapshot.observationTime)" + ) + #endif + do { + let writeResult = try commitSnapshot(snapshot, writeToken) + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + writeResult == .written + ? "snapshot write succeeded" + : "snapshot write superseded" + ) + #endif + return writeResult == .written ? .refreshed : .superseded + } catch { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log("snapshot write failed") + #endif + return .failed + } + } + + static func synchronizedSnapshotTime( + clock: WidgetServerClock + ) async -> CurrentWeatherSnapshotTime? { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log("clock synchronization started") + let synchronized = await clock.synchronize() + #else + _ = await clock.synchronize() + #endif + let hasSynchronized = await clock.hasSynchronized + #if DEBUG + if synchronized { + WidgetWeatherRefreshDiagnostics.log("clock synchronized") + } else { + WidgetWeatherRefreshDiagnostics.log( + "clock failed retainedPreviousAnchor=\(hasSynchronized)" + ) + } + #endif + guard hasSynchronized else { + return nil + } + return await clock.currentWeatherSnapshotTime() + } + + #if DEBUG + private func fetchWeatherWithDiagnostics( + _ location: WidgetResolvedWeatherLocation + ) async throws -> CurrentWeatherRemoteDTO? { + WidgetWeatherRefreshDiagnostics.log( + "weather started sourceIdentifier=" + + location.address.sourceIdentifier + ) + do { + let observation = try await fetchWeather( + location.latitude, + location.longitude + ) + if let observation { + WidgetWeatherRefreshDiagnostics.log( + "weather observation received observationTime=" + + "\(observation.time)" + ) + } else { + WidgetWeatherRefreshDiagnostics.log("weather no observation") + } + return observation + } catch { + WidgetWeatherRefreshDiagnostics.log("weather failed") + throw error + } + } + #endif +} diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift index 5b7436329..14604b4b7 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift @@ -1,7 +1,7 @@ import Foundation import SwiftUI -enum CurrentWeatherWidgetCondition: String, Decodable { +enum CurrentWeatherWidgetCondition: String, Decodable, Sendable { case clear case cloudy case overcast @@ -69,7 +69,7 @@ enum CurrentWeatherWidgetCondition: String, Decodable { } } -struct CurrentWeatherWidgetSnapshot: Decodable { +struct CurrentWeatherWidgetSnapshot: Codable, Sendable { let schemaVersion: Int let sourceIdentifier: String? @@ -251,4 +251,153 @@ struct CurrentWeatherWidgetSnapshot: Decodable { forKey: .rain ) } + + func encode(to encoder: Encoder) throws { + guard schemaVersion == 5 else { + throw EncodingError.invalidValue( + schemaVersion, + .init( + codingPath: encoder.codingPath, + debugDescription: + "Only current-weather snapshot schema version 5 can be encoded." + ) + ) + } + + guard let sourceIdentifier else { + throw EncodingError.invalidValue( + sourceIdentifier as Any, + .init( + codingPath: encoder.codingPath, + debugDescription: + "Schema version 5 requires a source identifier." + ) + ) + } + + guard let address = CurrentWeatherSnapshotAddress( + sourceIdentifier: sourceIdentifier + ) else { + throw EncodingError.invalidValue( + sourceIdentifier, + .init( + codingPath: encoder.codingPath, + debugDescription: + "Invalid current-weather snapshot source identifier." + ) + ) + } + + guard CurrentWeatherSnapshotAddress( + sourceIdentifier: "region:\(regionCode)" + ) != nil else { + throw EncodingError.invalidValue( + regionCode, + .init( + codingPath: encoder.codingPath, + debugDescription: + "Current-weather snapshot region code must be exactly three ASCII digits." + ) + ) + } + + if case let .saved(addressRegionCode) = address { + guard addressRegionCode == regionCode else { + throw EncodingError.invalidValue( + regionCode, + .init( + codingPath: encoder.codingPath, + debugDescription: + "Saved snapshot source identifier and payload region code must match." + ) + ) + } + } + + var container = encoder.container( + keyedBy: CodingKeys.self + ) + + try container.encode( + schemaVersion, + forKey: .schemaVersion + ) + try container.encode( + sourceIdentifier, + forKey: .sourceIdentifier + ) + try container.encode( + regionCode, + forKey: .regionCode + ) + try container.encode( + regionName, + forKey: .regionName + ) + try container.encode( + observationTime, + forKey: .observationTime + ) + try container.encode( + stationName, + forKey: .stationName + ) + try container.encode( + weather, + forKey: .weather + ) + try container.encode( + weatherCode, + forKey: .weatherCode + ) + try container.encode( + condition.rawValue, + forKey: .condition + ) + try container.encode( + isNight, + forKey: .isNight + ) + try container.encode( + nextDayNightTransitionTime, + forKey: .nextDayNightTransitionTime + ) + try container.encode( + calibratedTimeOffsetMilliseconds, + forKey: .calibratedTimeOffsetMilliseconds + ) + + if let temperature { + try container.encode( + temperature, + forKey: .temperature + ) + } else { + try container.encodeNil( + forKey: .temperature + ) + } + + if let humidity { + try container.encode( + humidity, + forKey: .humidity + ) + } else { + try container.encodeNil( + forKey: .humidity + ) + } + + if let rain { + try container.encode( + rain, + forKey: .rain + ) + } else { + try container.encodeNil( + forKey: .rain + ) + } + } } diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotFactory.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotFactory.swift new file mode 100644 index 000000000..992533d64 --- /dev/null +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotFactory.swift @@ -0,0 +1,40 @@ +enum CurrentWeatherWidgetSnapshotFactory { + static func make( + observation: CurrentWeatherRemoteDTO, + location: WidgetResolvedWeatherLocation, + time: CurrentWeatherSnapshotTime + ) -> CurrentWeatherWidgetSnapshot { + let isNight = WidgetSolarTime.isNight( + unixMilliseconds: time.calibratedNowUnixMilliseconds, + latitude: location.latitude, + longitude: location.longitude + ) + + let nextTransition = + WidgetSolarTime.nextDayNightTransition( + unixMilliseconds: + time.calibratedNowUnixMilliseconds, + latitude: location.latitude, + longitude: location.longitude + ) + + return CurrentWeatherWidgetSnapshot( + schemaVersion: 5, + sourceIdentifier: location.address.sourceIdentifier, + regionCode: location.regionCode, + regionName: location.regionName, + observationTime: observation.time, + stationName: observation.stationName, + weather: observation.weather, + weatherCode: observation.weatherCode, + condition: observation.condition, + isNight: isNight, + nextDayNightTransitionTime: Int(nextTransition), + calibratedTimeOffsetMilliseconds: + time.calibratedTimeOffsetMilliseconds, + temperature: observation.temperature, + humidity: observation.humidity, + rain: observation.rain + ) + } +} diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift new file mode 100644 index 000000000..1e4925a47 --- /dev/null +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift @@ -0,0 +1,51 @@ +import Foundation + +enum CurrentWeatherWidgetSnapshotWriterError: Error { + case invalidSourceIdentifier +} + +struct CurrentWeatherWidgetSnapshotWriter: Sendable { + let containerURL: URL + + func beginWrite( + for address: CurrentWeatherSnapshotAddress + ) throws -> CurrentWeatherSnapshotWriteToken { + try CurrentWeatherSnapshotStorage( + containerURL: containerURL + ).beginWrite(for: address) + } + + func write( + _ snapshot: CurrentWeatherWidgetSnapshot, + using token: CurrentWeatherSnapshotWriteToken + ) throws -> CurrentWeatherSnapshotWriteResult { + let data = try JSONEncoder().encode(snapshot) + + let address = try address(for: snapshot) + guard address == token.address else { + throw CurrentWeatherSnapshotStorageError.invalidWriteToken + } + + return try CurrentWeatherSnapshotStorage( + containerURL: containerURL + ).replace( + data, + using: token + ) + } + + private func address( + for snapshot: CurrentWeatherWidgetSnapshot + ) throws -> CurrentWeatherSnapshotAddress { + guard + let sourceIdentifier = snapshot.sourceIdentifier, + let address = CurrentWeatherSnapshotAddress( + sourceIdentifier: sourceIdentifier + ) + else { + throw CurrentWeatherWidgetSnapshotWriterError + .invalidSourceIdentifier + } + return address + } +} diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift b/ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift index 1a3916ce5..2f179f3e5 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift @@ -1,6 +1,6 @@ import Foundation -struct CurrentWeatherWidgetTimelineState: Equatable { +struct CurrentWeatherWidgetTimelineState: Equatable, Sendable { /// Device-clock date supplied to WidgetKit for entry scheduling. let date: Date let isStale: Bool diff --git a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift new file mode 100644 index 000000000..4dd5d63c6 --- /dev/null +++ b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift @@ -0,0 +1,337 @@ +import Foundation +#if DEBUG +import OSLog + +enum WidgetWeatherRefreshDiagnostics { + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.exptech.dpip", + category: "WidgetWeatherRefresh" + ) + + static func log(_ message: String) { + logger.debug("\(message, privacy: .public)") + } + + static func targetIdentifier(_ target: WidgetLocationTarget) -> String { + switch target { + case .currentLocation: + return "current-location" + case .saved(let regionCode): + return "region:\(regionCode)" + case .invalid: + return "invalid" + } + } + + static func snapshotSummary( + _ snapshot: CurrentWeatherWidgetSnapshot? + ) -> String { + guard let snapshot else { + return "unavailable" + } + + return "sourceIdentifier=\(snapshot.sourceIdentifier ?? "none") " + + "regionCode=\(snapshot.regionCode) " + + "observationTime=\(snapshot.observationTime)" + } + + static func cacheChanged( + from previous: CurrentWeatherWidgetSnapshot?, + to current: CurrentWeatherWidgetSnapshot? + ) -> Bool { + guard let previous, let current else { + return (previous == nil) != (current == nil) + } + + return previous.sourceIdentifier != current.sourceIdentifier + || previous.regionCode != current.regionCode + || previous.observationTime != current.observationTime + } +} +#endif + +struct DPIPWidgetTimelinePlan: Sendable { + let snapshot: CurrentWeatherWidgetSnapshot? + let states: [CurrentWeatherWidgetTimelineState] + let reloadDate: Date +} + +struct DPIPWidgetTimelinePlanner: Sendable { + typealias LoadSnapshot = @Sendable ( + WidgetLocationTarget + ) -> CurrentWeatherWidgetSnapshot? + typealias RefreshSaved = @Sendable ( + WidgetLocationTarget + ) async -> CurrentWeatherWidgetRefreshResult + typealias RefreshCurrent = @Sendable () async + -> CurrentWeatherWidgetRefreshResult + typealias Now = @Sendable () -> Date + + private let staleAfter: TimeInterval + private let refreshInterval: TimeInterval + private let loadSnapshot: LoadSnapshot + private let refreshSaved: RefreshSaved + private let refreshCurrent: RefreshCurrent + private let now: Now + + init( + staleAfter: TimeInterval, + refreshInterval: TimeInterval, + loadSnapshot: @escaping LoadSnapshot, + refreshSaved: @escaping RefreshSaved, + refreshCurrent: @escaping RefreshCurrent, + now: @escaping Now + ) { + self.staleAfter = staleAfter + self.refreshInterval = refreshInterval + self.loadSnapshot = loadSnapshot + self.refreshSaved = refreshSaved + self.refreshCurrent = refreshCurrent + self.now = now + } + + func plan( + for target: WidgetLocationTarget + ) async -> DPIPWidgetTimelinePlan { + #if DEBUG + let snapshotBeforeRefresh: CurrentWeatherWidgetSnapshot? + switch target { + case .saved, .currentLocation: + snapshotBeforeRefresh = loadSnapshot(target) + WidgetWeatherRefreshDiagnostics.log( + "cacheBefore " + + WidgetWeatherRefreshDiagnostics.snapshotSummary( + snapshotBeforeRefresh + ) + ) + case .invalid: + snapshotBeforeRefresh = nil + } + #endif + + switch target { + case .saved: + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "refresh began target=" + + WidgetWeatherRefreshDiagnostics.targetIdentifier(target) + ) + #endif + + #if DEBUG + let refreshResult = await refreshSaved(target) + WidgetWeatherRefreshDiagnostics.log( + "refresh result=\(refreshResult.diagnosticName)" + ) + #else + _ = await refreshSaved(target) + #endif + case .currentLocation: + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "refresh began target=current-location" + ) + let refreshResult = await refreshCurrent() + WidgetWeatherRefreshDiagnostics.log( + "refresh result=\(refreshResult.diagnosticName)" + ) + #else + _ = await refreshCurrent() + #endif + case .invalid: + break + } + + // Always reload after the refresh attempt. Failed refreshes leave the + // same-location cache untouched, so this also provides SWR behavior. + let snapshot = loadSnapshot(target) + let deviceNow = now() + let states = CurrentWeatherWidgetTimeline.states( + snapshot: snapshot, + deviceNow: deviceNow, + staleAfter: staleAfter + ) + let reloadDate = deviceNow.addingTimeInterval(refreshInterval) + + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "cacheAfter " + + WidgetWeatherRefreshDiagnostics.snapshotSummary(snapshot) + ) + switch target { + case .saved, .currentLocation: + let cacheChanged = WidgetWeatherRefreshDiagnostics.cacheChanged( + from: snapshotBeforeRefresh, + to: snapshot + ) + WidgetWeatherRefreshDiagnostics.log( + "cacheChanged=\(cacheChanged)" + ) + case .invalid: + break + } + let stale = states.first?.isStale ?? false + WidgetWeatherRefreshDiagnostics.log( + "timeline entries=\(states.count) stale=\(stale) " + + "reloadIntervalSeconds=\(Int(refreshInterval)) " + + "reloadDateUnix=\(Int(reloadDate.timeIntervalSince1970))" + ) + #endif + + return DPIPWidgetTimelinePlan( + snapshot: snapshot, + states: states, + reloadDate: reloadDate + ) + } +} + +struct DPIPWidgetProviderDependencies: Sendable { + typealias LoadSnapshot = DPIPWidgetTimelinePlanner.LoadSnapshot + + let loadSnapshot: LoadSnapshot + let timelinePlanner: DPIPWidgetTimelinePlanner + + func snapshot( + for target: WidgetLocationTarget + ) -> CurrentWeatherWidgetSnapshot? { + loadSnapshot(target) + } +} + +enum DPIPWidgetProviderRuntime { + private static let appGroupIdentifier = + "group.com.exptech.dpip.dpip.widgets" + private static let staleAfter: TimeInterval = 30 * 60 + + #if DEBUG + static let refreshInterval: TimeInterval = 60 + #else + static let refreshInterval: TimeInterval = 20 * 60 + #endif + + // This process-scoped dependency graph retains one clock for every + // getTimeline call handled by the current extension process. + static let shared: DPIPWidgetProviderDependencies = { + let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) + let snapshotStore = WidgetSnapshotStore( + containerURL: containerURL + ) + let loadSnapshot: DPIPWidgetTimelinePlanner.LoadSnapshot = { + target in + snapshotStore.loadCurrentWeatherSnapshot(for: target) + } + + let weatherClient = CurrentWeatherClient() + let serverClock = WidgetServerClock() + let writer = containerURL.map { + CurrentWeatherWidgetSnapshotWriter(containerURL: $0) + } + let refreshSaved: DPIPWidgetTimelinePlanner.RefreshSaved = { target in + guard let containerURL, let writer else { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "catalog unavailable appGroupContainer=false" + ) + #endif + return .unavailable + } + + #if DEBUG + WidgetWeatherRefreshDiagnostics.log("catalog load started") + #endif + let catalog = WidgetLocationCatalogStore( + containerURL: containerURL + ).load() + #if DEBUG + if let catalog { + WidgetWeatherRefreshDiagnostics.log( + "catalog load succeeded locations=" + + "\(catalog.locations.count)" + ) + } else { + WidgetWeatherRefreshDiagnostics.log( + "catalog load failed" + ) + } + #endif + let service = SavedCurrentWeatherWidgetRefreshService( + resolver: SavedWidgetLocationResolver( + catalog: catalog + ), + weatherClient: weatherClient, + clock: serverClock, + writer: writer + ) + + return await service.refresh(target: target) + } + let refreshCurrent: DPIPWidgetTimelinePlanner.RefreshCurrent = { + guard let writer else { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "current location refresh unavailable " + + "appGroupContainer=false" + ) + #endif + return .unavailable + } + guard let resolver = + WidgetTownshipResolverRuntime.shared + else { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "township resolver unavailable" + ) + #endif + return .unavailable + } + + let service = + CurrentLocationCurrentWeatherWidgetRefreshService( + acquireLocation: { @MainActor in + await WidgetCurrentLocationClient() + .acquireLocation() + }, + resolver: resolver, + weatherClient: weatherClient, + clock: serverClock, + writer: writer + ) + return await service.refresh() + } + + return DPIPWidgetProviderDependencies( + loadSnapshot: loadSnapshot, + timelinePlanner: DPIPWidgetTimelinePlanner( + staleAfter: staleAfter, + refreshInterval: refreshInterval, + loadSnapshot: loadSnapshot, + refreshSaved: refreshSaved, + refreshCurrent: refreshCurrent, + now: { Date.now } + ) + ) + }() +} + +#if DEBUG +private extension CurrentWeatherWidgetRefreshResult { + var diagnosticName: String { + switch self { + case .refreshed: + return "refreshed" + case .superseded: + return "superseded" + case .noObservation: + return "noObservation" + case .unavailable: + return "unavailable" + case .failed: + return "failed" + } + } +} +#endif diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index 51b3bc3ea..e0ca3157a 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -5,7 +5,14 @@ import Intents struct DPIPWidgetProvider: IntentTimelineProvider { typealias Intent = WeatherWidgetConfigurationIntent private let staleAfter: TimeInterval = 30 * 60 - private let snapshotStore = WidgetSnapshotStore() + private let dependencies: DPIPWidgetProviderDependencies + + init( + dependencies: DPIPWidgetProviderDependencies = + DPIPWidgetProviderRuntime.shared + ) { + self.dependencies = dependencies + } private func snapshot( for configuration: WeatherWidgetConfigurationIntent @@ -14,9 +21,7 @@ struct DPIPWidgetProvider: IntentTimelineProvider { identifier: configuration.location?.identifier ) - return snapshotStore.loadCurrentWeatherSnapshot( - for: target - ) + return dependencies.snapshot(for: target) } func placeholder(in context: Context) -> DPIPWidgetEntry { @@ -57,31 +62,50 @@ struct DPIPWidgetProvider: IntentTimelineProvider { in context: Context, completion: @escaping (Timeline) -> Void ) { - let deviceNow = Date() - let snapshot = snapshot(for: configuration) + let target = WidgetLocationTarget( + identifier: configuration.location?.identifier + ) - let entries = CurrentWeatherWidgetTimeline.states( - snapshot: snapshot, - deviceNow: deviceNow, - staleAfter: staleAfter + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "getTimeline target=" + + WidgetWeatherRefreshDiagnostics.targetIdentifier(target) ) - .map { state in + switch target { + case .saved(let regionCode): + WidgetWeatherRefreshDiagnostics.log( + "target=saved region=\(regionCode)" + ) + case .currentLocation: + WidgetWeatherRefreshDiagnostics.log( + "target=current-location nativeRefresh=enabled " + + "cacheReload=enabled" + ) + case .invalid: + WidgetWeatherRefreshDiagnostics.log("target=invalid") + } + #endif + + Task { + let plan = await dependencies.timelinePlanner.plan( + for: target + ) + let entries = plan.states.map { state in DPIPWidgetEntry( date: state.date, - snapshot: snapshot, + snapshot: plan.snapshot, isStale: state.isStale, isNight: state.isNight ) } - completion( - Timeline( - entries: entries, - // The app owns refreshes. This timeline projects only the - // stale deadline and one solar transition in the snapshot. - policy: .never + completion( + Timeline( + entries: entries, + policy: .after(plan.reloadDate) + ) ) - ) + } } } @@ -104,11 +128,20 @@ struct DPIPWidgetsEntryView : View { VStack(alignment: .leading) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 2) { - Text(snapshot.regionName) - .font(.headline) - .lineLimit(1) - .minimumScaleFactor(0.8) - .layoutPriority(1) + HStack(spacing: 4) { + Text(snapshot.regionName) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.8) + .layoutPriority(1) + + if snapshot.sourceIdentifier == "current-location" { + Image(systemName: "location.fill") + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize() + } + } Text(observationDate, style: .time) .lineLimit(1) diff --git a/ios/DPIPWidgets/Info.plist b/ios/DPIPWidgets/Info.plist index 0f118fb75..d9f22de03 100644 --- a/ios/DPIPWidgets/Info.plist +++ b/ios/DPIPWidgets/Info.plist @@ -2,6 +2,8 @@ + NSWidgetWantsLocation + NSExtension NSExtensionPointIdentifier diff --git a/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift b/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift new file mode 100644 index 000000000..9de6d9e23 --- /dev/null +++ b/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift @@ -0,0 +1,77 @@ +struct SavedCurrentWeatherWidgetRefreshService: Sendable { + typealias ResolveLocation = @Sendable ( + WidgetLocationTarget + ) -> WidgetResolvedWeatherLocation? + typealias BeginWrite = @Sendable ( + CurrentWeatherSnapshotAddress + ) throws -> CurrentWeatherSnapshotWriteToken + + private let resolveLocation: ResolveLocation + private let beginWrite: BeginWrite + private let pipeline: CurrentWeatherWidgetRefreshPipeline + + init( + resolver: SavedWidgetLocationResolver, + weatherClient: CurrentWeatherClient, + clock: WidgetServerClock, + writer: CurrentWeatherWidgetSnapshotWriter + ) { + self.init( + resolveLocation: { target in + resolver.resolve(target: target) + }, + beginWrite: { address in + try writer.beginWrite(for: address) + }, + pipeline: CurrentWeatherWidgetRefreshPipeline( + weatherClient: weatherClient, + clock: clock, + writer: writer + ) + ) + } + + init( + resolveLocation: @escaping ResolveLocation, + beginWrite: @escaping BeginWrite, + pipeline: CurrentWeatherWidgetRefreshPipeline + ) { + self.resolveLocation = resolveLocation + self.beginWrite = beginWrite + self.pipeline = pipeline + } + + func refresh( + target: WidgetLocationTarget + ) async -> CurrentWeatherWidgetRefreshResult { + guard let location = resolveLocation(target) else { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "location resolution unavailable target=" + + WidgetWeatherRefreshDiagnostics.targetIdentifier(target) + ) + #endif + return .unavailable + } + + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "location resolved sourceIdentifier=" + + location.address.sourceIdentifier + + " regionCode=\(location.regionCode)" + ) + #endif + + let writeToken: CurrentWeatherSnapshotWriteToken + do { + writeToken = try beginWrite(location.address) + } catch { + return .failed + } + + return await pipeline.refresh( + location: location, + writeToken: writeToken + ) + } +} diff --git a/ios/DPIPWidgets/SavedWidgetLocationResolver.swift b/ios/DPIPWidgets/SavedWidgetLocationResolver.swift new file mode 100644 index 000000000..8b5f54e80 --- /dev/null +++ b/ios/DPIPWidgets/SavedWidgetLocationResolver.swift @@ -0,0 +1,27 @@ +import Foundation + +struct SavedWidgetLocationResolver: Sendable { + let catalog: WidgetLocationCatalog? + + func resolve( + target: WidgetLocationTarget + ) -> WidgetResolvedWeatherLocation? { + guard + case let .saved(regionCode) = target, + let catalog, + let location = catalog.locations.first(where: { + $0.regionCode == regionCode + }) + else { + return nil + } + + return WidgetResolvedWeatherLocation( + address: .saved(regionCode: regionCode), + regionCode: location.regionCode, + regionName: location.displayName, + latitude: location.latitude, + longitude: location.longitude + ) + } +} diff --git a/ios/DPIPWidgets/WidgetCurrentLocationClient.swift b/ios/DPIPWidgets/WidgetCurrentLocationClient.swift new file mode 100644 index 000000000..53496ffb6 --- /dev/null +++ b/ios/DPIPWidgets/WidgetCurrentLocationClient.swift @@ -0,0 +1,451 @@ +import CoreLocation +import Foundation + +struct WidgetCurrentLocation: Equatable, Sendable { + let latitude: Double + let longitude: Double + + init?(latitude: Double, longitude: Double) { + guard latitude.isFinite, + longitude.isFinite, + (-90...90).contains(latitude), + (-180...180).contains(longitude) + else { + return nil + } + + self.latitude = latitude + self.longitude = longitude + } +} + +enum WidgetCurrentLocationResult: Equatable, Sendable { + case acquired(WidgetCurrentLocation) + case unavailable + case timedOut + case failed +} + +enum WidgetLocationAuthorization: Equatable, Sendable { + case notDetermined + case restricted + case denied + case authorizedAlways + case authorizedWhenInUse + case unknown + + init(_ status: CLAuthorizationStatus) { + switch status { + case .notDetermined: + self = .notDetermined + case .restricted: + self = .restricted + case .denied: + self = .denied + case .authorizedAlways: + self = .authorizedAlways + case .authorizedWhenInUse: + self = .authorizedWhenInUse + @unknown default: + self = .unknown + } + } + + var permitsLocationRequest: Bool { + self == .authorizedAlways || self == .authorizedWhenInUse + } + + #if DEBUG + var diagnosticName: String { + switch self { + case .notDetermined: + return "not-determined" + case .restricted: + return "restricted" + case .denied: + return "denied" + case .authorizedAlways: + return "authorized-always" + case .authorizedWhenInUse: + return "authorized-when-in-use" + case .unknown: + return "unknown" + } + } + #endif +} + +struct WidgetLocationSample: Equatable, Sendable { + let latitude: Double + let longitude: Double + let timestamp: Date +} + +@MainActor +protocol WidgetLocationManagerDelegate: AnyObject { + func widgetLocationManager( + _ manager: any WidgetLocationManaging, + didUpdate samples: [WidgetLocationSample] + ) + + func widgetLocationManagerDidFail( + _ manager: any WidgetLocationManaging + ) +} + +@MainActor +protocol WidgetLocationManaging: AnyObject { + var delegate: (any WidgetLocationManagerDelegate)? { get set } + var authorization: WidgetLocationAuthorization { get } + var isAuthorizedForWidgetUpdates: Bool { get } + var desiredAccuracy: Double { get set } + + func startUpdatingLocation() + func stopUpdatingLocation() +} + +@MainActor +protocol WidgetLocationTimeoutCancellable: AnyObject { + func cancel() +} + +@MainActor +protocol WidgetLocationTimeoutScheduling: AnyObject { + func schedule( + after interval: TimeInterval, + action: @escaping @MainActor () -> Void + ) -> any WidgetLocationTimeoutCancellable +} + +@MainActor +final class WidgetCurrentLocationClient { + typealias ServicesEnabled = @MainActor () -> Bool + typealias MakeManager = @MainActor () -> any WidgetLocationManaging + typealias Now = @MainActor () -> Date + + nonisolated static let defaultTimeout: TimeInterval = 10 + nonisolated static let defaultMaximumAge: TimeInterval = 10 * 60 + nonisolated static let defaultRequestStartTolerance: TimeInterval = 1 + nonisolated static let defaultDesiredAccuracy: Double = + kCLLocationAccuracyKilometer + + private let servicesEnabled: ServicesEnabled + private let makeManager: MakeManager + private let timeoutScheduler: any WidgetLocationTimeoutScheduling + private let timeout: TimeInterval + private let maximumAge: TimeInterval + private let requestStartTolerance: TimeInterval + private let desiredAccuracy: Double + private let now: Now + private var activeRequests: [UUID: WidgetCurrentLocationRequest] = [:] + + init( + servicesEnabled: @escaping ServicesEnabled = { + CLLocationManager.locationServicesEnabled() + }, + makeManager: @escaping MakeManager = { + CoreLocationWidgetLocationManager() + }, + timeoutScheduler: (any WidgetLocationTimeoutScheduling)? = nil, + timeout: TimeInterval = defaultTimeout, + maximumAge: TimeInterval = defaultMaximumAge, + requestStartTolerance: TimeInterval = defaultRequestStartTolerance, + desiredAccuracy: Double = defaultDesiredAccuracy, + now: @escaping Now = Date.init + ) { + self.servicesEnabled = servicesEnabled + self.makeManager = makeManager + self.timeoutScheduler = timeoutScheduler + ?? WidgetLocationDispatchTimeoutScheduler() + self.timeout = timeout + self.maximumAge = maximumAge + self.requestStartTolerance = requestStartTolerance + self.desiredAccuracy = desiredAccuracy + self.now = now + } + + func acquireLocation() async -> WidgetCurrentLocationResult { + guard servicesEnabled() else { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "current-location authorizationState=services-disabled " + + "widgetUpdatesAuthorized=false" + ) + WidgetWeatherRefreshDiagnostics.log("current location unavailable") + #endif + return .unavailable + } + + let manager = makeManager() + let authorization = manager.authorization + let widgetUpdatesAuthorized = + manager.isAuthorizedForWidgetUpdates + + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "current-location authorizationState=" + + authorization.diagnosticName + + " widgetUpdatesAuthorized=\(widgetUpdatesAuthorized)" + ) + #endif + + guard authorization.permitsLocationRequest, + widgetUpdatesAuthorized + else { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log("current location unavailable") + #endif + return .unavailable + } + + return await withCheckedContinuation { continuation in + let requestID = UUID() + let request = WidgetCurrentLocationRequest( + manager: manager, + timeoutScheduler: timeoutScheduler, + timeout: timeout, + maximumAge: maximumAge, + requestStartTolerance: requestStartTolerance, + desiredAccuracy: desiredAccuracy, + now: now + ) { [weak self] result in + self?.activeRequests[requestID] = nil + continuation.resume(returning: result) + } + activeRequests[requestID] = request + request.start() + } + } +} + +@MainActor +private final class WidgetCurrentLocationRequest: + WidgetLocationManagerDelegate +{ + typealias Completion = @MainActor (WidgetCurrentLocationResult) -> Void + + private let manager: any WidgetLocationManaging + private let timeoutScheduler: any WidgetLocationTimeoutScheduling + private let timeout: TimeInterval + private let maximumAge: TimeInterval + private let requestStartTolerance: TimeInterval + private let desiredAccuracy: Double + private let now: WidgetCurrentLocationClient.Now + private var completion: Completion? + private var timeoutCancellation: ( + any WidgetLocationTimeoutCancellable + )? + private var startedAt: Date? + private var hasFinished = false + + init( + manager: any WidgetLocationManaging, + timeoutScheduler: any WidgetLocationTimeoutScheduling, + timeout: TimeInterval, + maximumAge: TimeInterval, + requestStartTolerance: TimeInterval, + desiredAccuracy: Double, + now: @escaping WidgetCurrentLocationClient.Now, + completion: @escaping Completion + ) { + self.manager = manager + self.timeoutScheduler = timeoutScheduler + self.timeout = timeout + self.maximumAge = maximumAge + self.requestStartTolerance = requestStartTolerance + self.desiredAccuracy = desiredAccuracy + self.now = now + self.completion = completion + } + + func start() { + manager.delegate = self + manager.desiredAccuracy = desiredAccuracy + startedAt = now() + timeoutCancellation = timeoutScheduler.schedule( + after: timeout + ) { [weak self] in + self?.finish(.timedOut) + } + + #if DEBUG + WidgetWeatherRefreshDiagnostics.log("current location request started") + #endif + manager.startUpdatingLocation() + } + + func widgetLocationManager( + _ manager: any WidgetLocationManaging, + didUpdate samples: [WidgetLocationSample] + ) { + guard !hasFinished else { + return + } + + guard let startedAt else { + return + } + + let requestNow = now() + let earliestAcceptedTimestamp = startedAt.addingTimeInterval( + -requestStartTolerance + ) + let location: WidgetCurrentLocation? = samples.reversed().compactMap { + sample -> WidgetCurrentLocation? in + let age = requestNow.timeIntervalSince(sample.timestamp) + guard age >= -requestStartTolerance, + age <= maximumAge, + sample.timestamp >= earliestAcceptedTimestamp + else { + return nil + } + return WidgetCurrentLocation( + latitude: sample.latitude, + longitude: sample.longitude + ) + }.first + + guard let location else { + return + } + finish(.acquired(location)) + } + + func widgetLocationManagerDidFail( + _ manager: any WidgetLocationManaging + ) { + finish(.failed) + } + + private func finish(_ result: WidgetCurrentLocationResult) { + guard !hasFinished else { + return + } + hasFinished = true + + timeoutCancellation?.cancel() + timeoutCancellation = nil + manager.stopUpdatingLocation() + manager.delegate = nil + + #if DEBUG + switch result { + case .acquired: + WidgetWeatherRefreshDiagnostics.log("current location acquired") + case .unavailable: + WidgetWeatherRefreshDiagnostics.log("current location unavailable") + case .timedOut: + WidgetWeatherRefreshDiagnostics.log("current location timeout") + case .failed: + WidgetWeatherRefreshDiagnostics.log("current location failed") + } + #endif + + let completion = completion + self.completion = nil + completion?(result) + } +} + +@MainActor +private final class CoreLocationWidgetLocationManager: NSObject, + WidgetLocationManaging, + CLLocationManagerDelegate +{ + weak var delegate: (any WidgetLocationManagerDelegate)? + + var authorization: WidgetLocationAuthorization { + WidgetLocationAuthorization(manager.authorizationStatus) + } + + var isAuthorizedForWidgetUpdates: Bool { + manager.isAuthorizedForWidgetUpdates + } + + var desiredAccuracy: Double { + get { manager.desiredAccuracy } + set { manager.desiredAccuracy = newValue } + } + + private let manager: CLLocationManager + + override init() { + manager = CLLocationManager() + super.init() + manager.delegate = self + } + + func startUpdatingLocation() { + manager.startUpdatingLocation() + } + + func stopUpdatingLocation() { + manager.stopUpdatingLocation() + } + + nonisolated func locationManager( + _ manager: CLLocationManager, + didUpdateLocations locations: [CLLocation] + ) { + let samples = locations.map { location in + WidgetLocationSample( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude, + timestamp: location.timestamp + ) + } + Task { @MainActor [weak self] in + guard let self else { + return + } + delegate?.widgetLocationManager(self, didUpdate: samples) + } + } + + nonisolated func locationManager( + _ manager: CLLocationManager, + didFailWithError error: any Error + ) { + Task { @MainActor [weak self] in + guard let self else { + return + } + delegate?.widgetLocationManagerDidFail(self) + } + } +} + +@MainActor +private final class WidgetLocationDispatchTimeoutScheduler: + WidgetLocationTimeoutScheduling +{ + func schedule( + after interval: TimeInterval, + action: @escaping @MainActor () -> Void + ) -> any WidgetLocationTimeoutCancellable { + let workItem = DispatchWorkItem { + action() + } + DispatchQueue.main.asyncAfter( + deadline: .now() + interval, + execute: workItem + ) + return WidgetLocationDispatchTimeoutCancellation(workItem: workItem) + } +} + +@MainActor +private final class WidgetLocationDispatchTimeoutCancellation: + WidgetLocationTimeoutCancellable +{ + private var workItem: DispatchWorkItem? + + init(workItem: DispatchWorkItem) { + self.workItem = workItem + } + + func cancel() { + workItem?.cancel() + workItem = nil + } +} diff --git a/ios/DPIPWidgets/WidgetLocationTarget.swift b/ios/DPIPWidgets/WidgetLocationTarget.swift index 3f7c86781..23704e8c8 100644 --- a/ios/DPIPWidgets/WidgetLocationTarget.swift +++ b/ios/DPIPWidgets/WidgetLocationTarget.swift @@ -1,6 +1,6 @@ import Foundation -enum WidgetLocationTarget: Equatable { +enum WidgetLocationTarget: Equatable, Sendable { case currentLocation case saved(regionCode: String) case invalid(identifier: String) diff --git a/ios/DPIPWidgets/WidgetResolvedWeatherLocation.swift b/ios/DPIPWidgets/WidgetResolvedWeatherLocation.swift new file mode 100644 index 000000000..1dab277a6 --- /dev/null +++ b/ios/DPIPWidgets/WidgetResolvedWeatherLocation.swift @@ -0,0 +1,51 @@ +enum WidgetResolvedWeatherLocationValidation { + static func isValidRegionCode( + _ regionCode: String + ) -> Bool { + let bytes = regionCode.utf8 + + return bytes.count == 3 + && bytes.allSatisfy { byte in + byte >= 48 && byte <= 57 + } + } +} + +struct WidgetResolvedWeatherLocation: Equatable, Sendable { + let address: CurrentWeatherSnapshotAddress + let regionCode: String + let regionName: String + let latitude: Double + let longitude: Double + + init?( + address: CurrentWeatherSnapshotAddress, + regionCode: String, + regionName: String, + latitude: Double, + longitude: Double + ) { + guard + WidgetResolvedWeatherLocationValidation + .isValidRegionCode(regionCode), + latitude.isFinite, + longitude.isFinite, + (-90 ... 90).contains(latitude), + (-180 ... 180).contains(longitude) + else { + return nil + } + + if case let .saved(addressRegionCode) = address { + guard addressRegionCode == regionCode else { + return nil + } + } + + self.address = address + self.regionCode = regionCode + self.regionName = regionName + self.latitude = latitude + self.longitude = longitude + } +} diff --git a/ios/DPIPWidgets/WidgetSNTPClient.swift b/ios/DPIPWidgets/WidgetSNTPClient.swift new file mode 100644 index 000000000..1d3f2262c --- /dev/null +++ b/ios/DPIPWidgets/WidgetSNTPClient.swift @@ -0,0 +1,386 @@ +import Foundation +import Network + +protocol WidgetWallTimeSource: Sendable { + func now() -> Date +} + +struct WidgetSystemWallTimeSource: WidgetWallTimeSource { + func now() -> Date { + Date() + } +} + +struct WidgetSNTPExchange: Sendable { + let response: Data + let clientTransmitTime: Date + let clientReceiveTime: Date +} + +protocol WidgetSNTPHostQuerying: Sendable { + func query( + host: String, + timeout: TimeInterval + ) async throws -> WidgetSNTPExchange +} + +protocol WidgetServerTimeSource: Sendable { + func serverTimeUnixMilliseconds() async throws -> Int64 +} + +enum WidgetSNTPError: Error, Equatable { + case allHostsFailed + case cancelled + case connectionFailed + case invalidResponse + case timedOut +} + +enum WidgetNTPPacket { + static let length = 48 + static let unixEpochDelta: TimeInterval = 2_208_988_800 + private static let eraSeconds: TimeInterval = 4_294_967_296 + + static func request(transmitTime: Date) -> Data { + var packet = Data(repeating: 0, count: length) + // Leap indicator 0, NTP version 3, client mode 3. + packet[0] = 0x1B + writeTimestamp( + unixTime: transmitTime.timeIntervalSince1970, + to: &packet, + at: 40 + ) + return packet + } + + static func correctedUnixMilliseconds( + exchange: WidgetSNTPExchange + ) throws -> Int64 { + let response = exchange.response + guard response.count >= length else { + throw WidgetSNTPError.invalidResponse + } + + let firstByte = response[0] + let leapIndicator = firstByte >> 6 + let version = (firstByte >> 3) & 0x07 + let mode = firstByte & 0x07 + let stratum = response[1] + + guard leapIndicator != 3, + version == 3 || version == 4, + mode == 4, + (1...15).contains(stratum) + else { + throw WidgetSNTPError.invalidResponse + } + + guard !timestampIsZero(in: response, at: 32), + !timestampIsZero(in: response, at: 40) + else { + throw WidgetSNTPError.invalidResponse + } + + let serverReceiveTime = try unixTime( + from: response, + at: 32, + near: exchange.clientReceiveTime.timeIntervalSince1970 + ) + let serverTransmitTime = try unixTime( + from: response, + at: 40, + near: exchange.clientReceiveTime.timeIntervalSince1970 + ) + + guard serverTransmitTime >= serverReceiveTime + else { + throw WidgetSNTPError.invalidResponse + } + + let offset = offsetSeconds( + clientTransmitTime: + exchange.clientTransmitTime.timeIntervalSince1970, + serverReceiveTime: serverReceiveTime, + serverTransmitTime: serverTransmitTime, + clientReceiveTime: + exchange.clientReceiveTime.timeIntervalSince1970 + ) + let correctedTime = + exchange.clientReceiveTime.timeIntervalSince1970 + offset + + guard correctedTime.isFinite, + correctedTime >= 0, + correctedTime <= Double(Int64.max) / 1_000 + else { + throw WidgetSNTPError.invalidResponse + } + + return Int64((correctedTime * 1_000).rounded()) + } + + static func offsetSeconds( + clientTransmitTime: TimeInterval, + serverReceiveTime: TimeInterval, + serverTransmitTime: TimeInterval, + clientReceiveTime: TimeInterval + ) -> TimeInterval { + ( + (serverReceiveTime - clientTransmitTime) + + (serverTransmitTime - clientReceiveTime) + ) / 2 + } + + static func unixTime( + from packet: Data, + at offset: Int, + near referenceUnixTime: TimeInterval + ) throws -> TimeInterval { + guard offset >= 0, packet.count >= offset + 8 else { + throw WidgetSNTPError.invalidResponse + } + + let seconds = UInt32(packet[offset]) << 24 + | UInt32(packet[offset + 1]) << 16 + | UInt32(packet[offset + 2]) << 8 + | UInt32(packet[offset + 3]) + let fraction = UInt32(packet[offset + 4]) << 24 + | UInt32(packet[offset + 5]) << 16 + | UInt32(packet[offset + 6]) << 8 + | UInt32(packet[offset + 7]) + + let eraZeroUnixTime = TimeInterval(seconds) - unixEpochDelta + // The wire format carries no era number. Select the era nearest T4, + // as required to unfold timestamps after the February 2036 rollover. + let era = ( + (referenceUnixTime - eraZeroUnixTime) / eraSeconds + ).rounded() + return eraZeroUnixTime + era * eraSeconds + + TimeInterval(fraction) / 4_294_967_296 + } + + static func writeTimestamp( + unixTime: TimeInterval, + to packet: inout Data, + at offset: Int + ) { + let ntpTime = unixTime + unixEpochDelta + let wholeSeconds = ntpTime.rounded(.down) + var eraSeconds = wholeSeconds.truncatingRemainder( + dividingBy: self.eraSeconds + ) + if eraSeconds < 0 { + eraSeconds += self.eraSeconds + } + let seconds = UInt32(eraSeconds) + let fractionalSeconds = ntpTime - wholeSeconds + let fraction = UInt32( + (fractionalSeconds * 4_294_967_296).rounded(.down) + ) + + write(seconds, to: &packet, at: offset) + write(fraction, to: &packet, at: offset + 4) + } + + private static func write( + _ value: UInt32, + to packet: inout Data, + at offset: Int + ) { + packet[offset] = UInt8((value >> 24) & 0xFF) + packet[offset + 1] = UInt8((value >> 16) & 0xFF) + packet[offset + 2] = UInt8((value >> 8) & 0xFF) + packet[offset + 3] = UInt8(value & 0xFF) + } + + private static func timestampIsZero( + in packet: Data, + at offset: Int + ) -> Bool { + packet[offset..<(offset + 8)].allSatisfy { $0 == 0 } + } +} + +struct WidgetSNTPClient: WidgetServerTimeSource { + static let primaryHost = "time.exptech.com.tw" + static let backupHost = "time.apple.com" + + private let hosts: [String] + private let hostTimeout: TimeInterval + private let hostQuery: any WidgetSNTPHostQuerying + + init( + hosts: [String] = [primaryHost, backupHost], + hostTimeout: TimeInterval = 3, + hostQuery: any WidgetSNTPHostQuerying = WidgetNetworkSNTPHostQuery() + ) { + self.hosts = hosts + self.hostTimeout = hostTimeout + self.hostQuery = hostQuery + } + + func serverTimeUnixMilliseconds() async throws -> Int64 { + for host in hosts { + do { + let exchange = try await hostQuery.query( + host: host, + timeout: hostTimeout + ) + return try WidgetNTPPacket.correctedUnixMilliseconds( + exchange: exchange + ) + } catch is CancellationError { + throw CancellationError() + } catch WidgetSNTPError.cancelled { + throw CancellationError() + } catch { + continue + } + } + throw WidgetSNTPError.allHostsFailed + } +} + +struct WidgetNetworkSNTPHostQuery: WidgetSNTPHostQuerying { + private let wallClock: any WidgetWallTimeSource + + init( + wallClock: any WidgetWallTimeSource = WidgetSystemWallTimeSource() + ) { + self.wallClock = wallClock + } + + func query( + host: String, + timeout: TimeInterval + ) async throws -> WidgetSNTPExchange { + try Task.checkCancellation() + + let connection = NWConnection( + host: NWEndpoint.Host(host), + port: NWEndpoint.Port(rawValue: 123)!, + using: .udp + ) + let context = WidgetSNTPQueryContext() + let queue = DispatchQueue( + label: "com.exptech.dpip.widget-sntp.\(UUID().uuidString)" + ) + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + context.install( + connection: connection, + continuation: continuation + ) + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + let transmitTime = wallClock.now() + let request = WidgetNTPPacket.request( + transmitTime: transmitTime + ) + connection.send( + content: request, + completion: .contentProcessed { error in + if error != nil { + context.finish( + .failure( + WidgetSNTPError.connectionFailed + ) + ) + return + } + + connection.receiveMessage { + data, + _, + _, + error in + let receiveTime = wallClock.now() + guard error == nil, let data else { + context.finish( + .failure( + WidgetSNTPError + .connectionFailed + ) + ) + return + } + context.finish( + .success( + WidgetSNTPExchange( + response: data, + clientTransmitTime: + transmitTime, + clientReceiveTime: + receiveTime + ) + ) + ) + } + } + ) + case .failed: + context.finish( + .failure(WidgetSNTPError.connectionFailed) + ) + case .cancelled: + context.finish( + .failure(WidgetSNTPError.cancelled) + ) + default: + break + } + } + + queue.asyncAfter(deadline: .now() + timeout) { + context.finish(.failure(WidgetSNTPError.timedOut)) + } + connection.start(queue: queue) + } + } onCancel: { + context.finish(.failure(WidgetSNTPError.cancelled)) + } + } +} + +private final class WidgetSNTPQueryContext: @unchecked Sendable { + private let lock = NSLock() + private var connection: NWConnection? + private var continuation: + CheckedContinuation? + private var pendingResult: Result? + + func install( + connection: NWConnection, + continuation: CheckedContinuation + ) { + lock.lock() + if let pendingResult { + lock.unlock() + connection.cancel() + continuation.resume(with: pendingResult) + return + } + self.connection = connection + self.continuation = continuation + lock.unlock() + } + + func finish(_ result: Result) { + lock.lock() + guard pendingResult == nil else { + lock.unlock() + return + } + pendingResult = result + let connection = connection + let continuation = continuation + self.connection = nil + self.continuation = nil + lock.unlock() + + connection?.cancel() + continuation?.resume(with: result) + } +} diff --git a/ios/DPIPWidgets/WidgetServerClock.swift b/ios/DPIPWidgets/WidgetServerClock.swift new file mode 100644 index 000000000..8ccdfeaea --- /dev/null +++ b/ios/DPIPWidgets/WidgetServerClock.swift @@ -0,0 +1,150 @@ +import Foundation + +protocol WidgetMonotonicTimeSource: Sendable { + func elapsedMilliseconds() -> Int64 +} + +struct WidgetSystemMonotonicTimeSource: WidgetMonotonicTimeSource { + func elapsedMilliseconds() -> Int64 { + Int64(ProcessInfo.processInfo.systemUptime * 1_000) + } +} + +protocol WidgetServerClockTimeoutRunning: Sendable { + func serverTimeUnixMilliseconds( + from source: any WidgetServerTimeSource, + timeout: TimeInterval + ) async throws -> Int64 +} + +enum WidgetServerClockError: Error, Equatable { + case timedOut +} + +struct WidgetTaskServerClockTimeout: WidgetServerClockTimeoutRunning { + func serverTimeUnixMilliseconds( + from source: any WidgetServerTimeSource, + timeout: TimeInterval + ) async throws -> Int64 { + try await withThrowingTaskGroup( + of: Int64.self, + returning: Int64.self + ) { group in + group.addTask { + try await source.serverTimeUnixMilliseconds() + } + group.addTask { + let nanoseconds = UInt64(timeout * 1_000_000_000) + try await Task.sleep(nanoseconds: nanoseconds) + throw WidgetServerClockError.timedOut + } + + defer { + group.cancelAll() + } + guard let result = try await group.next() else { + throw WidgetServerClockError.timedOut + } + return result + } + } +} + +actor WidgetServerClock { + private let deviceClock: any WidgetWallTimeSource + private let monotonicClock: any WidgetMonotonicTimeSource + private let serverTimeSource: any WidgetServerTimeSource + private let timeoutRunner: any WidgetServerClockTimeoutRunning + private let synchronizationTimeout: TimeInterval + + private var anchorServerUnixMilliseconds: Int64? + private var anchorMonotonicMilliseconds: Int64? + private var synchronizationTask: Task? + + init( + deviceClock: any WidgetWallTimeSource = WidgetSystemWallTimeSource(), + monotonicClock: any WidgetMonotonicTimeSource = + WidgetSystemMonotonicTimeSource(), + serverTimeSource: any WidgetServerTimeSource = WidgetSNTPClient(), + timeoutRunner: any WidgetServerClockTimeoutRunning = + WidgetTaskServerClockTimeout(), + synchronizationTimeout: TimeInterval = 8 + ) { + self.deviceClock = deviceClock + self.monotonicClock = monotonicClock + self.serverTimeSource = serverTimeSource + self.timeoutRunner = timeoutRunner + self.synchronizationTimeout = synchronizationTimeout + } + + var hasSynchronized: Bool { + anchorServerUnixMilliseconds != nil + && anchorMonotonicMilliseconds != nil + } + + @discardableResult + func synchronize() async -> Bool { + if let synchronizationTask { + return await synchronizationTask.value + } + + let task = Task { () -> Bool in + do { + let serverTime = try await timeoutRunner + .serverTimeUnixMilliseconds( + from: serverTimeSource, + timeout: synchronizationTimeout + ) + anchorServerUnixMilliseconds = serverTime + anchorMonotonicMilliseconds = + monotonicClock.elapsedMilliseconds() + synchronizationTask = nil + return true + } catch { + synchronizationTask = nil + return false + } + } + synchronizationTask = task + return await task.value + } + + func calibratedNowUnixMilliseconds() -> Int64 { + guard let anchorServerUnixMilliseconds, + let anchorMonotonicMilliseconds + else { + return deviceUnixMilliseconds() + } + + return anchorServerUnixMilliseconds + + monotonicClock.elapsedMilliseconds() + - anchorMonotonicMilliseconds + } + + func currentWeatherSnapshotTime() -> CurrentWeatherSnapshotTime { + let deviceNow = deviceUnixMilliseconds() + guard let anchorServerUnixMilliseconds, + let anchorMonotonicMilliseconds + else { + return CurrentWeatherSnapshotTime( + calibratedNowUnixMilliseconds: deviceNow, + calibratedTimeOffsetMilliseconds: 0 + ) + } + + let calibratedNow = anchorServerUnixMilliseconds + + monotonicClock.elapsedMilliseconds() + - anchorMonotonicMilliseconds + return CurrentWeatherSnapshotTime( + calibratedNowUnixMilliseconds: calibratedNow, + calibratedTimeOffsetMilliseconds: + Int(calibratedNow - deviceNow) + ) + } + + private func deviceUnixMilliseconds() -> Int64 { + Int64( + (deviceClock.now().timeIntervalSince1970 * 1_000).rounded() + ) + } +} diff --git a/ios/DPIPWidgets/WidgetSnapshotStore.swift b/ios/DPIPWidgets/WidgetSnapshotStore.swift index 76cfa55cc..2542a1609 100644 --- a/ios/DPIPWidgets/WidgetSnapshotStore.swift +++ b/ios/DPIPWidgets/WidgetSnapshotStore.swift @@ -14,16 +14,15 @@ enum WidgetSnapshotKind { } } -struct WidgetSnapshotStore { - private let appGroupIdentifier = - "group.com.exptech.dpip.dpip.widgets" +struct WidgetSnapshotStore: Sendable { + private let appGroupContainerURL: URL? + + init(containerURL: URL?) { + appGroupContainerURL = containerURL + } func snapshotURL(for kind: WidgetSnapshotKind) -> URL? { - guard let appGroupContainerURL = - FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: appGroupIdentifier - ) - else { + guard let appGroupContainerURL else { return nil } @@ -63,25 +62,13 @@ struct WidgetSnapshotStore { return nil } - guard let appGroupContainerURL = - FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: - appGroupIdentifier - ) - else { + guard let appGroupContainerURL else { return nil } - return appGroupContainerURL - .appendingPathComponent( - "WidgetSnapshots", - isDirectory: true - ) - .appendingPathComponent( - "current-weather", - isDirectory: true - ) - .appendingPathComponent(address.filename) + return CurrentWeatherSnapshotStorage( + containerURL: appGroupContainerURL + ).snapshotURL(for: address) } func loadCurrentWeatherSnapshot( diff --git a/ios/DPIPWidgets/WidgetSolarTime.swift b/ios/DPIPWidgets/WidgetSolarTime.swift new file mode 100644 index 000000000..c16c61fa7 --- /dev/null +++ b/ios/DPIPWidgets/WidgetSolarTime.swift @@ -0,0 +1,329 @@ +import Foundation + +enum WidgetSolarTime { + private static let millisecondsPerDay = 86_400_000.0 + private static let j2000UnixDayOffset = 10_957.5 + private static let degreesToRadians = Double.pi / 180 + + static func positiveModulo( + _ value: Double, + modulus: Double + ) -> Double { + let remainder = value.truncatingRemainder( + dividingBy: modulus + ) + + if remainder == 0 { + return 0 + } + + return remainder > 0 + ? remainder + : remainder + modulus + } + + static func julianDays( + unixMilliseconds: Int64 + ) -> Double { + Double(unixMilliseconds) / Self.millisecondsPerDay + - Self.j2000UnixDayOffset + } + + static func solarTerms( + unixMilliseconds: Int64 + ) -> SolarTerms { + let n = julianDays( + unixMilliseconds: unixMilliseconds + ) + + let meanLongitudeDegrees = positiveModulo( + 280.460 + 0.9856474 * n, + modulus: 360 + ) + + let meanAnomalyRadians = positiveModulo( + 357.528 + 0.9856003 * n, + modulus: 360 + ) * Self.degreesToRadians + + let eclipticLongitudeRadians = ( + meanLongitudeDegrees + + 1.915 * sin(meanAnomalyRadians) + + 0.020 * sin(2 * meanAnomalyRadians) + ) * Self.degreesToRadians + + let obliquityRadians = ( + 23.439 - 0.0000004 * n + ) * Self.degreesToRadians + + let rightAscensionRadians = atan2( + cos(obliquityRadians) + * sin(eclipticLongitudeRadians), + cos(eclipticLongitudeRadians) + ) + + let declinationRadians = asin( + sin(obliquityRadians) + * sin(eclipticLongitudeRadians) + ) + + return SolarTerms( + meanLongitudeDegrees: meanLongitudeDegrees, + rightAscensionRadians: rightAscensionRadians, + declinationRadians: declinationRadians + ) + } + + static func sunTimes( + unixMilliseconds: Int64, + latitude: Double, + longitude: Double, + utcOffsetHours: Double = 8 + ) -> SunTimes { + let terms = solarTerms( + unixMilliseconds: unixMilliseconds + ) + + let latitudeRadians = + latitude * Self.degreesToRadians + + let altitudeRadians = + -0.833 * Self.degreesToRadians + + let cosHourAngle = ( + sin(altitudeRadians) + - sin(latitudeRadians) + * sin(terms.declinationRadians) + ) / ( + cos(latitudeRadians) + * cos(terms.declinationRadians) + ) + + if cosHourAngle <= -1 { + return SunTimes( + sunriseLocalHours: 0, + sunsetLocalHours: 24 + ) + } + + if cosHourAngle >= 1 { + return SunTimes( + sunriseLocalHours: 12, + sunsetLocalHours: 12 + ) + } + + let hourAngleHours = + acos(cosHourAngle) + / Self.degreesToRadians + / 15 + + var equationOfTimeHours = ( + terms.meanLongitudeDegrees + * Self.degreesToRadians + - terms.rightAscensionRadians + ) / Self.degreesToRadians / 15 + + equationOfTimeHours = positiveModulo( + equationOfTimeHours + 12, + modulus: 24 + ) - 12 + + let solarNoon = + 12 + - longitude / 15 + + utcOffsetHours + - equationOfTimeHours + + return SunTimes( + sunriseLocalHours: + solarNoon - hourAngleHours, + sunsetLocalHours: + solarNoon + hourAngleHours + ) + } + + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private static func localDayContext( + unixMilliseconds: Int64, + utcOffsetHours: Double + ) -> LocalDayContext { + let offsetMinutes = Int( + (utcOffsetHours * 60) + .rounded(.toNearestOrAwayFromZero) + ) + + let offsetMilliseconds = + Int64(offsetMinutes) * 60 * 1_000 + + let shiftedUnixMilliseconds = + unixMilliseconds + offsetMilliseconds + + let shiftedDate = Date( + timeIntervalSince1970: + Double(shiftedUnixMilliseconds) / 1_000 + ) + + let components = Self.utcCalendar.dateComponents( + [.year, .month, .day, .hour, .minute, .second], + from: shiftedDate + ) + + let year = components.year! + let month = components.month! + let day = components.day! + let hour = components.hour! + let minute = components.minute! + let second = components.second! + + let localNoon = Self.utcCalendar.date( + from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: year, + month: month, + day: day, + hour: 12 + ) + )! + + let localNoonMilliseconds = Int64( + ( + localNoon.timeIntervalSince1970 * 1_000 + ).rounded(.toNearestOrAwayFromZero) + ) + + return LocalDayContext( + anchorUnixMilliseconds: + localNoonMilliseconds - offsetMilliseconds, + localSecond: + hour * 3_600 + + minute * 60 + + second + ) + } + + static func isNight( + unixMilliseconds: Int64, + latitude: Double, + longitude: Double, + utcOffsetHours: Double = 8 + ) -> Bool { + let context = localDayContext( + unixMilliseconds: unixMilliseconds, + utcOffsetHours: utcOffsetHours + ) + + let times = sunTimes( + unixMilliseconds: + context.anchorUnixMilliseconds, + latitude: latitude, + longitude: longitude, + utcOffsetHours: utcOffsetHours + ) + + let sunSeconds = roundedSunSeconds(times) + + return context.localSecond < sunSeconds.sunrise + || context.localSecond >= sunSeconds.sunset + } + + static func nextDayNightTransition( + unixMilliseconds: Int64, + latitude: Double, + longitude: Double, + utcOffsetHours: Double = 8 + ) -> Int64 { + let context = localDayContext( + unixMilliseconds: unixMilliseconds, + utcOffsetHours: utcOffsetHours + ) + + let todayTimes = sunTimes( + unixMilliseconds: + context.anchorUnixMilliseconds, + latitude: latitude, + longitude: longitude, + utcOffsetHours: utcOffsetHours + ) + + let today = roundedSunSeconds(todayTimes) + + let todayLocalMidnightMilliseconds = + context.anchorUnixMilliseconds + - 12 * 60 * 60 * 1_000 + + if context.localSecond < today.sunrise { + return ( + todayLocalMidnightMilliseconds + + Int64(today.sunrise) * 1_000 + ) / 1_000 + } + + if context.localSecond < today.sunset { + return ( + todayLocalMidnightMilliseconds + + Int64(today.sunset) * 1_000 + ) / 1_000 + } + + let tomorrowAnchorMilliseconds = + context.anchorUnixMilliseconds + + 86_400_000 + + let tomorrowTimes = sunTimes( + unixMilliseconds: + tomorrowAnchorMilliseconds, + latitude: latitude, + longitude: longitude, + utcOffsetHours: utcOffsetHours + ) + + let tomorrow = roundedSunSeconds(tomorrowTimes) + + let tomorrowLocalMidnightMilliseconds = + tomorrowAnchorMilliseconds + - 12 * 60 * 60 * 1_000 + + return ( + tomorrowLocalMidnightMilliseconds + + Int64(tomorrow.sunrise) * 1_000 + ) / 1_000 + } + + private static func roundedSunSeconds( + _ times: SunTimes + ) -> (sunrise: Int, sunset: Int) { + let sunrise = Int( + (times.sunriseLocalHours * 3_600) + .rounded(.toNearestOrAwayFromZero) + ) + + let sunset = Int( + (times.sunsetLocalHours * 3_600) + .rounded(.toNearestOrAwayFromZero) + ) + + return (sunrise, sunset) + } +} + +struct SolarTerms { + let meanLongitudeDegrees: Double + let rightAscensionRadians: Double + let declinationRadians: Double +} + +struct SunTimes { + let sunriseLocalHours: Double + let sunsetLocalHours: Double +} + +private struct LocalDayContext { + let anchorUnixMilliseconds: Int64 + let localSecond: Int +} diff --git a/ios/DPIPWidgets/WidgetTownshipBoundaries.bin b/ios/DPIPWidgets/WidgetTownshipBoundaries.bin new file mode 100644 index 000000000..e32490c78 Binary files /dev/null and b/ios/DPIPWidgets/WidgetTownshipBoundaries.bin differ diff --git a/ios/DPIPWidgets/WidgetTownshipDirectory.json b/ios/DPIPWidgets/WidgetTownshipDirectory.json new file mode 100644 index 000000000..b777df815 --- /dev/null +++ b/ios/DPIPWidgets/WidgetTownshipDirectory.json @@ -0,0 +1 @@ +{"schemaVersion":1,"townships":[{"regionCode":"100","displayName":"中正區","administrativeAreaName":"臺北市","latitude":25.032188,"longitude":121.5183226},{"regionCode":"103","displayName":"大同區","administrativeAreaName":"臺北市","latitude":25.0661934,"longitude":121.515268},{"regionCode":"104","displayName":"中山區","administrativeAreaName":"臺北市","latitude":25.0642771,"longitude":121.5335776},{"regionCode":"105","displayName":"松山區","administrativeAreaName":"臺北市","latitude":25.049847,"longitude":121.577241},{"regionCode":"106","displayName":"大安區","administrativeAreaName":"臺北市","latitude":25.02642,"longitude":121.534511},{"regionCode":"108","displayName":"萬華區","administrativeAreaName":"臺北市","latitude":25.034839,"longitude":121.4997957},{"regionCode":"110","displayName":"信義區","administrativeAreaName":"臺北市","latitude":25.0377271,"longitude":121.5818185},{"regionCode":"111","displayName":"士林區","administrativeAreaName":"臺北市","latitude":25.0927548,"longitude":121.519565},{"regionCode":"112","displayName":"北投區","administrativeAreaName":"臺北市","latitude":25.1323666,"longitude":121.5029268},{"regionCode":"114","displayName":"內湖區","administrativeAreaName":"臺北市","latitude":25.06929,"longitude":121.588949},{"regionCode":"115","displayName":"南港區","administrativeAreaName":"臺北市","latitude":25.0547059,"longitude":121.6066929},{"regionCode":"116","displayName":"文山區","administrativeAreaName":"臺北市","latitude":24.98964,"longitude":121.5700826},{"regionCode":"200","displayName":"仁愛區","administrativeAreaName":"基隆市","latitude":25.125069,"longitude":121.736754},{"regionCode":"201","displayName":"信義區","administrativeAreaName":"基隆市","latitude":25.1294717,"longitude":121.7512671},{"regionCode":"202","displayName":"中正區","administrativeAreaName":"基隆市","latitude":25.1407924,"longitude":121.7592534},{"regionCode":"203","displayName":"中山區","administrativeAreaName":"基隆市","latitude":25.152899,"longitude":121.729552},{"regionCode":"204","displayName":"安樂區","administrativeAreaName":"基隆市","latitude":25.121176,"longitude":121.7230804},{"regionCode":"205","displayName":"暖暖區","administrativeAreaName":"基隆市","latitude":25.1001834,"longitude":121.735933},{"regionCode":"206","displayName":"七堵區","administrativeAreaName":"基隆市","latitude":25.097653,"longitude":121.7165013},{"regionCode":"207","displayName":"萬里區","administrativeAreaName":"新北市","latitude":25.178066,"longitude":121.689548},{"regionCode":"208","displayName":"金山區","administrativeAreaName":"新北市","latitude":25.222241,"longitude":121.636629},{"regionCode":"209","displayName":"南竿鄉","administrativeAreaName":"連江縣","latitude":26.1529312,"longitude":119.9387995},{"regionCode":"210","displayName":"北竿鄉","administrativeAreaName":"連江縣","latitude":26.225073,"longitude":119.9970589},{"regionCode":"211","displayName":"莒光鄉","administrativeAreaName":"連江縣","latitude":25.9718863,"longitude":119.9329207},{"regionCode":"212","displayName":"東引鄉","administrativeAreaName":"連江縣","latitude":26.3672636,"longitude":120.4898789},{"regionCode":"220","displayName":"板橋區","administrativeAreaName":"新北市","latitude":25.0096156,"longitude":121.4592358},{"regionCode":"221","displayName":"汐止區","administrativeAreaName":"新北市","latitude":25.064261,"longitude":121.65869},{"regionCode":"222","displayName":"深坑區","administrativeAreaName":"新北市","latitude":25.0025007,"longitude":121.615944},{"regionCode":"223","displayName":"石碇區","administrativeAreaName":"新北市","latitude":24.9914859,"longitude":121.6579019},{"regionCode":"224","displayName":"瑞芳區","administrativeAreaName":"新北市","latitude":25.1087635,"longitude":121.8098935},{"regionCode":"226","displayName":"平溪區","administrativeAreaName":"新北市","latitude":25.026689,"longitude":121.73819},{"regionCode":"227","displayName":"雙溪區","administrativeAreaName":"新北市","latitude":25.0337322,"longitude":121.8657341},{"regionCode":"228","displayName":"貢寮區","administrativeAreaName":"新北市","latitude":25.0209103,"longitude":121.9061177},{"regionCode":"231","displayName":"新店區","administrativeAreaName":"新北市","latitude":24.9672763,"longitude":121.5419478},{"regionCode":"232","displayName":"坪林區","administrativeAreaName":"新北市","latitude":24.9361367,"longitude":121.7117653},{"regionCode":"233","displayName":"烏來區","administrativeAreaName":"新北市","latitude":24.8717802,"longitude":121.5478634},{"regionCode":"234","displayName":"永和區","administrativeAreaName":"新北市","latitude":25.0091768,"longitude":121.5202731},{"regionCode":"235","displayName":"中和區","administrativeAreaName":"新北市","latitude":24.9985208,"longitude":121.5007413},{"regionCode":"236","displayName":"土城區","administrativeAreaName":"新北市","latitude":24.9723361,"longitude":121.4429389},{"regionCode":"237","displayName":"三峽區","administrativeAreaName":"新北市","latitude":24.9341863,"longitude":121.369083},{"regionCode":"238","displayName":"樹林區","administrativeAreaName":"新北市","latitude":24.9899673,"longitude":121.4246321},{"regionCode":"239","displayName":"鶯歌區","administrativeAreaName":"新北市","latitude":24.9560294,"longitude":121.3544312},{"regionCode":"241","displayName":"三重區","administrativeAreaName":"新北市","latitude":25.0607692,"longitude":121.4884178},{"regionCode":"242","displayName":"新莊區","administrativeAreaName":"新北市","latitude":25.035976,"longitude":121.450478},{"regionCode":"243","displayName":"泰山區","administrativeAreaName":"新北市","latitude":25.059291,"longitude":121.431495},{"regionCode":"244","displayName":"林口區","administrativeAreaName":"新北市","latitude":25.0768252,"longitude":121.3886134},{"regionCode":"247","displayName":"蘆洲區","administrativeAreaName":"新北市","latitude":25.0847112,"longitude":121.4737354},{"regionCode":"248","displayName":"五股區","administrativeAreaName":"新北市","latitude":25.0830393,"longitude":121.4380781},{"regionCode":"249","displayName":"八里區","administrativeAreaName":"新北市","latitude":25.1537592,"longitude":121.4064305},{"regionCode":"251","displayName":"淡水區","administrativeAreaName":"新北市","latitude":25.1870254,"longitude":121.4437254},{"regionCode":"252","displayName":"三芝區","administrativeAreaName":"新北市","latitude":25.257748,"longitude":121.5009345},{"regionCode":"253","displayName":"石門區","administrativeAreaName":"新北市","latitude":25.291248,"longitude":121.567631},{"regionCode":"260","displayName":"宜蘭市","administrativeAreaName":"宜蘭縣","latitude":24.7520373,"longitude":121.7531493},{"regionCode":"261","displayName":"頭城鎮","administrativeAreaName":"宜蘭縣","latitude":24.8548387,"longitude":121.8213189},{"regionCode":"262","displayName":"礁溪鄉","administrativeAreaName":"宜蘭縣","latitude":24.8234257,"longitude":121.7711725},{"regionCode":"263","displayName":"壯圍鄉","administrativeAreaName":"宜蘭縣","latitude":24.746832,"longitude":121.785759},{"regionCode":"264","displayName":"員山鄉","administrativeAreaName":"宜蘭縣","latitude":24.7433944,"longitude":121.7232538},{"regionCode":"265","displayName":"羅東鎮","administrativeAreaName":"宜蘭縣","latitude":24.6769245,"longitude":121.7669529},{"regionCode":"266","displayName":"三星鄉","administrativeAreaName":"宜蘭縣","latitude":24.665483,"longitude":121.654047},{"regionCode":"267","displayName":"大同鄉","administrativeAreaName":"宜蘭縣","latitude":24.677412,"longitude":121.6090927},{"regionCode":"268","displayName":"五結鄉","administrativeAreaName":"宜蘭縣","latitude":24.684772,"longitude":121.7982898},{"regionCode":"269","displayName":"冬山鄉","administrativeAreaName":"宜蘭縣","latitude":24.634338,"longitude":121.792851},{"regionCode":"270","displayName":"蘇澳鎮","administrativeAreaName":"宜蘭縣","latitude":24.594315,"longitude":121.8421903},{"regionCode":"272","displayName":"南澳鄉","administrativeAreaName":"宜蘭縣","latitude":24.465996,"longitude":121.803714},{"regionCode":"300","displayName":"北區","administrativeAreaName":"新竹市","latitude":24.8163726,"longitude":120.9703141},{"regionCode":"301","displayName":"東區","administrativeAreaName":"新竹市","latitude":24.8051881,"longitude":120.9732327},{"regionCode":"302","displayName":"竹北市","administrativeAreaName":"新竹縣","latitude":24.8395807,"longitude":121.0040235},{"regionCode":"303","displayName":"湖口鄉","administrativeAreaName":"新竹縣","latitude":24.9010068,"longitude":121.0478749},{"regionCode":"304","displayName":"新豐鄉","administrativeAreaName":"新竹縣","latitude":24.9007026,"longitude":120.9852313},{"regionCode":"305","displayName":"新埔鎮","administrativeAreaName":"新竹縣","latitude":24.8256201,"longitude":121.0740799},{"regionCode":"306","displayName":"關西鎮","administrativeAreaName":"新竹縣","latitude":24.7922806,"longitude":121.1759547},{"regionCode":"307","displayName":"芎林鄉","administrativeAreaName":"新竹縣","latitude":24.773309,"longitude":121.081855},{"regionCode":"308","displayName":"寶山鄉","administrativeAreaName":"新竹縣","latitude":24.7654715,"longitude":120.9913066},{"regionCode":"309","displayName":"香山區","administrativeAreaName":"新竹市","latitude":24.7940445,"longitude":120.9422678},{"regionCode":"310","displayName":"竹東鎮","administrativeAreaName":"新竹縣","latitude":24.7366942,"longitude":121.0916513},{"regionCode":"311","displayName":"五峰鄉","administrativeAreaName":"新竹縣","latitude":24.6320786,"longitude":121.119596},{"regionCode":"312","displayName":"橫山鄉","administrativeAreaName":"新竹縣","latitude":24.7167807,"longitude":121.1414985},{"regionCode":"313","displayName":"尖石鄉","administrativeAreaName":"新竹縣","latitude":24.705004,"longitude":121.2020864},{"regionCode":"314","displayName":"北埔鄉","administrativeAreaName":"新竹縣","latitude":24.7019901,"longitude":121.0563354},{"regionCode":"315","displayName":"峨眉鄉","administrativeAreaName":"新竹縣","latitude":24.6887921,"longitude":121.0195946},{"regionCode":"320","displayName":"中壢區","administrativeAreaName":"桃園市","latitude":24.9656124,"longitude":121.2249927},{"regionCode":"324","displayName":"平鎮區","administrativeAreaName":"桃園市","latitude":24.9456694,"longitude":121.2181884},{"regionCode":"325","displayName":"龍潭區","administrativeAreaName":"桃園市","latitude":24.8704855,"longitude":121.2224472},{"regionCode":"326","displayName":"楊梅區","administrativeAreaName":"桃園市","latitude":24.9075611,"longitude":121.145803},{"regionCode":"327","displayName":"新屋區","administrativeAreaName":"桃園市","latitude":24.9725439,"longitude":121.105533},{"regionCode":"328","displayName":"觀音區","administrativeAreaName":"桃園市","latitude":25.0276516,"longitude":121.0836028},{"regionCode":"330","displayName":"桃園區","administrativeAreaName":"桃園市","latitude":24.993919,"longitude":121.3016657},{"regionCode":"333","displayName":"龜山區","administrativeAreaName":"桃園市","latitude":24.9925139,"longitude":121.337824},{"regionCode":"334","displayName":"八德區","administrativeAreaName":"桃園市","latitude":24.9289862,"longitude":121.2846406},{"regionCode":"335","displayName":"大溪區","administrativeAreaName":"桃園市","latitude":24.880548,"longitude":121.287142},{"regionCode":"336","displayName":"復興區","administrativeAreaName":"桃園市","latitude":24.8147989,"longitude":121.3511305},{"regionCode":"337","displayName":"大園區","administrativeAreaName":"桃園市","latitude":25.0638556,"longitude":121.1954603},{"regionCode":"338","displayName":"蘆竹區","administrativeAreaName":"桃園市","latitude":25.046925,"longitude":121.295077},{"regionCode":"350","displayName":"竹南鎮","administrativeAreaName":"苗栗縣","latitude":24.6838448,"longitude":120.8733503},{"regionCode":"351","displayName":"頭份市","administrativeAreaName":"苗栗縣","latitude":24.6865276,"longitude":120.9132454},{"regionCode":"352","displayName":"三灣鄉","administrativeAreaName":"苗栗縣","latitude":24.6548694,"longitude":120.9602559},{"regionCode":"353","displayName":"南庄鄉","administrativeAreaName":"苗栗縣","latitude":24.5970648,"longitude":121.000339},{"regionCode":"354","displayName":"獅潭鄉","administrativeAreaName":"苗栗縣","latitude":24.539419,"longitude":120.920496},{"regionCode":"356","displayName":"後龍鎮","administrativeAreaName":"苗栗縣","latitude":24.613682,"longitude":120.792046},{"regionCode":"357","displayName":"通霄鎮","administrativeAreaName":"苗栗縣","latitude":24.4912805,"longitude":120.684249},{"regionCode":"358","displayName":"苑裡鎮","administrativeAreaName":"苗栗縣","latitude":24.4396522,"longitude":120.653261},{"regionCode":"360","displayName":"苗栗市","administrativeAreaName":"苗栗縣","latitude":24.5616772,"longitude":120.8190175},{"regionCode":"361","displayName":"造橋鄉","administrativeAreaName":"苗栗縣","latitude":24.638552,"longitude":120.8651632},{"regionCode":"362","displayName":"頭屋鄉","administrativeAreaName":"苗栗縣","latitude":24.5778158,"longitude":120.8511057},{"regionCode":"363","displayName":"公館鄉","administrativeAreaName":"苗栗縣","latitude":24.5058646,"longitude":120.8284935},{"regionCode":"364","displayName":"大湖鄉","administrativeAreaName":"苗栗縣","latitude":24.4234081,"longitude":120.8661781},{"regionCode":"365","displayName":"泰安鄉","administrativeAreaName":"苗栗縣","latitude":24.4471976,"longitude":120.9081718},{"regionCode":"366","displayName":"銅鑼鄉","administrativeAreaName":"苗栗縣","latitude":24.4870514,"longitude":120.7878718},{"regionCode":"367","displayName":"三義鄉","administrativeAreaName":"苗栗縣","latitude":24.4129179,"longitude":120.7702392},{"regionCode":"368","displayName":"西湖鄉","administrativeAreaName":"苗栗縣","latitude":24.5601976,"longitude":120.7554922},{"regionCode":"369","displayName":"卓蘭鎮","administrativeAreaName":"苗栗縣","latitude":24.3130501,"longitude":120.8245152},{"regionCode":"400","displayName":"中區","administrativeAreaName":"臺中市","latitude":24.1439458,"longitude":120.6794414},{"regionCode":"401","displayName":"東區","administrativeAreaName":"臺中市","latitude":24.1366469,"longitude":120.7037332},{"regionCode":"402","displayName":"南區","administrativeAreaName":"臺中市","latitude":24.1171412,"longitude":120.6635905},{"regionCode":"403","displayName":"西區","administrativeAreaName":"臺中市","latitude":24.1413252,"longitude":120.6710753},{"regionCode":"404","displayName":"北區","administrativeAreaName":"臺中市","latitude":24.1658534,"longitude":120.6822936},{"regionCode":"406","displayName":"北屯區","administrativeAreaName":"臺中市","latitude":24.1826848,"longitude":120.686403},{"regionCode":"407","displayName":"西屯區","administrativeAreaName":"臺中市","latitude":24.1658213,"longitude":120.6336717},{"regionCode":"408","displayName":"南屯區","administrativeAreaName":"臺中市","latitude":24.1345298,"longitude":120.6442903},{"regionCode":"411","displayName":"太平區","administrativeAreaName":"臺中市","latitude":24.1266572,"longitude":120.7185562},{"regionCode":"412","displayName":"大里區","administrativeAreaName":"臺中市","latitude":24.0999535,"longitude":120.6859207},{"regionCode":"413","displayName":"霧峰區","administrativeAreaName":"臺中市","latitude":24.0613153,"longitude":120.7000868},{"regionCode":"414","displayName":"烏日區","administrativeAreaName":"臺中市","latitude":24.104605,"longitude":120.6238467},{"regionCode":"420","displayName":"豐原區","administrativeAreaName":"臺中市","latitude":24.2419087,"longitude":120.7181281},{"regionCode":"421","displayName":"后里區","administrativeAreaName":"臺中市","latitude":24.3049377,"longitude":120.7107454},{"regionCode":"422","displayName":"石岡區","administrativeAreaName":"臺中市","latitude":24.274898,"longitude":120.7803458},{"regionCode":"423","displayName":"東勢區","administrativeAreaName":"臺中市","latitude":24.2585728,"longitude":120.8279546},{"regionCode":"424","displayName":"和平區","administrativeAreaName":"臺中市","latitude":24.1752225,"longitude":120.8835809},{"regionCode":"426","displayName":"新社區","administrativeAreaName":"臺中市","latitude":24.233957,"longitude":120.809416},{"regionCode":"427","displayName":"潭子區","administrativeAreaName":"臺中市","latitude":24.2031708,"longitude":120.7228978},{"regionCode":"428","displayName":"大雅區","administrativeAreaName":"臺中市","latitude":24.2291463,"longitude":120.6478436},{"regionCode":"429","displayName":"神岡區","administrativeAreaName":"臺中市","latitude":24.2579749,"longitude":120.6614216},{"regionCode":"432","displayName":"大肚區","administrativeAreaName":"臺中市","latitude":24.1511295,"longitude":120.5457643},{"regionCode":"433","displayName":"沙鹿區","administrativeAreaName":"臺中市","latitude":24.2333313,"longitude":120.566317},{"regionCode":"434","displayName":"龍井區","administrativeAreaName":"臺中市","latitude":24.1924488,"longitude":120.5457999},{"regionCode":"435","displayName":"梧棲區","administrativeAreaName":"臺中市","latitude":24.2549063,"longitude":120.5317002},{"regionCode":"436","displayName":"清水區","administrativeAreaName":"臺中市","latitude":24.2681562,"longitude":120.559717},{"regionCode":"437","displayName":"大甲區","administrativeAreaName":"臺中市","latitude":24.349083,"longitude":120.622468},{"regionCode":"438","displayName":"外埔區","administrativeAreaName":"臺中市","latitude":24.3321248,"longitude":120.654334},{"regionCode":"439","displayName":"大安區","administrativeAreaName":"臺中市","latitude":24.346126,"longitude":120.5866075},{"regionCode":"500","displayName":"彰化市","administrativeAreaName":"彰化縣","latitude":24.0809056,"longitude":120.5422565},{"regionCode":"502","displayName":"芬園鄉","administrativeAreaName":"彰化縣","latitude":24.013628,"longitude":120.628964},{"regionCode":"503","displayName":"花壇鄉","administrativeAreaName":"彰化縣","latitude":24.0296582,"longitude":120.5382578},{"regionCode":"504","displayName":"秀水鄉","administrativeAreaName":"彰化縣","latitude":24.035321,"longitude":120.5028098},{"regionCode":"505","displayName":"鹿港鎮","administrativeAreaName":"彰化縣","latitude":24.0572566,"longitude":120.4350548},{"regionCode":"506","displayName":"福興鄉","administrativeAreaName":"彰化縣","latitude":24.0478713,"longitude":120.4439546},{"regionCode":"507","displayName":"線西鄉","administrativeAreaName":"彰化縣","latitude":24.1306678,"longitude":120.470656},{"regionCode":"508","displayName":"和美鎮","administrativeAreaName":"彰化縣","latitude":24.1085584,"longitude":120.4941497},{"regionCode":"509","displayName":"伸港鄉","administrativeAreaName":"彰化縣","latitude":24.1596067,"longitude":120.4861141},{"regionCode":"510","displayName":"員林市","administrativeAreaName":"彰化縣","latitude":23.9589162,"longitude":120.5743943},{"regionCode":"511","displayName":"社頭鄉","administrativeAreaName":"彰化縣","latitude":23.8968186,"longitude":120.5858074},{"regionCode":"512","displayName":"永靖鄉","administrativeAreaName":"彰化縣","latitude":23.9245962,"longitude":120.5477204},{"regionCode":"513","displayName":"埔心鄉","administrativeAreaName":"彰化縣","latitude":23.952988,"longitude":120.54354},{"regionCode":"514","displayName":"溪湖鎮","administrativeAreaName":"彰化縣","latitude":23.9664245,"longitude":120.4832787},{"regionCode":"515","displayName":"大村鄉","administrativeAreaName":"彰化縣","latitude":23.9934239,"longitude":120.547268},{"regionCode":"516","displayName":"埔鹽鄉","administrativeAreaName":"彰化縣","latitude":24.000442,"longitude":120.4636578},{"regionCode":"520","displayName":"田中鎮","administrativeAreaName":"彰化縣","latitude":23.8614831,"longitude":120.5809585},{"regionCode":"521","displayName":"北斗鎮","administrativeAreaName":"彰化縣","latitude":23.8713826,"longitude":120.5218625},{"regionCode":"522","displayName":"田尾鄉","administrativeAreaName":"彰化縣","latitude":23.8922407,"longitude":120.5259709},{"regionCode":"523","displayName":"埤頭鄉","administrativeAreaName":"彰化縣","latitude":23.8914618,"longitude":120.4621725},{"regionCode":"524","displayName":"溪州鄉","administrativeAreaName":"彰化縣","latitude":23.8516188,"longitude":120.498976},{"regionCode":"525","displayName":"竹塘鄉","administrativeAreaName":"彰化縣","latitude":23.8605045,"longitude":120.4277961},{"regionCode":"526","displayName":"二林鎮","administrativeAreaName":"彰化縣","latitude":23.8998045,"longitude":120.3742654},{"regionCode":"527","displayName":"大城鄉","administrativeAreaName":"彰化縣","latitude":23.852314,"longitude":120.3208972},{"regionCode":"528","displayName":"芳苑鄉","administrativeAreaName":"彰化縣","latitude":23.924354,"longitude":120.320389},{"regionCode":"530","displayName":"二水鄉","administrativeAreaName":"彰化縣","latitude":23.8066858,"longitude":120.6190203},{"regionCode":"540","displayName":"南投市","administrativeAreaName":"南投縣","latitude":23.9116414,"longitude":120.6874199},{"regionCode":"541","displayName":"中寮鄉","administrativeAreaName":"南投縣","latitude":23.8790067,"longitude":120.7660301},{"regionCode":"542","displayName":"草屯鎮","administrativeAreaName":"南投縣","latitude":23.9736845,"longitude":120.6802819},{"regionCode":"544","displayName":"國姓鄉","administrativeAreaName":"南投縣","latitude":24.040019,"longitude":120.8575192},{"regionCode":"545","displayName":"埔里鎮","administrativeAreaName":"南投縣","latitude":23.9665377,"longitude":120.9691809},{"regionCode":"546","displayName":"仁愛鄉","administrativeAreaName":"南投縣","latitude":24.021544,"longitude":121.1320616},{"regionCode":"551","displayName":"名間鄉","administrativeAreaName":"南投縣","latitude":23.8382161,"longitude":120.702985},{"regionCode":"552","displayName":"集集鎮","administrativeAreaName":"南投縣","latitude":23.8283683,"longitude":120.7864885},{"regionCode":"553","displayName":"水里鄉","administrativeAreaName":"南投縣","latitude":23.8113991,"longitude":120.8560852},{"regionCode":"555","displayName":"魚池鄉","administrativeAreaName":"南投縣","latitude":23.8957849,"longitude":120.9356849},{"regionCode":"556","displayName":"信義鄉","administrativeAreaName":"南投縣","latitude":23.696797,"longitude":120.854557},{"regionCode":"557","displayName":"竹山鎮","administrativeAreaName":"南投縣","latitude":23.7578635,"longitude":120.6716995},{"regionCode":"558","displayName":"鹿谷鄉","administrativeAreaName":"南投縣","latitude":23.7458415,"longitude":120.7534428},{"regionCode":"600","displayName":"東區","administrativeAreaName":"嘉義市","latitude":23.4786578,"longitude":120.4534596},{"regionCode":"601","displayName":"西區","administrativeAreaName":"嘉義市","latitude":23.4646967,"longitude":120.4352822},{"regionCode":"602","displayName":"番路鄉","administrativeAreaName":"嘉義縣","latitude":23.4644973,"longitude":120.554286},{"regionCode":"603","displayName":"梅山鄉","administrativeAreaName":"嘉義縣","latitude":23.5850678,"longitude":120.5554676},{"regionCode":"604","displayName":"竹崎鄉","administrativeAreaName":"嘉義縣","latitude":23.5230788,"longitude":120.5513988},{"regionCode":"605","displayName":"阿里山鄉","administrativeAreaName":"嘉義縣","latitude":23.4712117,"longitude":120.7135217},{"regionCode":"606","displayName":"中埔鄉","administrativeAreaName":"嘉義縣","latitude":23.425139,"longitude":120.522952},{"regionCode":"607","displayName":"大埔鄉","administrativeAreaName":"嘉義縣","latitude":23.2947446,"longitude":120.593585},{"regionCode":"608","displayName":"水上鄉","administrativeAreaName":"嘉義縣","latitude":23.430147,"longitude":120.40943},{"regionCode":"611","displayName":"鹿草鄉","administrativeAreaName":"嘉義縣","latitude":23.4113835,"longitude":120.3082682},{"regionCode":"612","displayName":"太保市","administrativeAreaName":"嘉義縣","latitude":23.458967,"longitude":120.3323479},{"regionCode":"613","displayName":"朴子市","administrativeAreaName":"嘉義縣","latitude":23.4575288,"longitude":120.2459226},{"regionCode":"614","displayName":"東石鄉","administrativeAreaName":"嘉義縣","latitude":23.4587147,"longitude":120.1537883},{"regionCode":"615","displayName":"六腳鄉","administrativeAreaName":"嘉義縣","latitude":23.4940922,"longitude":120.2907962},{"regionCode":"616","displayName":"新港鄉","administrativeAreaName":"嘉義縣","latitude":23.5519744,"longitude":120.3478039},{"regionCode":"621","displayName":"民雄鄉","administrativeAreaName":"嘉義縣","latitude":23.5516417,"longitude":120.4283616},{"regionCode":"622","displayName":"大林鎮","administrativeAreaName":"嘉義縣","latitude":23.603931,"longitude":120.471178},{"regionCode":"623","displayName":"溪口鄉","administrativeAreaName":"嘉義縣","latitude":23.6025304,"longitude":120.392323},{"regionCode":"624","displayName":"義竹鄉","administrativeAreaName":"嘉義縣","latitude":23.3361064,"longitude":120.2430475},{"regionCode":"625","displayName":"布袋鎮","administrativeAreaName":"嘉義縣","latitude":23.3781734,"longitude":120.1669564},{"regionCode":"630","displayName":"斗南鎮","administrativeAreaName":"雲林縣","latitude":23.680105,"longitude":120.47772},{"regionCode":"631","displayName":"大埤鄉","administrativeAreaName":"雲林縣","latitude":23.6460343,"longitude":120.4314818},{"regionCode":"632","displayName":"虎尾鎮","administrativeAreaName":"雲林縣","latitude":23.7083539,"longitude":120.4451923},{"regionCode":"633","displayName":"土庫鎮","administrativeAreaName":"雲林縣","latitude":23.6820621,"longitude":120.3899062},{"regionCode":"634","displayName":"褒忠鄉","administrativeAreaName":"雲林縣","latitude":23.691007,"longitude":120.3037335},{"regionCode":"635","displayName":"東勢鄉","administrativeAreaName":"雲林縣","latitude":23.6753034,"longitude":120.2525936},{"regionCode":"636","displayName":"臺西鄉","administrativeAreaName":"雲林縣","latitude":23.700477,"longitude":120.1957632},{"regionCode":"637","displayName":"崙背鄉","administrativeAreaName":"雲林縣","latitude":23.7619087,"longitude":120.3591424},{"regionCode":"638","displayName":"麥寮鄉","administrativeAreaName":"雲林縣","latitude":23.7539853,"longitude":120.2513078},{"regionCode":"640","displayName":"斗六市","administrativeAreaName":"雲林縣","latitude":23.6971143,"longitude":120.5269987},{"regionCode":"643","displayName":"林內鄉","administrativeAreaName":"雲林縣","latitude":23.7562156,"longitude":120.6129427},{"regionCode":"646","displayName":"古坑鄉","administrativeAreaName":"雲林縣","latitude":23.6426312,"longitude":120.5619595},{"regionCode":"647","displayName":"莿桐鄉","administrativeAreaName":"雲林縣","latitude":23.7610077,"longitude":120.5025072},{"regionCode":"648","displayName":"西螺鎮","administrativeAreaName":"雲林縣","latitude":23.7977736,"longitude":120.465685},{"regionCode":"649","displayName":"二崙鄉","administrativeAreaName":"雲林縣","latitude":23.7711012,"longitude":120.4129596},{"regionCode":"651","displayName":"北港鎮","administrativeAreaName":"雲林縣","latitude":23.5759236,"longitude":120.302446},{"regionCode":"652","displayName":"水林鄉","administrativeAreaName":"雲林縣","latitude":23.5727035,"longitude":120.2459507},{"regionCode":"653","displayName":"口湖鄉","administrativeAreaName":"雲林縣","latitude":23.5827378,"longitude":120.1858552},{"regionCode":"654","displayName":"四湖鄉","administrativeAreaName":"雲林縣","latitude":23.6378317,"longitude":120.2242949},{"regionCode":"655","displayName":"元長鄉","administrativeAreaName":"雲林縣","latitude":23.642881,"longitude":120.31976},{"regionCode":"700","displayName":"中西區","administrativeAreaName":"臺南市","latitude":22.9922364,"longitude":120.2056571},{"regionCode":"701","displayName":"東區","administrativeAreaName":"臺南市","latitude":22.9802421,"longitude":120.224004},{"regionCode":"702","displayName":"南區","administrativeAreaName":"臺南市","latitude":22.9611326,"longitude":120.1885687},{"regionCode":"704","displayName":"北區","administrativeAreaName":"臺南市","latitude":22.9997522,"longitude":120.2030341},{"regionCode":"708","displayName":"安平區","administrativeAreaName":"臺南市","latitude":22.9945789,"longitude":120.1688523},{"regionCode":"709","displayName":"安南區","administrativeAreaName":"臺南市","latitude":23.0472321,"longitude":120.184714},{"regionCode":"710","displayName":"永康區","administrativeAreaName":"臺南市","latitude":23.0260699,"longitude":120.2570647},{"regionCode":"711","displayName":"歸仁區","administrativeAreaName":"臺南市","latitude":22.967286,"longitude":120.2940045},{"regionCode":"712","displayName":"新化區","administrativeAreaName":"臺南市","latitude":23.0385411,"longitude":120.310896},{"regionCode":"713","displayName":"左鎮區","administrativeAreaName":"臺南市","latitude":23.0567783,"longitude":120.408708},{"regionCode":"714","displayName":"玉井區","administrativeAreaName":"臺南市","latitude":23.1237866,"longitude":120.4601109},{"regionCode":"715","displayName":"楠西區","administrativeAreaName":"臺南市","latitude":23.174143,"longitude":120.486337},{"regionCode":"716","displayName":"南化區","administrativeAreaName":"臺南市","latitude":23.042988,"longitude":120.477816},{"regionCode":"717","displayName":"仁德區","administrativeAreaName":"臺南市","latitude":22.97243,"longitude":120.251685},{"regionCode":"718","displayName":"關廟區","administrativeAreaName":"臺南市","latitude":22.9630039,"longitude":120.3278144},{"regionCode":"719","displayName":"龍崎區","administrativeAreaName":"臺南市","latitude":22.9632926,"longitude":120.3649712},{"regionCode":"720","displayName":"官田區","administrativeAreaName":"臺南市","latitude":23.1930442,"longitude":120.3154967},{"regionCode":"721","displayName":"麻豆區","administrativeAreaName":"臺南市","latitude":23.1849449,"longitude":120.2584456},{"regionCode":"722","displayName":"佳里區","administrativeAreaName":"臺南市","latitude":23.1652648,"longitude":120.1770306},{"regionCode":"723","displayName":"西港區","administrativeAreaName":"臺南市","latitude":23.1229825,"longitude":120.203413},{"regionCode":"724","displayName":"七股區","administrativeAreaName":"臺南市","latitude":23.1403809,"longitude":120.1391359},{"regionCode":"725","displayName":"將軍區","administrativeAreaName":"臺南市","latitude":23.19905,"longitude":120.158702},{"regionCode":"726","displayName":"學甲區","administrativeAreaName":"臺南市","latitude":23.2304835,"longitude":120.1822926},{"regionCode":"727","displayName":"北門區","administrativeAreaName":"臺南市","latitude":23.267723,"longitude":120.125445},{"regionCode":"730","displayName":"新營區","administrativeAreaName":"臺南市","latitude":23.3101426,"longitude":120.3167031},{"regionCode":"731","displayName":"後壁區","administrativeAreaName":"臺南市","latitude":23.3659836,"longitude":120.3619362},{"regionCode":"732","displayName":"白河區","administrativeAreaName":"臺南市","latitude":23.3512886,"longitude":120.415752},{"regionCode":"733","displayName":"東山區","administrativeAreaName":"臺南市","latitude":23.3261625,"longitude":120.4045009},{"regionCode":"734","displayName":"六甲區","administrativeAreaName":"臺南市","latitude":23.2318098,"longitude":120.3474201},{"regionCode":"735","displayName":"下營區","administrativeAreaName":"臺南市","latitude":23.2356921,"longitude":120.2643838},{"regionCode":"736","displayName":"柳營區","administrativeAreaName":"臺南市","latitude":23.278395,"longitude":120.311673},{"regionCode":"737","displayName":"鹽水區","administrativeAreaName":"臺南市","latitude":23.320027,"longitude":120.266097},{"regionCode":"741","displayName":"善化區","administrativeAreaName":"臺南市","latitude":23.1324288,"longitude":120.2967849},{"regionCode":"742","displayName":"大內區","administrativeAreaName":"臺南市","latitude":23.1192073,"longitude":120.3568212},{"regionCode":"743","displayName":"山上區","administrativeAreaName":"臺南市","latitude":23.1036173,"longitude":120.3526487},{"regionCode":"744","displayName":"新市區","administrativeAreaName":"臺南市","latitude":23.0789967,"longitude":120.2951827},{"regionCode":"745","displayName":"安定區","administrativeAreaName":"臺南市","latitude":23.121593,"longitude":120.237118},{"regionCode":"800","displayName":"新興區","administrativeAreaName":"高雄市","latitude":22.6310347,"longitude":120.3101095},{"regionCode":"801","displayName":"前金區","administrativeAreaName":"高雄市","latitude":22.6275276,"longitude":120.2942181},{"regionCode":"802","displayName":"苓雅區","administrativeAreaName":"高雄市","latitude":22.621759,"longitude":120.312194},{"regionCode":"803","displayName":"鹽埕區","administrativeAreaName":"高雄市","latitude":22.6247166,"longitude":120.2868098},{"regionCode":"804","displayName":"鼓山區","administrativeAreaName":"高雄市","latitude":22.636776,"longitude":120.2809626},{"regionCode":"805","displayName":"旗津區","administrativeAreaName":"高雄市","latitude":22.5900263,"longitude":120.28471},{"regionCode":"806","displayName":"前鎮區","administrativeAreaName":"高雄市","latitude":22.5865658,"longitude":120.318307},{"regionCode":"807","displayName":"三民區","administrativeAreaName":"高雄市","latitude":22.647684,"longitude":120.299851},{"regionCode":"811","displayName":"楠梓區","administrativeAreaName":"高雄市","latitude":22.7283655,"longitude":120.3263681},{"regionCode":"812","displayName":"小港區","administrativeAreaName":"高雄市","latitude":22.5652134,"longitude":120.3380368},{"regionCode":"813","displayName":"左營區","administrativeAreaName":"高雄市","latitude":22.6899834,"longitude":120.2950135},{"regionCode":"814","displayName":"仁武區","administrativeAreaName":"高雄市","latitude":22.7013806,"longitude":120.3479837},{"regionCode":"815","displayName":"大社區","administrativeAreaName":"高雄市","latitude":22.730156,"longitude":120.346671},{"regionCode":"820","displayName":"岡山區","administrativeAreaName":"高雄市","latitude":22.7974649,"longitude":120.2950724},{"regionCode":"821","displayName":"路竹區","administrativeAreaName":"高雄市","latitude":22.8547435,"longitude":120.2592442},{"regionCode":"822","displayName":"阿蓮區","administrativeAreaName":"高雄市","latitude":22.8832024,"longitude":120.3274131},{"regionCode":"823","displayName":"田寮區","administrativeAreaName":"高雄市","latitude":22.8698825,"longitude":120.3594334},{"regionCode":"824","displayName":"燕巢區","administrativeAreaName":"高雄市","latitude":22.7881575,"longitude":120.3619685},{"regionCode":"825","displayName":"橋頭區","administrativeAreaName":"高雄市","latitude":22.757591,"longitude":120.305809},{"regionCode":"826","displayName":"梓官區","administrativeAreaName":"高雄市","latitude":22.7606303,"longitude":120.2671523},{"regionCode":"827","displayName":"彌陀區","administrativeAreaName":"高雄市","latitude":22.7814893,"longitude":120.2507011},{"regionCode":"828","displayName":"永安區","administrativeAreaName":"高雄市","latitude":22.818298,"longitude":120.224189},{"regionCode":"829","displayName":"湖內區","administrativeAreaName":"高雄市","latitude":22.9083136,"longitude":120.211713},{"regionCode":"830","displayName":"鳳山區","administrativeAreaName":"高雄市","latitude":22.627075,"longitude":120.362525},{"regionCode":"831","displayName":"大寮區","administrativeAreaName":"高雄市","latitude":22.6055196,"longitude":120.3956199},{"regionCode":"832","displayName":"林園區","administrativeAreaName":"高雄市","latitude":22.5129453,"longitude":120.3946805},{"regionCode":"833","displayName":"鳥松區","administrativeAreaName":"高雄市","latitude":22.6598339,"longitude":120.364363},{"regionCode":"840","displayName":"大樹區","administrativeAreaName":"高雄市","latitude":22.6839054,"longitude":120.4143416},{"regionCode":"842","displayName":"旗山區","administrativeAreaName":"高雄市","latitude":22.888642,"longitude":120.48349},{"regionCode":"843","displayName":"美濃區","administrativeAreaName":"高雄市","latitude":22.8947952,"longitude":120.5419493},{"regionCode":"844","displayName":"六龜區","administrativeAreaName":"高雄市","latitude":22.9984391,"longitude":120.6327082},{"regionCode":"845","displayName":"內門區","administrativeAreaName":"高雄市","latitude":22.9428391,"longitude":120.462606},{"regionCode":"846","displayName":"杉林區","administrativeAreaName":"高雄市","latitude":22.9717391,"longitude":120.540058},{"regionCode":"847","displayName":"甲仙區","administrativeAreaName":"高雄市","latitude":23.0839058,"longitude":120.587695},{"regionCode":"848","displayName":"桃源區","administrativeAreaName":"高雄市","latitude":23.1592827,"longitude":120.7641372},{"regionCode":"849","displayName":"那瑪夏區","administrativeAreaName":"高雄市","latitude":23.277092,"longitude":120.720243},{"regionCode":"851","displayName":"茂林區","administrativeAreaName":"高雄市","latitude":22.886187,"longitude":120.663266},{"regionCode":"852","displayName":"茄萣區","administrativeAreaName":"高雄市","latitude":22.9065231,"longitude":120.1824729},{"regionCode":"880","displayName":"馬公市","administrativeAreaName":"澎湖縣","latitude":23.566159,"longitude":119.578692},{"regionCode":"881","displayName":"西嶼鄉","administrativeAreaName":"澎湖縣","latitude":23.601088,"longitude":119.5069847},{"regionCode":"882","displayName":"望安鄉","administrativeAreaName":"澎湖縣","latitude":23.3576577,"longitude":119.500894},{"regionCode":"883","displayName":"七美鄉","administrativeAreaName":"澎湖縣","latitude":23.2067107,"longitude":119.4244825},{"regionCode":"884","displayName":"白沙鄉","administrativeAreaName":"澎湖縣","latitude":23.666538,"longitude":119.598639},{"regionCode":"885","displayName":"湖西鄉","administrativeAreaName":"澎湖縣","latitude":23.584072,"longitude":119.6528318},{"regionCode":"890","displayName":"金沙鎮","administrativeAreaName":"金門縣","latitude":24.488898,"longitude":118.413132},{"regionCode":"891","displayName":"金湖鎮","administrativeAreaName":"金門縣","latitude":24.4414641,"longitude":118.4171018},{"regionCode":"892","displayName":"金寧鄉","administrativeAreaName":"金門縣","latitude":24.450966,"longitude":118.334887},{"regionCode":"893","displayName":"金城鎮","administrativeAreaName":"金門縣","latitude":24.432824,"longitude":118.320697},{"regionCode":"894","displayName":"烈嶼鄉","administrativeAreaName":"金門縣","latitude":24.4295409,"longitude":118.2445892},{"regionCode":"896","displayName":"烏坵鄉","administrativeAreaName":"金門縣","latitude":24.9887062,"longitude":119.4531826},{"regionCode":"900","displayName":"屏東市","administrativeAreaName":"屏東縣","latitude":22.662498,"longitude":120.4914295},{"regionCode":"901","displayName":"三地門鄉","administrativeAreaName":"屏東縣","latitude":22.7162015,"longitude":120.6541301},{"regionCode":"902","displayName":"霧臺鄉","administrativeAreaName":"屏東縣","latitude":22.7490518,"longitude":120.7282593},{"regionCode":"903","displayName":"瑪家鄉","administrativeAreaName":"屏東縣","latitude":22.7086763,"longitude":120.6494041},{"regionCode":"904","displayName":"九如鄉","administrativeAreaName":"屏東縣","latitude":22.740429,"longitude":120.4903298},{"regionCode":"905","displayName":"里港鄉","administrativeAreaName":"屏東縣","latitude":22.7792625,"longitude":120.4944974},{"regionCode":"906","displayName":"高樹鄉","administrativeAreaName":"屏東縣","latitude":22.8267314,"longitude":120.600241},{"regionCode":"907","displayName":"鹽埔鄉","administrativeAreaName":"屏東縣","latitude":22.7543743,"longitude":120.5727019},{"regionCode":"908","displayName":"長治鄉","administrativeAreaName":"屏東縣","latitude":22.676538,"longitude":120.5272963},{"regionCode":"909","displayName":"麟洛鄉","administrativeAreaName":"屏東縣","latitude":22.6506473,"longitude":120.5272056},{"regionCode":"911","displayName":"竹田鄉","administrativeAreaName":"屏東縣","latitude":22.584724,"longitude":120.543981},{"regionCode":"912","displayName":"內埔鄉","administrativeAreaName":"屏東縣","latitude":22.608438,"longitude":120.57045},{"regionCode":"913","displayName":"萬丹鄉","administrativeAreaName":"屏東縣","latitude":22.5894894,"longitude":120.4850182},{"regionCode":"920","displayName":"潮州鎮","administrativeAreaName":"屏東縣","latitude":22.549845,"longitude":120.5429633},{"regionCode":"921","displayName":"泰武鄉","administrativeAreaName":"屏東縣","latitude":22.5919045,"longitude":120.6319498},{"regionCode":"922","displayName":"來義鄉","administrativeAreaName":"屏東縣","latitude":22.5261844,"longitude":120.6315782},{"regionCode":"923","displayName":"萬巒鄉","administrativeAreaName":"屏東縣","latitude":22.5726416,"longitude":120.567841},{"regionCode":"924","displayName":"崁頂鄉","administrativeAreaName":"屏東縣","latitude":22.5147343,"longitude":120.5140493},{"regionCode":"925","displayName":"新埤鄉","administrativeAreaName":"屏東縣","latitude":22.4701881,"longitude":120.5498736},{"regionCode":"926","displayName":"南州鄉","administrativeAreaName":"屏東縣","latitude":22.490404,"longitude":120.509879},{"regionCode":"927","displayName":"林邊鄉","administrativeAreaName":"屏東縣","latitude":22.431504,"longitude":120.5097562},{"regionCode":"928","displayName":"東港鎮","administrativeAreaName":"屏東縣","latitude":22.464021,"longitude":120.45911},{"regionCode":"929","displayName":"琉球鄉","administrativeAreaName":"屏東縣","latitude":22.348635,"longitude":120.3827309},{"regionCode":"931","displayName":"佳冬鄉","administrativeAreaName":"屏東縣","latitude":22.419209,"longitude":120.5524196},{"regionCode":"932","displayName":"新園鄉","administrativeAreaName":"屏東縣","latitude":22.5438351,"longitude":120.4614914},{"regionCode":"940","displayName":"枋寮鄉","administrativeAreaName":"屏東縣","latitude":22.3655097,"longitude":120.5934906},{"regionCode":"941","displayName":"枋山鄉","administrativeAreaName":"屏東縣","latitude":22.2639616,"longitude":120.6524803},{"regionCode":"942","displayName":"春日鄉","administrativeAreaName":"屏東縣","latitude":22.3710005,"longitude":120.6290908},{"regionCode":"943","displayName":"獅子鄉","administrativeAreaName":"屏東縣","latitude":22.201775,"longitude":120.705438},{"regionCode":"944","displayName":"車城鄉","administrativeAreaName":"屏東縣","latitude":22.0739409,"longitude":120.714276},{"regionCode":"945","displayName":"牡丹鄉","administrativeAreaName":"屏東縣","latitude":22.1261502,"longitude":120.7743059},{"regionCode":"946","displayName":"恆春鎮","administrativeAreaName":"屏東縣","latitude":22.0037401,"longitude":120.7472461},{"regionCode":"947","displayName":"滿州鄉","administrativeAreaName":"屏東縣","latitude":22.020813,"longitude":120.838632},{"regionCode":"950","displayName":"臺東市","administrativeAreaName":"臺東縣","latitude":22.7548208,"longitude":121.1465131},{"regionCode":"951","displayName":"綠島鄉","administrativeAreaName":"臺東縣","latitude":22.6693578,"longitude":121.4685142},{"regionCode":"952","displayName":"蘭嶼鄉","administrativeAreaName":"臺東縣","latitude":22.0244984,"longitude":121.5560627},{"regionCode":"953","displayName":"延平鄉","administrativeAreaName":"臺東縣","latitude":22.9025753,"longitude":121.0860671},{"regionCode":"954","displayName":"卑南鄉","administrativeAreaName":"臺東縣","latitude":22.7827393,"longitude":121.0870294},{"regionCode":"955","displayName":"鹿野鄉","administrativeAreaName":"臺東縣","latitude":22.9393083,"longitude":121.1519859},{"regionCode":"956","displayName":"關山鎮","administrativeAreaName":"臺東縣","latitude":23.0474453,"longitude":121.1630554},{"regionCode":"957","displayName":"海端鄉","administrativeAreaName":"臺東縣","latitude":23.102057,"longitude":121.176541},{"regionCode":"958","displayName":"池上鄉","administrativeAreaName":"臺東縣","latitude":23.1223101,"longitude":121.2151887},{"regionCode":"959","displayName":"東河鄉","administrativeAreaName":"臺東縣","latitude":22.9689404,"longitude":121.3028937},{"regionCode":"961","displayName":"成功鎮","administrativeAreaName":"臺東縣","latitude":23.1050697,"longitude":121.3808747},{"regionCode":"962","displayName":"長濱鄉","administrativeAreaName":"臺東縣","latitude":23.3149961,"longitude":121.4514207},{"regionCode":"963","displayName":"太麻里鄉","administrativeAreaName":"臺東縣","latitude":22.615548,"longitude":121.007607},{"regionCode":"964","displayName":"金峰鄉","administrativeAreaName":"臺東縣","latitude":22.5946397,"longitude":120.9607096},{"regionCode":"965","displayName":"大武鄉","administrativeAreaName":"臺東縣","latitude":22.340518,"longitude":120.890073},{"regionCode":"966","displayName":"達仁鄉","administrativeAreaName":"臺東縣","latitude":22.296818,"longitude":120.882973},{"regionCode":"970","displayName":"花蓮市","administrativeAreaName":"花蓮縣","latitude":23.9820651,"longitude":121.6067705},{"regionCode":"971","displayName":"新城鄉","administrativeAreaName":"花蓮縣","latitude":24.0392994,"longitude":121.6041173},{"regionCode":"972","displayName":"秀林鄉","administrativeAreaName":"花蓮縣","latitude":24.1185835,"longitude":121.6248326},{"regionCode":"973","displayName":"吉安鄉","administrativeAreaName":"花蓮縣","latitude":23.9729455,"longitude":121.5636438},{"regionCode":"974","displayName":"壽豐鄉","administrativeAreaName":"花蓮縣","latitude":23.8703424,"longitude":121.5088259},{"regionCode":"975","displayName":"鳳林鎮","administrativeAreaName":"花蓮縣","latitude":23.7447637,"longitude":121.4515822},{"regionCode":"976","displayName":"光復鄉","administrativeAreaName":"花蓮縣","latitude":23.669342,"longitude":121.4233065},{"regionCode":"977","displayName":"豐濱鄉","administrativeAreaName":"花蓮縣","latitude":23.6012024,"longitude":121.5211488},{"regionCode":"978","displayName":"瑞穗鄉","administrativeAreaName":"花蓮縣","latitude":23.4964553,"longitude":121.3757788},{"regionCode":"979","displayName":"萬榮鄉","administrativeAreaName":"花蓮縣","latitude":23.714875,"longitude":121.4109617},{"regionCode":"981","displayName":"玉里鎮","administrativeAreaName":"花蓮縣","latitude":23.335527,"longitude":121.315197},{"regionCode":"982","displayName":"卓溪鄉","administrativeAreaName":"花蓮縣","latitude":23.346478,"longitude":121.303451},{"regionCode":"983","displayName":"富里鄉","administrativeAreaName":"花蓮縣","latitude":23.1794845,"longitude":121.250233}]} diff --git a/ios/DPIPWidgets/WidgetTownshipResolver.swift b/ios/DPIPWidgets/WidgetTownshipResolver.swift new file mode 100644 index 000000000..c87c6d674 --- /dev/null +++ b/ios/DPIPWidgets/WidgetTownshipResolver.swift @@ -0,0 +1,661 @@ +import Foundation + +enum WidgetTownshipResourceError: Error, Equatable { + case missingResource(String) + case unsupportedDirectorySchema(Int) + case invalidDirectory + case invalidBoundaryData + case incompatibleCodeSets +} + +struct WidgetTownship: Equatable, Sendable { + let regionCode: String + let displayName: String + let administrativeAreaName: String + let latitude: Double + let longitude: Double + + init?( + regionCode: String, + displayName: String, + administrativeAreaName: String, + latitude: Double, + longitude: Double + ) { + guard + WidgetResolvedWeatherLocationValidation + .isValidRegionCode(regionCode), + !displayName.isEmpty, + !administrativeAreaName.isEmpty, + latitude.isFinite, + longitude.isFinite, + (-90 ... 90).contains(latitude), + (-180 ... 180).contains(longitude) + else { + return nil + } + + self.regionCode = regionCode + self.displayName = displayName + self.administrativeAreaName = administrativeAreaName + self.latitude = latitude + self.longitude = longitude + } +} + +struct WidgetTownshipDirectory: Sendable { + static let supportedSchemaVersion = 1 + + let townships: [WidgetTownship] + private let byCode: [String: WidgetTownship] + + init(townships: [WidgetTownship]) throws { + guard !townships.isEmpty else { + throw WidgetTownshipResourceError.invalidDirectory + } + + let byCode = Dictionary( + townships.map { ($0.regionCode, $0) }, + uniquingKeysWith: { first, _ in + // The count check below rejects the full directory. + // Keeping the first value here avoids an initializer trap. + first + } + ) + guard byCode.count == townships.count else { + throw WidgetTownshipResourceError.invalidDirectory + } + + self.townships = townships + self.byCode = byCode + } + + static func decode(_ data: Data) throws -> Self { + let raw: RawDirectory + do { + raw = try JSONDecoder().decode(RawDirectory.self, from: data) + } catch { + throw WidgetTownshipResourceError.invalidDirectory + } + + guard raw.schemaVersion == supportedSchemaVersion else { + throw WidgetTownshipResourceError.unsupportedDirectorySchema( + raw.schemaVersion + ) + } + + let townships = try raw.townships.map { rawTownship in + guard let township = WidgetTownship( + regionCode: rawTownship.regionCode, + displayName: rawTownship.displayName, + administrativeAreaName: rawTownship.administrativeAreaName, + latitude: rawTownship.latitude, + longitude: rawTownship.longitude + ) else { + throw WidgetTownshipResourceError.invalidDirectory + } + return township + } + return try Self(townships: townships) + } + + var regionCodes: Set { + Set(byCode.keys) + } + + func township(regionCode: String) -> WidgetTownship? { + byCode[regionCode] + } + + /// Matches Dart TownDirectory.nearest: one cosine for the query latitude, + /// source-directory order for ties, and strict `<` replacement. + func nearest(latitude: Double, longitude: Double) -> WidgetTownship? { + let cosine = cos(latitude * .pi / 180) + var best: WidgetTownship? + var bestSquaredDistance = Double.infinity + + for township in townships { + let latitudeDelta = latitude - township.latitude + let longitudeDelta = (longitude - township.longitude) * cosine + let squaredDistance = latitudeDelta * latitudeDelta + + longitudeDelta * longitudeDelta + if squaredDistance < bestSquaredDistance { + bestSquaredDistance = squaredDistance + best = township + } + } + return best + } +} + +private struct RawDirectory: Decodable { + let schemaVersion: Int + let townships: [RawTownship] +} + +private struct RawTownship: Decodable { + let regionCode: String + let displayName: String + let administrativeAreaName: String + let latitude: Double + let longitude: Double +} + +struct WidgetTownshipCoordinate: Equatable, Sendable { + let longitude: Double + let latitude: Double +} + +struct WidgetTownshipPolygon: Sendable { + /// First ring is the outer ring; subsequent rings are holes. + let rings: [[WidgetTownshipCoordinate]] + + init(rings: [[WidgetTownshipCoordinate]]) throws { + guard !rings.isEmpty, rings.allSatisfy({ $0.count >= 3 }) else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + self.rings = rings + } +} + +struct WidgetTownshipShape: Sendable { + let regionCode: String + let polygons: [WidgetTownshipPolygon] + let minLongitude: Double + let minLatitude: Double + let maxLongitude: Double + let maxLatitude: Double + + init( + regionCode: String, + polygons: [WidgetTownshipPolygon] + ) throws { + guard + WidgetResolvedWeatherLocationValidation + .isValidRegionCode(regionCode), + !polygons.isEmpty + else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + + var minLongitude = Double.infinity + var minLatitude = Double.infinity + var maxLongitude = -Double.infinity + var maxLatitude = -Double.infinity + for polygon in polygons { + for ring in polygon.rings { + for point in ring { + guard + point.longitude.isFinite, + point.latitude.isFinite, + (-180 ... 180).contains(point.longitude), + (-90 ... 90).contains(point.latitude) + else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + minLongitude = min(minLongitude, point.longitude) + minLatitude = min(minLatitude, point.latitude) + maxLongitude = max(maxLongitude, point.longitude) + maxLatitude = max(maxLatitude, point.latitude) + } + } + } + + self.regionCode = regionCode + self.polygons = polygons + self.minLongitude = minLongitude + self.minLatitude = minLatitude + self.maxLongitude = maxLongitude + self.maxLatitude = maxLatitude + } + + func contains(latitude: Double, longitude: Double) -> Bool { + guard + longitude >= minLongitude, + longitude <= maxLongitude, + latitude >= minLatitude, + latitude <= maxLatitude + else { + return false + } + + for polygon in polygons { + guard Self.isInside( + ring: polygon.rings[0], + latitude: latitude, + longitude: longitude + ) else { + continue + } + + let isInsideHole = polygon.rings.dropFirst().contains { ring in + Self.isInside( + ring: ring, + latitude: latitude, + longitude: longitude + ) + } + if !isInsideHole { + return true + } + } + return false + } + + /// Ray casting with the same comparisons and axis order as Dart. + private static func isInside( + ring: [WidgetTownshipCoordinate], + latitude: Double, + longitude: Double + ) -> Bool { + var inside = false + var previous = ring[ring.count - 1] + + for current in ring { + if (current.latitude > latitude) + != (previous.latitude > latitude), + longitude + < (previous.longitude - current.longitude) + * (latitude - current.latitude) + / (previous.latitude - current.latitude) + + current.longitude + { + inside.toggle() + } + previous = current + } + return inside + } +} + +struct WidgetTownshipBoundaryTable: Sendable { + private let shapesByCode: [String: WidgetTownshipShape] + private let grid: WidgetTownshipGrid + + init(shapes: [WidgetTownshipShape]) throws { + guard !shapes.isEmpty else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + + let byCode = Dictionary( + shapes.map { ($0.regionCode, $0) }, + uniquingKeysWith: { first, _ in first } + ) + guard byCode.count == shapes.count else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + + shapesByCode = byCode + grid = WidgetTownshipGrid(shapes: shapes) + } + + static func decode(_ data: Data) throws -> Self { + do { + var reader = WidgetTownshipBinaryReader(data: data) + let townCount = try reader.readCount(maximum: 10_000) + guard townCount > 0 else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + + var shapes: [WidgetTownshipShape] = [] + shapes.reserveCapacity(townCount) + for _ in 0 ..< townCount { + let codeLength = try reader.readCount(maximum: 16) + let codeBytes = try reader.readBytes(count: codeLength) + guard let regionCode = String( + bytes: codeBytes, + encoding: .ascii + ) else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + + let polygonCount = try reader.readCount(maximum: 100_000) + guard polygonCount > 0 else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + var polygons: [WidgetTownshipPolygon] = [] + polygons.reserveCapacity(polygonCount) + + for _ in 0 ..< polygonCount { + let ringCount = try reader.readCount(maximum: 100_000) + guard ringCount > 0 else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + var rings: [[WidgetTownshipCoordinate]] = [] + rings.reserveCapacity(ringCount) + + for _ in 0 ..< ringCount { + let pointCount = try reader.readCount( + maximum: 2_000_000 + ) + guard pointCount >= 3 else { + throw WidgetTownshipResourceError + .invalidBoundaryData + } + + var points: [WidgetTownshipCoordinate] = [] + points.reserveCapacity(pointCount) + var longitudeInteger = 0 + var latitudeInteger = 0 + + for _ in 0 ..< pointCount { + longitudeInteger = try Self.add( + Self.unzigzag(try reader.readVarint()), + to: longitudeInteger + ) + latitudeInteger = try Self.add( + Self.unzigzag(try reader.readVarint()), + to: latitudeInteger + ) + points.append( + WidgetTownshipCoordinate( + longitude: + Double(longitudeInteger) / 10_000, + latitude: + Double(latitudeInteger) / 10_000 + ) + ) + } + rings.append(points) + } + polygons.append( + try WidgetTownshipPolygon(rings: rings) + ) + } + shapes.append( + try WidgetTownshipShape( + regionCode: regionCode, + polygons: polygons + ) + ) + } + + guard reader.isAtEnd else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + return try Self(shapes: shapes) + } catch let error as WidgetTownshipResourceError { + throw error + } catch { + throw WidgetTownshipResourceError.invalidBoundaryData + } + } + + var regionCodes: Set { + Set(shapesByCode.keys) + } + + func codeAt(latitude: Double, longitude: Double) -> String? { + for regionCode in grid.candidates( + latitude: latitude, + longitude: longitude + ) { + if shapesByCode[regionCode]?.contains( + latitude: latitude, + longitude: longitude + ) == true { + return regionCode + } + } + return nil + } + + private static func unzigzag(_ value: UInt64) throws -> Int { + guard value >> 1 <= UInt64(Int.max) else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + let magnitude = Int(value >> 1) + return value & 1 == 0 ? magnitude : -magnitude - 1 + } + + private static func add(_ delta: Int, to value: Int) throws -> Int { + let (sum, overflow) = value.addingReportingOverflow(delta) + guard !overflow else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + return sum + } +} + +private struct WidgetTownshipGrid: Sendable { + private static let cellSize = 0.05 + + private let minLongitude: Double + private let minLatitude: Double + private let columnCount: Int + private let rowCount: Int + private let cells: [[String]?] + + init(shapes: [WidgetTownshipShape]) { + let minLongitude = shapes.map(\.minLongitude).min()! + let minLatitude = shapes.map(\.minLatitude).min()! + let maxLongitude = shapes.map(\.maxLongitude).max()! + let maxLatitude = shapes.map(\.maxLatitude).max()! + let columnCount = Int( + ceil((maxLongitude - minLongitude) / Self.cellSize) + ) + 1 + let rowCount = Int( + ceil((maxLatitude - minLatitude) / Self.cellSize) + ) + 1 + var cells = Array<[String]?>( + repeating: nil, + count: columnCount * rowCount + ) + + for shape in shapes { + let firstColumn = Int(floor( + (shape.minLongitude - minLongitude) / Self.cellSize + )) + let lastColumn = Int(floor( + (shape.maxLongitude - minLongitude) / Self.cellSize + )) + let firstRow = Int(floor( + (shape.minLatitude - minLatitude) / Self.cellSize + )) + let lastRow = Int(floor( + (shape.maxLatitude - minLatitude) / Self.cellSize + )) + for row in firstRow ... lastRow { + for column in firstColumn ... lastColumn { + let index = row * columnCount + column + if cells[index] == nil { + cells[index] = [] + } + cells[index]?.append(shape.regionCode) + } + } + } + + self.minLongitude = minLongitude + self.minLatitude = minLatitude + self.columnCount = columnCount + self.rowCount = rowCount + self.cells = cells + } + + func candidates(latitude: Double, longitude: Double) -> [String] { + let column = Int(floor( + (longitude - minLongitude) / Self.cellSize + )) + let row = Int(floor((latitude - minLatitude) / Self.cellSize)) + guard + column >= 0, + column < columnCount, + row >= 0, + row < rowCount + else { + return [] + } + return cells[row * columnCount + column] ?? [] + } +} + +private struct WidgetTownshipBinaryReader { + private let bytes: [UInt8] + private var position = 0 + + init(data: Data) { + bytes = Array(data) + } + + var isAtEnd: Bool { + position == bytes.count + } + + mutating func readBytes(count: Int) throws -> ArraySlice { + guard + count >= 0, + position <= bytes.count, + count <= bytes.count - position + else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + defer { position += count } + return bytes[position ..< position + count] + } + + mutating func readCount(maximum: Int) throws -> Int { + let value = try readVarint() + guard value <= UInt64(maximum) else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + return Int(value) + } + + mutating func readVarint() throws -> UInt64 { + var value: UInt64 = 0 + var shift: UInt64 = 0 + + for _ in 0 ..< 10 { + guard position < bytes.count else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + let byte = bytes[position] + position += 1 + let payload = UInt64(byte & 0x7F) + guard shift < 64, payload <= UInt64.max >> shift else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + value |= payload << shift + if byte & 0x80 == 0 { + return value + } + shift += 7 + } + throw WidgetTownshipResourceError.invalidBoundaryData + } +} + +struct WidgetTownshipResolver: Sendable { + let boundaries: WidgetTownshipBoundaryTable + let directory: WidgetTownshipDirectory + + init( + boundaries: WidgetTownshipBoundaryTable, + directory: WidgetTownshipDirectory + ) throws { + guard boundaries.regionCodes == directory.regionCodes else { + throw WidgetTownshipResourceError.incompatibleCodeSets + } + self.boundaries = boundaries + self.directory = directory + } + + /// Exact polygon first; nearest authoritative centroid only when no polygon + /// contains the coordinate. Resource failures happen before this value can + /// be constructed, so corrupt boundaries never degrade to nearest-only. + func resolve( + _ currentLocation: WidgetCurrentLocation + ) -> WidgetResolvedWeatherLocation? { + let exactCode = boundaries.codeAt( + latitude: currentLocation.latitude, + longitude: currentLocation.longitude + ) + let township: WidgetTownship? + if let exactCode { + township = directory.township(regionCode: exactCode) + } else { + township = directory.nearest( + latitude: currentLocation.latitude, + longitude: currentLocation.longitude + ) + } + + guard let township else { + return nil + } + return WidgetResolvedWeatherLocation( + address: .currentLocation, + regionCode: township.regionCode, + regionName: township.displayName, + latitude: township.latitude, + longitude: township.longitude + ) + } +} + +struct WidgetTownshipResourceLoader: Sendable { + private let directoryName: String + private let boundaryName: String + + init( + directoryName: String = "WidgetTownshipDirectory", + boundaryName: String = "WidgetTownshipBoundaries" + ) { + self.directoryName = directoryName + self.boundaryName = boundaryName + } + + func load(bundle: Bundle) throws -> WidgetTownshipResolver { + guard let directoryURL = bundle.url( + forResource: directoryName, + withExtension: "json" + ) else { + throw WidgetTownshipResourceError.missingResource( + "\(directoryName).json" + ) + } + guard let boundaryURL = bundle.url( + forResource: boundaryName, + withExtension: "bin" + ) else { + throw WidgetTownshipResourceError.missingResource( + "\(boundaryName).bin" + ) + } + + let directoryData: Data + let boundaryData: Data + do { + directoryData = try Data(contentsOf: directoryURL) + boundaryData = try Data(contentsOf: boundaryURL) + } catch { + throw WidgetTownshipResourceError.missingResource( + "Widget township resource" + ) + } + return try load( + directoryData: directoryData, + boundaryData: boundaryData + ) + } + + func load( + directoryData: Data, + boundaryData: Data + ) throws -> WidgetTownshipResolver { + let directory = try WidgetTownshipDirectory.decode(directoryData) + let boundaries = try WidgetTownshipBoundaryTable.decode(boundaryData) + return try WidgetTownshipResolver( + boundaries: boundaries, + directory: directory + ) + } +} + +/// Swift static initialization is lazy and once-only. The immutable resolver +/// is therefore decoded at most once per Widget Extension process. +enum WidgetTownshipResolverRuntime { + static let shared: WidgetTownshipResolver? = try? + WidgetTownshipResourceLoader().load(bundle: .main) +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 90ad709f2..1e53a28ef 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -21,8 +21,14 @@ 27CACBAA305BD77F0046F79C /* DPIPWidgetIntentsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 27CACBA1305BD77F0046F79C /* DPIPWidgetIntentsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 27CACC3E305C64AA0046F79C /* WidgetLocationIntentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CACC3D305C64AA0046F79C /* WidgetLocationIntentTests.swift */; }; 27CACC80305C67530046F79C /* WidgetLocationCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CACC7F305C67530046F79C /* WidgetLocationCatalogTests.swift */; }; + 28A000000000000000000003 /* WidgetSNTPClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A000000000000000000001 /* WidgetSNTPClientTests.swift */; }; + 28A000000000000000000004 /* WidgetServerClockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A000000000000000000002 /* WidgetServerClockTests.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 4F1000000000000000000002 /* WidgetCurrentLocationClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F1000000000000000000001 /* WidgetCurrentLocationClientTests.swift */; }; + 4F3000000000000000000002 /* CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F3000000000000000000001 /* CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift */; }; + 4F4000000000000000000002 /* WidgetRefreshTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4000000000000000000001 /* WidgetRefreshTestSupport.swift */; }; + 4F5000000000000000000002 /* CurrentWeatherWidgetTestFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5000000000000000000001 /* CurrentWeatherWidgetTestFixtures.swift */; }; 522508B9301F863A006148C2 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 522508B7301F863A006148C2 /* InfoPlist.strings */; }; 72E4CBC23930C168D057AC64 /* Sounds/warn.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 3AE87ED82FDB896B2B5C5F1B /* Sounds/warn.aiff */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; @@ -47,9 +53,14 @@ D91A00000000000000000011 /* CurrentWeatherWidgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000001 /* CurrentWeatherWidgetTests.swift */; }; D91A00000000000000000012 /* CurrentWeatherWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */; }; D91A00000000000000000013 /* CurrentWeatherWidgetTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */; }; + D91A00000000000000000014 /* CurrentWeatherRemoteDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000004 /* CurrentWeatherRemoteDTO.swift */; }; DB8E84D957A6C2B12EEB0AB7 /* Sounds/report.aiff in Resources */ = {isa = PBXBuildFile; fileRef = FD769D73A7C4BE3619C1F9FB /* Sounds/report.aiff */; }; DP1PF1REBASE0001PL1ST010 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */; }; E1EBD3F0B2991F7D60DF56C5 /* Sounds/weather.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 762B2690D1D999F97D84162C /* Sounds/weather.aiff */; }; + E7A100000000000000000001 /* CurrentWeatherWidgetSnapshotWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */; }; + E7A200000000000000000001 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A200000000000000000002 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift */; }; + F4E200000000000000000001 /* DPIPWidgetProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E200000000000000000002 /* DPIPWidgetProviderTests.swift */; }; + F4F200000000000000000001 /* WidgetTownshipResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F200000000000000000002 /* WidgetTownshipResolverTests.swift */; }; F56D5186166227E46D96919D /* Sounds/info.aiff in Resources */ = {isa = PBXBuildFile; fileRef = F9BCED8E5498E9FD7A454E8F /* Sounds/info.aiff */; }; /* End PBXBuildFile section */ @@ -115,11 +126,17 @@ 27CACBA2305BD77F0046F79C /* Intents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Intents.framework; path = System/Library/Frameworks/Intents.framework; sourceTree = SDKROOT; }; 27CACC3D305C64AA0046F79C /* WidgetLocationIntentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetLocationIntentTests.swift; sourceTree = ""; }; 27CACC7F305C67530046F79C /* WidgetLocationCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetLocationCatalogTests.swift; sourceTree = ""; }; + 28A000000000000000000001 /* WidgetSNTPClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSNTPClientTests.swift; sourceTree = ""; }; + 28A000000000000000000002 /* WidgetServerClockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetServerClockTests.swift; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3AE87ED82FDB896B2B5C5F1B /* Sounds/warn.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/warn.aiff; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 4D00D2962CF4598B61D0C722 /* Sounds/eew_alert.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/eew_alert.aiff; sourceTree = ""; }; + 4F1000000000000000000001 /* WidgetCurrentLocationClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCurrentLocationClientTests.swift; sourceTree = ""; }; + 4F3000000000000000000001 /* CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift; sourceTree = ""; }; + 4F4000000000000000000001 /* WidgetRefreshTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetRefreshTestSupport.swift; sourceTree = ""; }; + 4F5000000000000000000001 /* CurrentWeatherWidgetTestFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWeatherWidgetTestFixtures.swift; sourceTree = ""; }; 522508B8301F863A006148C2 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/InfoPlist.strings; sourceTree = ""; }; 522508BA301F8687006148C2 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/InfoPlist.strings"; sourceTree = ""; }; 522508BB301F868B006148C2 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -157,7 +174,12 @@ D91A00000000000000000001 /* CurrentWeatherWidgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWeatherWidgetTests.swift; sourceTree = ""; }; D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CurrentWeatherWidgetSnapshot.swift; path = ../DPIPWidgets/CurrentWeatherWidgetSnapshot.swift; sourceTree = ""; }; D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CurrentWeatherWidgetTimeline.swift; path = ../DPIPWidgets/CurrentWeatherWidgetTimeline.swift; sourceTree = ""; }; + D91A00000000000000000004 /* CurrentWeatherRemoteDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CurrentWeatherRemoteDTO.swift; path = ../DPIPWidgets/CurrentWeatherRemoteDTO.swift; sourceTree = ""; }; DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWeatherWidgetSnapshotWriterTests.swift; sourceTree = ""; }; + E7A200000000000000000002 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedCurrentWeatherWidgetRefreshServiceTests.swift; sourceTree = ""; }; + F4E200000000000000000002 /* DPIPWidgetProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPIPWidgetProviderTests.swift; sourceTree = ""; }; + F4F200000000000000000002 /* WidgetTownshipResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetTownshipResolverTests.swift; sourceTree = ""; }; F9BCED8E5498E9FD7A454E8F /* Sounds/info.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/info.aiff; sourceTree = ""; }; FD769D73A7C4BE3619C1F9FB /* Sounds/report.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/report.aiff; sourceTree = ""; }; /* End PBXFileReference section */ @@ -167,6 +189,8 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( CurrentWeatherSnapshotAddress.swift, + CurrentWeatherSnapshotStorage.swift, + WidgetLocationCatalog.swift, ); target = 2715FCD430570C1C0014DC8A /* DPIPWidgetsExtension */; }; @@ -174,6 +198,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( CurrentWeatherSnapshotAddress.swift, + CurrentWeatherSnapshotStorage.swift, ); target = 97C146ED1CF9000F007C117D /* Runner */; }; @@ -181,6 +206,8 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( CurrentWeatherSnapshotAddress.swift, + CurrentWeatherSnapshotStorage.swift, + WidgetLocationCatalog.swift, ); target = 331C8080294A63A400263BE5 /* RunnerTests */; }; @@ -217,7 +244,6 @@ 27CACC5E305C66560046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - WidgetLocationCatalog.swift, WidgetLocationOptions.swift, ); target = 331C8080294A63A400263BE5 /* RunnerTests */; @@ -225,14 +251,39 @@ 27CACD20305C6E2D0046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + CurrentWeatherClient.swift, + CurrentLocationCurrentWeatherWidgetRefreshService.swift, + CurrentWeatherSnapshotTime.swift, + CurrentWeatherWidgetSnapshotFactory.swift, + CurrentWeatherWidgetRefreshPipeline.swift, + CurrentWeatherWidgetSnapshotWriter.swift, + DPIPWidgetProviderSupport.swift, + SavedCurrentWeatherWidgetRefreshService.swift, + SavedWidgetLocationResolver.swift, + WidgetCurrentLocationClient.swift, WidgetLocationTarget.swift, + WidgetResolvedWeatherLocation.swift, + WidgetServerClock.swift, + WidgetSnapshotStore.swift, + WidgetSNTPClient.swift, + WidgetSolarTime.swift, + WidgetTownshipBoundaries.bin, + WidgetTownshipDirectory.json, + WidgetTownshipResolver.swift, ); target = 331C8080294A63A400263BE5 /* RunnerTests */; }; + E7A100000000000000000003 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + CurrentWeatherSnapshotStorage.swift, + ); + target = 27CACBA0305BD77F0046F79C /* DPIPWidgetIntentsExtension */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 270FCE9B305CD066003D1E62 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (270FCEA1305CD07F003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 270FCEA3305CD1DD003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 270FCEA6305CD2D6003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; }; + 270FCE9B305CD066003D1E62 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (270FCEA1305CD07F003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 270FCEA3305CD1DD003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 270FCEA6305CD2D6003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, E7A100000000000000000003 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; }; 2715FCDB30570C1C0014DC8A /* DPIPWidgets */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2715FCEA30570C1D0014DC8A /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 27CACB9C305BD2A60046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 27CACD20305C6E2D0046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 27CACBB1305BD7E60046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = DPIPWidgets; sourceTree = ""; }; 27CACBA4305BD77F0046F79C /* DPIPWidgetIntentsExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (27CACC5E305C66560046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 27CACBAF305BD77F0046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = DPIPWidgetIntentsExtension; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -287,13 +338,24 @@ isa = PBXGroup; children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, + 28A000000000000000000001 /* WidgetSNTPClientTests.swift */, + 28A000000000000000000002 /* WidgetServerClockTests.swift */, + 4F1000000000000000000001 /* WidgetCurrentLocationClientTests.swift */, + 4F3000000000000000000001 /* CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift */, + 4F4000000000000000000001 /* WidgetRefreshTestSupport.swift */, + 4F5000000000000000000001 /* CurrentWeatherWidgetTestFixtures.swift */, D91A00000000000000000001 /* CurrentWeatherWidgetTests.swift */, D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */, D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */, + D91A00000000000000000004 /* CurrentWeatherRemoteDTO.swift */, 27CACC3D305C64AA0046F79C /* WidgetLocationIntentTests.swift */, 27CACC7F305C67530046F79C /* WidgetLocationCatalogTests.swift */, 270FCEA4305CD2D6003D1E62 /* CurrentWeatherSnapshotAddressTests.swift */, 270FCEF4305D1EB2003D1E62 /* WidgetSnapshotFileTests.swift */, + E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */, + E7A200000000000000000002 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift */, + F4E200000000000000000002 /* DPIPWidgetProviderTests.swift */, + F4F200000000000000000002 /* WidgetTownshipResolverTests.swift */, ); path = RunnerTests; sourceTree = ""; @@ -623,6 +685,12 @@ buildActionMask = 2147483647; files = ( 270FCEA5305CD2D6003D1E62 /* CurrentWeatherSnapshotAddressTests.swift in Sources */, + 28A000000000000000000003 /* WidgetSNTPClientTests.swift in Sources */, + 28A000000000000000000004 /* WidgetServerClockTests.swift in Sources */, + 4F1000000000000000000002 /* WidgetCurrentLocationClientTests.swift in Sources */, + 4F3000000000000000000002 /* CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift in Sources */, + 4F4000000000000000000002 /* WidgetRefreshTestSupport.swift in Sources */, + 4F5000000000000000000002 /* CurrentWeatherWidgetTestFixtures.swift in Sources */, 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, D91A00000000000000000011 /* CurrentWeatherWidgetTests.swift in Sources */, D91A00000000000000000012 /* CurrentWeatherWidgetSnapshot.swift in Sources */, @@ -630,6 +698,11 @@ 270FCEF5305D1EB2003D1E62 /* WidgetSnapshotFileTests.swift in Sources */, 27CACC3E305C64AA0046F79C /* WidgetLocationIntentTests.swift in Sources */, D91A00000000000000000013 /* CurrentWeatherWidgetTimeline.swift in Sources */, + D91A00000000000000000014 /* CurrentWeatherRemoteDTO.swift in Sources */, + E7A100000000000000000001 /* CurrentWeatherWidgetSnapshotWriterTests.swift in Sources */, + E7A200000000000000000001 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift in Sources */, + F4E200000000000000000001 /* DPIPWidgetProviderTests.swift in Sources */, + F4F200000000000000000001 /* WidgetTownshipResolverTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/WidgetSnapshotPlugin.swift b/ios/Runner/WidgetSnapshotPlugin.swift index 1f919160d..4300336a0 100644 --- a/ios/Runner/WidgetSnapshotPlugin.swift +++ b/ios/Runner/WidgetSnapshotPlugin.swift @@ -69,16 +69,40 @@ enum WidgetSnapshotFile { return data } + @discardableResult static func replace( _ data: Data, kind: WidgetSnapshotKind, sourceIdentifier: String? = nil, - in container: URL - ) throws { - let destination: URL - + in container: URL, + currentWeatherWriteToken: CurrentWeatherSnapshotWriteToken? = nil + ) throws -> CurrentWeatherSnapshotWriteResult { do { - destination = try snapshotURL( + if kind == .currentWeather { + guard + let sourceIdentifier, + let address = CurrentWeatherSnapshotAddress( + sourceIdentifier: sourceIdentifier + ) + else { + throw WidgetSnapshotError.invalidPayload + } + + let storage = CurrentWeatherSnapshotStorage( + containerURL: container + ) + let token = try currentWeatherWriteToken + ?? storage.beginWrite(for: address) + guard token.address == address else { + throw WidgetSnapshotError.invalidPayload + } + return try storage.replace( + data, + using: token + ) + } + + let destination = try snapshotURL( kind: kind, sourceIdentifier: sourceIdentifier, in: container @@ -93,6 +117,30 @@ enum WidgetSnapshotFile { to: destination, options: .atomic ) + return .written + } catch let error as WidgetSnapshotError { + throw error + } catch { + throw WidgetSnapshotError.writeFailed + } + } + + static func beginCurrentWeatherWrite( + sourceIdentifier: String?, + in container: URL + ) throws -> CurrentWeatherSnapshotWriteToken { + do { + guard + let sourceIdentifier, + let address = CurrentWeatherSnapshotAddress( + sourceIdentifier: sourceIdentifier + ) + else { + throw WidgetSnapshotError.invalidPayload + } + return try CurrentWeatherSnapshotStorage( + containerURL: container + ).beginWrite(for: address) } catch let error as WidgetSnapshotError { throw error } catch { @@ -133,12 +181,9 @@ enum WidgetSnapshotFile { throw WidgetSnapshotError.invalidPayload } - return directory - .appendingPathComponent( - "current-weather", - isDirectory: true - ) - .appendingPathComponent(address.filename) + return CurrentWeatherSnapshotStorage( + containerURL: container + ).snapshotURL(for: address) case .weatherForecast: return directory.appendingPathComponent( @@ -198,24 +243,44 @@ public final class WidgetSnapshotPlugin: NSObject, FlutterPlugin { return } - writeQueue.async { - // An absent key or a profile without this entitlement must fail closed. - guard let group = Bundle.main.object(forInfoDictionaryKey: "DPIPWidgetAppGroupIdentifier") as? String, - let container = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: group) - else { - DispatchQueue.main.async { result(self.flutterError(.appGroupUnavailable)) } - return - } + // Reserve current-weather ordering at MethodChannel request entry, before + // the Runner write queue can delay this request behind unrelated files. + guard let group = Bundle.main.object( + forInfoDictionaryKey: "DPIPWidgetAppGroupIdentifier" + ) as? String, + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: group) + else { + result(flutterError(.appGroupUnavailable)) + return + } + + let currentWeatherWriteToken: CurrentWeatherSnapshotWriteToken? + do { + currentWeatherWriteToken = kind == .currentWeather + ? try WidgetSnapshotFile.beginCurrentWeatherWrite( + sourceIdentifier: sourceIdentifier, + in: container + ) + : nil + } catch let error as WidgetSnapshotError { + result(flutterError(error)) + return + } catch { + result(flutterError(.writeFailed)) + return + } + writeQueue.async { do { - try WidgetSnapshotFile.replace( + let writeResult = try WidgetSnapshotFile.replace( data, kind: kind, sourceIdentifier: sourceIdentifier, - in: container + in: container, + currentWeatherWriteToken: currentWeatherWriteToken ) - if let widgetKind = kind.widgetKind { + if writeResult == .written, let widgetKind = kind.widgetKind { WidgetCenter.shared.reloadTimelines(ofKind: widgetKind) } DispatchQueue.main.async { result(nil) } diff --git a/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift b/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift new file mode 100644 index 000000000..a7aebd3f1 --- /dev/null +++ b/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift @@ -0,0 +1,440 @@ +import Foundation +import XCTest + +@MainActor +final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: + XCTestCase +{ + private var containerURL: URL! + private let preciseLocation = WidgetCurrentLocation( + latitude: 24.181234, + longitude: 120.612345 + )! + + override func setUpWithError() throws { + containerURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: containerURL) + containerURL = nil + } + + func testSuccessfulRefreshRunsPipelineAndWritesResolvedSnapshot() + async throws + { + let recorder = CurrentLocationRefreshEventRecorder() + let weather = ScriptedCurrentWeather( + result: .success(try makeObservation()), + onFetch: { recorder.record(.fetchWeather) } + ) + let writer = CurrentWeatherSnapshotWriterSpy( + onWrite: { recorder.record(.writeSnapshot) } + ) + let service = makeService( + recorder: recorder, + weather: weather, + writeSnapshot: writer.write + ) + + let result = await service.refresh() + + XCTAssertEqual(result, .refreshed) + let snapshot = try XCTUnwrap(writer.snapshots.first) + XCTAssertEqual(snapshot.schemaVersion, 5) + XCTAssertEqual(snapshot.sourceIdentifier, "current-location") + XCTAssertEqual(snapshot.regionCode, "407") + XCTAssertEqual(snapshot.regionName, "西屯區") + XCTAssertEqual(snapshot.stationName, "西屯測站") + XCTAssertEqual(snapshot.calibratedTimeOffsetMilliseconds, 321) + + let events = recorder.events + XCTAssertLessThan( + try XCTUnwrap(events.firstIndex(of: .beginWrite)), + try XCTUnwrap(events.firstIndex(of: .acquireLocation)) + ) + XCTAssertLessThan( + try XCTUnwrap(events.firstIndex(of: .acquireLocation)), + try XCTUnwrap(events.firstIndex(of: .resolveTownship)) + ) + XCTAssertLessThan( + try XCTUnwrap(events.firstIndex(of: .resolveTownship)), + try XCTUnwrap(events.firstIndex(of: .fetchWeather)) + ) + XCTAssertLessThan( + try XCTUnwrap(events.firstIndex(of: .resolveTownship)), + try XCTUnwrap(events.firstIndex(of: .synchronizeClock)) + ) + XCTAssertGreaterThan( + try XCTUnwrap(events.firstIndex(of: .writeSnapshot)), + try XCTUnwrap(events.firstIndex(of: .fetchWeather)) + ) + XCTAssertGreaterThan( + try XCTUnwrap(events.firstIndex(of: .writeSnapshot)), + try XCTUnwrap(events.firstIndex(of: .synchronizeClock)) + ) + } + + func testWeatherUsesTownshipCentroidInsteadOfPreciseLocation() + async throws + { + let weather = ScriptedCurrentWeather( + result: .success(try makeObservation()) + ) + let service = makeService( + weather: weather, + writeSnapshot: CurrentWeatherSnapshotWriterSpy().write + ) + + let result = await service.refresh() + XCTAssertEqual(result, .refreshed) + + let coordinates = await weather.coordinates + XCTAssertEqual(coordinates.count, 1) + XCTAssertEqual(coordinates.first?.latitude, 24.1813400) + XCTAssertEqual(coordinates.first?.longitude, 120.6466200) + XCTAssertNotEqual( + coordinates.first?.latitude, + preciseLocation.latitude + ) + XCTAssertNotEqual( + coordinates.first?.longitude, + preciseLocation.longitude + ) + } + + func testLocationAcquisitionFailuresDoNotStartPipeline() async throws { + let cases: [( + WidgetCurrentLocationResult, + CurrentWeatherWidgetRefreshResult + )] = [ + (.unavailable, .unavailable), + (.timedOut, .unavailable), + (.failed, .failed), + ] + + for (locationResult, expectedResult) in cases { + let weather = ScriptedCurrentWeather( + result: .success(try makeObservation()) + ) + let clock = ScriptedSnapshotClock(sample: makeSnapshotTime()) + let writer = CurrentWeatherSnapshotWriterSpy() + let service = makeService( + locationResult: locationResult, + weather: weather, + synchronizeClock: { + await clock.synchronizeAndSample() + }, + writeSnapshot: writer.write + ) + + let result = await service.refresh() + let weatherCallCount = await weather.callCount + let clockCallCount = await clock.callCount + XCTAssertEqual(result, expectedResult) + XCTAssertEqual(weatherCallCount, 0) + XCTAssertEqual(clockCallCount, 0) + XCTAssertEqual(writer.writeCount, 0) + } + } + + func testTownshipResolutionFailureDoesNotStartNetworkOrWrite() + async throws + { + let weather = ScriptedCurrentWeather( + result: .success(try makeObservation()) + ) + let clock = ScriptedSnapshotClock(sample: makeSnapshotTime()) + let writer = CurrentWeatherSnapshotWriterSpy() + let service = makeService( + shouldResolveTownship: false, + weather: weather, + synchronizeClock: { + await clock.synchronizeAndSample() + }, + writeSnapshot: writer.write + ) + + let result = await service.refresh() + let weatherCallCount = await weather.callCount + let clockCallCount = await clock.callCount + XCTAssertEqual(result, .unavailable) + XCTAssertEqual(weatherCallCount, 0) + XCTAssertEqual(clockCallCount, 0) + XCTAssertEqual(writer.writeCount, 0) + } + + func testResolvedSavedAddressIsRejectedWithoutFallback() async throws { + let weather = ScriptedCurrentWeather( + result: .success(try makeObservation()) + ) + let writer = CurrentWeatherSnapshotWriterSpy() + let savedLocation = try XCTUnwrap( + WidgetResolvedWeatherLocation( + address: .saved(regionCode: "407"), + regionCode: "407", + regionName: "西屯區", + latitude: 24.1813400, + longitude: 120.6466200 + ) + ) + let service = makeService( + resolvedLocation: savedLocation, + weather: weather, + writeSnapshot: writer.write + ) + + let result = await service.refresh() + let weatherCallCount = await weather.callCount + XCTAssertEqual(result, .unavailable) + XCTAssertEqual(weatherCallCount, 0) + XCTAssertEqual(writer.writeCount, 0) + } + + func testNoObservationLeavesExistingCurrentLocationCacheUntouched() + async throws + { + let oldData = try seedExistingSnapshot() + let weather = ScriptedCurrentWeather(result: .success(nil)) + let writer = CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + let service = makeService( + weather: weather, + writeSnapshot: { snapshot in + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: .currentLocation, + using: writer + ) + } + ) + + let result = await service.refresh() + XCTAssertEqual(result, .noObservation) + XCTAssertEqual(try cachedData(), oldData) + } + + func testWeatherFailureLeavesExistingCurrentLocationCacheUntouched() + async throws + { + let oldData = try seedExistingSnapshot() + let weather = ScriptedCurrentWeather( + result: .failure(.scripted) + ) + let writer = CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + let service = makeService( + weather: weather, + writeSnapshot: { snapshot in + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: .currentLocation, + using: writer + ) + } + ) + + let result = await service.refresh() + XCTAssertEqual(result, .failed) + XCTAssertEqual(try cachedData(), oldData) + } + + func testTownshipChangeReplacesOnlyCanonicalCurrentLocationFile() + async throws + { + let writer = CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + let firstService = makeService( + resolvedLocation: resolvedLocation( + regionCode: "407", + regionName: "西屯區", + latitude: 24.1813400, + longitude: 120.6466200 + ), + weather: ScriptedCurrentWeather( + result: .success(try makeObservation()) + ), + writeSnapshot: { snapshot in + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: .currentLocation, + using: writer + ) + } + ) + let secondService = makeService( + resolvedLocation: resolvedLocation( + regionCode: "110", + regionName: "信義區", + latitude: 25.0333200, + longitude: 121.5701000 + ), + weather: ScriptedCurrentWeather( + result: .success(try makeObservation()) + ), + writeSnapshot: { snapshot in + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: .currentLocation, + using: writer + ) + } + ) + + let firstResult = await firstService.refresh() + let secondResult = await secondService.refresh() + XCTAssertEqual(firstResult, .refreshed) + XCTAssertEqual(secondResult, .refreshed) + + let snapshot = try JSONDecoder().decode( + CurrentWeatherWidgetSnapshot.self, + from: cachedData() + ) + XCTAssertEqual(snapshot.sourceIdentifier, "current-location") + XCTAssertEqual(snapshot.regionCode, "110") + XCTAssertEqual(snapshot.regionName, "信義區") + let directory = CurrentWeatherSnapshotStorage( + containerURL: containerURL + ).snapshotURL(for: .currentLocation) + .deletingLastPathComponent() + XCTAssertEqual( + try FileManager.default.contentsOfDirectory(atPath: directory.path), + ["current-location.json"] + ) + } + + private func makeService( + locationResult: WidgetCurrentLocationResult? = nil, + resolvedLocation: WidgetResolvedWeatherLocation? = nil, + shouldResolveTownship: Bool = true, + recorder: CurrentLocationRefreshEventRecorder? = nil, + weather: ScriptedCurrentWeather, + synchronizeClock: @escaping + CurrentWeatherWidgetRefreshPipeline.SynchronizeClock = { + CurrentWeatherSnapshotTime( + calibratedNowUnixMilliseconds: 1_710_907_200_000, + calibratedTimeOffsetMilliseconds: 321 + ) + }, + writeSnapshot: @escaping @Sendable ( + CurrentWeatherWidgetSnapshot + ) throws -> Void + ) -> CurrentLocationCurrentWeatherWidgetRefreshService { + let preciseLocation = preciseLocation + let defaultResolvedLocation = self.resolvedLocation() + return CurrentLocationCurrentWeatherWidgetRefreshService( + acquireLocation: { @MainActor in + recorder?.record(.acquireLocation) + return locationResult ?? .acquired(preciseLocation) + }, + resolveTownship: { location in + recorder?.record(.resolveTownship) + guard shouldResolveTownship, + location == preciseLocation + else { + return nil + } + if let resolvedLocation { + return resolvedLocation + } + return defaultResolvedLocation + }, + beginWrite: { address in + recorder?.record(.beginWrite) + return CurrentWeatherSnapshotWriteToken( + address: address, + generation: 1 + ) + }, + pipeline: CurrentWeatherWidgetRefreshPipeline( + fetchWeather: { latitude, longitude in + try await weather.fetch( + latitude: latitude, + longitude: longitude + ) + }, + synchronizeClock: { + recorder?.record(.synchronizeClock) + return await synchronizeClock() + }, + commitSnapshot: { snapshot, _ in + try writeSnapshot(snapshot) + return .written + } + ) + ) + } + + private func resolvedLocation( + regionCode: String = "407", + regionName: String = "西屯區", + latitude: Double = 24.1813400, + longitude: Double = 120.6466200 + ) -> WidgetResolvedWeatherLocation { + CurrentWeatherWidgetTestFixtures.resolvedLocation( + regionCode: regionCode, + regionName: regionName, + latitude: latitude, + longitude: longitude + ) + } + + private func makeObservation() throws -> CurrentWeatherRemoteDTO { + try CurrentWeatherWidgetTestFixtures.observation() + } + + private func makeSnapshotTime() -> CurrentWeatherSnapshotTime { + CurrentWeatherWidgetTestFixtures.snapshotTime() + } + + @discardableResult + private func seedExistingSnapshot() throws -> Data { + try CurrentWeatherWidgetTestFixtures.seed( + CurrentWeatherWidgetTestFixtures.snapshot( + sourceIdentifier: "current-location", + regionCode: "407" + ), + at: .currentLocation, + containerURL: containerURL + ) + } + + private func cachedData() throws -> Data { + try CurrentWeatherWidgetTestFixtures.cachedData( + at: .currentLocation, + containerURL: containerURL + ) + } +} + +private enum CurrentLocationRefreshEvent: Equatable { + case beginWrite + case acquireLocation + case resolveTownship + case fetchWeather + case synchronizeClock + case writeSnapshot +} + +private final class CurrentLocationRefreshEventRecorder: + @unchecked Sendable +{ + private let lock = NSLock() + private var storedEvents: [CurrentLocationRefreshEvent] = [] + + var events: [CurrentLocationRefreshEvent] { + lock.lock() + defer { lock.unlock() } + return storedEvents + } + + func record(_ event: CurrentLocationRefreshEvent) { + lock.lock() + storedEvents.append(event) + lock.unlock() + } +} diff --git a/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift b/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift new file mode 100644 index 000000000..e50085ee8 --- /dev/null +++ b/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift @@ -0,0 +1,1215 @@ +import XCTest +@testable import Runner + +final class CurrentWeatherSnapshotStorageTests: XCTestCase { + private var containerURL: URL! + + override func setUpWithError() throws { + containerURL = FileManager.default.temporaryDirectory + .appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: containerURL) + containerURL = nil + } + + func testCurrentLocationResolvesCanonicalURL() { + let url = storage.snapshotURL(for: .currentLocation) + + XCTAssertEqual( + url, + containerURL + .appendingPathComponent( + "WidgetSnapshots", + isDirectory: true + ) + .appendingPathComponent( + "current-weather", + isDirectory: true + ) + .appendingPathComponent("current-location.json") + ) + } + + func testSavedRegionResolvesCanonicalURL() { + let url = storage.snapshotURL( + for: .saved(regionCode: "407") + ) + + XCTAssertEqual( + url, + containerURL + .appendingPathComponent( + "WidgetSnapshots", + isDirectory: true + ) + .appendingPathComponent( + "current-weather", + isDirectory: true + ) + .appendingPathComponent("region-407.json") + ) + } + + func testReplaceCreatesMissingIntermediateDirectories() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + let token = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: token + ), + .written + ) + + var isDirectory: ObjCBool = false + let directoryExists = FileManager.default.fileExists( + atPath: storage.snapshotURL(for: address) + .deletingLastPathComponent().path, + isDirectory: &isDirectory + ) + + XCTAssertTrue(directoryExists) + XCTAssertTrue(isDirectory.boolValue) + } + + func testNewerObservationTimeWins() throws { + let address = CurrentWeatherSnapshotAddress.saved( + regionCode: "407" + ) + let first = try storage.beginWrite(for: address) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + observationTime: 100 + ), + using: first + ), + .written + ) + let second = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + observationTime: 200 + ), + using: second + ), + .written + ) + XCTAssertEqual(try cachedSnapshot(for: address).observationTime, 200) + } + + func testOlderObservationArrivingLaterIsRejected() throws { + let address = CurrentWeatherSnapshotAddress.saved( + regionCode: "407" + ) + let olderRefresh = try storage.beginWrite(for: address) + let newerRefresh = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + observationTime: 200 + ), + using: newerRefresh + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + observationTime: 100 + ), + using: olderRefresh + ), + .rejected + ) + XCTAssertEqual(try cachedSnapshot(for: address).observationTime, 200) + } + + func testSavedRegionOlderGenerationWithNewerObservationWins() + throws + { + let address = CurrentWeatherSnapshotAddress.saved( + regionCode: "407" + ) + let olderRefresh = try storage.beginWrite(for: address) + let newerRefresh = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + observationTime: 100 + ), + using: newerRefresh + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + observationTime: 200 + ), + using: olderRefresh + ), + .written + ) + XCTAssertEqual(try cachedSnapshot(for: address).observationTime, 200) + } + + func testSameObservationNewerCurrentLocationTownshipWins() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + let region407 = try storage.beginWrite(for: address) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: region407 + ), + .written + ) + let region110 = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: region110 + ), + .written + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "110") + } + + func testSameObservationOlderCurrentLocationCannotRevertTownship() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + let older407 = try storage.beginWrite(for: address) + let newer110 = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: newer110 + ), + .written + ) + let bytesAfterNewerWrite = try Data( + contentsOf: storage.snapshotURL(for: address) + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: older407 + ), + .rejected + ) + + XCTAssertEqual( + try Data(contentsOf: storage.snapshotURL(for: address)), + bytesAfterNewerWrite + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "110") + } + + func testOlderCurrentLocationWithNewerObservationCannotRevertTownship() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + let older407 = try storage.beginWrite(for: address) + let newer110 = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: newer110 + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 200 + ), + using: older407 + ), + .rejected + ) + + let cached = try cachedSnapshot(for: address) + XCTAssertEqual(cached.regionCode, "110") + XCTAssertEqual(cached.observationTime, 100) + } + + func testSameCurrentLocationRegionUsesObservationBeforeGeneration() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + let older = try storage.beginWrite(for: address) + let newer = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: newer + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 200 + ), + using: older + ), + .written + ) + XCTAssertEqual(try cachedSnapshot(for: address).observationTime, 200) + } + + func testSameRegionWeatherWriteRetainsNewerLocationFence() throws { + let address = CurrentWeatherSnapshotAddress.currentLocation + let olderWeather = try storage.beginWrite(for: address) + let intermediateTownship = try storage.beginWrite(for: address) + let newestSameTownship = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: newestSameTownship + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 200 + ), + using: olderWeather + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 300 + ), + using: intermediateTownship + ), + .rejected + ) + + let cached = try cachedSnapshot(for: address) + XCTAssertEqual(cached.regionCode, "407") + XCTAssertEqual(cached.observationTime, 200) + } + + func testRejectedWeatherStillAdvancesCurrentLocationFence() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + let initial = try storage.beginWrite(for: address) + let intermediateTownship = try storage.beginWrite(for: address) + let newestSameTownship = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 200 + ), + using: initial + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: newestSameTownship + ), + .rejected + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 300 + ), + using: intermediateTownship + ), + .rejected + ) + + let cached = try cachedSnapshot(for: address) + XCTAssertEqual(cached.regionCode, "407") + XCTAssertEqual(cached.observationTime, 200) + } + + func testSavedRegionUsesGenerationForSameObservation() throws { + let address = CurrentWeatherSnapshotAddress.saved( + regionCode: "407" + ) + let older = try storage.beginWrite(for: address) + let newer = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + regionName: "newer", + observationTime: 100 + ), + using: newer + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + regionName: "older", + observationTime: 100 + ), + using: older + ), + .rejected + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionName, "newer") + } + + func testDifferentTargetsHaveIndependentGenerationsAndCaches() + throws + { + let current = CurrentWeatherSnapshotAddress.currentLocation + let saved = CurrentWeatherSnapshotAddress.saved(regionCode: "407") + let currentToken = try storage.beginWrite(for: current) + let savedToken = try storage.beginWrite(for: saved) + + XCTAssertEqual(currentToken.generation, 1) + XCTAssertEqual(savedToken.generation, 1) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: currentToken + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + observationTime: 100 + ), + using: savedToken + ), + .written + ) + XCTAssertEqual(try cachedSnapshot(for: current).regionCode, "110") + XCTAssertEqual(try cachedSnapshot(for: saved).regionCode, "407") + } + + func testPreFixSchemaFiveCacheAcceptsFirstEqualObservationWrite() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + try seedUncoordinatedSnapshot( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + for: address + ) + let firstCoordinatedRefresh = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: firstCoordinatedRefresh + ), + .written + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "110") + } + + func testLegacyInjectedGenerationMigratesToSidecarOnly() throws { + let address = CurrentWeatherSnapshotAddress.currentLocation + var legacySnapshot = try XCTUnwrap( + JSONSerialization.jsonObject( + with: snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ) + ) as? [String: Any] + ) + legacySnapshot["_dpipStorageWriteGeneration"] = 3 + try seedUncoordinatedSnapshot( + JSONSerialization.data(withJSONObject: legacySnapshot), + for: address + ) + let stateURL = orderingStateURL(for: address) + try FileManager.default.createDirectory( + at: stateURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data( + "{\"schemaVersion\":1,\"lastIssuedGeneration\":5}".utf8 + ).write(to: stateURL, options: .atomic) + + let migrated = try storage.beginWrite(for: address) + XCTAssertEqual(migrated.generation, 6) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: migrated + ), + .written + ) + + let canonical = try XCTUnwrap( + JSONSerialization.jsonObject( + with: Data(contentsOf: storage.snapshotURL(for: address)) + ) as? [String: Any] + ) + XCTAssertNil(canonical["_dpipStorageWriteGeneration"]) + let sidecar = try XCTUnwrap( + JSONSerialization.jsonObject( + with: Data(contentsOf: stateURL) + ) as? [String: Any] + ) + XCTAssertEqual(sidecar["schemaVersion"] as? Int, 2) + let committed = try XCTUnwrap( + sidecar["committedSnapshot"] as? [String: Any] + ) + XCTAssertEqual(committed["snapshotGeneration"] as? Int, 6) + } + + func testLegacyCacheAcceptsFirstValidCoordinatedWrite() throws { + let address = CurrentWeatherSnapshotAddress.currentLocation + var legacy = try XCTUnwrap( + JSONSerialization.jsonObject( + with: snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ) + ) as? [String: Any] + ) + legacy["schemaVersion"] = 4 + legacy.removeValue(forKey: "sourceIdentifier") + try seedUncoordinatedSnapshot( + JSONSerialization.data(withJSONObject: legacy), + for: address + ) + let firstCoordinatedRefresh = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: firstCoordinatedRefresh + ), + .written + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "110") + } + + func testDeterministicInterleavingRejectsLateOlderRefresh() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + let older = try storage.beginWrite(for: address) + let newer = try storage.beginWrite(for: address) + let firstAccessBlocked = expectation( + description: "older write is blocked before compare" + ) + let olderFinished = expectation(description: "older write finished") + let newerFinished = expectation(description: "newer write finished") + let coordinator = BlockingFirstSnapshotCoordinator( + firstAccessBlocked: firstAccessBlocked + ) + let interleavedStorage = CurrentWeatherSnapshotStorage( + containerURL: containerURL, + coordinator: coordinator + ) + let olderResult = SnapshotWriteResultBox() + let newerResult = SnapshotWriteResultBox() + + DispatchQueue.global().async { + olderResult.set( + Result { + try interleavedStorage.replace( + self.snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: older + ) + } + ) + olderFinished.fulfill() + } + wait(for: [firstAccessBlocked], timeout: 2) + DispatchQueue.global().async { + newerResult.set( + Result { + try interleavedStorage.replace( + self.snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: newer + ) + } + ) + newerFinished.fulfill() + } + wait(for: [newerFinished], timeout: 2) + coordinator.releaseFirstAccess() + wait(for: [olderFinished], timeout: 2) + + XCTAssertEqual(try newerResult.get().get(), .written) + XCTAssertEqual(try olderResult.get().get(), .rejected) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "110") + } + + func testCoordinationFailurePreservesExistingCache() throws { + let address = CurrentWeatherSnapshotAddress.currentLocation + let token = try storage.beginWrite(for: address) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: token + ), + .written + ) + let original = try Data(contentsOf: storage.snapshotURL(for: address)) + let nextToken = try storage.beginWrite(for: address) + let failingStorage = CurrentWeatherSnapshotStorage( + containerURL: containerURL, + coordinator: FailingSnapshotCoordinator() + ) + + XCTAssertThrowsError( + try failingStorage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: nextToken + ) + ) + XCTAssertEqual( + try Data(contentsOf: storage.snapshotURL(for: address)), + original + ) + } + + func testCrashBeforeSnapshotReplacementFencesAllocatedTokens() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + let initial = try storage.beginWrite(for: address) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: initial + ), + .written + ) + let olderInFlight = try storage.beginWrite(for: address) + let interrupted = try storage.beginWrite(for: address) + let failingStorage = CurrentWeatherSnapshotStorage( + containerURL: containerURL, + persister: FailingNthSnapshotPersister(failAt: 2) + ) + + XCTAssertThrowsError( + try failingStorage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 50 + ), + using: interrupted + ) + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "407") + + let recovered = try storage.beginWrite(for: address) + XCTAssertEqual(recovered.generation, 4) + XCTAssertThrowsError( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 300 + ), + using: olderInFlight + ) + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 50 + ), + using: recovered + ), + .written + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "110") + } + + func testCrashAfterSnapshotReplacementRecoversCommittedOrdering() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + let initial = try storage.beginWrite(for: address) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: initial + ), + .written + ) + let olderInFlight = try storage.beginWrite(for: address) + let interrupted = try storage.beginWrite(for: address) + let failingStorage = CurrentWeatherSnapshotStorage( + containerURL: containerURL, + persister: FailingNthSnapshotPersister(failAt: 3) + ) + + XCTAssertThrowsError( + try failingStorage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 50 + ), + using: interrupted + ) + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "110") + + let recovered = try storage.beginWrite(for: address) + XCTAssertEqual(recovered.generation, 4) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 300 + ), + using: olderInFlight + ), + .rejected + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "110") + } + + func testSidecarSnapshotMismatchFailsClosed() throws { + let address = CurrentWeatherSnapshotAddress.currentLocation + let initial = try storage.beginWrite(for: address) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: initial + ), + .written + ) + let inFlight = try storage.beginWrite(for: address) + let mismatched = snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 200 + ) + try seedUncoordinatedSnapshot(mismatched, for: address) + + XCTAssertThrowsError( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 300 + ), + using: inFlight + ) + ) + XCTAssertEqual( + try Data(contentsOf: storage.snapshotURL(for: address)), + mismatched + ) + XCTAssertThrowsError(try storage.beginWrite(for: address)) + } + + private var storage: CurrentWeatherSnapshotStorage { + CurrentWeatherSnapshotStorage(containerURL: containerURL) + } + + private func snapshotData( + sourceIdentifier: String, + regionCode: String, + regionName: String = "西屯區", + observationTime: Int + ) -> Data { + try! JSONEncoder().encode( + CurrentWeatherWidgetSnapshot( + schemaVersion: 5, + sourceIdentifier: sourceIdentifier, + regionCode: regionCode, + regionName: regionName, + observationTime: observationTime, + stationName: "測站", + weather: "晴", + weatherCode: 100, + condition: .clear, + isNight: false, + nextDayNightTransitionTime: 200, + calibratedTimeOffsetMilliseconds: 0, + temperature: 25, + humidity: 60, + rain: 0 + ) + ) + } + + private func cachedSnapshot( + for address: CurrentWeatherSnapshotAddress + ) throws -> CurrentWeatherWidgetSnapshot { + try JSONDecoder().decode( + CurrentWeatherWidgetSnapshot.self, + from: Data(contentsOf: storage.snapshotURL(for: address)) + ) + } + + private func seedUncoordinatedSnapshot( + _ data: Data, + for address: CurrentWeatherSnapshotAddress + ) throws { + let url = storage.snapshotURL(for: address) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: url, options: .atomic) + } + + private func orderingStateURL( + for address: CurrentWeatherSnapshotAddress + ) -> URL { + containerURL + .appendingPathComponent("WidgetSnapshots", isDirectory: true) + .appendingPathComponent( + "current-weather-ordering", + isDirectory: true + ) + .appendingPathComponent(address.filename + ".json") + } +} + +private enum SnapshotCoordinationTestError: Error { + case failed + case missingResult +} + +private struct FailingSnapshotCoordinator: + CurrentWeatherSnapshotCoordinating +{ + func coordinate( + writingItemAt url: URL, + _ accessor: (URL) throws -> T + ) throws -> T { + throw SnapshotCoordinationTestError.failed + } +} + +private final class BlockingFirstSnapshotCoordinator: + CurrentWeatherSnapshotCoordinating, + @unchecked Sendable +{ + private let lock = NSLock() + private let firstAccessBlocked: XCTestExpectation + private let firstAccessGate = DispatchSemaphore(value: 0) + private var accessCount = 0 + + init(firstAccessBlocked: XCTestExpectation) { + self.firstAccessBlocked = firstAccessBlocked + } + + func coordinate( + writingItemAt url: URL, + _ accessor: (URL) throws -> T + ) throws -> T { + lock.lock() + accessCount += 1 + let shouldBlock = accessCount == 1 + lock.unlock() + + if shouldBlock { + firstAccessBlocked.fulfill() + firstAccessGate.wait() + } + return try accessor(url) + } + + func releaseFirstAccess() { + firstAccessGate.signal() + } +} + +private final class SnapshotWriteResultBox: @unchecked Sendable { + private let lock = NSLock() + private var result: Result? + + func set( + _ result: Result + ) { + lock.lock() + self.result = result + lock.unlock() + } + + func get() throws -> Result { + lock.lock() + defer { lock.unlock() } + guard let result else { + throw SnapshotCoordinationTestError.missingResult + } + return result + } +} + +private final class FailingNthSnapshotPersister: + CurrentWeatherSnapshotPersisting, + @unchecked Sendable +{ + private let lock = NSLock() + private let failAt: Int + private var writeCount = 0 + + init(failAt: Int) { + self.failAt = failAt + } + + func write(_ data: Data, to url: URL) throws { + lock.lock() + writeCount += 1 + let shouldFail = writeCount == failAt + lock.unlock() + + if shouldFail { + throw SnapshotCoordinationTestError.failed + } + try data.write(to: url, options: .atomic) + } +} + +final class CurrentWeatherWidgetSnapshotWriterTests: XCTestCase { + private var containerURL: URL! + + override func setUpWithError() throws { + containerURL = FileManager.default.temporaryDirectory + .appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: containerURL) + containerURL = nil + } + + func testValidSavedSnapshotWritesRegionCache() throws { + let snapshot = makeSnapshot( + sourceIdentifier: "region:407", + regionCode: "407" + ) + + try write(snapshot, to: .saved(regionCode: "407")) + + XCTAssertTrue( + FileManager.default.fileExists( + atPath: snapshotURL( + for: .saved(regionCode: "407") + ).path + ) + ) + } + + func testValidCurrentLocationSnapshotWritesCurrentCache() + throws + { + let snapshot = makeSnapshot( + sourceIdentifier: "current-location", + regionCode: "407" + ) + + try write(snapshot, to: .currentLocation) + + XCTAssertTrue( + FileManager.default.fileExists( + atPath: snapshotURL(for: .currentLocation).path + ) + ) + } + + func testSavedLocationCachesAreIndependent() throws { + let region407 = makeSnapshot( + sourceIdentifier: "region:407", + regionCode: "407", + regionName: "西屯區" + ) + let region100 = makeSnapshot( + sourceIdentifier: "region:100", + regionCode: "100", + regionName: "中正區" + ) + + try write(region407, to: .saved(regionCode: "407")) + let region407Bytes = try Data( + contentsOf: snapshotURL( + for: .saved(regionCode: "407") + ) + ) + try write(region100, to: .saved(regionCode: "100")) + + XCTAssertEqual( + try Data( + contentsOf: snapshotURL( + for: .saved(regionCode: "407") + ) + ), + region407Bytes + ) + XCTAssertTrue( + FileManager.default.fileExists( + atPath: snapshotURL( + for: .saved(regionCode: "100") + ).path + ) + ) + } + + func testCurrentLocationCacheIsIndependentFromSavedCache() + throws + { + let saved = makeSnapshot( + sourceIdentifier: "region:407", + regionCode: "407" + ) + let current = makeSnapshot( + sourceIdentifier: "current-location", + regionCode: "407" + ) + + try write(saved, to: .saved(regionCode: "407")) + let savedBytes = try Data( + contentsOf: snapshotURL( + for: .saved(regionCode: "407") + ) + ) + try write(current, to: .currentLocation) + + XCTAssertEqual( + try Data( + contentsOf: snapshotURL( + for: .saved(regionCode: "407") + ) + ), + savedBytes + ) + XCTAssertTrue( + FileManager.default.fileExists( + atPath: snapshotURL(for: .currentLocation).path + ) + ) + } + + func testEncodingFailurePreservesExistingCacheBytes() + throws + { + let address = CurrentWeatherSnapshotAddress.saved( + regionCode: "407" + ) + let valid = makeSnapshot( + sourceIdentifier: "region:407", + regionCode: "407" + ) + + try write(valid, to: address) + let originalBytes = try Data( + contentsOf: snapshotURL(for: address) + ) + + let legacy = makeSnapshot( + schemaVersion: 4, + sourceIdentifier: "region:407", + regionCode: "407" + ) + + XCTAssertThrowsError(try write(legacy, to: address)) + XCTAssertEqual( + try Data(contentsOf: snapshotURL(for: address)), + originalBytes + ) + } + + private var writer: CurrentWeatherWidgetSnapshotWriter { + CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + } + + private func write( + _ snapshot: CurrentWeatherWidgetSnapshot, + to address: CurrentWeatherSnapshotAddress + ) throws { + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: address, + using: writer + ) + } + + private func snapshotURL( + for address: CurrentWeatherSnapshotAddress + ) -> URL { + CurrentWeatherSnapshotStorage( + containerURL: containerURL + ).snapshotURL(for: address) + } + + private func makeSnapshot( + schemaVersion: Int = 5, + sourceIdentifier: String, + regionCode: String, + regionName: String = "西屯區" + ) -> CurrentWeatherWidgetSnapshot { + CurrentWeatherWidgetSnapshot( + schemaVersion: schemaVersion, + sourceIdentifier: sourceIdentifier, + regionCode: regionCode, + regionName: regionName, + observationTime: 1_789_567_200, + stationName: "西屯", + weather: "雷雨", + weatherCode: 214, + condition: .thunderstorm, + isNight: true, + nextDayNightTransitionTime: 1_789_562_700, + calibratedTimeOffsetMilliseconds: -300_000, + temperature: 27.5, + humidity: 83, + rain: 12.5 + ) + } +} diff --git a/ios/RunnerTests/CurrentWeatherWidgetTestFixtures.swift b/ios/RunnerTests/CurrentWeatherWidgetTestFixtures.swift new file mode 100644 index 000000000..8dae084a5 --- /dev/null +++ b/ios/RunnerTests/CurrentWeatherWidgetTestFixtures.swift @@ -0,0 +1,129 @@ +import Foundation + +enum CurrentWeatherWidgetTestFixtures { + static func observation( + stationName: String = "西屯測站", + time: Int = 1_710_900_000 + ) throws -> CurrentWeatherRemoteDTO { + let data = Data( + """ + { + "station": {"name": "\(stationName)"}, + "time": \(time), + "data": { + "weather": "晴", + "weatherCode": 100, + "temperature": 28.5, + "humidity": 70, + "rain": 0 + } + } + """.utf8 + ) + return try JSONDecoder().decode(CurrentWeatherRemoteDTO.self, from: data) + } + + static func snapshotTime( + offsetMilliseconds: Int = 321 + ) -> CurrentWeatherSnapshotTime { + CurrentWeatherSnapshotTime( + calibratedNowUnixMilliseconds: 1_710_907_200_000, + calibratedTimeOffsetMilliseconds: offsetMilliseconds + ) + } + + static func resolvedLocation( + address: CurrentWeatherSnapshotAddress = .currentLocation, + regionCode: String = "407", + regionName: String = "西屯區", + latitude: Double = 24.1813400, + longitude: Double = 120.6466200 + ) -> WidgetResolvedWeatherLocation { + WidgetResolvedWeatherLocation( + address: address, + regionCode: regionCode, + regionName: regionName, + latitude: latitude, + longitude: longitude + )! + } + + static func snapshot( + sourceIdentifier: String, + regionCode: String, + regionName: String = "舊快取", + schemaVersion: Int = 5 + ) -> CurrentWeatherWidgetSnapshot { + CurrentWeatherWidgetSnapshot( + schemaVersion: schemaVersion, + sourceIdentifier: sourceIdentifier, + regionCode: regionCode, + regionName: regionName, + observationTime: 1_700_000_000, + stationName: "舊測站", + weather: "陰", + weatherCode: 300, + condition: .overcast, + isNight: false, + nextDayNightTransitionTime: 1_700_010_000, + calibratedTimeOffsetMilliseconds: 123, + temperature: 20, + humidity: 60, + rain: 1 + ) + } + + static func catalogLocation( + regionCode: String + ) -> WidgetLocationCatalogLocation? { + switch regionCode { + case "242": + return WidgetLocationCatalogLocation( + regionCode: "242", + displayName: "新莊區", + administrativeAreaName: "新北市", + latitude: 25.0358303, + longitude: 121.4500307 + ) + case "433": + return WidgetLocationCatalogLocation( + regionCode: "433", + displayName: "沙鹿區", + administrativeAreaName: "臺中市", + latitude: 24.2338622, + longitude: 120.565703 + ) + default: + return nil + } + } + + static func seed( + _ snapshot: CurrentWeatherWidgetSnapshot, + at address: CurrentWeatherSnapshotAddress, + containerURL: URL + ) throws -> Data { + let writer = CurrentWeatherWidgetSnapshotWriter(containerURL: containerURL) + try write(snapshot, to: address, using: writer) + return try cachedData(at: address, containerURL: containerURL) + } + + static func write( + _ snapshot: CurrentWeatherWidgetSnapshot, + to address: CurrentWeatherSnapshotAddress, + using writer: CurrentWeatherWidgetSnapshotWriter + ) throws { + let token = try writer.beginWrite(for: address) + _ = try writer.write(snapshot, using: token) + } + + static func cachedData( + at address: CurrentWeatherSnapshotAddress, + containerURL: URL + ) throws -> Data { + let url = CurrentWeatherSnapshotStorage( + containerURL: containerURL + ).snapshotURL(for: address) + return try Data(contentsOf: url) + } +} diff --git a/ios/RunnerTests/CurrentWeatherWidgetTests.swift b/ios/RunnerTests/CurrentWeatherWidgetTests.swift index 7ec4cb963..0e5ab7cec 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetTests.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetTests.swift @@ -1,6 +1,298 @@ import Foundation import XCTest +final class CurrentWeatherRemoteDTOTests: XCTestCase { + func testDecodesValidClearResponse() throws { + let weather = try decode( + """ + { + "id": "C0X16", + "station": { + "name": "仁德", + "lat": 22.9683, + "lon": 120.2577, + "altitude": 26, + "distance": 0.81 + }, + "time": 1789567200, + "data": { + "weather": "晴", + "weatherCode": 100, + "temperature": 28.5, + "humidity": 70, + "rain": 0.0, + "wind": { "speed": 1.5, "beaufort": 1 }, + "gust": { "speed": 3.0, "beaufort": 2 } + } + } + """ + ) + + XCTAssertEqual(weather.stationName, "仁德") + XCTAssertEqual(weather.time, 1_789_567_200) + XCTAssertEqual(weather.weather, "晴") + XCTAssertEqual(weather.weatherCode, 100) + XCTAssertEqual(weather.condition, .clear) + XCTAssertEqual(weather.temperature, 28.5) + XCTAssertEqual(weather.humidity, 70) + XCTAssertEqual(weather.rain, 0) + } + + func testDecodesValidRainResponse() throws { + let weather = try decode( + validJSON( + weather: "有雨", + weatherCode: 106 + ) + ) + + XCTAssertEqual(weather.weather, "有雨") + XCTAssertEqual(weather.weatherCode, 106) + XCTAssertEqual(weather.condition, .rain) + } + + func testMissingSentinelsDecodeAsNil() throws { + let weather = try decode( + validJSON( + temperature: "-99", + humidity: "-99", + rain: "-99" + ) + ) + + XCTAssertNil(weather.temperature) + XCTAssertNil(weather.humidity) + XCTAssertNil(weather.rain) + } + + func testMissingOptionalKeysDecodeAsNil() throws { + let weather = try decode( + """ + { + "station": { "name": "仁德" }, + "time": 1789567200, + "data": { + "weather": "晴", + "weatherCode": 100 + } + } + """ + ) + + XCTAssertNil(weather.temperature) + XCTAssertNil(weather.humidity) + XCTAssertNil(weather.rain) + } + + func testExplicitNullOptionalValuesDecodeAsNil() throws { + let weather = try decode( + validJSON( + temperature: "null", + humidity: "null", + rain: "null" + ) + ) + + XCTAssertNil(weather.temperature) + XCTAssertNil(weather.humidity) + XCTAssertNil(weather.rain) + } + + func testIntegerJSONValuesDecodeAsDouble() throws { + let weather = try decode( + validJSON( + temperature: "28", + rain: "1" + ) + ) + + XCTAssertEqual(weather.temperature, 28.0) + XCTAssertEqual(weather.rain, 1.0) + } + + func testIntegralFloatingPointJSONValueDecodesAsInt() throws { + let weather = try decode( + validJSON(humidity: "70.0") + ) + + XCTAssertEqual(weather.humidity, 70) + } + + func testRejectsFractionalJSONValueForInt() { + XCTAssertThrowsError( + try decode(validJSON(humidity: "70.5")) + ) + } + + func testRejectsMissingStationName() { + XCTAssertThrowsError( + try decode( + validJSON(station: "{}") + ) + ) + } + + func testRejectsMissingTime() { + XCTAssertThrowsError( + try decode( + """ + { + "station": { "name": "仁德" }, + "data": { + "weather": "晴", + "weatherCode": 100 + } + } + """ + ) + ) + } + + func testRejectsMissingWeather() { + XCTAssertThrowsError( + try decode( + validJSON(weather: nil) + ) + ) + } + + func testRejectsMissingWeatherCode() { + XCTAssertThrowsError( + try decode( + validJSON(weatherCode: nil) + ) + ) + } + + func testRejectsMalformedDataStructure() { + XCTAssertThrowsError( + try decode( + """ + { + "station": { "name": "仁德" }, + "time": 1789567200, + "data": [] + } + """ + ) + ) + } + + func testRejectsMalformedRequiredFieldType() { + XCTAssertThrowsError( + try decode( + """ + { + "station": { "name": "仁德" }, + "time": "1789567200", + "data": { + "weather": "晴", + "weatherCode": 100 + } + } + """ + ) + ) + } + + func testMatchesDartWeatherCodeSemantics() { + let cases: [(Int, CurrentWeatherWidgetCondition)] = [ + (100, .clear), + (200, .cloudy), + (300, .overcast), + (101, .fog), + (102, .fog), + (105, .fog), + (103, .thunderstorm), + (104, .thunderstorm), + (114, .thunderstorm), + (115, .thunderstorm), + (116, .thunderstorm), + (117, .thunderstorm), + (118, .thunderstorm), + (119, .thunderstorm), + (106, .rain), + (107, .rain), + (111, .rain), + (113, .rain), + (108, .snow), + (109, .snow), + (110, .snow), + (112, .snow), + ] + + for (code, condition) in cases { + XCTAssertEqual( + currentWeatherWidgetCondition(for: code), + condition, + "weather code \(code)" + ) + } + } + + func testPhenomenonTakesPrecedenceOverSkyState() { + let cases: [(Int, CurrentWeatherWidgetCondition)] = [ + (106, .rain), + (214, .thunderstorm), + (305, .fog), + ] + + for (code, condition) in cases { + XCTAssertEqual( + currentWeatherWidgetCondition(for: code), + condition, + "weather code \(code)" + ) + } + } + + func testUnknownWeatherCodesReturnUnknown() { + for code in [0, -1, 420] { + XCTAssertEqual( + currentWeatherWidgetCondition(for: code), + .unknown, + "weather code \(code)" + ) + } + } + + private func decode(_ json: String) throws -> CurrentWeatherRemoteDTO { + try JSONDecoder().decode( + CurrentWeatherRemoteDTO.self, + from: Data(json.utf8) + ) + } + + private func validJSON( + station: String = #"{ "name": "仁德" }"#, + time: String = "1789567200", + weather: String? = "晴", + weatherCode: Int? = 100, + temperature: String = "28.5", + humidity: String = "70", + rain: String = "0.0" + ) -> String { + let weatherField = weather.map { #""weather": "\#($0)","# } ?? "" + let weatherCodeField = weatherCode.map { + #""weatherCode": \#($0),"# + } ?? "" + + return """ + { + "station": \(station), + "time": \(time), + "data": { + \(weatherField) + \(weatherCodeField) + "temperature": \(temperature), + "humidity": \(humidity), + "rain": \(rain) + } + } + """ + } +} + final class CurrentWeatherWidgetSnapshotTests: XCTestCase { func testDecodesSchemaVersionFiveSnapshot() throws { let snapshot = try decode( @@ -227,6 +519,311 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { from: Data(json.utf8) ) } + + private func makeSnapshot( + schemaVersion: Int = 5, + sourceIdentifier: String? = "region:100", + regionCode: String = "100", + condition: CurrentWeatherWidgetCondition = .clear, + temperature: Double? = 28.5, + humidity: Int? = 70, + rain: Double? = 0 + ) -> CurrentWeatherWidgetSnapshot { + CurrentWeatherWidgetSnapshot( + schemaVersion: schemaVersion, + sourceIdentifier: sourceIdentifier, + regionCode: regionCode, + regionName: "中正區", + observationTime: 1_710_900_000, + stationName: "臺北", + weather: "晴", + weatherCode: 100, + condition: condition, + isNight: false, + nextDayNightTransitionTime: 1_710_929_103, + calibratedTimeOffsetMilliseconds: -5_000, + temperature: temperature, + humidity: humidity, + rain: rain + ) + } + + func testEncodesSchemaVersionFiveWithAllFields() throws { + let snapshot = CurrentWeatherWidgetSnapshot( + schemaVersion: 5, + sourceIdentifier: "region:100", + regionCode: "100", + regionName: "中正區", + observationTime: 1_710_900_000, + stationName: "臺北", + weather: "晴", + weatherCode: 100, + condition: .clear, + isNight: false, + nextDayNightTransitionTime: 1_710_929_103, + calibratedTimeOffsetMilliseconds: -5_000, + temperature: 28.5, + humidity: 70, + rain: 0 + ) + + let data = try JSONEncoder().encode(snapshot) + + let json = try XCTUnwrap( + JSONSerialization.jsonObject( + with: data + ) as? [String: Any] + ) + + XCTAssertEqual(json.count, 15) + + XCTAssertEqual(json["schemaVersion"] as? Int, 5) + XCTAssertEqual( + json["sourceIdentifier"] as? String, + "region:100" + ) + XCTAssertEqual( + json["regionCode"] as? String, + "100" + ) + XCTAssertEqual( + json["regionName"] as? String, + "中正區" + ) + XCTAssertEqual( + json["observationTime"] as? Int, + 1_710_900_000 + ) + XCTAssertEqual( + json["stationName"] as? String, + "臺北" + ) + XCTAssertEqual( + json["weather"] as? String, + "晴" + ) + XCTAssertEqual( + json["weatherCode"] as? Int, + 100 + ) + XCTAssertEqual( + json["condition"] as? String, + "clear" + ) + XCTAssertEqual( + json["isNight"] as? Bool, + false + ) + XCTAssertEqual( + json["nextDayNightTransitionTime"] as? Int, + 1_710_929_103 + ) + XCTAssertEqual( + json["calibratedTimeOffsetMilliseconds"] as? Int, + -5_000 + ) + XCTAssertEqual( + json["temperature"] as? Double, + 28.5 + ) + XCTAssertEqual( + json["humidity"] as? Int, + 70 + ) + XCTAssertEqual( + json["rain"] as? Double, + 0 + ) + } + + func testEncodingPreservesExplicitNullWeatherValues() + throws + { + let snapshot = CurrentWeatherWidgetSnapshot( + schemaVersion: 5, + sourceIdentifier: "region:100", + regionCode: "100", + regionName: "中正區", + observationTime: 1_710_900_000, + stationName: "臺北", + weather: "晴", + weatherCode: 100, + condition: .clear, + isNight: false, + nextDayNightTransitionTime: 1_710_929_103, + calibratedTimeOffsetMilliseconds: 0, + temperature: nil, + humidity: nil, + rain: nil + ) + + let data = try JSONEncoder().encode(snapshot) + + let json = try XCTUnwrap( + JSONSerialization.jsonObject( + with: data + ) as? [String: Any] + ) + + XCTAssertEqual(json.count, 15) + + XCTAssertTrue(json["temperature"] is NSNull) + XCTAssertTrue(json["humidity"] is NSNull) + XCTAssertTrue(json["rain"] is NSNull) + } + + func testSchemaVersionFourCannotBeEncoded() { + let snapshot = makeSnapshot(schemaVersion: 4) + + XCTAssertThrowsError( + try JSONEncoder().encode(snapshot) + ) + } + + func testSchemaVersionFiveWithoutSourceIdentifierCannotBeEncoded() { + let snapshot = makeSnapshot(sourceIdentifier: nil) + + XCTAssertThrowsError( + try JSONEncoder().encode(snapshot) + ) + } + + func testSchemaVersionFiveRejectsMalformedSourceIdentifiers() { + let malformedSourceIdentifiers = [ + "", + "region:", + "region:10", + "region:1000", + "region:ABC", + "region:100", + "something-else", + ] + + for sourceIdentifier in malformedSourceIdentifiers { + let snapshot = makeSnapshot( + sourceIdentifier: sourceIdentifier + ) + + XCTAssertThrowsError( + try JSONEncoder().encode(snapshot), + sourceIdentifier + ) + } + } + + func testSavedSourceIdentifierMustMatchPayloadRegionCode() { + let snapshot = makeSnapshot( + sourceIdentifier: "region:100", + regionCode: "407" + ) + + XCTAssertThrowsError( + try JSONEncoder().encode(snapshot) + ) + } + + func testCurrentLocationWithResolvedRegionCodeCanBeEncoded() { + let snapshot = makeSnapshot( + sourceIdentifier: "current-location", + regionCode: "407" + ) + + XCTAssertNoThrow( + try JSONEncoder().encode(snapshot) + ) + } + + func testEncodedSchemaVersionFiveContainsExactlyContractKeys() throws { + let data = try JSONEncoder().encode(makeSnapshot()) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) + as? [String: Any] + ) + + XCTAssertEqual( + Set(json.keys), + Set([ + "schemaVersion", + "sourceIdentifier", + "regionCode", + "regionName", + "observationTime", + "stationName", + "weather", + "weatherCode", + "condition", + "isNight", + "nextDayNightTransitionTime", + "calibratedTimeOffsetMilliseconds", + "temperature", + "humidity", + "rain", + ]) + ) + } + + func testConditionEncodesUsingRawSchemaValue() throws { + let data = try JSONEncoder().encode( + makeSnapshot(condition: .thunderstorm) + ) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) + as? [String: Any] + ) + + XCTAssertEqual( + json["condition"] as? String, + "thunderstorm" + ) + } + + func testSchemaVersionFiveRoundTripPreservesAllFields() throws { + let original = CurrentWeatherWidgetSnapshot( + schemaVersion: 5, + sourceIdentifier: "region:407", + regionCode: "407", + regionName: "西屯區", + observationTime: 1_789_567_200, + stationName: "西屯", + weather: "雷雨", + weatherCode: 214, + condition: .thunderstorm, + isNight: true, + nextDayNightTransitionTime: 1_789_562_700, + calibratedTimeOffsetMilliseconds: -300_000, + temperature: 27.5, + humidity: 83, + rain: 12.5 + ) + + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + CurrentWeatherWidgetSnapshot.self, + from: encoded + ) + + XCTAssertEqual(decoded.schemaVersion, original.schemaVersion) + XCTAssertEqual(decoded.sourceIdentifier, original.sourceIdentifier) + XCTAssertEqual(decoded.regionCode, original.regionCode) + XCTAssertEqual(decoded.regionName, original.regionName) + XCTAssertEqual(decoded.observationTime, original.observationTime) + XCTAssertEqual(decoded.stationName, original.stationName) + XCTAssertEqual(decoded.weather, original.weather) + XCTAssertEqual(decoded.weatherCode, original.weatherCode) + XCTAssertEqual(decoded.condition, original.condition) + XCTAssertEqual(decoded.isNight, original.isNight) + XCTAssertEqual( + decoded.nextDayNightTransitionTime, + original.nextDayNightTransitionTime + ) + XCTAssertEqual( + decoded.calibratedTimeOffsetMilliseconds, + original.calibratedTimeOffsetMilliseconds + ) + XCTAssertEqual(decoded.temperature, original.temperature) + XCTAssertEqual(decoded.humidity, original.humidity) + XCTAssertEqual(decoded.rain, original.rain) + } } final class CurrentWeatherWidgetConditionTests: XCTestCase { @@ -540,3 +1137,808 @@ final class CurrentWeatherWidgetTimelineTests: XCTestCase { Date(timeIntervalSince1970: timestamp) } } + +private final class MockURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (URLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest( + for request: URLRequest + ) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.requestHandler else { + client?.urlProtocol( + self, + didFailWithError: URLError(.badServerResponse) + ) + return + } + + do { + let (response, data) = try handler(request) + + client?.urlProtocol( + self, + didReceive: response, + cacheStoragePolicy: .notAllowed + ) + + client?.urlProtocol( + self, + didLoad: data + ) + + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol( + self, + didFailWithError: error + ) + } + } + + override func stopLoading() {} +} + +final class CurrentWeatherClientTests: XCTestCase { + private var session: URLSession! + private var client: CurrentWeatherClient! + + override func setUp() { + super.setUp() + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + + session = URLSession(configuration: configuration) + client = CurrentWeatherClient(session: session) + } + + override func tearDown() { + MockURLProtocol.requestHandler = nil + session.invalidateAndCancel() + + client = nil + session = nil + + super.tearDown() + } + + func testMakeURLBuildsRealtimeEndpoint() throws { + let url = try client.makeURL( + latitude: 24.1658, + longitude: 120.6336 + ) + + let components = try XCTUnwrap( + URLComponents(url: url, resolvingAgainstBaseURL: false) + ) + + XCTAssertEqual( + url.absoluteString, + "https://api.core-tnn1.exptech.dev/api/v5/meteor/weather/realtime/24.1658,120.6336" + ) + XCTAssertEqual(components.scheme, "https") + XCTAssertEqual(components.host, "api.core-tnn1.exptech.dev") + XCTAssertEqual( + components.path, + "/api/v5/meteor/weather/realtime/24.1658,120.6336" + ) + XCTAssertNil(components.query) + } + + func testMakeURLPreservesNegativeCoordinatesAndOrdering() throws { + let url = try client.makeURL( + latitude: -24.1658, + longitude: -120.6336 + ) + + XCTAssertEqual( + url.absoluteString, + "https://api.core-tnn1.exptech.dev/api/v5/meteor/weather/realtime/-24.1658,-120.6336" + ) + } + + func testMakeURLAcceptsCoordinateBoundaries() throws { + XCTAssertEqual( + try client.makeURL( + latitude: 90, + longitude: 180 + ).absoluteString, + "https://api.core-tnn1.exptech.dev/api/v5/meteor/weather/realtime/90.0,180.0" + ) + + XCTAssertEqual( + try client.makeURL( + latitude: -90, + longitude: -180 + ).absoluteString, + "https://api.core-tnn1.exptech.dev/api/v5/meteor/weather/realtime/-90.0,-180.0" + ) + } + + func testMakeURLRejectsInvalidLatitude() { + XCTAssertThrowsError( + try client.makeURL(latitude: 90.1, longitude: 120) + ) { error in + XCTAssertEqual( + error as? CurrentWeatherClientError, + .invalidCoordinate + ) + } + + XCTAssertThrowsError( + try client.makeURL(latitude: -90.1, longitude: 120) + ) { error in + XCTAssertEqual( + error as? CurrentWeatherClientError, + .invalidCoordinate + ) + } + } + + func testMakeURLRejectsInvalidLongitude() { + XCTAssertThrowsError( + try client.makeURL(latitude: 24, longitude: 180.1) + ) { error in + XCTAssertEqual( + error as? CurrentWeatherClientError, + .invalidCoordinate + ) + } + + XCTAssertThrowsError( + try client.makeURL(latitude: 24, longitude: -180.1) + ) { error in + XCTAssertEqual( + error as? CurrentWeatherClientError, + .invalidCoordinate + ) + } + } + + func testMakeURLRejectsNonFiniteCoordinates() { + let invalidCoordinates: [(Double, Double)] = [ + (.nan, 120), + (.infinity, 120), + (-.infinity, 120), + (24, .nan), + (24, .infinity), + (24, -.infinity), + ] + + for (latitude, longitude) in invalidCoordinates { + XCTAssertThrowsError( + try client.makeURL( + latitude: latitude, + longitude: longitude + ) + ) { error in + XCTAssertEqual( + error as? CurrentWeatherClientError, + .invalidCoordinate + ) + } + } + } + + func testFetchReturnsDecodedWeather() async throws { + let json = """ + { + "id": "C0F9T", + "station": { + "name": "西屯" + }, + "time": 1789877400, + "data": { + "weather": "晴", + "weatherCode": 100, + "temperature": 30.6, + "humidity": 66, + "rain": 0 + } + } + """ + + MockURLProtocol.requestHandler = { request in + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.timeoutInterval, 6) + XCTAssertEqual( + request.url?.absoluteString, + "https://api.core-tnn1.exptech.dev/api/v5/meteor/weather/realtime/24.1658,120.6336" + ) + + let response = try XCTUnwrap( + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ) + ) + + return ( + response, + Data(json.utf8) + ) + } + + let result = try await client.fetch( + latitude: 24.1658, + longitude: 120.6336 + ) + + let weather = try XCTUnwrap(result) + + XCTAssertEqual(weather.stationName, "西屯") + XCTAssertEqual(weather.time, 1789877400) + XCTAssertEqual(weather.weather, "晴") + XCTAssertEqual(weather.weatherCode, 100) + XCTAssertEqual(weather.temperature, 30.6) + XCTAssertEqual(weather.humidity, 66) + XCTAssertEqual(weather.rain, 0) + } + + func testFetchReturnsNilForStructurallyEmptyObjects() async throws { + for body in ["{}", " { \n } \n"] { + setHTTPResponse(data: Data(body.utf8)) + + let result = try await fetch() + + XCTAssertNil(result, body) + } + } + + func testFetchRejectsMalformedOrWrongShapeResponses() async { + let cases = [ + ("malformed JSON", #"{"station":"#), + ("top-level array", "[]"), + ("missing realtime structure", #"{"message":"ok"}"#), + ( + "invalid required field type", + """ + { + "station": { "name": "西屯" }, + "time": "1789877400", + "data": { + "weather": "晴", + "weatherCode": 100 + } + } + """ + ), + ] + + for (name, body) in cases { + setHTTPResponse(data: Data(body.utf8)) + + do { + _ = try await fetch() + XCTFail("Expected decoding failure for \(name)") + } catch { + XCTAssertFalse( + error is CurrentWeatherClientError, + "\(name): \(error)" + ) + } + } + } + + func testFetchRejectsHTTPFailures() async { + for statusCode in [404, 500] { + setHTTPResponse( + statusCode: statusCode, + data: Data("{}".utf8) + ) + + await assertFetchThrows( + .httpStatus(statusCode), + context: "HTTP \(statusCode)" + ) + } + } + + func testFetchRejectsOversizedResponse() async { + setHTTPResponse( + data: Data(repeating: 0x20, count: 128 * 1024 + 1) + ) + + await assertFetchThrows(.responseTooLarge) + } + + func testFetchPropagatesTransportFailure() async { + MockURLProtocol.requestHandler = { _ in + throw URLError(.timedOut) + } + + do { + _ = try await fetch() + XCTFail("Expected transport failure") + } catch { + XCTAssertEqual((error as? URLError)?.code, .timedOut) + XCTAssertFalse(error is CurrentWeatherClientError) + } + } + + func testFetchRejectsNonHTTPResponse() async { + MockURLProtocol.requestHandler = { request in + let url = try XCTUnwrap(request.url) + let response = URLResponse( + url: url, + mimeType: "application/json", + expectedContentLength: 2, + textEncodingName: "utf-8" + ) + + return (response, Data("{}".utf8)) + } + + await assertFetchThrows(.invalidResponse) + } + + private func fetch() async throws -> CurrentWeatherRemoteDTO? { + try await client.fetch( + latitude: 24.1658, + longitude: 120.6336 + ) + } + + private func setHTTPResponse( + statusCode: Int = 200, + data: Data + ) { + MockURLProtocol.requestHandler = { request in + let url = try XCTUnwrap(request.url) + let response = try XCTUnwrap( + HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + ) + ) + + return (response, data) + } + } + + private func assertFetchThrows( + _ expectedError: CurrentWeatherClientError, + context: String = "", + file: StaticString = #filePath, + line: UInt = #line + ) async { + do { + _ = try await fetch() + XCTFail( + "Expected \(expectedError) \(context)", + file: file, + line: line + ) + } catch { + XCTAssertEqual( + error as? CurrentWeatherClientError, + expectedError, + context, + file: file, + line: line + ) + } + } +} + +private struct SolarGolden { + let id: String + let latitude: Double + let longitude: Double + let nowUnixSeconds: Int64 + let expectedIsNight: Bool + let expectedNextTransitionUnixSeconds: Int64 +} + +private let solarGoldens: [SolarGolden] = [ + // 臺北市中正區, region 100 + .init(id: "100-2024-02-29-noon", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_709_179_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_709_200_528), + .init(id: "100-2024-03-20-noon", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_710_907_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_710_929_103), + .init(id: "100-2024-06-21-noon", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_718_942_400, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_718_966_796), + .init(id: "100-2024-09-22-noon", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_726_977_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_726_998_631), + .init(id: "100-2024-12-21-noon", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_734_753_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_734_772_166), + .init(id: "100-2024-12-31-noon", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_735_617_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_735_636_511), + + // 臺中市中區, region 400 + .init(id: "400-2024-02-29-noon", latitude: 24.1439458, longitude: 120.6794414, nowUnixSeconds: 1_709_179_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_709_200_763), + .init(id: "400-2024-03-20-noon", latitude: 24.1439458, longitude: 120.6794414, nowUnixSeconds: 1_710_907_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_710_929_303), + .init(id: "400-2024-06-21-noon", latitude: 24.1439458, longitude: 120.6794414, nowUnixSeconds: 1_718_942_400, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_718_966_881), + .init(id: "400-2024-09-22-noon", latitude: 24.1439458, longitude: 120.6794414, nowUnixSeconds: 1_726_977_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_726_998_830), + .init(id: "400-2024-12-21-noon", latitude: 24.1439458, longitude: 120.6794414, nowUnixSeconds: 1_734_753_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_734_772_480), + .init(id: "400-2024-12-31-noon", latitude: 24.1439458, longitude: 120.6794414, nowUnixSeconds: 1_735_617_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_735_636_822), + + // 高雄市新興區, region 800 + .init(id: "800-2024-02-29-noon", latitude: 22.6310347, longitude: 120.3101095, nowUnixSeconds: 1_709_179_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_709_200_908), + .init(id: "800-2024-03-20-noon", latitude: 22.6310347, longitude: 120.3101095, nowUnixSeconds: 1_710_907_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_710_929_389), + .init(id: "800-2024-06-21-noon", latitude: 22.6310347, longitude: 120.3101095, nowUnixSeconds: 1_718_942_400, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_718_966_776), + .init(id: "800-2024-09-22-noon", latitude: 22.6310347, longitude: 120.3101095, nowUnixSeconds: 1_726_977_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_726_998_915), + .init(id: "800-2024-12-21-noon", latitude: 22.6310347, longitude: 120.3101095, nowUnixSeconds: 1_734_753_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_734_772_755), + .init(id: "800-2024-12-31-noon", latitude: 22.6310347, longitude: 120.3101095, nowUnixSeconds: 1_735_617_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_735_637_094), + + // 花蓮縣花蓮市, region 970 + .init(id: "970-2024-02-29-noon", latitude: 23.9820651, longitude: 121.6067705, nowUnixSeconds: 1_709_179_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_709_200_546), + .init(id: "970-2024-03-20-noon", latitude: 23.9820651, longitude: 121.6067705, nowUnixSeconds: 1_710_907_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_710_929_080), + .init(id: "970-2024-06-21-noon", latitude: 23.9820651, longitude: 121.6067705, nowUnixSeconds: 1_718_942_400, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_718_966_637), + .init(id: "970-2024-09-22-noon", latitude: 23.9820651, longitude: 121.6067705, nowUnixSeconds: 1_726_977_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_726_998_607), + .init(id: "970-2024-12-21-noon", latitude: 23.9820651, longitude: 121.6067705, nowUnixSeconds: 1_734_753_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_734_772_277), + .init(id: "970-2024-12-31-noon", latitude: 23.9820651, longitude: 121.6067705, nowUnixSeconds: 1_735_617_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_735_636_619), + + // 臺東縣蘭嶼鄉, region 952 + .init(id: "952-2024-02-29-noon", latitude: 22.0244984, longitude: 121.5560627, nowUnixSeconds: 1_709_179_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_709_200_631), + .init(id: "952-2024-03-20-noon", latitude: 22.0244984, longitude: 121.5560627, nowUnixSeconds: 1_710_907_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_710_929_089), + .init(id: "952-2024-06-21-noon", latitude: 22.0244984, longitude: 121.5560627, nowUnixSeconds: 1_718_942_400, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_718_966_401), + .init(id: "952-2024-09-22-noon", latitude: 22.0244984, longitude: 121.5560627, nowUnixSeconds: 1_726_977_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_726_998_615), + .init(id: "952-2024-12-21-noon", latitude: 22.0244984, longitude: 121.5560627, nowUnixSeconds: 1_734_753_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_734_772_530), + .init(id: "952-2024-12-31-noon", latitude: 22.0244984, longitude: 121.5560627, nowUnixSeconds: 1_735_617_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_735_636_867), + + // Taipei exact boundaries, 2024-03-20 + .init(id: "100-before-sunrise", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_710_885_457, expectedIsNight: true, expectedNextTransitionUnixSeconds: 1_710_885_458), + .init(id: "100-at-sunrise", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_710_885_458, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_710_929_103), + .init(id: "100-after-sunrise", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_710_885_459, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_710_929_103), + .init(id: "100-before-sunset", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_710_929_102, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_710_929_103), + .init(id: "100-at-sunset", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_710_929_103, expectedIsNight: true, expectedNextTransitionUnixSeconds: 1_710_971_796), + .init(id: "100-after-sunset", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_710_929_104, expectedIsNight: true, expectedNextTransitionUnixSeconds: 1_710_971_796), + + // 2024-12-31 after sunset -> 2025-01-01 sunrise + .init(id: "100-year-rollover-after-sunset", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_735_636_512, expectedIsNight: true, expectedNextTransitionUnixSeconds: 1_735_684_745), +] + +final class WidgetSolarTimeTests: XCTestCase { + func testSolarGoldensMatchDartAuthority() { + for golden in solarGoldens { + let (nowUnixMilliseconds, overflow) = + golden.nowUnixSeconds.multipliedReportingOverflow( + by: 1_000 + ) + + XCTAssertFalse( + overflow, + "Unix millisecond conversion overflowed for \(golden.id)" + ) + + guard !overflow else { + continue + } + + XCTAssertEqual( + WidgetSolarTime.isNight( + unixMilliseconds: nowUnixMilliseconds, + latitude: golden.latitude, + longitude: golden.longitude + ), + golden.expectedIsNight, + "Unexpected day/night state for \(golden.id)" + ) + + XCTAssertEqual( + WidgetSolarTime.nextDayNightTransition( + unixMilliseconds: nowUnixMilliseconds, + latitude: golden.latitude, + longitude: golden.longitude + ), + golden.expectedNextTransitionUnixSeconds, + "Unexpected next transition for \(golden.id)" + ) + } + } + + func testPositiveModuloKeepsPositiveValue() { + XCTAssertEqual( + WidgetSolarTime.positiveModulo( + 10, + modulus: 360 + ), + 10 + ) + } + + func testPositiveModuloWrapsOverflow() { + XCTAssertEqual( + WidgetSolarTime.positiveModulo( + 370, + modulus: 360 + ), + 10 + ) + } + + func testPositiveModuloWrapsNegativeValue() { + XCTAssertEqual( + WidgetSolarTime.positiveModulo( + -10, + modulus: 360 + ), + 350 + ) + } + + func testPositiveModuloWrapsMultipleNegativeCycles() { + XCTAssertEqual( + WidgetSolarTime.positiveModulo( + -730, + modulus: 360 + ), + 350 + ) + } + + func testPositiveModuloNormalizesNegativeZero() { + let result = WidgetSolarTime.positiveModulo( + -0.0, + modulus: 360 + ) + + XCTAssertEqual(result, 0) + XCTAssertEqual(result.sign, .plus) + } + + func testJulianDaysIsZeroAtJ2000Noon() { + XCTAssertEqual( + WidgetSolarTime.julianDays( + unixMilliseconds: 946_728_000_000 + ), + 0 + ) + } + + func testJulianDaysIsNegativeHalfAtJ2000Midnight() { + XCTAssertEqual( + WidgetSolarTime.julianDays( + unixMilliseconds: 946_684_800_000 + ), + -0.5 + ) + } + + func testJulianDaysAdvancesOnePerDay() { + XCTAssertEqual( + WidgetSolarTime.julianDays( + unixMilliseconds: 946_814_400_000 + ), + 1 + ) + } + + func testJulianDaysMatchesUnixEpochOffset() { + XCTAssertEqual( + WidgetSolarTime.julianDays( + unixMilliseconds: 0 + ), + -10_957.5 + ) + } + + func testSolarTermsAtJ2000() { + let terms = WidgetSolarTime.solarTerms( + unixMilliseconds: 946_728_000_000 + ) + + XCTAssertEqual( + terms.meanLongitudeDegrees, + 280.460, + accuracy: 0.000_001 + ) + + XCTAssertEqual( + terms.rightAscensionRadians, + -1.3738212627, + accuracy: 0.000_000_001 + ) + + XCTAssertEqual( + terms.declinationRadians, + -0.4020091673, + accuracy: 0.000_000_001 + ) + } + + func testIsNightIgnoresSubsecondWithinBoundarySecond() { + let latitude = 25.032188 + let longitude = 121.5183226 + + XCTAssertFalse( + WidgetSolarTime.isNight( + unixMilliseconds: 1_710_885_458_999, + latitude: latitude, + longitude: longitude + ) + ) + + XCTAssertTrue( + WidgetSolarTime.isNight( + unixMilliseconds: 1_710_929_103_999, + latitude: latitude, + longitude: longitude + ) + ) + } + +} + +final class WidgetResolvedWeatherLocationTests: XCTestCase { + func testAcceptsValidSavedLocation() { + let location = WidgetResolvedWeatherLocation( + address: .saved(regionCode: "407"), + regionCode: "407", + regionName: "西屯區", + latitude: 24.1658213, + longitude: 120.6336717 + ) + + XCTAssertEqual( + location?.address, + .saved(regionCode: "407") + ) + XCTAssertEqual(location?.regionCode, "407") + XCTAssertEqual(location?.regionName, "西屯區") + XCTAssertEqual(location?.latitude, 24.1658213) + XCTAssertEqual(location?.longitude, 120.6336717) + } + + func testRejectsSavedAddressRegionMismatch() { + XCTAssertNil( + WidgetResolvedWeatherLocation( + address: .saved(regionCode: "407"), + regionCode: "100", + regionName: "中正區", + latitude: 25.032188, + longitude: 121.5183226 + ) + ) + } + + func testAcceptsCurrentLocationWithResolvedRegion() { + let location = WidgetResolvedWeatherLocation( + address: .currentLocation, + regionCode: "407", + regionName: "西屯區", + latitude: 24.1658213, + longitude: 120.6336717 + ) + + XCTAssertEqual( + location?.address, + .currentLocation + ) + + XCTAssertEqual( + location?.regionCode, + "407" + ) + } + + func testRejectsInvalidRegionCode() { + XCTAssertNil( + WidgetResolvedWeatherLocation( + address: .currentLocation, + regionCode: "40A", + regionName: "西屯區", + latitude: 24.1658213, + longitude: 120.6336717 + ) + ) + } + + func testRejectsInvalidCoordinates() { + XCTAssertNil( + WidgetResolvedWeatherLocation( + address: .currentLocation, + regionCode: "407", + regionName: "西屯區", + latitude: 91, + longitude: 120.6336717 + ) + ) + + XCTAssertNil( + WidgetResolvedWeatherLocation( + address: .currentLocation, + regionCode: "407", + regionName: "西屯區", + latitude: 24.1658213, + longitude: .infinity + ) + ) + } +} + +final class CurrentWeatherWidgetSnapshotFactoryTests: XCTestCase { + func testCreatesSchemaFiveSnapshotFromResolvedInputs() throws { + let observation = try makeObservation() + + let location = try XCTUnwrap( + WidgetResolvedWeatherLocation( + address: .saved(regionCode: "100"), + regionCode: "100", + regionName: "中正區", + latitude: 25.032188, + longitude: 121.5183226 + ) + ) + + let time = CurrentWeatherSnapshotTime( + calibratedNowUnixMilliseconds: + 1_710_907_200_000, + calibratedTimeOffsetMilliseconds: + -5_000 + ) + + let snapshot = + CurrentWeatherWidgetSnapshotFactory.make( + observation: observation, + location: location, + time: time + ) + + XCTAssertEqual(snapshot.schemaVersion, 5) + XCTAssertEqual( + snapshot.sourceIdentifier, + "region:100" + ) + + XCTAssertEqual(snapshot.regionCode, "100") + XCTAssertEqual(snapshot.regionName, "中正區") + + XCTAssertEqual( + snapshot.observationTime, + 1_710_900_000 + ) + + XCTAssertEqual(snapshot.stationName, "臺北") + XCTAssertEqual(snapshot.weather, "晴") + XCTAssertEqual(snapshot.weatherCode, 100) + XCTAssertEqual(snapshot.condition, .clear) + + XCTAssertFalse(snapshot.isNight) + XCTAssertEqual( + snapshot.nextDayNightTransitionTime, + 1_710_929_103 + ) + + XCTAssertEqual( + snapshot.calibratedTimeOffsetMilliseconds, + -5_000 + ) + + XCTAssertEqual(snapshot.temperature, 28.5) + XCTAssertEqual(snapshot.humidity, 70) + XCTAssertEqual(snapshot.rain, 0) + } + + private func makeObservation() throws + -> CurrentWeatherRemoteDTO + { + let json = """ + { + "station": { + "name": "臺北" + }, + "time": 1710900000, + "data": { + "weather": "晴", + "weatherCode": 100, + "temperature": 28.5, + "humidity": 70, + "rain": 0 + } + } + """ + + return try JSONDecoder().decode( + CurrentWeatherRemoteDTO.self, + from: Data(json.utf8) + ) + } +} diff --git a/ios/RunnerTests/DPIPWidgetProviderTests.swift b/ios/RunnerTests/DPIPWidgetProviderTests.swift new file mode 100644 index 000000000..67be67349 --- /dev/null +++ b/ios/RunnerTests/DPIPWidgetProviderTests.swift @@ -0,0 +1,435 @@ +import Foundation +import XCTest + +final class DPIPWidgetProviderTests: XCTestCase { + private let staleAfter: TimeInterval = 30 * 60 + private let now = Date(timeIntervalSince1970: 10_000) + + func testSavedTargetInvokesRefresh() async { + let target = WidgetLocationTarget.saved(regionCode: "407") + let store = ProviderTimelineTestStore( + refreshResult: .failed, + snapshots: [(target, snapshot(for: target))] + ) + + _ = await planner(store: store).plan(for: target) + + XCTAssertEqual(store.savedRefreshTargets, [target]) + XCTAssertEqual(store.currentRefreshCount, 0) + } + + func testSavedTargetReloadsCacheAfterSuccessfulRefresh() async { + let target = WidgetLocationTarget.saved(regionCode: "407") + let oldSnapshot = snapshot( + for: target, + stationName: "old-station" + ) + let refreshedSnapshot = snapshot( + for: target, + stationName: "refreshed-station" + ) + let store = ProviderTimelineTestStore( + refreshResult: .refreshed, + snapshots: [(target, oldSnapshot)], + refreshedSnapshot: refreshedSnapshot + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertEqual( + store.events, + [.load(target), .refreshSaved(target), .load(target)] + ) + XCTAssertEqual(plan.snapshot?.stationName, "refreshed-station") + } + + func testSavedRefreshFailureKeepsCachedSnapshot() async { + let target = WidgetLocationTarget.saved(regionCode: "407") + let store = ProviderTimelineTestStore( + refreshResult: .failed, + snapshots: [ + (target, snapshot( + for: target, + stationName: "cached-station" + )), + ] + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertEqual(plan.snapshot?.stationName, "cached-station") + XCTAssertEqual(store.cachedSnapshot(for: target)?.regionCode, "407") + } + + func testSavedNoObservationKeepsCachedSnapshot() async { + let target = WidgetLocationTarget.saved(regionCode: "407") + let store = ProviderTimelineTestStore( + refreshResult: .noObservation, + snapshots: [ + (target, snapshot( + for: target, + stationName: "cached-station" + )), + ] + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertEqual(plan.snapshot?.stationName, "cached-station") + XCTAssertEqual(store.cachedSnapshot(for: target)?.regionCode, "407") + } + + func testSavedUnavailableKeepsSameLocationCache() async { + let target = WidgetLocationTarget.saved(regionCode: "407") + let otherTarget = WidgetLocationTarget.saved(regionCode: "242") + let store = ProviderTimelineTestStore( + refreshResult: .unavailable, + snapshots: [ + (target, snapshot( + for: target, + stationName: "region-407" + )), + (otherTarget, snapshot( + for: otherTarget, + stationName: "region-242" + )), + ] + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertEqual(plan.snapshot?.stationName, "region-407") + XCTAssertEqual(store.loadTargets, [target, target]) + } + + func testCurrentLocationInvokesOnlyCurrentRefresh() async { + let target = WidgetLocationTarget.currentLocation + let store = ProviderTimelineTestStore( + refreshResult: .refreshed, + currentRefreshResult: .refreshed, + snapshots: [(target, snapshot(for: target))] + ) + + _ = await planner(store: store).plan(for: target) + + XCTAssertTrue(store.savedRefreshTargets.isEmpty) + XCTAssertEqual(store.currentRefreshCount, 1) + } + + func testCurrentLocationSuccessReloadsCurrentLocationCache() async { + let target = WidgetLocationTarget.currentLocation + let refreshedSnapshot = snapshot( + for: target, + stationName: "refreshed-current-location" + ) + let store = ProviderTimelineTestStore( + refreshResult: .failed, + currentRefreshResult: .refreshed, + snapshots: [(target, snapshot(for: target))], + refreshedSnapshot: refreshedSnapshot + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertEqual( + store.events, + [.load(target), .refreshCurrent, .load(target)] + ) + XCTAssertEqual( + plan.snapshot?.stationName, + "refreshed-current-location" + ) + } + + func testCurrentLocationFailureReloadsStaleCache() async { + let target = WidgetLocationTarget.currentLocation + let store = ProviderTimelineTestStore( + refreshResult: .failed, + currentRefreshResult: .failed, + snapshots: [ + (target, snapshot( + for: target, + stationName: "current-location-cache" + )), + ] + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertEqual( + plan.snapshot?.stationName, + "current-location-cache" + ) + XCTAssertEqual( + store.events, + [.load(target), .refreshCurrent, .load(target)] + ) + } + + func testCurrentLocationFailureWithoutCacheKeepsNoDataBehavior() async { + let target = WidgetLocationTarget.currentLocation + let store = ProviderTimelineTestStore( + refreshResult: .failed, + currentRefreshResult: .failed, + snapshots: [] + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertNil(plan.snapshot) + XCTAssertEqual(plan.states.count, 1) + XCTAssertEqual( + store.events, + [.load(target), .refreshCurrent, .load(target)] + ) + } + + func testInvalidTargetDoesNotInvokeRefreshOrUseOtherCache() async { + let target = WidgetLocationTarget.invalid( + identifier: "region:invalid" + ) + let savedTarget = WidgetLocationTarget.saved(regionCode: "407") + let store = ProviderTimelineTestStore( + refreshResult: .refreshed, + snapshots: [(savedTarget, snapshot(for: savedTarget))] + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertTrue(store.savedRefreshTargets.isEmpty) + XCTAssertEqual(store.currentRefreshCount, 0) + XCTAssertNil(plan.snapshot) + XCTAssertEqual(store.loadTargets, [target]) + } + + #if DEBUG + func testDebugTimelineRequestsRefreshAfterSixtySeconds() async { + let target = WidgetLocationTarget.currentLocation + let store = ProviderTimelineTestStore( + refreshResult: .failed, + snapshots: [] + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertEqual(DPIPWidgetProviderRuntime.refreshInterval, 60) + XCTAssertGreaterThan(plan.reloadDate, now) + XCTAssertEqual( + plan.reloadDate, + now.addingTimeInterval(60) + ) + } + #else + func testReleaseTimelineRequestsRefreshAfterTwentyMinutes() async { + let target = WidgetLocationTarget.currentLocation + let store = ProviderTimelineTestStore( + refreshResult: .failed, + snapshots: [] + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertEqual(DPIPWidgetProviderRuntime.refreshInterval, 20 * 60) + XCTAssertGreaterThan(plan.reloadDate, now) + XCTAssertEqual( + plan.reloadDate, + now.addingTimeInterval(20 * 60) + ) + } + #endif + + func testSnapshotPathIsCacheOnlyAndDoesNotInvokeRefresh() { + let target = WidgetLocationTarget.currentLocation + let store = ProviderTimelineTestStore( + refreshResult: .refreshed, + currentRefreshResult: .refreshed, + snapshots: [(target, snapshot(for: target))] + ) + let dependencies = DPIPWidgetProviderDependencies( + loadSnapshot: store.load, + timelinePlanner: planner(store: store) + ) + + let loaded = dependencies.snapshot(for: target) + + XCTAssertEqual(loaded?.sourceIdentifier, "current-location") + XCTAssertEqual(store.loadTargets, [target]) + XCTAssertTrue(store.savedRefreshTargets.isEmpty) + XCTAssertEqual(store.currentRefreshCount, 0) + } + + private func planner( + store: ProviderTimelineTestStore + ) -> DPIPWidgetTimelinePlanner { + let fixedNow = now + return DPIPWidgetTimelinePlanner( + staleAfter: staleAfter, + refreshInterval: DPIPWidgetProviderRuntime.refreshInterval, + loadSnapshot: store.load, + refreshSaved: store.refreshSaved, + refreshCurrent: store.refreshCurrent, + now: { fixedNow } + ) + } + + private func snapshot( + for target: WidgetLocationTarget, + stationName: String = "station" + ) -> CurrentWeatherWidgetSnapshot { + let regionCode: String + switch target { + case .saved(let savedRegionCode): + regionCode = savedRegionCode + case .currentLocation, .invalid: + regionCode = "407" + } + + return CurrentWeatherWidgetSnapshot( + schemaVersion: 5, + sourceIdentifier: target.sourceIdentifier, + regionCode: regionCode, + regionName: "測試地區", + observationTime: 10_000, + stationName: stationName, + weather: "晴", + weatherCode: 100, + condition: .clear, + isNight: false, + nextDayNightTransitionTime: 0, + calibratedTimeOffsetMilliseconds: 0, + temperature: 28, + humidity: 70, + rain: 0 + ) + } +} + +private enum ProviderTimelineEvent: Equatable { + case refreshSaved(WidgetLocationTarget) + case refreshCurrent + case load(WidgetLocationTarget) +} + +private final class ProviderTimelineTestStore: @unchecked Sendable { + private let lock = NSLock() + private let savedRefreshResult: CurrentWeatherWidgetRefreshResult + private let currentRefreshResult: CurrentWeatherWidgetRefreshResult + private let refreshedSnapshot: CurrentWeatherWidgetSnapshot? + + private var storedSnapshots: [String: CurrentWeatherWidgetSnapshot] + private var storedEvents: [ProviderTimelineEvent] = [] + + init( + refreshResult: CurrentWeatherWidgetRefreshResult, + currentRefreshResult: CurrentWeatherWidgetRefreshResult = .failed, + snapshots: [( + WidgetLocationTarget, + CurrentWeatherWidgetSnapshot + )], + refreshedSnapshot: CurrentWeatherWidgetSnapshot? = nil + ) { + savedRefreshResult = refreshResult + self.currentRefreshResult = currentRefreshResult + self.refreshedSnapshot = refreshedSnapshot + storedSnapshots = Dictionary( + uniqueKeysWithValues: snapshots.map { + (Self.key(for: $0.0), $0.1) + } + ) + } + + func refreshSaved( + target: WidgetLocationTarget + ) async -> CurrentWeatherWidgetRefreshResult { + recordSavedRefresh(target) + return savedRefreshResult + } + + func refreshCurrent() async -> CurrentWeatherWidgetRefreshResult { + recordCurrentRefresh() + return currentRefreshResult + } + + func load( + target: WidgetLocationTarget + ) -> CurrentWeatherWidgetSnapshot? { + lock.lock() + defer { lock.unlock() } + storedEvents.append(.load(target)) + return storedSnapshots[Self.key(for: target)] + } + + func cachedSnapshot( + for target: WidgetLocationTarget + ) -> CurrentWeatherWidgetSnapshot? { + lock.lock() + defer { lock.unlock() } + return storedSnapshots[Self.key(for: target)] + } + + var events: [ProviderTimelineEvent] { + lock.lock() + defer { lock.unlock() } + return storedEvents + } + + var savedRefreshTargets: [WidgetLocationTarget] { + events.compactMap { event in + guard case let .refreshSaved(target) = event else { + return nil + } + return target + } + } + + var currentRefreshCount: Int { + events.filter { $0 == .refreshCurrent }.count + } + + var loadTargets: [WidgetLocationTarget] { + events.compactMap { event in + guard case let .load(target) = event else { + return nil + } + return target + } + } + + private func recordSavedRefresh( + _ target: WidgetLocationTarget + ) { + lock.lock() + defer { lock.unlock() } + storedEvents.append(.refreshSaved(target)) + + if savedRefreshResult == .refreshed, + let refreshedSnapshot { + storedSnapshots[Self.key(for: target)] = refreshedSnapshot + } + } + + private func recordCurrentRefresh() { + lock.lock() + defer { lock.unlock() } + let target = WidgetLocationTarget.currentLocation + storedEvents.append(.refreshCurrent) + + if currentRefreshResult == .refreshed, + let refreshedSnapshot { + storedSnapshots[Self.key(for: target)] = refreshedSnapshot + } + } + + private static func key( + for target: WidgetLocationTarget + ) -> String { + switch target { + case .currentLocation: + return "current-location" + case .saved(let regionCode): + return "region:\(regionCode)" + case .invalid(let identifier): + return "invalid:\(identifier)" + } + } +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 435d33bf8..76ba46e0e 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -50,11 +50,65 @@ final class RunnerTests: XCTestCase { XCTAssertEqual(try Data(contentsOf: target), second) } + func testCurrentWeatherRequestTokenPreservesArrivalOrder() throws { + let container = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: container) } + let kind = try WidgetSnapshotFile.kind("currentWeather") + let older = try WidgetSnapshotFile.beginCurrentWeatherWrite( + sourceIdentifier: "current-location", + in: container + ) + let newer = try WidgetSnapshotFile.beginCurrentWeatherWrite( + sourceIdentifier: "current-location", + in: container + ) + let olderData = try WidgetSnapshotFile.payload( + "{\"schemaVersion\":5,\"observationTime\":100,\"regionCode\":\"407\"}" + ) + let newerData = try WidgetSnapshotFile.payload( + "{\"schemaVersion\":5,\"observationTime\":100,\"regionCode\":\"110\"}" + ) + + XCTAssertEqual( + try WidgetSnapshotFile.replace( + newerData, + kind: kind, + sourceIdentifier: "current-location", + in: container, + currentWeatherWriteToken: newer + ), + .written + ) + XCTAssertEqual( + try WidgetSnapshotFile.replace( + olderData, + kind: kind, + sourceIdentifier: "current-location", + in: container, + currentWeatherWriteToken: older + ), + .rejected + ) + + let target = try WidgetSnapshotFile.snapshotURL( + kind: kind, + sourceIdentifier: "current-location", + in: container + ) + let stored = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(contentsOf: target)) + as? [String: Any] + ) + XCTAssertEqual(stored["regionCode"] as? String, "110") + } + func testSnapshotClearIsIdempotent() throws { let container = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: container) } let kind = try WidgetSnapshotFile.kind("currentWeather") - let data = try WidgetSnapshotFile.payload("{\"schemaVersion\":1}") + let data = try WidgetSnapshotFile.payload( + "{\"schemaVersion\":5,\"observationTime\":100,\"regionCode\":\"220\"}" + ) let directory = container.appendingPathComponent("WidgetSnapshots") let legacyTarget = directory.appendingPathComponent("current-weather.json") let perLocationTarget = try WidgetSnapshotFile.snapshotURL( diff --git a/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift b/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift new file mode 100644 index 000000000..f9a482df4 --- /dev/null +++ b/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift @@ -0,0 +1,543 @@ +import Foundation +import XCTest + +final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { + private var containerURL: URL! + + override func setUpWithError() throws { + containerURL = FileManager.default.temporaryDirectory + .appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: containerURL) + containerURL = nil + } + + func testSavedTargetWritesCorrectRegionCache() async throws { + let weather = ScriptedCurrentWeather( + result: .success(try makeObservation()) + ) + let writer = CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + let service = try makeService( + codes: ["242", "433"], + weather: weather, + writeSnapshot: { snapshot in + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: .saved(regionCode: "242"), + using: writer + ) + } + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .refreshed) + let storage = CurrentWeatherSnapshotStorage( + containerURL: containerURL + ) + let savedURL = storage.snapshotURL( + for: .saved(regionCode: "242") + ) + XCTAssertTrue(FileManager.default.fileExists(atPath: savedURL.path)) + XCTAssertFalse( + FileManager.default.fileExists( + atPath: storage.snapshotURL( + for: .saved(regionCode: "433") + ).path + ) + ) + } + + func testUnresolvableTargetsDoNotStartRefreshWork() async throws { + let targets: [WidgetLocationTarget] = [ + .currentLocation, + .invalid(identifier: "region:242"), + .saved(regionCode: "433"), + ] + + for target in targets { + let weather = ScriptedCurrentWeather( + result: .success(try makeObservation()) + ) + let clock = ScriptedSnapshotClock(sample: makeSnapshotTime()) + let writer = CurrentWeatherSnapshotWriterSpy() + let service = try makeService( + codes: ["242"], + weather: weather, + synchronizeClock: { + await clock.synchronizeAndSample() + }, + writeSnapshot: writer.write + ) + + let result = await service.refresh(target: target) + let weatherCallCount = await weather.callCount + let clockCallCount = await clock.callCount + + XCTAssertEqual(result, .unavailable, "\(target)") + XCTAssertEqual(weatherCallCount, 0, "\(target)") + XCTAssertEqual(clockCallCount, 0, "\(target)") + XCTAssertEqual(writer.writeCount, 0, "\(target)") + } + } + + func testSuccessfulRefreshWritesSchemaFiveResolvedSnapshot() async throws { + let observation = try makeObservation() + let weather = ScriptedCurrentWeather( + result: .success(observation) + ) + let clock = makeClock( + serverResults: [.success(10_000)] + ) + let writer = CurrentWeatherSnapshotWriterSpy() + let service = try makeService( + codes: ["242"], + weather: weather, + clock: clock, + writeSnapshot: writer.write + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .refreshed) + let snapshot = try XCTUnwrap(writer.snapshots.first) + XCTAssertEqual(snapshot.schemaVersion, 5) + XCTAssertEqual(snapshot.sourceIdentifier, "region:242") + XCTAssertEqual(snapshot.regionCode, "242") + XCTAssertEqual(snapshot.regionName, "新莊區") + XCTAssertEqual(snapshot.observationTime, observation.time) + XCTAssertEqual(snapshot.stationName, observation.stationName) + XCTAssertEqual(snapshot.weather, observation.weather) + XCTAssertEqual(snapshot.weatherCode, observation.weatherCode) + XCTAssertEqual(snapshot.temperature, observation.temperature) + XCTAssertEqual(snapshot.humidity, observation.humidity) + XCTAssertEqual(snapshot.rain, observation.rain) + XCTAssertEqual(snapshot.calibratedTimeOffsetMilliseconds, 5_000) + let coordinates = await weather.coordinates + XCTAssertEqual(coordinates.count, 1) + XCTAssertEqual(coordinates.first?.latitude, 25.0358303) + XCTAssertEqual(coordinates.first?.longitude, 121.4500307) + } + + func testFirstClockSyncFailureDoesNotWrite() async throws { + let weather = ScriptedCurrentWeather( + result: .success(try makeObservation()) + ) + let writer = CurrentWeatherSnapshotWriterSpy() + let clock = makeClock( + serverResults: [.failure(WidgetRefreshTestError.scripted)] + ) + let service = try makeService( + codes: ["242"], + weather: weather, + clock: clock, + writeSnapshot: writer.write + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .failed) + let hasSynchronized = await clock.hasSynchronized + let weatherCallCount = await weather.callCount + XCTAssertFalse(hasSynchronized) + XCTAssertEqual(weatherCallCount, 1) + XCTAssertEqual(writer.writeCount, 0) + } + + func testLaterClockSyncFailureUsesRetainedAnchor() async throws { + let wallClock = TestWallClock(milliseconds: 5_000) + let monotonicClock = TestMonotonicClock(milliseconds: 100) + let clock = makeClock( + wallClock: wallClock, + monotonicClock: monotonicClock, + serverResults: [ + .success(10_000), + .failure(WidgetRefreshTestError.scripted), + ] + ) + let firstSyncSucceeded = await clock.synchronize() + XCTAssertTrue(firstSyncSucceeded) + monotonicClock.set(milliseconds: 600) + + let weather = ScriptedCurrentWeather( + result: .success(try makeObservation()) + ) + let writer = CurrentWeatherSnapshotWriterSpy() + let service = try makeService( + codes: ["242"], + weather: weather, + clock: clock, + writeSnapshot: writer.write + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .refreshed) + let hasSynchronized = await clock.hasSynchronized + XCTAssertTrue(hasSynchronized) + XCTAssertEqual(writer.writeCount, 1) + XCTAssertEqual( + writer.snapshots.first?.calibratedTimeOffsetMilliseconds, + 5_500 + ) + } + + func testWeatherFailureKeepsExistingCache() async throws { + let oldData = try seedExistingSnapshot(regionCode: "242") + let weather = ScriptedCurrentWeather( + result: .failure(.scripted) + ) + let writer = CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + let service = try makeService( + codes: ["242"], + weather: weather, + writeSnapshot: { snapshot in + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: .saved(regionCode: "242"), + using: writer + ) + } + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .failed) + XCTAssertEqual(try cachedData(regionCode: "242"), oldData) + } + + func testNoObservationKeepsExistingCache() async throws { + let oldData = try seedExistingSnapshot(regionCode: "242") + let weather = ScriptedCurrentWeather(result: .success(nil)) + let writer = CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + let service = try makeService( + codes: ["242"], + weather: weather, + writeSnapshot: { snapshot in + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: .saved(regionCode: "242"), + using: writer + ) + } + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .noObservation) + XCTAssertEqual(try cachedData(regionCode: "242"), oldData) + } + + func testWriterFailureKeepsExistingCache() async throws { + let oldData = try seedExistingSnapshot(regionCode: "242") + let weather = ScriptedCurrentWeather( + result: .success(try makeObservation()) + ) + let writer = CurrentWeatherSnapshotWriterSpy(error: .scripted) + let service = try makeService( + codes: ["242"], + weather: weather, + writeSnapshot: writer.write + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .failed) + XCTAssertEqual(writer.writeCount, 1) + XCTAssertEqual(try cachedData(regionCode: "242"), oldData) + } + + func testRegionARefreshNeverOverwritesRegionBCache() async throws { + let regionBData = try seedExistingSnapshot(regionCode: "433") + let weather = ScriptedCurrentWeather( + result: .success(try makeObservation()) + ) + let writer = CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + let service = try makeService( + codes: ["242", "433"], + weather: weather, + writeSnapshot: { snapshot in + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: .saved(regionCode: "242"), + using: writer + ) + } + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .refreshed) + XCTAssertEqual(try cachedData(regionCode: "433"), regionBData) + XCTAssertNotNil(try? cachedData(regionCode: "242")) + } + + func testWeatherAndClockStartBeforeEitherCompletes() async throws { + let weatherGate = AsyncOperationGate() + let clockGate = AsyncOperationGate() + let observation = try makeObservation() + let writer = CurrentWeatherSnapshotWriterSpy() + let resolver = try makeResolver(codes: ["242"]) + let service = SavedCurrentWeatherWidgetRefreshService( + resolveLocation: resolver.resolve, + beginWrite: { address in + CurrentWeatherSnapshotWriteToken( + address: address, + generation: 1 + ) + }, + pipeline: CurrentWeatherWidgetRefreshPipeline( + fetchWeather: { _, _ in + await weatherGate.wait() + return observation + }, + synchronizeClock: { + await clockGate.wait() + return self.makeSnapshotTime() + }, + commitSnapshot: { snapshot, _ in + try writer.write(snapshot) + return .written + } + ) + ) + + let refresh = Task { + await service.refresh( + target: .saved(regionCode: "242") + ) + } + for _ in 0..<200 { + if await weatherGate.hasStarted, + await clockGate.hasStarted { + break + } + await Task.yield() + } + + let weatherStarted = await weatherGate.hasStarted + let clockStarted = await clockGate.hasStarted + XCTAssertTrue(weatherStarted) + XCTAssertTrue(clockStarted) + await weatherGate.open() + await clockGate.open() + let result = await refresh.value + XCTAssertEqual(result, .refreshed) + } + + func testRejectedWriteReturnsSuperseded() async throws { + let resolver = try makeResolver(codes: ["242"]) + let weather = ScriptedCurrentWeather( + result: .success(try makeObservation()) + ) + let expectedToken = CurrentWeatherSnapshotWriteToken( + address: .saved(regionCode: "242"), + generation: 7 + ) + let service = SavedCurrentWeatherWidgetRefreshService( + resolveLocation: resolver.resolve, + beginWrite: { address in + XCTAssertEqual(address, expectedToken.address) + return expectedToken + }, + pipeline: CurrentWeatherWidgetRefreshPipeline( + fetchWeather: { latitude, longitude in + try await weather.fetch( + latitude: latitude, + longitude: longitude + ) + }, + synchronizeClock: makeSnapshotTime, + commitSnapshot: { _, token in + XCTAssertEqual(token, expectedToken) + return .rejected + } + ) + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .superseded) + } + + private func makeService( + codes: [String], + weather: ScriptedCurrentWeather, + synchronizeClock: @escaping + CurrentWeatherWidgetRefreshPipeline.SynchronizeClock = { + CurrentWeatherSnapshotTime( + calibratedNowUnixMilliseconds: 1_710_907_200_000, + calibratedTimeOffsetMilliseconds: 0 + ) + }, + writeSnapshot: @escaping @Sendable ( + CurrentWeatherWidgetSnapshot + ) throws -> Void + ) throws -> SavedCurrentWeatherWidgetRefreshService { + let resolver = try makeResolver(codes: codes) + return SavedCurrentWeatherWidgetRefreshService( + resolveLocation: resolver.resolve, + beginWrite: { address in + CurrentWeatherSnapshotWriteToken( + address: address, + generation: 1 + ) + }, + pipeline: CurrentWeatherWidgetRefreshPipeline( + fetchWeather: { latitude, longitude in + try await weather.fetch( + latitude: latitude, + longitude: longitude + ) + }, + synchronizeClock: synchronizeClock, + commitSnapshot: { snapshot, _ in + try writeSnapshot(snapshot) + return .written + } + ) + ) + } + + private func makeService( + codes: [String], + weather: ScriptedCurrentWeather, + clock: WidgetServerClock, + writeSnapshot: @escaping @Sendable ( + CurrentWeatherWidgetSnapshot + ) throws -> Void + ) throws -> SavedCurrentWeatherWidgetRefreshService { + let resolver = try makeResolver(codes: codes) + return SavedCurrentWeatherWidgetRefreshService( + resolveLocation: resolver.resolve, + beginWrite: { address in + CurrentWeatherSnapshotWriteToken( + address: address, + generation: 1 + ) + }, + pipeline: CurrentWeatherWidgetRefreshPipeline( + fetchWeather: { latitude, longitude in + try await weather.fetch( + latitude: latitude, + longitude: longitude + ) + }, + synchronizeClock: { + await CurrentWeatherWidgetRefreshPipeline + .synchronizedSnapshotTime(clock: clock) + }, + commitSnapshot: { snapshot, _ in + try writeSnapshot(snapshot) + return .written + } + ) + ) + } + + private func makeResolver( + codes: [String] + ) throws -> SavedWidgetLocationResolver { + let locations = try codes.map { code in + try XCTUnwrap(makeCatalogLocation(regionCode: code)) + } + let catalog = try XCTUnwrap( + WidgetLocationCatalog( + schemaVersion: 1, + locations: locations + ) + ) + return SavedWidgetLocationResolver(catalog: catalog) + } + + private func makeCatalogLocation( + regionCode: String + ) -> WidgetLocationCatalogLocation? { + CurrentWeatherWidgetTestFixtures.catalogLocation( + regionCode: regionCode + ) + } + + private func makeObservation() throws -> CurrentWeatherRemoteDTO { + try CurrentWeatherWidgetTestFixtures.observation( + stationName: "板橋" + ) + } + + private func makeSnapshotTime() -> CurrentWeatherSnapshotTime { + CurrentWeatherWidgetTestFixtures.snapshotTime( + offsetMilliseconds: 0 + ) + } + + private func makeClock( + wallClock: TestWallClock = TestWallClock(milliseconds: 5_000), + monotonicClock: TestMonotonicClock = + TestMonotonicClock(milliseconds: 100), + serverResults: [Result] + ) -> WidgetServerClock { + WidgetServerClock( + deviceClock: wallClock, + monotonicClock: monotonicClock, + serverTimeSource: ScriptedServerTimeSource( + results: serverResults + ), + timeoutRunner: PassthroughServerClockTimeoutRunner() + ) + } + + @discardableResult + private func seedExistingSnapshot( + regionCode: String + ) throws -> Data { + let address = CurrentWeatherSnapshotAddress.saved( + regionCode: regionCode + ) + return try CurrentWeatherWidgetTestFixtures.seed( + CurrentWeatherWidgetTestFixtures.snapshot( + sourceIdentifier: "region:\(regionCode)", + regionCode: regionCode + ), + at: address, + containerURL: containerURL + ) + } + + private func cachedData(regionCode: String) throws -> Data { + try CurrentWeatherWidgetTestFixtures.cachedData( + at: .saved(regionCode: regionCode), + containerURL: containerURL + ) + } +} diff --git a/ios/RunnerTests/WidgetCurrentLocationClientTests.swift b/ios/RunnerTests/WidgetCurrentLocationClientTests.swift new file mode 100644 index 000000000..43edab029 --- /dev/null +++ b/ios/RunnerTests/WidgetCurrentLocationClientTests.swift @@ -0,0 +1,523 @@ +import Foundation +import XCTest + +@MainActor +final class WidgetCurrentLocationClientTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_000_000) + + func testServicesDisabledReturnsUnavailable() async { + let manager = FakeWidgetLocationManager() + var managerWasCreated = false + let client = makeClient( + servicesEnabled: false, + manager: manager, + onMakeManager: { managerWasCreated = true } + ) + + let result = await client.acquireLocation() + + XCTAssertEqual(result, .unavailable) + XCTAssertFalse(managerWasCreated) + XCTAssertEqual(manager.startCount, 0) + } + + func testNotDeterminedReturnsUnavailable() async { + let manager = FakeWidgetLocationManager( + authorization: .notDetermined, + widgetUpdatesAuthorized: true + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .unavailable) + XCTAssertEqual(manager.startCount, 0) + } + + func testDeniedReturnsUnavailable() async { + let manager = FakeWidgetLocationManager( + authorization: .denied, + widgetUpdatesAuthorized: true + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .unavailable) + XCTAssertEqual(manager.startCount, 0) + } + + func testRestrictedReturnsUnavailable() async { + let manager = FakeWidgetLocationManager( + authorization: .restricted, + widgetUpdatesAuthorized: true + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .unavailable) + XCTAssertEqual(manager.startCount, 0) + } + + func testWidgetAuthorizationFalseReturnsUnavailable() async { + let manager = FakeWidgetLocationManager( + authorization: .authorizedWhenInUse, + widgetUpdatesAuthorized: false + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .unavailable) + XCTAssertEqual(manager.startCount, 0) + } + + func testAuthorizedWhenInUseMayAcquireLocation() async { + let expected = WidgetCurrentLocation( + latitude: 25.033, + longitude: 121.5654 + )! + let manager = successfulManager( + authorization: .authorizedWhenInUse, + location: expected + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .acquired(expected)) + XCTAssertEqual(manager.startCount, 1) + } + + func testAuthorizedAlwaysMayAcquireLocation() async { + let expected = WidgetCurrentLocation( + latitude: 22.6273, + longitude: 120.3014 + )! + let manager = successfulManager( + authorization: .authorizedAlways, + location: expected + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .acquired(expected)) + XCTAssertEqual(manager.startCount, 1) + } + + func testSampleGeneratedDuringActiveRequestIsAccepted() async { + let manager = FakeWidgetLocationManager( + event: .samples([ + WidgetLocationSample( + latitude: 24.1477, + longitude: 120.6736, + timestamp: now + ), + ]) + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual( + result, + .acquired( + WidgetCurrentLocation( + latitude: 24.1477, + longitude: 120.6736 + )! + ) + ) + } + + func testClearlyPreRequestCachedSampleWaitsUntilTimeout() async { + let manager = FakeWidgetLocationManager() + let scheduler = FakeWidgetLocationTimeoutScheduler() + let client = makeClient(manager: manager, scheduler: scheduler) + let task = Task { @MainActor in + await client.acquireLocation() + } + await waitForRequest(on: manager) + + manager.send( + samples: [ + WidgetLocationSample( + latitude: 25.033, + longitude: 121.5654, + timestamp: now.addingTimeInterval(-2) + ), + ] + ) + await Task.yield() + + XCTAssertEqual(manager.stopCount, 0) + XCTAssertNotNil(manager.delegate) + XCTAssertEqual(scheduler.lastCancellation?.cancelCount, 0) + + scheduler.fireLast() + let result = await task.value + + XCTAssertEqual(result, .timedOut) + XCTAssertEqual(manager.stopCount, 1) + XCTAssertNil(manager.delegate) + } + + func testCurrentSampleWinsAfterPreRequestCachedSample() async { + let manager = FakeWidgetLocationManager() + let scheduler = FakeWidgetLocationTimeoutScheduler() + let client = makeClient(manager: manager, scheduler: scheduler) + let task = Task { @MainActor in + await client.acquireLocation() + } + await waitForRequest(on: manager) + + manager.send( + samples: [ + WidgetLocationSample( + latitude: 24.1477, + longitude: 120.6736, + timestamp: now.addingTimeInterval(-30) + ), + ] + ) + manager.send( + samples: [ + WidgetLocationSample( + latitude: 25.033, + longitude: 121.5654, + timestamp: now + ), + ] + ) + + let result = await task.value + + XCTAssertEqual( + result, + .acquired( + WidgetCurrentLocation( + latitude: 25.033, + longitude: 121.5654 + )! + ) + ) + XCTAssertEqual(manager.stopCount, 1) + XCTAssertNil(manager.delegate) + } + + func testRequestStartToleranceAcceptsLegitimateNearStartSample() async { + let expected = WidgetCurrentLocation( + latitude: 25.033, + longitude: 121.5654 + )! + let manager = FakeWidgetLocationManager( + event: .samples([ + WidgetLocationSample( + latitude: expected.latitude, + longitude: expected.longitude, + timestamp: now.addingTimeInterval(-0.5) + ), + ]) + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .acquired(expected)) + } + + func testTenMinuteAbsoluteFreshnessLimitStillRejectsStaleSample() async { + let manager = FakeWidgetLocationManager() + let scheduler = FakeWidgetLocationTimeoutScheduler() + let client = makeClient( + manager: manager, + scheduler: scheduler, + requestStartTolerance: 700 + ) + let task = Task { @MainActor in + await client.acquireLocation() + } + await waitForRequest(on: manager) + + manager.send( + samples: [ + WidgetLocationSample( + latitude: 25.033, + longitude: 121.5654, + timestamp: now.addingTimeInterval(-601) + ), + ] + ) + scheduler.fireLast() + + let result = await task.value + + XCTAssertEqual(result, .timedOut) + } + + func testInvalidAndNonFiniteCoordinatesAreRejected() async { + let manager = FakeWidgetLocationManager() + let scheduler = FakeWidgetLocationTimeoutScheduler() + let client = makeClient(manager: manager, scheduler: scheduler) + let task = Task { @MainActor in + await client.acquireLocation() + } + await waitForRequest(on: manager) + + manager.send( + samples: [ + WidgetLocationSample( + latitude: 91, + longitude: 121.5654, + timestamp: now + ), + WidgetLocationSample( + latitude: .nan, + longitude: 121.5654, + timestamp: now + ), + ] + ) + scheduler.fireLast() + + let result = await task.value + + XCTAssertEqual(result, .timedOut) + } + + func testCoreLocationFailureReturnsFailure() async { + let manager = FakeWidgetLocationManager(event: .failure) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .failed) + } + + func testTimeoutIgnoresLateLocationAndCompletesOnlyOnce() async { + let manager = FakeWidgetLocationManager() + let scheduler = FakeWidgetLocationTimeoutScheduler() + let client = makeClient(manager: manager, scheduler: scheduler) + let task = Task { @MainActor in + await client.acquireLocation() + } + await waitForRequest(on: manager) + + scheduler.fireLast() + let result = await task.value + manager.send( + samples: [ + WidgetLocationSample( + latitude: 25.033, + longitude: 121.5654, + timestamp: now + ), + ] + ) + + XCTAssertEqual(result, .timedOut) + XCTAssertEqual(manager.stopCount, 1) + XCTAssertNil(manager.delegate) + XCTAssertEqual(scheduler.lastCancellation?.cancelCount, 1) + } + + func testSuccessCancelsTimeoutAndCompletesOnlyOnce() async { + let expected = WidgetCurrentLocation( + latitude: 25.033, + longitude: 121.5654 + )! + let manager = successfulManager( + authorization: .authorizedWhenInUse, + location: expected + ) + let scheduler = FakeWidgetLocationTimeoutScheduler() + let client = makeClient(manager: manager, scheduler: scheduler) + + let result = await client.acquireLocation() + scheduler.fireLast() + + XCTAssertEqual(result, .acquired(expected)) + XCTAssertEqual(manager.stopCount, 1) + XCTAssertNil(manager.delegate) + XCTAssertEqual(scheduler.lastCancellation?.cancelCount, 1) + } + + func testUnavailableDoesNotRequestFallbackLocation() async { + let manager = FakeWidgetLocationManager( + authorization: .authorizedWhenInUse, + widgetUpdatesAuthorized: false, + event: .samples([ + WidgetLocationSample( + latitude: 25.033, + longitude: 121.5654, + timestamp: now + ), + ]) + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .unavailable) + XCTAssertEqual(manager.startCount, 0) + XCTAssertEqual(manager.stopCount, 0) + } + + func testAcquisitionUsesTownshipAppropriateAccuracyAndTenSecondTimeout() async { + let expected = WidgetCurrentLocation( + latitude: 25.033, + longitude: 121.5654 + )! + let manager = successfulManager( + authorization: .authorizedWhenInUse, + location: expected + ) + let scheduler = FakeWidgetLocationTimeoutScheduler() + + _ = await makeClient( + manager: manager, + scheduler: scheduler + ).acquireLocation() + + XCTAssertEqual( + manager.desiredAccuracy, + WidgetCurrentLocationClient.defaultDesiredAccuracy + ) + XCTAssertEqual( + scheduler.scheduledIntervals, + [WidgetCurrentLocationClient.defaultTimeout] + ) + } + + private func makeClient( + servicesEnabled: Bool = true, + manager: FakeWidgetLocationManager, + scheduler: FakeWidgetLocationTimeoutScheduler? = nil, + requestStartTolerance: TimeInterval = + WidgetCurrentLocationClient.defaultRequestStartTolerance, + onMakeManager: @escaping @MainActor () -> Void = {} + ) -> WidgetCurrentLocationClient { + WidgetCurrentLocationClient( + servicesEnabled: { servicesEnabled }, + makeManager: { + onMakeManager() + return manager + }, + timeoutScheduler: scheduler + ?? FakeWidgetLocationTimeoutScheduler(), + requestStartTolerance: requestStartTolerance, + now: { self.now } + ) + } + + private func successfulManager( + authorization: WidgetLocationAuthorization, + location: WidgetCurrentLocation + ) -> FakeWidgetLocationManager { + FakeWidgetLocationManager( + authorization: authorization, + event: .samples([ + WidgetLocationSample( + latitude: location.latitude, + longitude: location.longitude, + timestamp: now + ), + ]) + ) + } + + private func waitForRequest( + on manager: FakeWidgetLocationManager + ) async { + for _ in 0..<100 where manager.startCount == 0 { + await Task.yield() + } + XCTAssertEqual(manager.startCount, 1) + } +} + +@MainActor +private final class FakeWidgetLocationManager: WidgetLocationManaging { + enum Event { + case none + case samples([WidgetLocationSample]) + case failure + } + + weak var delegate: (any WidgetLocationManagerDelegate)? + let authorization: WidgetLocationAuthorization + let isAuthorizedForWidgetUpdates: Bool + var desiredAccuracy: Double = 0 + private(set) var startCount = 0 + private(set) var stopCount = 0 + private let event: Event + + init( + authorization: WidgetLocationAuthorization = .authorizedWhenInUse, + widgetUpdatesAuthorized: Bool = true, + event: Event = .none + ) { + self.authorization = authorization + self.isAuthorizedForWidgetUpdates = widgetUpdatesAuthorized + self.event = event + } + + func startUpdatingLocation() { + startCount += 1 + switch event { + case .none: + break + case .samples(let samples): + send(samples: samples) + case .failure: + delegate?.widgetLocationManagerDidFail(self) + } + } + + func stopUpdatingLocation() { + stopCount += 1 + } + + func send(samples: [WidgetLocationSample]) { + delegate?.widgetLocationManager(self, didUpdate: samples) + } +} + +@MainActor +private final class FakeWidgetLocationTimeoutScheduler: + WidgetLocationTimeoutScheduling +{ + private(set) var scheduledIntervals: [TimeInterval] = [] + private(set) var cancellations: [FakeWidgetLocationTimeoutCancellation] = [] + + var lastCancellation: FakeWidgetLocationTimeoutCancellation? { + cancellations.last + } + + func schedule( + after interval: TimeInterval, + action: @escaping @MainActor () -> Void + ) -> any WidgetLocationTimeoutCancellable { + scheduledIntervals.append(interval) + let cancellation = FakeWidgetLocationTimeoutCancellation(action: action) + cancellations.append(cancellation) + return cancellation + } + + func fireLast() { + lastCancellation?.fire() + } +} + +@MainActor +private final class FakeWidgetLocationTimeoutCancellation: + WidgetLocationTimeoutCancellable +{ + private var action: (@MainActor () -> Void)? + private(set) var cancelCount = 0 + + init(action: @escaping @MainActor () -> Void) { + self.action = action + } + + func cancel() { + cancelCount += 1 + action = nil + } + + func fire() { + action?() + } +} diff --git a/ios/RunnerTests/WidgetLocationCatalogTests.swift b/ios/RunnerTests/WidgetLocationCatalogTests.swift index 55672317d..d8a5804c6 100644 --- a/ios/RunnerTests/WidgetLocationCatalogTests.swift +++ b/ios/RunnerTests/WidgetLocationCatalogTests.swift @@ -3,67 +3,359 @@ import XCTest final class WidgetLocationCatalogTests: XCTestCase { func testDecodesSchemaVersionOneCatalog() throws { + let catalog = try XCTUnwrap( + WidgetLocationCatalog.decode( + validCatalogData() + ) + ) + + XCTAssertEqual(catalog.schemaVersion, 1) + XCTAssertEqual(catalog.locations.count, 1) + XCTAssertEqual(catalog.locations[0].regionCode, "242") + XCTAssertEqual(catalog.locations[0].displayName, "新莊區") + XCTAssertEqual( + catalog.locations[0].administrativeAreaName, + "新北市" + ) + XCTAssertEqual(catalog.locations[0].latitude, 25.0358303) + XCTAssertEqual(catalog.locations[0].longitude, 121.4500307) + } + + func testEmptyCatalogIsValid() throws { + let catalog = try XCTUnwrap( + WidgetLocationCatalog.decode( + Data( + #"{"schemaVersion":1,"locations":[]}"#.utf8 + ) + ) + ) + + XCTAssertEqual(catalog.locations, []) + } + + func testAcceptsCoordinateBoundaries() throws { + let coordinates = [ + (-90.0, -180.0), + (-90.0, 180.0), + (90.0, -180.0), + (90.0, 180.0), + ] + + for (latitude, longitude) in coordinates { + let catalog = try XCTUnwrap( + WidgetLocationCatalog.decode( + validCatalogData( + latitude: latitude, + longitude: longitude + ) + ) + ) + + XCTAssertEqual(catalog.locations[0].latitude, latitude) + XCTAssertEqual(catalog.locations[0].longitude, longitude) + } + } + + func testRejectsUnsupportedSchemaVersion() { + XCTAssertNil( + WidgetLocationCatalog.decode( + validCatalogData(schemaVersion: 2) + ) + ) + } + + func testRejectsMalformedTopLevelJSON() { + XCTAssertNil( + WidgetLocationCatalog.decode( + Data(#"{"schemaVersion":1,"locations":["#.utf8) + ) + ) + XCTAssertNil( + WidgetLocationCatalog.decode( + Data(#"[]"#.utf8) + ) + ) + } + + func testRejectsMalformedEntryWithoutPartialCatalog() { let json = """ { "schemaVersion": 1, "locations": [ { - "regionCode": "220", - "displayName": "板橋區", + "regionCode": "242", + "displayName": "新莊區", "administrativeAreaName": "新北市", - "latitude": 25.0096156, - "longitude": 121.4592358 + "latitude": 25.0358303, + "longitude": 121.4500307 + }, + { + "regionCode": "433" } ] } """ - let catalog = WidgetLocationCatalogStore.decode( - Data(json.utf8) + XCTAssertNil( + WidgetLocationCatalog.decode(Data(json.utf8)) ) + } - XCTAssertNotNil(catalog) - XCTAssertEqual(catalog?.schemaVersion, 1) - XCTAssertEqual(catalog?.locations.count, 1) - XCTAssertEqual(catalog?.locations[0].regionCode, "220") - XCTAssertEqual(catalog?.locations[0].displayName, "板橋區") - XCTAssertEqual( - catalog?.locations[0].administrativeAreaName, - "新北市" - ) + func testRejectsInvalidRegionCode() { + for regionCode in ["", "24", "2420", "abc"] { + XCTAssertNil( + WidgetLocationCatalog.decode( + validCatalogData(regionCode: regionCode) + ), + regionCode + ) + } } - func testRejectsUnsupportedSchemaVersion() { - let json = """ - { - "schemaVersion": 2, - "locations": [] + func testRejectsUnicodeNumericRegionCode() { + for regionCode in ["242", "٢٤٢"] { + XCTAssertNil( + WidgetLocationCatalog.decode( + validCatalogData(regionCode: regionCode) + ), + regionCode + ) } - """ + } - XCTAssertNil( - WidgetLocationCatalogStore.decode( - Data(json.utf8) + func testRejectsNonFiniteLatitudeThroughDirectValidation() { + for latitude in [Double.nan, .infinity, -.infinity] { + XCTAssertNil( + WidgetLocationCatalogLocation( + regionCode: "242", + displayName: "新莊區", + administrativeAreaName: "新北市", + latitude: latitude, + longitude: 121.4500307 + ) ) - ) + } + } + + func testRejectsNonFiniteLongitudeThroughDirectValidation() { + for longitude in [Double.nan, .infinity, -.infinity] { + XCTAssertNil( + WidgetLocationCatalogLocation( + regionCode: "242", + displayName: "新莊區", + administrativeAreaName: "新北市", + latitude: 25.0358303, + longitude: longitude + ) + ) + } + } + + func testRejectsOutOfBoundsLatitude() { + for latitude in [-90.000_001, 90.000_001] { + XCTAssertNil( + WidgetLocationCatalog.decode( + validCatalogData(latitude: latitude) + ), + "latitude \(latitude)" + ) + } + } + + func testRejectsOutOfBoundsLongitude() { + for longitude in [-180.000_001, 180.000_001] { + XCTAssertNil( + WidgetLocationCatalog.decode( + validCatalogData(longitude: longitude) + ), + "longitude \(longitude)" + ) + } } - func testRejectsMalformedCatalog() { + func testRejectsDuplicateRegionCodes() { let json = """ { "schemaVersion": 1, "locations": [ { - "regionCode": "220" + "regionCode": "242", + "displayName": "新莊區", + "administrativeAreaName": "新北市", + "latitude": 25.0358303, + "longitude": 121.4500307 + }, + { + "regionCode": "242", + "displayName": "另一個地區", + "administrativeAreaName": "新北市", + "latitude": 25.1, + "longitude": 121.5 } ] } """ XCTAssertNil( - WidgetLocationCatalogStore.decode( - Data(json.utf8) + WidgetLocationCatalog.decode(Data(json.utf8)) + ) + } + + private func validCatalogData( + schemaVersion: Int = 1, + regionCode: String = "242", + displayName: String = "新莊區", + latitude: Double = 25.0358303, + longitude: Double = 121.4500307 + ) -> Data { + Data( + """ + { + "schemaVersion": \(schemaVersion), + "locations": [ + { + "regionCode": "\(regionCode)", + "displayName": "\(displayName)", + "administrativeAreaName": "新北市", + "latitude": \(latitude), + "longitude": \(longitude) + } + ] + } + """.utf8 + ) + } +} + +final class SavedWidgetLocationResolverTests: XCTestCase { + func testRegion242ResolvesExactEntry() throws { + let resolver = try makeResolver(codes: ["242", "433"]) + + XCTAssertEqual( + resolver.resolve( + target: WidgetLocationTarget(identifier: "region:242") + ), + WidgetResolvedWeatherLocation( + address: .saved(regionCode: "242"), + regionCode: "242", + regionName: "新莊區", + latitude: 25.0358303, + longitude: 121.4500307 + ) + ) + } + + func testRegion433ResolvesIndependently() throws { + let resolver = try makeResolver(codes: ["242", "433"]) + + XCTAssertEqual( + resolver.resolve( + target: WidgetLocationTarget(identifier: "region:433") + )?.regionCode, + "433" + ) + } + + func testMissingRegionReturnsUnresolved() throws { + let resolver = try makeResolver(codes: ["242"]) + + XCTAssertNil( + resolver.resolve( + target: WidgetLocationTarget(identifier: "region:433") + ) + ) + } + + func testRemovedRegionReturnsUnresolved() throws { + let resolverBeforeRemoval = try makeResolver(codes: ["242", "433"]) + let resolverAfterRemoval = try makeResolver(codes: ["433"]) + let target = WidgetLocationTarget(identifier: "region:242") + + XCTAssertNotNil(resolverBeforeRemoval.resolve(target: target)) + XCTAssertNil(resolverAfterRemoval.resolve(target: target)) + } + + func testMissingCatalogReturnsUnresolved() { + let resolver = SavedWidgetLocationResolver(catalog: nil) + + XCTAssertNil( + resolver.resolve( + target: WidgetLocationTarget(identifier: "region:242") + ) + ) + } + + func testCurrentLocationIsNotResolved() throws { + let resolver = try makeResolver(codes: ["242"]) + + XCTAssertNil( + resolver.resolve(target: .currentLocation) + ) + } + + func testInvalidTargetIsNotResolved() throws { + let resolver = try makeResolver(codes: ["242"]) + + XCTAssertNil( + resolver.resolve( + target: WidgetLocationTarget(identifier: "region:242") ) ) } + + func testEntryOrderDoesNotChangeExactMatching() throws { + let target = WidgetLocationTarget(identifier: "region:242") + let forward = try makeResolver(codes: ["242", "433"]) + let reversed = try makeResolver(codes: ["433", "242"]) + + XCTAssertEqual( + forward.resolve(target: target), + reversed.resolve(target: target) + ) + } + + private func makeResolver( + codes: [String] + ) throws -> SavedWidgetLocationResolver { + let locations = try codes.map(makeLocation) + let catalog = try XCTUnwrap( + WidgetLocationCatalog( + schemaVersion: 1, + locations: locations + ) + ) + + return SavedWidgetLocationResolver(catalog: catalog) + } + + private func makeLocation( + regionCode: String + ) throws -> WidgetLocationCatalogLocation { + switch regionCode { + case "242": + return try XCTUnwrap( + WidgetLocationCatalogLocation( + regionCode: "242", + displayName: "新莊區", + administrativeAreaName: "新北市", + latitude: 25.0358303, + longitude: 121.4500307 + ) + ) + + case "433": + return try XCTUnwrap( + WidgetLocationCatalogLocation( + regionCode: "433", + displayName: "沙鹿區", + administrativeAreaName: "臺中市", + latitude: 24.2338622, + longitude: 120.565703 + ) + ) + + default: + throw NSError(domain: "SavedWidgetLocationResolverTests", code: 1) + } + } } diff --git a/ios/RunnerTests/WidgetLocationIntentTests.swift b/ios/RunnerTests/WidgetLocationIntentTests.swift index 69efaf0db..cba75caae 100644 --- a/ios/RunnerTests/WidgetLocationIntentTests.swift +++ b/ios/RunnerTests/WidgetLocationIntentTests.swift @@ -1,3 +1,4 @@ +import Foundation import XCTest @testable import Runner @@ -52,25 +53,39 @@ final class WidgetLocationIntentTests: XCTestCase { XCTAssertEqual(options.first?.displayString, "所在地") } - func testCatalogLocationsPreserveSavedOrder() { - let catalog = WidgetLocationCatalog( - schemaVersion: 1, - locations: [ - WidgetLocationCatalogLocation( - regionCode: "220", - displayName: "板橋區", - administrativeAreaName: "新北市", - latitude: 25.0096156, - longitude: 121.4592358 - ), - WidgetLocationCatalogLocation( - regionCode: "302", - displayName: "竹北市", - administrativeAreaName: "新竹縣", - latitude: 24.8395807, - longitude: 121.0040235 - ) - ] + func testDefaultLocationOptionIsExplicitCurrentLocation() { + let option = makeCurrentWidgetLocationOption( + displayString: "所在地" + ) + + XCTAssertEqual(option.identifier, "current-location") + XCTAssertEqual(option.displayString, "所在地") + } + + func testCatalogLocationsPreserveSavedOrder() throws { + let firstLocation = try XCTUnwrap( + WidgetLocationCatalogLocation( + regionCode: "220", + displayName: "板橋區", + administrativeAreaName: "新北市", + latitude: 25.0096156, + longitude: 121.4592358 + ) + ) + let secondLocation = try XCTUnwrap( + WidgetLocationCatalogLocation( + regionCode: "302", + displayName: "竹北市", + administrativeAreaName: "新竹縣", + latitude: 24.8395807, + longitude: 121.0040235 + ) + ) + let catalog = try XCTUnwrap( + WidgetLocationCatalog( + schemaVersion: 1, + locations: [firstLocation, secondLocation] + ) ) let options = makeWidgetLocationOptions( @@ -97,6 +112,26 @@ final class WidgetLocationIntentTests: XCTestCase { ) } + func testMalformedCatalogStillYieldsCurrentLocationOnly() { + let malformedCatalog = WidgetLocationCatalog.decode( + Data(#"{"schemaVersion":1,"locations":[{}]}"#.utf8) + ) + + XCTAssertNil(malformedCatalog) + XCTAssertEqual( + makeWidgetLocationOptions( + from: malformedCatalog, + currentLocationDisplayString: "Current Location" + ), + [ + WidgetLocationOption( + identifier: "current-location", + displayString: "Current Location" + ) + ] + ) + } + func testLocationTargetDefaultsToCurrentLocation() { XCTAssertEqual( WidgetLocationTarget(identifier: nil), diff --git a/ios/RunnerTests/WidgetRefreshTestSupport.swift b/ios/RunnerTests/WidgetRefreshTestSupport.swift new file mode 100644 index 000000000..5b82b9bd0 --- /dev/null +++ b/ios/RunnerTests/WidgetRefreshTestSupport.swift @@ -0,0 +1,207 @@ +import Foundation + +enum WidgetRefreshTestError: Error, Sendable { + case scripted +} + +final class TestWallClock: WidgetWallTimeSource, @unchecked Sendable { + private let lock = NSLock() + private var milliseconds: Int64 + private var storedReadCount = 0 + + init(milliseconds: Int64) { + self.milliseconds = milliseconds + } + + var readCount: Int { + lock.withLock { storedReadCount } + } + + func now() -> Date { + lock.withLock { + storedReadCount += 1 + return Date( + timeIntervalSince1970: TimeInterval(milliseconds) / 1_000 + ) + } + } + + func set(milliseconds: Int64) { + lock.withLock { + self.milliseconds = milliseconds + } + } + + func resetReadCount() { + lock.withLock { + storedReadCount = 0 + } + } +} + +final class TestMonotonicClock: WidgetMonotonicTimeSource, + @unchecked Sendable +{ + private let lock = NSLock() + private var milliseconds: Int64 + private var storedReadCount = 0 + + init(milliseconds: Int64) { + self.milliseconds = milliseconds + } + + var readCount: Int { + lock.withLock { storedReadCount } + } + + func elapsedMilliseconds() -> Int64 { + lock.withLock { + storedReadCount += 1 + return milliseconds + } + } + + func set(milliseconds: Int64) { + lock.withLock { + self.milliseconds = milliseconds + } + } + + func resetReadCount() { + lock.withLock { + storedReadCount = 0 + } + } +} + +actor ScriptedServerTimeSource: WidgetServerTimeSource { + private(set) var callCount = 0 + private var results: [Result] + + init(results: [Result]) { + self.results = results + } + + func serverTimeUnixMilliseconds() throws -> Int64 { + callCount += 1 + guard !results.isEmpty else { + throw WidgetSNTPError.allHostsFailed + } + return try results.removeFirst().get() + } +} + +struct PassthroughServerClockTimeoutRunner: WidgetServerClockTimeoutRunning { + func serverTimeUnixMilliseconds( + from source: any WidgetServerTimeSource, + timeout: TimeInterval + ) async throws -> Int64 { + try await source.serverTimeUnixMilliseconds() + } +} + +actor AsyncOperationGate { + private(set) var hasStarted = false + private var isOpen = false + private var continuation: CheckedContinuation? + + func wait() async { + hasStarted = true + guard !isOpen else { + return + } + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func open() { + isOpen = true + continuation?.resume() + continuation = nil + } +} + +actor ScriptedCurrentWeather { + struct Coordinates: Equatable, Sendable { + let latitude: Double + let longitude: Double + } + + private(set) var callCount = 0 + private(set) var coordinates: [Coordinates] = [] + private let result: Result + private let onFetch: @Sendable () -> Void + + init( + result: Result, + onFetch: @escaping @Sendable () -> Void = {} + ) { + self.result = result + self.onFetch = onFetch + } + + func fetch( + latitude: Double, + longitude: Double + ) throws -> CurrentWeatherRemoteDTO? { + onFetch() + callCount += 1 + coordinates.append( + Coordinates(latitude: latitude, longitude: longitude) + ) + return try result.get() + } +} + +actor ScriptedSnapshotClock { + private(set) var callCount = 0 + private let sample: CurrentWeatherSnapshotTime? + + init(sample: CurrentWeatherSnapshotTime?) { + self.sample = sample + } + + func synchronizeAndSample() -> CurrentWeatherSnapshotTime? { + callCount += 1 + return sample + } +} + +final class CurrentWeatherSnapshotWriterSpy: @unchecked Sendable { + private let lock = NSLock() + private let error: WidgetRefreshTestError? + private let onWrite: @Sendable () -> Void + private var storedSnapshots: [CurrentWeatherWidgetSnapshot] = [] + private var storedWriteCount = 0 + + init( + error: WidgetRefreshTestError? = nil, + onWrite: @escaping @Sendable () -> Void = {} + ) { + self.error = error + self.onWrite = onWrite + } + + var snapshots: [CurrentWeatherWidgetSnapshot] { + lock.withLock { storedSnapshots } + } + + var writeCount: Int { + lock.withLock { storedWriteCount } + } + + func write(_ snapshot: CurrentWeatherWidgetSnapshot) throws { + onWrite() + let error = lock.withLock { + storedWriteCount += 1 + if self.error == nil { + storedSnapshots.append(snapshot) + } + return self.error + } + if let error { + throw error + } + } +} diff --git a/ios/RunnerTests/WidgetSNTPClientTests.swift b/ios/RunnerTests/WidgetSNTPClientTests.swift new file mode 100644 index 000000000..3f0a06516 --- /dev/null +++ b/ios/RunnerTests/WidgetSNTPClientTests.swift @@ -0,0 +1,247 @@ +import Foundation +import XCTest + +final class WidgetSNTPClientTests: XCTestCase { + func testPrimarySuccessDoesNotQueryBackup() async throws { + let query = ScriptedSNTPHostQuery( + results: [.success(makeExchange(offsetMilliseconds: 250))] + ) + let client = WidgetSNTPClient(hostQuery: query) + + let result = try await client.serverTimeUnixMilliseconds() + + XCTAssertEqual(result, 10_250) + let calls = await query.calls + XCTAssertEqual(calls.map(\.host), [WidgetSNTPClient.primaryHost]) + XCTAssertEqual(calls.map(\.timeout), [3]) + } + + func testPrimaryFailureFallsBackToBackup() async throws { + let query = ScriptedSNTPHostQuery( + results: [ + .failure(WidgetSNTPError.connectionFailed), + .success(makeExchange(offsetMilliseconds: -400)), + ] + ) + let client = WidgetSNTPClient(hostQuery: query) + + let result = try await client.serverTimeUnixMilliseconds() + + XCTAssertEqual(result, 9_600) + let calls = await query.calls + XCTAssertEqual( + calls.map(\.host), + [WidgetSNTPClient.primaryHost, WidgetSNTPClient.backupHost] + ) + XCTAssertEqual(calls.map(\.timeout), [3, 3]) + } + + func testPrimaryTimeoutFallsBackWithoutRealDelay() async throws { + let query = ScriptedSNTPHostQuery( + results: [ + .failure(WidgetSNTPError.timedOut), + .success(makeExchange(offsetMilliseconds: 0)), + ] + ) + let client = WidgetSNTPClient(hostQuery: query) + + let result = try await client.serverTimeUnixMilliseconds() + + XCTAssertEqual(result, 10_000) + let calls = await query.calls + XCTAssertEqual(calls.map(\.timeout), [3, 3]) + } + + func testBothHostsFail() async { + let query = ScriptedSNTPHostQuery( + results: [ + .failure(WidgetSNTPError.connectionFailed), + .failure(WidgetSNTPError.timedOut), + ] + ) + let client = WidgetSNTPClient(hostQuery: query) + + do { + _ = try await client.serverTimeUnixMilliseconds() + XCTFail("Expected all hosts to fail") + } catch let error as WidgetSNTPError { + XCTAssertEqual(error, .allHostsFailed) + } catch { + XCTFail("Unexpected error: \(error)") + } + + let calls = await query.calls + XCTAssertEqual( + calls.map(\.host), + [WidgetSNTPClient.primaryHost, WidgetSNTPClient.backupHost] + ) + } + + func testMalformedAndUndersizedResponsesAreRejected() { + let undersized = WidgetSNTPExchange( + response: Data(repeating: 0, count: 47), + clientTransmitTime: date(milliseconds: 9_000), + clientReceiveTime: date(milliseconds: 10_000) + ) + XCTAssertThrowsError( + try WidgetNTPPacket.correctedUnixMilliseconds( + exchange: undersized + ) + ) { error in + XCTAssertEqual(error as? WidgetSNTPError, .invalidResponse) + } + + var malformedPacket = makeServerPacket( + serverReceiveMilliseconds: 9_500, + serverTransmitMilliseconds: 9_600 + ) + malformedPacket[0] = 0x23 + let malformed = WidgetSNTPExchange( + response: malformedPacket, + clientTransmitTime: date(milliseconds: 9_000), + clientReceiveTime: date(milliseconds: 10_000) + ) + XCTAssertThrowsError( + try WidgetNTPPacket.correctedUnixMilliseconds( + exchange: malformed + ) + ) { error in + XCTAssertEqual(error as? WidgetSNTPError, .invalidResponse) + } + } + + func testNTPTimeConvertsToUnixEpochWithFraction() throws { + var packet = Data(repeating: 0, count: WidgetNTPPacket.length) + WidgetNTPPacket.writeTimestamp( + unixTime: 0.5, + to: &packet, + at: 32 + ) + + let unixTime = try WidgetNTPPacket.unixTime( + from: packet, + at: 32, + near: 0.5 + ) + + XCTAssertEqual(unixTime, 0.5, accuracy: 0.000_001) + } + + func testNTPTimeUnfoldsEraAfter2036Rollover() throws { + let unixTimeIn2040: TimeInterval = 2_208_988_800 + var packet = Data(repeating: 0, count: WidgetNTPPacket.length) + WidgetNTPPacket.writeTimestamp( + unixTime: unixTimeIn2040, + to: &packet, + at: 32 + ) + + let decoded = try WidgetNTPPacket.unixTime( + from: packet, + at: 32, + near: unixTimeIn2040 + ) + + XCTAssertEqual(decoded, unixTimeIn2040, accuracy: 0.000_001) + } + + func testOffsetFormulaSupportsPositiveAndNegativeOffsets() { + let positive = WidgetNTPPacket.offsetSeconds( + clientTransmitTime: 100, + serverReceiveTime: 106, + serverTransmitTime: 107, + clientReceiveTime: 103 + ) + let negative = WidgetNTPPacket.offsetSeconds( + clientTransmitTime: 100, + serverReceiveTime: 96, + serverTransmitTime: 97, + clientReceiveTime: 103 + ) + + XCTAssertEqual(positive, 5) + XCTAssertEqual(negative, -5) + } + + func testRequestIsStandardFortyEightByteClientPacket() { + let request = WidgetNTPPacket.request( + transmitTime: date(milliseconds: 10_000) + ) + + XCTAssertEqual(request.count, 48) + XCTAssertEqual(request[0], 0x1B) + } + + private func makeExchange( + offsetMilliseconds: Int64 + ) -> WidgetSNTPExchange { + let clientTransmitMilliseconds: Int64 = 9_000 + let clientReceiveMilliseconds: Int64 = 10_000 + let serverReceiveMilliseconds = + clientTransmitMilliseconds + offsetMilliseconds + 100 + let serverTransmitMilliseconds = + clientReceiveMilliseconds + offsetMilliseconds - 100 + + return WidgetSNTPExchange( + response: makeServerPacket( + serverReceiveMilliseconds: serverReceiveMilliseconds, + serverTransmitMilliseconds: serverTransmitMilliseconds + ), + clientTransmitTime: date( + milliseconds: clientTransmitMilliseconds + ), + clientReceiveTime: date( + milliseconds: clientReceiveMilliseconds + ) + ) + } + + private func makeServerPacket( + serverReceiveMilliseconds: Int64, + serverTransmitMilliseconds: Int64 + ) -> Data { + var packet = Data(repeating: 0, count: WidgetNTPPacket.length) + packet[0] = 0x24 + packet[1] = 1 + WidgetNTPPacket.writeTimestamp( + unixTime: TimeInterval(serverReceiveMilliseconds) / 1_000, + to: &packet, + at: 32 + ) + WidgetNTPPacket.writeTimestamp( + unixTime: TimeInterval(serverTransmitMilliseconds) / 1_000, + to: &packet, + at: 40 + ) + return packet + } + + private func date(milliseconds: Int64) -> Date { + Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1_000) + } +} + +private actor ScriptedSNTPHostQuery: WidgetSNTPHostQuerying { + struct Call: Sendable { + let host: String + let timeout: TimeInterval + } + + private(set) var calls: [Call] = [] + private var results: [Result] + + init(results: [Result]) { + self.results = results + } + + func query( + host: String, + timeout: TimeInterval + ) async throws -> WidgetSNTPExchange { + calls.append(Call(host: host, timeout: timeout)) + guard !results.isEmpty else { + throw WidgetSNTPError.connectionFailed + } + return try results.removeFirst().get() + } +} diff --git a/ios/RunnerTests/WidgetServerClockTests.swift b/ios/RunnerTests/WidgetServerClockTests.swift new file mode 100644 index 000000000..370bbd4ba --- /dev/null +++ b/ios/RunnerTests/WidgetServerClockTests.swift @@ -0,0 +1,271 @@ +import Foundation +import XCTest + +final class WidgetServerClockTests: XCTestCase { + func testStartsUnsynchronized() async { + let clock = makeClock() + + let hasSynchronized = await clock.hasSynchronized + + XCTAssertFalse(hasSynchronized) + } + + func testSuccessfulSyncCreatesAnchor() async { + let monotonic = TestMonotonicClock(milliseconds: 400) + let clock = makeClock( + monotonic: monotonic, + source: ScriptedServerTimeSource(results: [.success(10_000)]) + ) + + let succeeded = await clock.synchronize() + let hasSynchronized = await clock.hasSynchronized + let calibratedNow = await clock.calibratedNowUnixMilliseconds() + + XCTAssertTrue(succeeded) + XCTAssertTrue(hasSynchronized) + XCTAssertEqual(calibratedNow, 10_000) + } + + func testCalibratedNowAdvancesWithMonotonicElapsedTime() async { + let monotonic = TestMonotonicClock(milliseconds: 400) + let clock = makeClock( + monotonic: monotonic, + source: ScriptedServerTimeSource(results: [.success(10_000)]) + ) + let synchronized = await clock.synchronize() + XCTAssertTrue(synchronized) + + monotonic.set(milliseconds: 1_900) + let calibratedNow = await clock.calibratedNowUnixMilliseconds() + + XCTAssertEqual(calibratedNow, 11_500) + } + + func testDeviceWallClockJumpDoesNotMoveCalibratedNow() async { + let wallClock = TestWallClock(milliseconds: 10_000) + let monotonic = TestMonotonicClock(milliseconds: 400) + let clock = makeClock( + wallClock: wallClock, + monotonic: monotonic, + source: ScriptedServerTimeSource(results: [.success(20_000)]) + ) + let synchronized = await clock.synchronize() + XCTAssertTrue(synchronized) + monotonic.set(milliseconds: 900) + let beforeJump = await clock.calibratedNowUnixMilliseconds() + + wallClock.set(milliseconds: 9_999_999) + let afterJump = await clock.calibratedNowUnixMilliseconds() + + XCTAssertEqual(beforeJump, 20_500) + XCTAssertEqual(afterJump, beforeJump) + } + + func testFailedLaterSyncPreservesPreviousAnchor() async { + let monotonic = TestMonotonicClock(milliseconds: 100) + let source = ScriptedServerTimeSource( + results: [ + .success(10_000), + .failure(WidgetSNTPError.allHostsFailed), + ] + ) + let clock = makeClock(monotonic: monotonic, source: source) + let firstSyncSucceeded = await clock.synchronize() + XCTAssertTrue(firstSyncSucceeded) + monotonic.set(milliseconds: 600) + + let secondSyncSucceeded = await clock.synchronize() + let hasSynchronized = await clock.hasSynchronized + let calibratedNow = await clock.calibratedNowUnixMilliseconds() + + XCTAssertFalse(secondSyncSucceeded) + XCTAssertTrue(hasSynchronized) + XCTAssertEqual(calibratedNow, 10_500) + } + + func testZeroOffsetIsStillSynchronized() async { + let wallClock = TestWallClock(milliseconds: 10_000) + let clock = makeClock( + wallClock: wallClock, + source: ScriptedServerTimeSource(results: [.success(10_000)]) + ) + + let synchronized = await clock.synchronize() + XCTAssertTrue(synchronized) + let sample = await clock.currentWeatherSnapshotTime() + let hasSynchronized = await clock.hasSynchronized + + XCTAssertTrue(hasSynchronized) + XCTAssertEqual(sample.calibratedTimeOffsetMilliseconds, 0) + } + + func testSnapshotOffsetIsCalibratedMinusDevice() async { + let aheadClock = makeClock( + wallClock: TestWallClock(milliseconds: 15_000), + source: ScriptedServerTimeSource(results: [.success(10_000)]) + ) + let aheadSynchronized = await aheadClock.synchronize() + XCTAssertTrue(aheadSynchronized) + + let behindClock = makeClock( + wallClock: TestWallClock(milliseconds: 5_000), + source: ScriptedServerTimeSource(results: [.success(10_000)]) + ) + let behindSynchronized = await behindClock.synchronize() + XCTAssertTrue(behindSynchronized) + + let aheadSample = await aheadClock.currentWeatherSnapshotTime() + let behindSample = await behindClock.currentWeatherSnapshotTime() + XCTAssertEqual(aheadSample.calibratedTimeOffsetMilliseconds, -5_000) + XCTAssertEqual(behindSample.calibratedTimeOffsetMilliseconds, 5_000) + } + + func testSnapshotTimeUsesOneLogicalClockSample() async { + let wallClock = TestWallClock(milliseconds: 8_000) + let monotonic = TestMonotonicClock(milliseconds: 100) + let clock = makeClock( + wallClock: wallClock, + monotonic: monotonic, + source: ScriptedServerTimeSource(results: [.success(10_000)]) + ) + let synchronized = await clock.synchronize() + XCTAssertTrue(synchronized) + monotonic.set(milliseconds: 350) + wallClock.resetReadCount() + monotonic.resetReadCount() + + let sample = await clock.currentWeatherSnapshotTime() + + XCTAssertEqual(sample.calibratedNowUnixMilliseconds, 10_250) + XCTAssertEqual(sample.calibratedTimeOffsetMilliseconds, 2_250) + XCTAssertEqual(wallClock.readCount, 1) + XCTAssertEqual(monotonic.readCount, 1) + } + + func testConcurrentSyncCallsShareUnderlyingWork() async { + let source = GatedServerTimeSource() + let clock = makeClock(source: source) + + let first = Task { await clock.synchronize() } + while await source.callCount == 0 { + await Task.yield() + } + let second = Task { await clock.synchronize() } + for _ in 0..<20 { + await Task.yield() + } + + let callsBeforeCompletion = await source.callCount + XCTAssertEqual(callsBeforeCompletion, 1) + await source.succeed(with: 10_000) + let firstResult = await first.value + let secondResult = await second.value + let finalCallCount = await source.callCount + XCTAssertTrue(firstResult) + XCTAssertTrue(secondResult) + XCTAssertEqual(finalCallCount, 1) + } + + func testOuterTimeoutLeavesClockUnsynchronizedWithoutWaiting() async { + let source = ScriptedServerTimeSource(results: [.success(10_000)]) + let timeoutRunner = ScriptedTimeoutRunner( + results: [.failure(WidgetServerClockError.timedOut)] + ) + let clock = WidgetServerClock( + deviceClock: TestWallClock(milliseconds: 4_000), + monotonicClock: TestMonotonicClock(milliseconds: 0), + serverTimeSource: source, + timeoutRunner: timeoutRunner + ) + + let succeeded = await clock.synchronize() + let hasSynchronized = await clock.hasSynchronized + let callCount = await source.callCount + let timeouts = await timeoutRunner.timeouts + + XCTAssertFalse(succeeded) + XCTAssertFalse(hasSynchronized) + XCTAssertEqual(callCount, 0) + XCTAssertEqual(timeouts, [8]) + } + + func testOuterTimeoutPreservesExistingAnchor() async { + let monotonic = TestMonotonicClock(milliseconds: 100) + let timeoutRunner = ScriptedTimeoutRunner( + results: [ + .success(10_000), + .failure(WidgetServerClockError.timedOut), + ] + ) + let clock = WidgetServerClock( + deviceClock: TestWallClock(milliseconds: 4_000), + monotonicClock: monotonic, + serverTimeSource: + ScriptedServerTimeSource(results: [.success(99_999)]), + timeoutRunner: timeoutRunner + ) + let firstSucceeded = await clock.synchronize() + monotonic.set(milliseconds: 600) + + let secondSucceeded = await clock.synchronize() + let hasSynchronized = await clock.hasSynchronized + let calibratedNow = await clock.calibratedNowUnixMilliseconds() + + XCTAssertTrue(firstSucceeded) + XCTAssertFalse(secondSucceeded) + XCTAssertTrue(hasSynchronized) + XCTAssertEqual(calibratedNow, 10_500) + } + + private func makeClock( + wallClock: TestWallClock = TestWallClock(milliseconds: 1_000), + monotonic: TestMonotonicClock = + TestMonotonicClock(milliseconds: 0), + source: any WidgetServerTimeSource = + ScriptedServerTimeSource(results: [.success(2_000)]) + ) -> WidgetServerClock { + WidgetServerClock( + deviceClock: wallClock, + monotonicClock: monotonic, + serverTimeSource: source, + timeoutRunner: PassthroughServerClockTimeoutRunner() + ) + } +} + +private actor GatedServerTimeSource: WidgetServerTimeSource { + private(set) var callCount = 0 + private var continuation: CheckedContinuation? + + func serverTimeUnixMilliseconds() async throws -> Int64 { + callCount += 1 + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + } + } + + func succeed(with value: Int64) { + continuation?.resume(returning: value) + continuation = nil + } +} + +private actor ScriptedTimeoutRunner: WidgetServerClockTimeoutRunning { + private(set) var timeouts: [TimeInterval] = [] + private var results: [Result] + + init(results: [Result]) { + self.results = results + } + + func serverTimeUnixMilliseconds( + from source: any WidgetServerTimeSource, + timeout: TimeInterval + ) async throws -> Int64 { + timeouts.append(timeout) + guard !results.isEmpty else { + throw WidgetServerClockError.timedOut + } + return try results.removeFirst().get() + } +} diff --git a/ios/RunnerTests/WidgetTownshipResolverTests.swift b/ios/RunnerTests/WidgetTownshipResolverTests.swift new file mode 100644 index 000000000..00c9e90c6 --- /dev/null +++ b/ios/RunnerTests/WidgetTownshipResolverTests.swift @@ -0,0 +1,406 @@ +import Foundation +import XCTest + +final class WidgetTownshipResolverTests: XCTestCase { + func testAdjacentTownshipsAndAsymmetricCoordinatesResolveExactly() throws { + let boundaries = try makeSyntheticBoundaries() + + XCTAssertEqual( + boundaries.codeAt(latitude: 24.05, longitude: 120.05), + "100" + ) + XCTAssertEqual( + boundaries.codeAt(latitude: 24.05, longitude: 120.15), + "101" + ) + } + + func testPolygonHoleDoesNotResolveAsContained() throws { + let boundaries = try makeSyntheticBoundaries() + + XCTAssertEqual( + boundaries.codeAt(latitude: 24.21, longitude: 120.01), + "102" + ) + XCTAssertNil( + boundaries.codeAt(latitude: 24.25, longitude: 120.05) + ) + } + + func testMultipartTownshipMatchesEitherPart() throws { + let boundaries = try makeSyntheticBoundaries() + + XCTAssertEqual( + boundaries.codeAt(latitude: 24.21, longitude: 120.21), + "103" + ) + XCTAssertEqual( + boundaries.codeAt(latitude: 24.21, longitude: 120.26), + "103" + ) + } + + func testPointOutsideEveryTownshipReturnsNil() throws { + let boundaries = try makeSyntheticBoundaries() + + XCTAssertNil( + boundaries.codeAt(latitude: 24.5, longitude: 120.5) + ) + XCTAssertNil( + boundaries.codeAt(latitude: 24.15, longitude: 120.05) + ) + } + + func testResolverFallsBackToNearestCentroid() throws { + let resolver = try WidgetTownshipResolver( + boundaries: makeSyntheticBoundaries(), + directory: makeSyntheticDirectory() + ) + let preciseLocation = try XCTUnwrap( + WidgetCurrentLocation(latitude: 24.18, longitude: 120.05) + ) + + let resolved = try XCTUnwrap(resolver.resolve(preciseLocation)) + + XCTAssertEqual(resolved.address, .currentLocation) + XCTAssertEqual(resolved.regionCode, "102") + XCTAssertEqual(resolved.regionName, "Hole區") + XCTAssertEqual(resolved.latitude, 24.25) + XCTAssertEqual(resolved.longitude, 120.05) + XCTAssertNotEqual(resolved.latitude, preciseLocation.latitude) + } + + func testIncompatibleBoundaryAndDirectoryCodesFailClosed() throws { + let boundary = try WidgetTownshipBoundaryTable( + shapes: [ + makeShape( + code: "100", + polygons: [ + [[ + (120.0, 24.0), + (120.1, 24.0), + (120.1, 24.1), + (120.0, 24.1), + (120.0, 24.0), + ]], + ] + ), + ] + ) + + XCTAssertThrowsError( + try WidgetTownshipResolver( + boundaries: boundary, + directory: makeSyntheticDirectory() + ) + ) { error in + XCTAssertEqual( + error as? WidgetTownshipResourceError, + .incompatibleCodeSets + ) + } + } + + func testMalformedAndTruncatedBoundariesFailCleanly() { + XCTAssertThrowsError( + try WidgetTownshipBoundaryTable.decode(Data([0x80])) + ) { error in + XCTAssertEqual( + error as? WidgetTownshipResourceError, + .invalidBoundaryData + ) + } + } + + func testMalformedAndUnsupportedDirectoriesFailCleanly() { + XCTAssertThrowsError( + try WidgetTownshipDirectory.decode(Data("{}".utf8)) + ) { error in + XCTAssertEqual( + error as? WidgetTownshipResourceError, + .invalidDirectory + ) + } + + let unsupported = Data( + #"{"schemaVersion":2,"townships":[]}"#.utf8 + ) + XCTAssertThrowsError( + try WidgetTownshipDirectory.decode(unsupported) + ) { error in + XCTAssertEqual( + error as? WidgetTownshipResourceError, + .unsupportedDirectorySchema(2) + ) + } + } + + func testMissingBundleResourcesFailClosed() { + let loader = WidgetTownshipResourceLoader( + directoryName: "missing-directory", + boundaryName: "missing-boundaries" + ) + + XCTAssertThrowsError(try loader.load(bundle: productionBundle)) { + error in + XCTAssertEqual( + error as? WidgetTownshipResourceError, + .missingResource("missing-directory.json") + ) + } + } + + func testProductionResourceCodeSetsAndDirectoryOrderMatch() throws { + let resolver = try loadProductionResolver() + + XCTAssertEqual(resolver.directory.townships.count, 368) + XCTAssertEqual(resolver.boundaries.regionCodes.count, 368) + XCTAssertEqual( + resolver.boundaries.regionCodes, + resolver.directory.regionCodes + ) + XCTAssertEqual( + resolver.directory.townships.prefix(3).map(\.regionCode), + ["100", "103", "104"] + ) + XCTAssertEqual( + resolver.directory.townships.suffix(3).map(\.regionCode), + ["981", "982", "983"] + ) + XCTAssertTrue( + resolver.directory.regionCodes.allSatisfy { code in + code.utf8.count == 3 + && code.utf8.allSatisfy { $0 >= 48 && $0 <= 57 } + } + ) + } + + func testProductionGoldenCoordinatesResolveToTownshipCentroids() throws { + let resolver = try loadProductionResolver() + let goldens: [( + latitude: Double, + longitude: Double, + code: String, + name: String, + centroidLatitude: Double, + centroidLongitude: Double + )] = [ + (25.0330, 121.5645, "110", "信義區", 25.0377271, 121.5818185), + (24.1616, 120.6478, "407", "西屯區", 24.1658213, 120.6336717), + (22.6210, 120.3120, "802", "苓雅區", 22.621759, 120.312194), + (24.1000, 121.6000, "972", "秀林鄉", 24.1185835, 121.6248326), + ] + + for golden in goldens { + XCTAssertEqual( + resolver.boundaries.codeAt( + latitude: golden.latitude, + longitude: golden.longitude + ), + golden.code + ) + let input = try XCTUnwrap( + WidgetCurrentLocation( + latitude: golden.latitude, + longitude: golden.longitude + ) + ) + let resolved = try XCTUnwrap(resolver.resolve(input)) + XCTAssertEqual(resolved.address, .currentLocation) + XCTAssertEqual(resolved.regionCode, golden.code) + XCTAssertEqual(resolved.regionName, golden.name) + XCTAssertEqual(resolved.latitude, golden.centroidLatitude) + XCTAssertEqual(resolved.longitude, golden.centroidLongitude) + XCTAssertTrue( + resolved.latitude != input.latitude + || resolved.longitude != input.longitude + ) + } + } + + func testProductionSeaPointUsesDartNearestCentroidResult() throws { + let resolver = try loadProductionResolver() + let seaLocation = try XCTUnwrap( + WidgetCurrentLocation(latitude: 24.0, longitude: 120.0) + ) + + XCTAssertNil( + resolver.boundaries.codeAt( + latitude: seaLocation.latitude, + longitude: seaLocation.longitude + ) + ) + let resolved = try XCTUnwrap(resolver.resolve(seaLocation)) + XCTAssertEqual(resolved.regionCode, "528") + XCTAssertEqual(resolved.regionName, "芳苑鄉") + XCTAssertEqual(resolved.latitude, 23.924354) + XCTAssertEqual(resolved.longitude, 120.320389) + } + + func testTruncatedProductionBoundaryCannotDowngradeToNearest() throws { + let directoryData = try productionResourceData( + name: "WidgetTownshipDirectory", + extension: "json" + ) + var boundaryData = try productionResourceData( + name: "WidgetTownshipBoundaries", + extension: "bin" + ) + boundaryData.removeLast() + + XCTAssertThrowsError( + try WidgetTownshipResourceLoader().load( + directoryData: directoryData, + boundaryData: boundaryData + ) + ) { error in + XCTAssertEqual( + error as? WidgetTownshipResourceError, + .invalidBoundaryData + ) + } + } + + private var productionBundle: Bundle { + Bundle(for: Self.self) + } + + private func loadProductionResolver() throws -> WidgetTownshipResolver { + try WidgetTownshipResourceLoader().load(bundle: productionBundle) + } + + private func productionResourceData( + name: String, + extension pathExtension: String + ) throws -> Data { + let url = try XCTUnwrap( + productionBundle.url( + forResource: name, + withExtension: pathExtension + ) + ) + return try Data(contentsOf: url) + } + + private func makeSyntheticDirectory() throws -> WidgetTownshipDirectory { + try WidgetTownshipDirectory( + townships: [ + try makeTownship("100", "Alpha區", 24.05, 120.05), + try makeTownship("101", "Beta區", 24.05, 120.15), + try makeTownship("102", "Hole區", 24.25, 120.05), + try makeTownship("103", "Multipart區", 24.21, 120.235), + ] + ) + } + + private func makeTownship( + _ code: String, + _ name: String, + _ latitude: Double, + _ longitude: Double + ) throws -> WidgetTownship { + try XCTUnwrap( + WidgetTownship( + regionCode: code, + displayName: name, + administrativeAreaName: "測試市", + latitude: latitude, + longitude: longitude + ) + ) + } + + private func makeSyntheticBoundaries() throws + -> WidgetTownshipBoundaryTable + { + try WidgetTownshipBoundaryTable( + shapes: [ + makeShape( + code: "100", + polygons: [ + [[ + (120.0, 24.0), + (120.1, 24.0), + (120.1, 24.1), + (120.0, 24.1), + (120.0, 24.0), + ]], + ] + ), + makeShape( + code: "101", + polygons: [ + [[ + (120.1, 24.0), + (120.2, 24.0), + (120.2, 24.1), + (120.1, 24.1), + (120.1, 24.0), + ]], + ] + ), + makeShape( + code: "102", + polygons: [ + [ + [ + (120.0, 24.2), + (120.1, 24.2), + (120.1, 24.3), + (120.0, 24.3), + (120.0, 24.2), + ], + [ + (120.03, 24.23), + (120.07, 24.23), + (120.07, 24.27), + (120.03, 24.27), + (120.03, 24.23), + ], + ], + ] + ), + makeShape( + code: "103", + polygons: [ + [[ + (120.2, 24.2), + (120.22, 24.2), + (120.22, 24.22), + (120.2, 24.22), + (120.2, 24.2), + ]], + [[ + (120.25, 24.2), + (120.27, 24.2), + (120.27, 24.22), + (120.25, 24.22), + (120.25, 24.2), + ]], + ] + ), + ] + ) + } + + private func makeShape( + code: String, + polygons: [[[(Double, Double)]]] + ) throws -> WidgetTownshipShape { + try WidgetTownshipShape( + regionCode: code, + polygons: try polygons.map { rings in + try WidgetTownshipPolygon( + rings: rings.map { ring in + ring.map { longitude, latitude in + WidgetTownshipCoordinate( + longitude: longitude, + latitude: latitude + ) + } + } + ) + } + ) + } +} diff --git a/ios/Shared/CurrentWeatherSnapshotAddress.swift b/ios/Shared/CurrentWeatherSnapshotAddress.swift index 08bac3144..259c91303 100644 --- a/ios/Shared/CurrentWeatherSnapshotAddress.swift +++ b/ios/Shared/CurrentWeatherSnapshotAddress.swift @@ -1,4 +1,4 @@ -enum CurrentWeatherSnapshotAddress: Equatable { +enum CurrentWeatherSnapshotAddress: Equatable, Sendable { case currentLocation case saved(regionCode: String) diff --git a/ios/Shared/CurrentWeatherSnapshotStorage.swift b/ios/Shared/CurrentWeatherSnapshotStorage.swift new file mode 100644 index 000000000..72de70b41 --- /dev/null +++ b/ios/Shared/CurrentWeatherSnapshotStorage.swift @@ -0,0 +1,594 @@ +import CryptoKit +import Foundation + +enum CurrentWeatherSnapshotWriteResult: Equatable, Sendable { + case written + case rejected +} + +struct CurrentWeatherSnapshotWriteToken: Equatable, Sendable { + let address: CurrentWeatherSnapshotAddress + let generation: Int64 +} + +enum CurrentWeatherSnapshotStorageError: Error { + case coordinationFailed + case invalidOrderingState + case invalidSnapshot + case generationExhausted + case invalidWriteToken +} + +protocol CurrentWeatherSnapshotCoordinating: Sendable { + func coordinate( + writingItemAt url: URL, + _ accessor: (URL) throws -> T + ) throws -> T +} + +struct CurrentWeatherSnapshotFileCoordinator: + CurrentWeatherSnapshotCoordinating +{ + func coordinate( + writingItemAt url: URL, + _ accessor: (URL) throws -> T + ) throws -> T { + let coordinator = NSFileCoordinator(filePresenter: nil) + var coordinationError: NSError? + var accessorResult: Result? + + coordinator.coordinate( + writingItemAt: url, + options: .forReplacing, + error: &coordinationError + ) { coordinatedURL in + accessorResult = Result { + try accessor(coordinatedURL) + } + } + + if let accessorResult { + return try accessorResult.get() + } + if let coordinationError { + throw coordinationError + } + throw CurrentWeatherSnapshotStorageError.coordinationFailed + } +} + +protocol CurrentWeatherSnapshotPersisting: Sendable { + func write(_ data: Data, to url: URL) throws +} + +struct CurrentWeatherSnapshotAtomicPersister: + CurrentWeatherSnapshotPersisting +{ + func write(_ data: Data, to url: URL) throws { + try data.write(to: url, options: .atomic) + } +} + +struct CurrentWeatherSnapshotStorage: Sendable { + let containerURL: URL + private let coordinator: any CurrentWeatherSnapshotCoordinating + private let persister: any CurrentWeatherSnapshotPersisting + + init( + containerURL: URL, + coordinator: any CurrentWeatherSnapshotCoordinating = + CurrentWeatherSnapshotFileCoordinator(), + persister: any CurrentWeatherSnapshotPersisting = + CurrentWeatherSnapshotAtomicPersister() + ) { + self.containerURL = containerURL + self.coordinator = coordinator + self.persister = persister + } + + func snapshotURL( + for address: CurrentWeatherSnapshotAddress + ) -> URL { + snapshotDirectoryURL + .appendingPathComponent(address.filename) + } + + /// Reserves a per-target generation when a logical refresh begins. + /// + /// The persisted counter, rather than a wall or monotonic clock, gives + /// Runner and Widget Extension processes one comparable ordering domain. + func beginWrite( + for address: CurrentWeatherSnapshotAddress + ) throws -> CurrentWeatherSnapshotWriteToken { + try createDirectories() + let destination = snapshotURL(for: address) + + return try coordinator.coordinate( + writingItemAt: destination + ) { coordinatedDestination in + let stateURL = orderingStateURL( + coordinatedSnapshotURL: coordinatedDestination + ) + let snapshotData = try loadSnapshotData( + at: coordinatedDestination + ) + var state = try reconcile( + loadedState: try loadOrderingState(at: stateURL), + snapshotData: snapshotData, + stateURL: stateURL, + mayAdoptSnapshotWithoutState: true + ) + + guard state.lastIssuedGeneration < Int64.max else { + throw CurrentWeatherSnapshotStorageError + .generationExhausted + } + + let generation = state.lastIssuedGeneration + 1 + state.lastIssuedGeneration = generation + try persist(state, to: stateURL) + + return CurrentWeatherSnapshotWriteToken( + address: address, + generation: generation + ) + } + } + + /// Compares and atomically replaces one canonical target snapshot. + /// + /// The sidecar is first marked with a pending commit, then the canonical + /// snapshot is replaced, and finally the sidecar is committed. Recovery + /// accepts only the old or pending snapshot fingerprint; any other pairing + /// fails closed. The snapshot bytes themselves remain the exact schema-v5 + /// payload produced by Dart or Swift. + func replace( + _ data: Data, + using token: CurrentWeatherSnapshotWriteToken + ) throws -> CurrentWeatherSnapshotWriteResult { + try createDirectories() + let destination = snapshotURL(for: token.address) + let candidateOrdering = try decodeSnapshotOrdering(from: data) + + return try coordinator.coordinate( + writingItemAt: destination + ) { coordinatedDestination in + let stateURL = orderingStateURL( + coordinatedSnapshotURL: coordinatedDestination + ) + guard let loadedState = try loadOrderingState(at: stateURL) else { + throw CurrentWeatherSnapshotStorageError.invalidWriteToken + } + let snapshotData = try loadSnapshotData( + at: coordinatedDestination + ) + var state = try reconcile( + loadedState: loadedState, + snapshotData: snapshotData, + stateURL: stateURL, + mayAdoptSnapshotWithoutState: false + ) + + guard + token.generation > state.rejectedThroughGeneration, + token.generation <= state.lastIssuedGeneration + else { + throw CurrentWeatherSnapshotStorageError.invalidWriteToken + } + + if let existing = state.committedSnapshot, + !shouldReplace( + address: token.address, + existing: existing, + candidate: candidateOrdering, + candidateGeneration: token.generation + ) + { + // A newer same-township acquisition still advances the + // Current Location identity fence even when its older weather + // observation cannot replace the canonical snapshot. + if token.address == .currentLocation, + candidateOrdering.regionCode == existing.regionCode, + token.generation > existing.locationGeneration + { + state.committedSnapshot = existing.withLocationGeneration( + token.generation + ) + try persist(state, to: stateURL) + } + return .rejected + } + + let locationGeneration = locationGeneration( + address: token.address, + existing: state.committedSnapshot, + candidate: candidateOrdering, + candidateGeneration: token.generation + ) + let candidateCommit = StoredSnapshotOrdering( + snapshotGeneration: token.generation, + locationGeneration: locationGeneration, + observationTime: candidateOrdering.observationTime, + regionCode: candidateOrdering.regionCode, + snapshotSHA256: sha256(data) + ) + + state.pendingCommit = candidateCommit + try persist(state, to: stateURL) + try persister.write(data, to: coordinatedDestination) + state.committedSnapshot = candidateCommit + state.pendingCommit = nil + try persist(state, to: stateURL) + return .written + } + } + + private var snapshotDirectoryURL: URL { + containerURL + .appendingPathComponent( + "WidgetSnapshots", + isDirectory: true + ) + .appendingPathComponent( + "current-weather", + isDirectory: true + ) + } + + private var orderingDirectoryURL: URL { + containerURL + .appendingPathComponent( + "WidgetSnapshots", + isDirectory: true + ) + .appendingPathComponent( + "current-weather-ordering", + isDirectory: true + ) + } + + private func createDirectories() throws { + try FileManager.default.createDirectory( + at: snapshotDirectoryURL, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: orderingDirectoryURL, + withIntermediateDirectories: true + ) + } + + private func orderingStateURL( + coordinatedSnapshotURL: URL + ) -> URL { + // Keep the coordinator-provided filename in case Foundation redirects + // the coordinated item, while storing ordering outside the canonical + // cache directory consumed by WidgetSnapshotStore. + orderingDirectoryURL.appendingPathComponent( + coordinatedSnapshotURL.lastPathComponent + ".json" + ) + } + + private func loadSnapshotData(at url: URL) throws -> Data? { + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + return try Data(contentsOf: url) + } + + private func loadOrderingState( + at url: URL + ) throws -> LoadedOrderingState? { + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + + let data = try Data(contentsOf: url) + let schemaVersion: Int + do { + schemaVersion = try JSONDecoder().decode( + OrderingStateVersion.self, + from: data + ).schemaVersion + } catch { + throw CurrentWeatherSnapshotStorageError.invalidOrderingState + } + + do { + switch schemaVersion { + case 1: + let legacy = try JSONDecoder().decode( + LegacyOrderingState.self, + from: data + ) + guard legacy.lastIssuedGeneration > 0 else { + throw CurrentWeatherSnapshotStorageError + .invalidOrderingState + } + return .legacy(legacy) + case 2: + let state = try JSONDecoder().decode( + OrderingState.self, + from: data + ) + try validate(state) + return .current(state) + default: + throw CurrentWeatherSnapshotStorageError + .invalidOrderingState + } + } catch let error as CurrentWeatherSnapshotStorageError { + throw error + } catch { + throw CurrentWeatherSnapshotStorageError.invalidOrderingState + } + } + + private func validate(_ state: OrderingState) throws { + guard + state.schemaVersion == 2, + state.lastIssuedGeneration >= 0, + state.rejectedThroughGeneration >= 0, + state.rejectedThroughGeneration <= state.lastIssuedGeneration + else { + throw CurrentWeatherSnapshotStorageError.invalidOrderingState + } + + for ordering in [ + state.committedSnapshot, + state.pendingCommit, + ].compactMap({ $0 }) { + guard + ordering.snapshotGeneration >= 0, + ordering.snapshotGeneration <= state.lastIssuedGeneration, + ordering.locationGeneration >= ordering.snapshotGeneration, + ordering.locationGeneration <= state.lastIssuedGeneration, + ordering.snapshotSHA256.count == 64, + ordering.snapshotSHA256.allSatisfy({ + $0.isHexDigit && !$0.isUppercase + }) + else { + throw CurrentWeatherSnapshotStorageError + .invalidOrderingState + } + } + } + + private func reconcile( + loadedState: LoadedOrderingState?, + snapshotData: Data?, + stateURL: URL, + mayAdoptSnapshotWithoutState: Bool + ) throws -> OrderingState { + switch loadedState { + case nil: + guard mayAdoptSnapshotWithoutState else { + throw CurrentWeatherSnapshotStorageError.invalidOrderingState + } + let committed = try snapshotData.map { + try adoptedOrdering(from: $0) + } + return OrderingState( + schemaVersion: 2, + lastIssuedGeneration: + committed?.snapshotGeneration ?? 0, + rejectedThroughGeneration: 0, + committedSnapshot: committed, + pendingCommit: nil + ) + + case .legacy(let legacy): + let committed = try snapshotData.map { + try adoptedOrdering(from: $0) + } + guard + (committed?.snapshotGeneration ?? 0) + <= legacy.lastIssuedGeneration + else { + throw CurrentWeatherSnapshotStorageError.invalidOrderingState + } + let migrated = OrderingState( + schemaVersion: 2, + lastIssuedGeneration: legacy.lastIssuedGeneration, + // Tokens issued by the old two-file protocol cannot be proven + // safe after migration, so only a newly allocated token may + // commit. + rejectedThroughGeneration: legacy.lastIssuedGeneration, + committedSnapshot: committed, + pendingCommit: nil + ) + try persist(migrated, to: stateURL) + return migrated + + case .current(var state): + if let pending = state.pendingCommit { + if try snapshotDataMatches(snapshotData, pending) { + // The snapshot replacement completed but the final sidecar + // update did not. Finish that commit during recovery. + state.committedSnapshot = pending + state.pendingCommit = nil + try persist(state, to: stateURL) + } else if try snapshotDataMatches( + snapshotData, + state.committedSnapshot + ) { + // The pending marker landed but the snapshot replacement + // did not. Fence every already-issued token through that + // generation before allowing a fresh acquisition. + state.rejectedThroughGeneration = max( + state.rejectedThroughGeneration, + pending.snapshotGeneration + ) + state.pendingCommit = nil + try persist(state, to: stateURL) + } else { + throw CurrentWeatherSnapshotStorageError + .invalidOrderingState + } + } else if try !snapshotDataMatches( + snapshotData, + state.committedSnapshot + ) { + throw CurrentWeatherSnapshotStorageError.invalidOrderingState + } + return state + } + } + + private func snapshotDataMatches( + _ data: Data?, + _ ordering: StoredSnapshotOrdering? + ) throws -> Bool { + switch (data, ordering) { + case (nil, nil): + return true + case (.some(let data), .some(let ordering)): + guard sha256(data) == ordering.snapshotSHA256 else { + return false + } + let decoded = try decodeSnapshotOrdering(from: data) + return decoded.observationTime == ordering.observationTime + && decoded.regionCode == ordering.regionCode + case (.none, .some), (.some, .none): + return false + } + } + + private func adoptedOrdering( + from data: Data + ) throws -> StoredSnapshotOrdering { + let decoded = try decodeSnapshotOrdering(from: data) + let generation = decoded.legacyGeneration ?? 0 + guard generation >= 0 else { + throw CurrentWeatherSnapshotStorageError.invalidOrderingState + } + return StoredSnapshotOrdering( + snapshotGeneration: generation, + locationGeneration: generation, + observationTime: decoded.observationTime, + regionCode: decoded.regionCode, + snapshotSHA256: sha256(data) + ) + } + + private func persist( + _ state: OrderingState, + to url: URL + ) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + try persister.write(encoder.encode(state), to: url) + } + + private func decodeSnapshotOrdering( + from data: Data + ) throws -> SnapshotOrdering { + do { + return try JSONDecoder().decode( + SnapshotOrdering.self, + from: data + ) + } catch { + throw CurrentWeatherSnapshotStorageError.invalidSnapshot + } + } + + private func shouldReplace( + address: CurrentWeatherSnapshotAddress, + existing: StoredSnapshotOrdering, + candidate: SnapshotOrdering, + candidateGeneration: Int64 + ) -> Bool { + switch address { + case .currentLocation: + if candidate.regionCode != existing.regionCode { + return candidateGeneration > existing.locationGeneration + } + case .saved: + break + } + + if candidate.observationTime != existing.observationTime { + return candidate.observationTime > existing.observationTime + } + return candidateGeneration > existing.snapshotGeneration + } + + private func locationGeneration( + address: CurrentWeatherSnapshotAddress, + existing: StoredSnapshotOrdering?, + candidate: SnapshotOrdering, + candidateGeneration: Int64 + ) -> Int64 { + guard + address == .currentLocation, + let existing, + candidate.regionCode == existing.regionCode + else { + return candidateGeneration + } + return max(existing.locationGeneration, candidateGeneration) + } + + private func sha256(_ data: Data) -> String { + SHA256.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + } +} + +private extension CurrentWeatherSnapshotStorage { + enum LoadedOrderingState { + case legacy(LegacyOrderingState) + case current(OrderingState) + } + + struct OrderingStateVersion: Decodable { + let schemaVersion: Int + } + + struct LegacyOrderingState: Decodable { + let schemaVersion: Int + let lastIssuedGeneration: Int64 + } + + struct OrderingState: Codable { + let schemaVersion: Int + var lastIssuedGeneration: Int64 + var rejectedThroughGeneration: Int64 + var committedSnapshot: StoredSnapshotOrdering? + var pendingCommit: StoredSnapshotOrdering? + } + + struct StoredSnapshotOrdering: Codable { + let snapshotGeneration: Int64 + let locationGeneration: Int64 + let observationTime: Int64 + let regionCode: String + let snapshotSHA256: String + + func withLocationGeneration( + _ generation: Int64 + ) -> StoredSnapshotOrdering { + StoredSnapshotOrdering( + snapshotGeneration: snapshotGeneration, + locationGeneration: generation, + observationTime: observationTime, + regionCode: regionCode, + snapshotSHA256: snapshotSHA256 + ) + } + } + + struct SnapshotOrdering: Decodable { + let observationTime: Int64 + let regionCode: String + let legacyGeneration: Int64? + + private enum CodingKeys: String, CodingKey { + case observationTime + case regionCode + case legacyGeneration = "_dpipStorageWriteGeneration" + } + } +} diff --git a/ios/Shared/WidgetLocationCatalog.swift b/ios/Shared/WidgetLocationCatalog.swift new file mode 100644 index 000000000..f0924c8ed --- /dev/null +++ b/ios/Shared/WidgetLocationCatalog.swift @@ -0,0 +1,190 @@ +import Foundation + +struct WidgetLocationCatalog: Equatable, Sendable { + static let supportedSchemaVersion = 1 + + let schemaVersion: Int + let locations: [WidgetLocationCatalogLocation] + + init?( + schemaVersion: Int, + locations: [WidgetLocationCatalogLocation] + ) { + guard schemaVersion == Self.supportedSchemaVersion else { + return nil + } + + let regionCodes = Set(locations.map(\.regionCode)) + + guard regionCodes.count == locations.count else { + return nil + } + + self.schemaVersion = schemaVersion + self.locations = locations + } + + static func decode(_ data: Data) -> Self? { + try? JSONDecoder().decode(Self.self, from: data) + } +} + +extension WidgetLocationCatalog: Decodable { + private enum CodingKeys: String, CodingKey { + case schemaVersion + case locations + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let schemaVersion = try container.decode( + Int.self, + forKey: .schemaVersion + ) + let locations = try container.decode( + [WidgetLocationCatalogLocation].self, + forKey: .locations + ) + + guard let catalog = Self( + schemaVersion: schemaVersion, + locations: locations + ) else { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: + "Unsupported schema version or duplicate region code." + ) + ) + } + + self = catalog + } +} + +struct WidgetLocationCatalogLocation: Equatable, Sendable { + let regionCode: String + let displayName: String + let administrativeAreaName: String + let latitude: Double + let longitude: Double + + init?( + regionCode: String, + displayName: String, + administrativeAreaName: String, + latitude: Double, + longitude: Double + ) { + guard + Self.isValidRegionCode(regionCode), + latitude.isFinite, + longitude.isFinite, + (-90 ... 90).contains(latitude), + (-180 ... 180).contains(longitude) + else { + return nil + } + + self.regionCode = regionCode + self.displayName = displayName + self.administrativeAreaName = administrativeAreaName + self.latitude = latitude + self.longitude = longitude + } + + private static func isValidRegionCode( + _ regionCode: String + ) -> Bool { + let bytes = regionCode.utf8 + + return bytes.count == 3 + && bytes.allSatisfy { byte in + byte >= 48 && byte <= 57 + } + } +} + +extension WidgetLocationCatalogLocation: Decodable { + private enum CodingKeys: String, CodingKey { + case regionCode + case displayName + case administrativeAreaName + case latitude + case longitude + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let regionCode = try container.decode( + String.self, + forKey: .regionCode + ) + let displayName = try container.decode( + String.self, + forKey: .displayName + ) + let administrativeAreaName = try container.decode( + String.self, + forKey: .administrativeAreaName + ) + let latitude = try container.decode( + Double.self, + forKey: .latitude + ) + let longitude = try container.decode( + Double.self, + forKey: .longitude + ) + + guard let location = Self( + regionCode: regionCode, + displayName: displayName, + administrativeAreaName: administrativeAreaName, + latitude: latitude, + longitude: longitude + ) else { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: + "Invalid region code or coordinate." + ) + ) + } + + self = location + } +} + +struct WidgetLocationCatalogStore: Sendable { + private let containerURL: URL? + + init() { + containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: + "group.com.exptech.dpip.dpip.widgets" + ) + } + + init(containerURL: URL) { + self.containerURL = containerURL + } + + func load() -> WidgetLocationCatalog? { + guard let containerURL else { + return nil + } + + let url = containerURL + .appendingPathComponent("WidgetSnapshots") + .appendingPathComponent("location-catalog.json") + + guard let data = try? Data(contentsOf: url) else { + return nil + } + + return WidgetLocationCatalog.decode(data) + } +} diff --git a/tool/check.sh b/tool/check.sh index b1e521399..69fb5539f 100755 --- a/tool/check.sh +++ b/tool/check.sh @@ -36,7 +36,8 @@ cached analyze "$(cache_key "${CODE_INPUTS[@]}")" tool/dev/analyze.sh # Five of these finish in under a quarter of a second and are run every time: # hashing their inputs would cost as much as running them, and a cache that # saves nothing is a cache that can only be wrong. -for gate in tooling l10n pubspec_lock notification_sounds build_info; do +for gate in tooling l10n pubspec_lock notification_sounds build_info \ + widget_township_resources; do step "check/$gate" "tool/check/$gate.sh" done diff --git a/tool/check/widget_township_resources.sh b/tool/check/widget_township_resources.sh new file mode 100755 index 000000000..1bf230485 --- /dev/null +++ b/tool/check/widget_township_resources.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Generated Widget township resources must match the authoritative Dart assets. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../.." + +python3 tool/gen/widget_township_resources.py --check diff --git a/tool/gen/widget_township_resources.py b/tool/gen/widget_township_resources.py new file mode 100644 index 000000000..ba5f5aba8 --- /dev/null +++ b/tool/gen/widget_township_resources.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Generate the iOS Widget township resources from the Dart assets. + +The Dart assets remain the only geographic source of truth. The boundary +payload is gzip-inflated without re-encoding, while the directory is reduced to +the fields the Widget Extension needs for resolution and weather queries. + +Run: + python3 tool/gen/widget_township_resources.py + python3 tool/gen/widget_township_resources.py --check +""" + +from __future__ import annotations + +import argparse +import gzip +import json +from pathlib import Path +import sys +from typing import Any + + +REPO = Path(__file__).resolve().parents[2] +DIRECTORY_SOURCE = REPO / "assets/location.json.gz" +BOUNDARY_SOURCE = REPO / "assets/map/town_boundaries.bin.gz" +DIRECTORY_OUTPUT = REPO / "ios/DPIPWidgets/WidgetTownshipDirectory.json" +BOUNDARY_OUTPUT = REPO / "ios/DPIPWidgets/WidgetTownshipBoundaries.bin" +SCHEMA_VERSION = 1 + + +def generated_outputs() -> dict[Path, bytes]: + with gzip.open(DIRECTORY_SOURCE, "rt", encoding="utf-8") as source: + source_directory: dict[str, dict[str, Any]] = json.load(source) + + townships = [] + for region_code, town in source_directory.items(): + townships.append( + { + "regionCode": region_code, + "displayName": town["town"] + town["townLevel"], + "administrativeAreaName": town["city"] + town["cityLevel"], + "latitude": town["lat"], + "longitude": town["lng"], + } + ) + + directory = { + "schemaVersion": SCHEMA_VERSION, + "townships": townships, + } + directory_bytes = ( + json.dumps( + directory, + ensure_ascii=False, + separators=(",", ":"), + ) + + "\n" + ).encode("utf-8") + boundary_bytes = gzip.decompress(BOUNDARY_SOURCE.read_bytes()) + + return { + DIRECTORY_OUTPUT: directory_bytes, + BOUNDARY_OUTPUT: boundary_bytes, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--check", + action="store_true", + help="fail when committed outputs differ from a fresh generation", + ) + args = parser.parse_args() + + outputs = generated_outputs() + if args.check: + stale = [ + path + for path, expected in outputs.items() + if not path.is_file() or path.read_bytes() != expected + ] + if stale: + for path in stale: + print( + f"stale generated resource: {path.relative_to(REPO)}", + file=sys.stderr, + ) + print( + "run: python3 tool/gen/widget_township_resources.py", + file=sys.stderr, + ) + return 1 + print("Widget township resources are up to date.") + return 0 + + for path, contents in outputs.items(): + path.write_bytes(contents) + print( + f"generated {path.relative_to(REPO)} ({len(contents)} bytes)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())