Skip to content
4 changes: 4 additions & 0 deletions IOSAccessAssessment.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
A3C22FD32CF194A600533BF7 /* CGImageUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C22FD22CF194A200533BF7 /* CGImageUtils.swift */; };
A3C22FD52CF1A97700533BF7 /* SegmentationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C22FD42CF1A97300533BF7 /* SegmentationModel.swift */; };
A3C22FD82CF2F0C300533BF7 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = A3C22FD72CF2F0C300533BF7 /* DequeModule */; };
CA924A932CEB9AB000FCA928 /* ChangesetService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA924A922CEB9AB000FCA928 /* ChangesetService.swift */; };
CAA947762CDE6FBD000C6918 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA947752CDE6FBB000C6918 /* LoginView.swift */; };
CAA947792CDE700A000C6918 /* AuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA947782CDE7007000C6918 /* AuthService.swift */; };
CAA9477B2CDE70D9000C6918 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9477A2CDE70D5000C6918 /* KeychainService.swift */; };
Expand Down Expand Up @@ -82,6 +83,7 @@
A3C22FD02CF1643E00533BF7 /* SharedImageData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImageData.swift; sourceTree = "<group>"; };
A3C22FD22CF194A200533BF7 /* CGImageUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGImageUtils.swift; sourceTree = "<group>"; };
A3C22FD42CF1A97300533BF7 /* SegmentationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentationModel.swift; sourceTree = "<group>"; };
CA924A922CEB9AB000FCA928 /* ChangesetService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangesetService.swift; sourceTree = "<group>"; };
CAA947752CDE6FBB000C6918 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
CAA947782CDE7007000C6918 /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = "<group>"; };
CAA9477A2CDE70D5000C6918 /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -231,6 +233,7 @@
children = (
CAA9477A2CDE70D5000C6918 /* KeychainService.swift */,
CAA947782CDE7007000C6918 /* AuthService.swift */,
CA924A922CEB9AB000FCA928 /* ChangesetService.swift */,
);
path = Services;
sourceTree = "<group>";
Expand Down Expand Up @@ -414,6 +417,7 @@
DAA7F8C02CA68400003666D8 /* SegmentationViewController.swift in Sources */,
DAA7F8BD2CA67A5A003666D8 /* CameraViewController.swift in Sources */,
55659C082BB785CB0094DF01 /* CameraController.swift in Sources */,
CA924A932CEB9AB000FCA928 /* ChangesetService.swift in Sources */,
DAA7F8C82CA76527003666D8 /* CIImageUtils.swift in Sources */,
55659C142BB786700094DF01 /* AnnotationView.swift in Sources */,
CAA947792CDE700A000C6918 /* AuthService.swift in Sources */,
Expand Down
124 changes: 124 additions & 0 deletions IOSAccessAssessment/Services/ChangesetService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
//
// ChangesetService.swift
// IOSAccessAssessment
//
// Created by Mariana Piz on 11.11.2024.
//

import Foundation

struct NodeData {
let latitude: Double
let longitude: Double
}

