From 625d73a5a3284a9b7962cd71ded1cf3e052a5b37 Mon Sep 17 00:00:00 2001 From: 2kai2kai2 <2kai2kai2@gmail.com> Date: Tue, 24 Oct 2023 16:21:47 -0400 Subject: [PATCH] Improve GPX implementation stability and docs --- .../Geo Extensions/GPXExtensions.swift | 139 +++++++++++++++--- .../AuthoredActivityContent.swift | 39 +++-- docs/ios-client/onboarding.md | 6 +- 3 files changed, 146 insertions(+), 38 deletions(-) diff --git a/apps/ios/GuideDogs/Code/App/Framework Extensions/Geo Extensions/GPXExtensions.swift b/apps/ios/GuideDogs/Code/App/Framework Extensions/Geo Extensions/GPXExtensions.swift index 8a990a70..3f03995a 100644 --- a/apps/ios/GuideDogs/Code/App/Framework Extensions/Geo Extensions/GPXExtensions.swift +++ b/apps/ios/GuideDogs/Code/App/Framework Extensions/Geo Extensions/GPXExtensions.swift @@ -93,13 +93,17 @@ extension GPXRoot { extension GPXExtensionsElement { /// Sets the first child tag with the specified name, or creates a new one if it does not exist. - public func set_property(_ name: String, to value: String) { - for child in children { - if child.name == name { - child.text = value - return - } + /// If value is `nil`, removes specified tags + public func set_property(_ name: String, to value: String?) { + guard let value = value else { + children.removeAll(where: { $0.name == name }) + return + } + if let child = children.first(where: { $0.name == name }) { + child.text = value + return } + let new_element = GPXExtensionsElement(name: value) new_element.text = value children.append(new_element) @@ -107,12 +111,7 @@ extension GPXExtensionsElement { /// Gets the first child tag with the specified name, or nil if not found public func get_property(_ name: String) -> String? { - for child in children { - if child.name == name { - return child.text - } - } - return nil + return children.first(where: { $0.name == name })?.text } } @@ -171,7 +170,7 @@ func BuildGPXExtension(_ type: GPXExtensionsKeys) -> GPXExtensionsElement { // TODO: Many of the properties in the various extensions are NSNumber in the Objective-C versions. That means they could be any number type, from floating point to integer types to booleans. We should probably figure out what they're actually supposed to be. /// child tags within a `GPXTrackPointExtensions` which has tag `gpxtpx:TrackPointExtension` -/// https://www8.garmin.com/xmlschemas/TrackPointExtensionv2.xsd +/// - seealso: [](https://www8.garmin.com/xmlschemas/TrackPointExtensionv2.xsd) enum GPXTrackPointExtensionsProperties : String { case kHeartRate = "gpxtpx:hr" // unsigned int case kCadence = "gpxtpx:cad" // unsigned int @@ -197,13 +196,18 @@ extension GPXExtensionView { } } +/// - seealso: [](https://trails.io/GPX/1/0/trails_1.0.xsd) enum GPXTrailsTrackExtensionsProperties : String { case kElementActivity = "trailsio:activity" } extension GPXExtensionView { - // TODO: this + public var activityType: String? { + get { get_single(.kElementActivity) } + set {set_single(.kElementActivity, to: newValue) } + } } +/// - seealso: [](https://trails.io/GPX/1/0/trails_1.0.xsd) enum GPXTrailsTrackPointExtensionsProperties : String { case kElementHorizontalAcc = "trailsio:hacc" case kElementVerticalAcc = "trailsio:vacc" @@ -282,6 +286,8 @@ enum GPXSoundscapeSharedContentExtensionsProperties : String { case kElementVersion = "gpxsc:version" // Experience Tags case kElementLocale = "gpxsc:locale" // 'required' + // ?? + case kElementRegion = "gpxsc:region" } /// attribute keys within a `GPXSoundscapeSharedContentExtensions` which has tag `gpxsc:meta` enum GPXSoundscapeSharedContentExtensionsAttributes : String { @@ -289,6 +295,30 @@ enum GPXSoundscapeSharedContentExtensionsAttributes : String { case kAttributeEndDate = "end" case kAttributeExpires = "expires" } +class GPXSoundscapeRegion { + var latitude: CLLocationDegrees + var longitude: CLLocationDegrees + var radius: CLLocationDistance + + init?(element: GPXExtensionsElement) { + guard element.name == GPXSoundscapeSharedContentExtensionsProperties.kElementRegion.rawValue, + let lat = element.attributes["lat"], + let lat = CLLocationDegrees(lat), + let lon = element.attributes["lon"], + let lon = CLLocationDegrees(lon), + let rad = element.attributes["radius"], + let rad = CLLocationDistance(rad) else { + return nil + } + latitude = lat + longitude = lon + radius = rad + } + + var region: CLCircularRegion { + return CLCircularRegion(center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), radius: radius, identifier: "SoundscapeExperienceRegion") + } +} extension GPXExtensionView { public var id: String? { get { get_single(.kElementID) } @@ -302,7 +332,16 @@ extension GPXExtensionView { get { get_single(.kElementVersion) } set { set_single(.kElementVersion, to: newValue) } } - // TODO: apparently there may be a GPXSoundscapeRegion in here too? + /// Seems to be unused. + public var region: GPXSoundscapeRegion? { + get { + guard let element = ref?.children.first(where: {$0.name == E.kElementRegion.rawValue}) else { + return nil + } + return GPXSoundscapeRegion(element: element) + } + // TODO: make a setter + } /// If set to an invalid or unknown value, will not catch that and will save/read back that locale identifier public var locale: Locale? { get { @@ -315,7 +354,7 @@ extension GPXExtensionView { } /// This is the correct date formatter for the GPX format private static let dateFormatter = ISO8601DateFormatter() - /// If nil, then start date is in the distant past + /// If `nil`, then start date is in the distant past public var startDate: Date? { get { guard let startStr = ref?.attributes[GPXSoundscapeSharedContentExtensionsAttributes.kAttributeStartDate.rawValue], @@ -332,7 +371,7 @@ extension GPXExtensionView { ref?.attributes[GPXSoundscapeSharedContentExtensionsAttributes.kAttributeStartDate.rawValue] = value } } - /// If nil, then end date in the distant future + /// If `nil`, then end date is in the distant future public var endDate: Date? { get { guard let endStr = ref?.attributes[GPXSoundscapeSharedContentExtensionsAttributes.kAttributeEndDate.rawValue], @@ -379,6 +418,13 @@ enum GPXSoundscapeAnnotationAttributes : String { case kAttributeTitle = "title" case kAttributeType = "type" } + +/// ```xml +/// From the following GPX tags: +/// +/// CONTENT HERE [0..N] +/// +/// ``` class GPXAnnotation { // TODO: make this work better with the GPX system var title: String? @@ -395,11 +441,18 @@ class GPXAnnotation { extension GPXExtensionView { public var annotations: [GPXAnnotation] { get { - // We store stuff as GPXSoundscapeLink which is an empty subclass of GPXLink return ref?.children.filter({$0.name == E.kAnnotation.rawValue}).compactMap( GPXAnnotation.init ) ?? [] } // TODO: a setter maybe? } + + /// Parses and returns the first `gpxsc:annotation` child found with the specified annotation type + public func getFirstAnnotation(withType type: String) -> GPXAnnotation? { + guard let element = ref?.children.first(where: {$0.name == E.kAnnotation.rawValue && $0.attributes[GPXSoundscapeAnnotationAttributes.kAttributeType.rawValue] == type }) else { + return nil + } + return GPXAnnotation(element: element) + } } /// child tags within a `GPXSoundscapeAnnotationExtensions` which has tag `gpxsc:annotations` @@ -426,14 +479,56 @@ extension GPXExtensionView { enum GPXSoundscapePOIExtensionsProperties : String { case kElementStreetAddress = "gpxsc:street" case kElementPhone = "gpxsc:phone" - case kElementHomepage = "gpxsc:link" // this is the same type as `GPXSoundscapeLink` + case kElementHomepage = "link" // I think this is a normal link, and lacks a "gpxsc:" +} +extension GPXExtensionView { + public var street: String? { + get { get_single(.kElementStreetAddress) } + set { set_single(.kElementStreetAddress, to: newValue) } + } + public var phone: String? { + get { get_single(.kElementPhone) } + set { set_single(.kElementPhone, to: newValue) } + } + public var homepage: GPXLink? { + get { + guard let element = ref?.children.first(where: { $0.name == E.kElementHomepage.rawValue }) else { + return nil + } + let link = GPXLink() + link.mimetype = element.get_property("type") + link.text = element.get_property("name") + link.href = element.attributes["href"] + return link + } + set { + // if set to nil, remove all links + guard let newValue = newValue else { + ref?.children.removeAll(where: { $0.name == E.kElementHomepage.rawValue }) + return + } + guard let element = ref?.children.first(where: { $0.name == E.kElementHomepage.rawValue }) else{ + // If there is no existing link element, create a new one + let element = GPXExtensionsElement(name: E.kElementHomepage.rawValue) + element.attributes["href"] = newValue.href + element.set_property("type", to: newValue.mimetype) + element.set_property("name", to: newValue.text) + ref?.children.append(element) + return + } + // otherwise there is an existing one: overwrite it + element.attributes["href"] = newValue.href + element.set_property("type", to: newValue.mimetype) + element.set_property("name", to: newValue.text) + } + } } /// CoreGPX seems to prefer to leave things just as `GPXExtensionsElement`s so we'll just use that -/// But, for ease of use add ``ExtensionWrapper`` to allow easy lookup of named tags +/// But, for ease of use add ``GPXExtensionView`` to allow easy lookup of named tags extension GPXExtensions { /// A getter specifically for our extensions - func get_ext(_ name: GPXExtensionsKeys) -> GPXExtensionsElement? { + private func get_ext(_ name: GPXExtensionsKeys) -> GPXExtensionsElement? { return children.first { $0.name == name.rawValue } } @@ -503,8 +598,6 @@ extension GPXExtensions { } return GPXExtensionView(ext) } - - // GPXSoundscapeRegion ???????? } // MARK: End Implementing Custom GPX Extensions diff --git a/apps/ios/GuideDogs/Code/Data/Authored Activities/AuthoredActivityContent.swift b/apps/ios/GuideDogs/Code/Data/Authored Activities/AuthoredActivityContent.swift index 7b3aed0e..e288a194 100644 --- a/apps/ios/GuideDogs/Code/Data/Authored Activities/AuthoredActivityContent.swift +++ b/apps/ios/GuideDogs/Code/Data/Authored Activities/AuthoredActivityContent.swift @@ -235,10 +235,14 @@ extension AuthoredActivityContent { pois: []) case "2": - guard let route = gpx.routes.first, route.points.count > 0 else { + // Version 2 uses the first route ``, taking the contained route points as its waypoints + // It then uses the top-level GPX waypoints `` as POIs + + guard let route = gpx.routes.first, !route.points.isEmpty else { return nil } + // Waypoints are strict about requiring names and locations let wpts: [ActivityWaypoint] = waypoints(from: route.points) // For waypoints in this experience, require names, descriptions, and street addresses @@ -246,8 +250,14 @@ extension AuthoredActivityContent { return nil } - // TODO: maybe don't use ! - let pois = gpx.waypoints.map { ActivityPOI(coordinate: $0.coordinate!, name: $0.name!, description: $0.desc) } + let pois: [ActivityPOI] = gpx.waypoints.compactMap { + guard let coord = $0.coordinate else { + // skip waypoints without a location (why does GPX allow this waypoints to lack a location?) + // TODO: maybe log a warning + return nil + } + return ActivityPOI(coordinate: coord, name: $0.name ?? "Unlabeled POI", description: $0.desc) + } return AuthoredActivityContent(id: id, type: actType, @@ -274,13 +284,13 @@ extension AuthoredActivityContent { let imageMimeTypes = Set(["image/jpeg", "image/jpg", "image/png"]) let audioMimeTypes = Set(["audio/mpeg", "audio/x-m4a"]) - return waypoints.map { wpt in - let links = wpt.extensions?.soundscapeLinkExtensions?.links.filter({ + return waypoints.compactMap { wpt in + let links: [GPXLink] = wpt.extensions?.soundscapeLinkExtensions?.links.filter({ guard let mimetype = $0.mimetype else { return false } return imageMimeTypes.contains(mimetype) }) ?? [] - let parsedImages = links.compactMap { (link) -> ActivityWaypointImage? in + let parsedImages: [ActivityWaypointImage] = links.compactMap { link in guard let href = link.href, let url = URL(string: href) else { return nil @@ -289,7 +299,7 @@ extension AuthoredActivityContent { return ActivityWaypointImage(url: url, altText: link.text) } - let parsedAudioClips = links.compactMap { (link) -> ActivityWaypointAudioClip? in + let parsedAudioClips: [ActivityWaypointAudioClip] = links.compactMap { link in guard let href = link.href, let url = URL(string: href) else { return nil @@ -298,11 +308,18 @@ extension AuthoredActivityContent { return ActivityWaypointAudioClip(url: url, description: link.text) } - let allAnnotations = wpt.extensions?.soundscapeAnnotationExtensions?.annotations - let departure = allAnnotations?.first(where: { $0.type == "departure" })?.content - let arrival = allAnnotations?.first(where: { $0.type == "arrival" })?.content + let annotations = wpt.extensions?.soundscapeAnnotationExtensions + let departure = annotations?.getFirstAnnotation(withType: "departure")?.content + let arrival = annotations?.getFirstAnnotation(withType: "arrival")?.content + + // Coordinate shouldn't be nil, but CoreGPX allows it to be so. + // For now we just skip those points + // TODO: there's probably a better way to enforce this + guard let coordinate = wpt.coordinate else { + return nil + } - return ActivityWaypoint(coordinate: wpt.coordinate!, // TODO: maybe shouldn't be ! + return ActivityWaypoint(coordinate: coordinate, name: wpt.name, description: wpt.desc, departureCallout: departure, diff --git a/docs/ios-client/onboarding.md b/docs/ios-client/onboarding.md index 77bd68be..5a2229a3 100644 --- a/docs/ios-client/onboarding.md +++ b/docs/ios-client/onboarding.md @@ -4,13 +4,11 @@ This document describes how to build and run the Soundscape iOS app. ## Supported Tooling Versions -As of Soundscape version 5.3.1 (October 2022): +As of Soundscape Community version 1.0.1 (October 2023): * macOS 12.6.1 * Xcode 13.4.1 * iOS 14.1 -* CocoaPods 1.11.3 (since removed) -* CocoaPods Patch 1.0.2 (since removed) ## Install Xcode @@ -31,7 +29,7 @@ xcode-select --install _Note:_ while macOS comes with a version of Ruby installed, you should install and use a non-system [Ruby](https://www.ruby-lang.org/) using a version manager like [RVM](https://rvm.io/) -## Install Fastlane +## Install Fastlane (optional) In the iOS project folder, run the following command to install the dependencies from the `Gemfile`: