diff --git a/OBAKit/Orchestration/Application.swift b/OBAKit/Orchestration/Application.swift index 8ab7c2113..2af72e3b4 100644 --- a/OBAKit/Orchestration/Application.swift +++ b/OBAKit/Orchestration/Application.swift @@ -278,6 +278,7 @@ public class Application: CoreApplication, PushServiceDelegate { } private var presentDonationUIOnActive = false + private var presentAddRegionAlertOnActive = false private var donationPromptID: String? public func pushService(_ pushService: PushService, receivedDonationPrompt id: String?) { @@ -363,6 +364,18 @@ public class Application: CoreApplication, PushServiceDelegate { presentDonationUIOnActive = false donationPromptID = nil } + + if presentAddRegionAlertOnActive, let topViewController { + // Show alert for nil addRegion data + let alertController = UIAlertController( + title: Strings.error, + message: OBALoc("region_url.error_messsage", value: "The provided region URL is invalid or does not point to a functional OBA server.", comment: "Error message of Custom Region URL if it's invalid or does not point to a functional OBA server"), + preferredStyle: .alert + ) + alertController.addAction(UIAlertAction(title: Strings.ok, style: .default)) + topViewController.present(alertController, animated: true) + presentAddRegionAlertOnActive = false + } } @objc public func applicationWillResignActive(_ application: UIApplication) { @@ -429,15 +442,50 @@ public class Application: CoreApplication, PushServiceDelegate { } let router = URLSchemeRouter(scheme: scheme) - guard - let stopData = router.decode(url: url), - let topViewController = topViewController - else { + + guard let urlType = router.decodeURLType(from: url) else { return false } - viewRouter.navigateTo(stopID: stopData.stopID, from: topViewController) - return true + switch urlType { + case .viewStop(let stopData): + guard let topViewController = self.topViewController else { return false } + viewRouter.navigateTo(stopID: stopData.stopID, from: topViewController) + return true + case .addRegion(let regionData): + viewRouter.rootNavigateTo(page: .map) + Task { @MainActor in + do { + guard let regionData else { + presentAddRegionAlertOnActive = true + return + } + + guard let regionCoordinate = try await self.apiService?.getAgenciesWithCoverage().list.first?.region else { + return + } + + // Adjustments for coordinate span + var adjustedRegionCoordinate = regionCoordinate + adjustedRegionCoordinate.span.latitudeDelta = 2 + adjustedRegionCoordinate.span.longitudeDelta = 2 + + // Create region provider + let regionProvider = RegionPickerCoordinator(regionsService: self.regionsService) + + // Construct Region from URL data + let currentRegion = Region(name: regionData.name, OBABaseURL: regionData.obaURL, coordinateRegion: adjustedRegionCoordinate, contactEmail: "example@example.com", openTripPlannerURL: regionData.otpURL) + + // Add and set current region + try await regionProvider.add(customRegion: currentRegion) + try await regionProvider.setCurrentRegion(to: currentRegion) + } catch { + presentAddRegionAlertOnActive = true + return + } + } + return true + } } override public func apiServicesRefreshed() { diff --git a/OBAKit/Strings/en.lproj/Localizable.strings b/OBAKit/Strings/en.lproj/Localizable.strings index 2c3964564..097a00282 100644 --- a/OBAKit/Strings/en.lproj/Localizable.strings +++ b/OBAKit/Strings/en.lproj/Localizable.strings @@ -770,3 +770,5 @@ /* Format string with placeholders for distance from stop, walking time to stop, and predicted arrival time. e.g. 1.2 miles, 17m: arriving at 09:41 A.M. */ "walk_time_view.distance_time_fmt" = "%1$@, %2$@: arriving at %3$@"; +/* Error message of Custom Region URL if it's invalid or does not point to a functional OBA server */ +"region_url.error_messsage" = "The provided region URL is invalid or does not point to a functional OBA server."; diff --git a/OBAKit/Strings/es.lproj/Localizable.strings b/OBAKit/Strings/es.lproj/Localizable.strings index 83af42a75..4f81cd237 100644 Binary files a/OBAKit/Strings/es.lproj/Localizable.strings and b/OBAKit/Strings/es.lproj/Localizable.strings differ diff --git a/OBAKit/Strings/it.lproj/Localizable.strings b/OBAKit/Strings/it.lproj/Localizable.strings index 1cc98f47b..442cd1e96 100644 Binary files a/OBAKit/Strings/it.lproj/Localizable.strings and b/OBAKit/Strings/it.lproj/Localizable.strings differ diff --git a/OBAKit/Strings/pl.lproj/Localizable.strings b/OBAKit/Strings/pl.lproj/Localizable.strings index 78af3a0e7..70ff92e2f 100644 Binary files a/OBAKit/Strings/pl.lproj/Localizable.strings and b/OBAKit/Strings/pl.lproj/Localizable.strings differ diff --git a/OBAKit/Strings/zh-Hans.lproj/Localizable.strings b/OBAKit/Strings/zh-Hans.lproj/Localizable.strings index be9273dff..d340dfaf7 100644 Binary files a/OBAKit/Strings/zh-Hans.lproj/Localizable.strings and b/OBAKit/Strings/zh-Hans.lproj/Localizable.strings differ diff --git a/OBAKitCore/DeepLinks/URLSchemeRouter.swift b/OBAKitCore/DeepLinks/URLSchemeRouter.swift index 54c275d33..7d051676d 100644 --- a/OBAKitCore/DeepLinks/URLSchemeRouter.swift +++ b/OBAKitCore/DeepLinks/URLSchemeRouter.swift @@ -15,6 +15,33 @@ public struct StopURLData { public let regionID: Int } +/// `AddRegionURLData` is a data structure that encapsulates the information needed to add a new region +/// through a deep link. It contains the name of the region, the URL to the OneBusAway (OBA) server, and an optional +/// URL to the OpenTripPlanner (OTP) server. +/// +/// - Parameters: +/// - name: The name of the region to be added. This is a human-readable string that identifies the region. +/// - obaURL: The URL to the OneBusAway (OBA) server for the region. This URL is used to access transit data. +/// - otpURL: An optional URL to the OpenTripPlanner (OTP) server. If provided, it can be used for trip planning. +/// If nil, it indicates that the region does not support OTP or that the URL was not provided. +public struct AddRegionURLData { + public let name: String + public let obaURL: URL + public let otpURL: URL? +} + +/// `URLType` represents the types of URLs that the `URLSchemeRouter` can handle. +/// It distinguishes between viewing a stop and adding a region through deep linking. +/// +/// - viewStop: A URL type for viewing details of a specific stop. +/// Contains a `StopURLData` object with the stop ID and region ID. +/// - addRegion: A URL type for adding a new region. +/// Contains an optional `AddRegionURLData` object with the necessary information for adding the region. +/// If the data is nil, it indicates that the URL didn't contain valid or complete data for adding a region. +public enum URLType { + case viewStop(StopURLData) + case addRegion(AddRegionURLData?) +} /// Provides support for deep linking into the app by way of a custom URL scheme. /// /// Custom URL scheme deep linking (e.g. `onebusaway://view-stop?region_id=1&stop_id=12345`) @@ -25,21 +52,37 @@ public class URLSchemeRouter: NSObject { /// The app bundle's URL scheme for extensions. private let scheme: String + private let viewStopHost = "view-stop" + private let addRegionHost = "add-region" + /// Creates a new URL Scheme Router. /// - Parameter scheme: The app bundle's `extensionURLScheme` value. public init(scheme: String) { self.scheme = scheme } - // MARK: - Stop URLs + /// Decode URL Types based on host + public func decodeURLType(from url: URL) -> URLType? { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } - private let viewStopHost = "view-stop" + switch components.host { + case viewStopHost: + return decodeViewStop(from: components) + case addRegionHost: + return decodeAddRegion(from: components) + default: + return nil + } + } + // MARK: - Stop URLs /// Encodes the ID for a Stop along with its Region ID into an URL with the scheme `extensionURLScheme`. /// - Parameters: /// - stopID: The ID for the Stop. /// - regionID: The ID for the Region that hosts the Stop. - public func encode(stopID: StopID, regionID: Int) -> URL { + public func encodeViewStop(stopID: StopID, regionID: Int) -> URL { var components = URLComponents() components.scheme = scheme components.host = viewStopHost @@ -50,17 +93,27 @@ public class URLSchemeRouter: NSObject { /// Decodes a `StopURLData` struct from `url`, which can be used to display a `StopViewController`. /// - Parameter url: An URL created from calling `URLSchemeRouter.encode()` - public func decode(url: URL) -> StopURLData? { + private func decodeViewStop(from components: URLComponents) -> URLType? { guard - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - components.host == viewStopHost, let stopID = components.queryItem(named: "stopID")?.value, let regionIDString = components.queryItem(named: "regionID")?.value, - let regionID = Int(regionIDString) - else { - return nil + let regionID = Int(regionIDString) else { + return nil } + return .viewStop(StopURLData(stopID: stopID, regionID: regionID)) + } - return StopURLData(stopID: stopID, regionID: regionID) + // MARK: - Add Region URLs + /// Encodes the OBA URL for adding custom region along with its Name into an URL with the scheme `extensionURLScheme`. It also has optional OTP URL + private func decodeAddRegion(from components: URLComponents) -> URLType? { + guard + let name = components.queryItem(named: "name")?.value, + let obaUrlString = components.queryItem(named: "oba-url")?.value, + let obaURL = URL(string: obaUrlString) else { + return .addRegion(nil) + } + let otpUrlString = components.queryItem(named: "otp-url")?.value + let otpURL = otpUrlString != nil ? URL(string: otpUrlString!) : nil + return .addRegion(AddRegionURLData(name: name, obaURL: obaURL, otpURL: otpURL)) } } diff --git a/OBAKitCore/Models/Region.swift b/OBAKitCore/Models/Region.swift index a04e426d2..698d44e09 100644 --- a/OBAKitCore/Models/Region.swift +++ b/OBAKitCore/Models/Region.swift @@ -194,7 +194,8 @@ public class Region: NSObject, Identifiable, Codable { /// - Parameter coordinateRegion: The coordinate region that circumscribes this region. /// - Parameter contactEmail: The contact email address for this region. /// - Parameter regionIdentifier: The identifier for this region. If unassigned, it will be given a random value. - public required init(name: String, OBABaseURL: URL, coordinateRegion: MKCoordinateRegion, contactEmail: String, regionIdentifier: Int? = nil) { + /// - Parameter regionIdentifier: The identifier for this region. If unassigned, it will be given a random value. + public required init(name: String, OBABaseURL: URL, coordinateRegion: MKCoordinateRegion, contactEmail: String, regionIdentifier: Int? = nil, openTripPlannerURL: URL? = nil) { self.name = name self.regionIdentifier = regionIdentifier ?? 1000 + Int.random(in: 0...999) isActive = true @@ -207,12 +208,14 @@ public class Region: NSObject, Identifiable, Codable { regionBounds = [bound] self.contactEmail = contactEmail + self.openTripPlannerURL = openTripPlannerURL + // Uninitialized properties facebookURL = nil language = "en_US" open311Servers = [] openTripPlannerContactEmail = nil - openTripPlannerURL = nil + paymentAndroidAppID = nil paymentWarningBody = nil paymentWarningTitle = nil diff --git a/TodayView/TodayViewController.swift b/TodayView/TodayViewController.swift index 354dfc00a..74efac4cc 100644 --- a/TodayView/TodayViewController.swift +++ b/TodayView/TodayViewController.swift @@ -183,7 +183,7 @@ class TodayViewController: UIViewController, BookmarkDataDelegate, NCWidgetProvi } let router = URLSchemeRouter(scheme: Bundle.main.extensionURLScheme!) - let url = router.encode(stopID: bookmark.stopID, regionID: bookmark.regionIdentifier) + let url = router.encodeViewStop(stopID: bookmark.stopID, regionID: bookmark.regionIdentifier) extensionContext?.open(url, completionHandler: nil) }