class ChangesetService {

private enum Constants {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a small point, but we can later move the constants to the global constants file, for better centralization, or at least in a common folder for constants.

static let baseUrl = "https://osm.workspaces-stage.sidewalks.washington.edu/api/0.6"
static let workspaceId = "168"
}

static let shared = ChangesetService()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea of using singletons, and imo we should have used something like that for the SharedImageData as well (along with adding it to a global store, instead of passing it to every view)

private init() {}

private let accessToken = KeychainService().getValue(for: .accessToken)
private(set) var changesetId: String?

func openChangeset(completion: @escaping (Result<String, Error>) -> Void) {
guard let url = URL(string: "\(Constants.baseUrl)/changeset/create"),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the fact that we now have two Constants structs will lead to confusion.
No problem tho, we will move all this to an ideal place in a future refactor.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a problem because the Constants enum is private for each struct

let accessToken
else { return }

var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue(Constants.workspaceId, forHTTPHeaderField: "X-Workspace")
request.setValue("application/xml", forHTTPHeaderField: "Content-Type")

guard let xmlData = """
<osm>
<changeset>
<tag k="created_by" v="GIG 1.0(4)" />
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the new PR that we raise for change sets (I think we will have to considering the conflicts), we should change this to match our own arguments.
Maybe for now: created_by = iOSPointMapper
comment can be left blank for now.

Copy link
Contributor Author

@mariana0412 mariana0412 Dec 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both "created_by" and "comment" are according to the documentation (4 b Open a changeset): https://docs.google.com/document/d/1qPSW5pYmVb-RXOiryBm452sLfTvFl38f_TCO4SElkSY/edit?tab=t.0

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I resolved merge conflicts

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but that example input is for GoInfoGame. We are a different creator, so we should be having our own identifier.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah cool. Let's proceed with this PR then

<tag k="comment" v="iOS OSM client" />
</changeset>
</osm>
"""
.data(using: .utf8)
else {
print("Failed to create XML data.")
return
}

request.httpBody = xmlData

URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}

if let data = data, let changesetId = String(data: data, encoding: .utf8) {
self.changesetId = changesetId
completion(.success(changesetId))
} else {
completion(.failure(NSError(domain: "ChangesetError",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to open changeset"])))
}
}.resume()
}

func uploadChanges(nodeData: NodeData, completion: @escaping (Result<Void, Error>) -> Void) {
guard let changesetId,
let accessToken,
let url = URL(string: "\(Constants.baseUrl)/changeset/\(changesetId)/upload")
else { return }

let xmlContent =
"""
<osmChange version="0.6" generator="GIG Change generator">
<create>
<node id="-1" lat="\(nodeData.latitude)" lon="\(nodeData.longitude)" changeset="\(changesetId)" />
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will have to of course, work on generalizing this on any kind of output that we get from the post-processed output of AnnotationView.

</create>
</osmChange>
"""

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue(Constants.workspaceId, forHTTPHeaderField: "X-Workspace")
request.setValue("application/xml", forHTTPHeaderField: "Content-Type")
request.httpBody = xmlContent.data(using: .utf8)

URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
completion(.success(()))
}.resume()
}

func closeChangeset(completion: @escaping (Result<Void, Error>) -> Void) {
Copy link
Collaborator

@himanshunaidu himanshunaidu Dec 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if we should be closing the ChangeSet in the ContentView as well, every time the back button is clicked.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nevermind. Change sets are generally closed automatically when this happens

guard let changesetId, let accessToken else { return }

guard let url = URL(string: "\(Constants.baseUrl)/changeset/\(changesetId)/close") else { return }

var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")

URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
self.changesetId = nil

completion(.success(()))
}.resume()
}

}
39 changes: 37 additions & 2 deletions IOSAccessAssessment/Views/AnnotationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,16 @@ struct AnnotationView: View {

Button(action: {
objectLocation.calcLocation(sharedImageData: sharedImageData, index: index)
self.nextSegment()
selectedOption = nil
uploadChanges()

if index == selection.count - 1 {
closeChangeset()
} else {
nextSegment()
}
}) {
Text("Next")
Text(index == selection.count - 1 ? "Finish" : "Next")
}
.padding()
}
Expand Down Expand Up @@ -157,6 +163,35 @@ struct AnnotationView: View {
func calculateProgress() -> Float {
return Float(self.index) / Float(self.sharedImageData.segmentedIndices.count)
}

private func uploadChanges() {
guard let nodeLatitude = objectLocation.latitude,
let nodeLongitude = objectLocation.longitude
else { return }

let nodeData = NodeData(latitude: nodeLatitude, longitude: nodeLongitude)

ChangesetService.shared.uploadChanges(nodeData: nodeData) { result in
switch result {
case .success:
print("Changes uploaded successfully.")
case .failure(let error):
print("Failed to upload changes: \(error.localizedDescription)")
}
}
}

private func closeChangeset() {
ChangesetService.shared.closeChangeset { result in
switch result {
case .success:
print("Changeset closed successfully.")
case .failure(let error):
print("Failed to close changeset: \(error.localizedDescription)")
}
}
}

}

struct ClassSelectionView: View {
Expand Down
112 changes: 63 additions & 49 deletions IOSAccessAssessment/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,61 +29,64 @@ struct ContentView: View {
var objectLocation = ObjectLocation()

var body: some View {
VStack {
if manager?.dataAvailable ?? false{
ZStack {
HostedCameraViewController(session: manager!.controller.captureSession,
frameRect: VerticalFrame.getColumnFrame(
width: UIScreen.main.bounds.width,
height: UIScreen.main.bounds.height,
row: 0)
)
HostedSegmentationViewController(segmentationImage: $segmentationModel.maskedSegmentationResults,
frameRect: VerticalFrame.getColumnFrame(
width: UIScreen.main.bounds.width,
height: UIScreen.main.bounds.height,
row: 1)
)
}
Button {
segmentationModel.performPerClassSegmentationRequest(with: sharedImageData.cameraImage!)
objectLocation.setLocationAndHeading()
manager?.stopStream()
} label: {
Image(systemName: "camera.circle.fill")
.resizable()
.frame(width: 60, height: 60)
.foregroundColor(.white)
}
VStack {
if manager?.dataAvailable ?? false{
ZStack {
HostedCameraViewController(session: manager!.controller.captureSession,
frameRect: VerticalFrame.getColumnFrame(
width: UIScreen.main.bounds.width,
height: UIScreen.main.bounds.height,
row: 0)
)
HostedSegmentationViewController(segmentationImage: $segmentationModel.maskedSegmentationResults,
frameRect: VerticalFrame.getColumnFrame(
width: UIScreen.main.bounds.width,
height: UIScreen.main.bounds.height,
row: 1)
)
}
else {
VStack {
SpinnerView()
Text("Camera settings in progress")
.padding(.top, 20)
}
Button {
segmentationModel.performPerClassSegmentationRequest(with: sharedImageData.cameraImage!)
objectLocation.setLocationAndHeading()
manager?.stopStream()
} label: {
Image(systemName: "camera.circle.fill")
.resizable()
.frame(width: 60, height: 60)
.foregroundColor(.white)
}
}
.navigationDestination(isPresented: $navigateToAnnotationView) {
AnnotationView(objectLocation: objectLocation,
classes: Constants.ClassConstants.classes,
selection: selection
)
}
.navigationBarTitle("Camera View", displayMode: .inline)
.onAppear {
if (manager == nil) {
segmentationModel.updateSegmentationRequest(selection: selection, completion: updateSharedImageSegmentation)
segmentationModel.updatePerClassSegmentationRequest(selection: selection,
completion: updatePerClassImageSegmentation)
manager = CameraManager(sharedImageData: sharedImageData, segmentationModel: segmentationModel)
} else {
manager?.resumeStream()
else {
VStack {
SpinnerView()
Text("Camera settings in progress")
.padding(.top, 20)
}
}
.onDisappear {
manager?.stopStream()
}
.navigationDestination(isPresented: $navigateToAnnotationView) {
AnnotationView(
objectLocation: objectLocation,
classes: Constants.ClassConstants.classes,
selection: selection
)
}
.navigationBarTitle("Camera View", displayMode: .inline)
.onAppear {
openChangeset()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to confirm, this is the only change that has been added to openChangeset right?
Kinda confused due to the spacing changes.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the change set creation fails, then I am not sure we should be proceeding with the ContentView recording since it will be for nothing.
Let's see what kind of response we can decide upon if that happens.
I am not a fan of crashing the application, but we will have to have a retry option.


if (manager == nil) {
segmentationModel.updateSegmentationRequest(selection: selection, completion: updateSharedImageSegmentation)
segmentationModel.updatePerClassSegmentationRequest(selection: selection,
completion: updatePerClassImageSegmentation)
manager = CameraManager(sharedImageData: sharedImageData, segmentationModel: segmentationModel)
} else {
manager?.resumeStream()
}
}
.onDisappear {
manager?.stopStream()
}
}

// Callbacks to the SegmentationModel
Expand All @@ -108,4 +111,15 @@ struct ContentView: View {
fatalError("Unable to process per-class segmentation \(error.localizedDescription)")
}
}

private func openChangeset() {
ChangesetService.shared.openChangeset { result in
switch result {
case .success(let changesetId):
print("Opened changeset with ID: \(changesetId)")
case .failure(let error):
print("Failed to open changeset: \(error.localizedDescription)")
}
}
}
}