From ba76d31cd444dc47d3776f958064069a1f078416 Mon Sep 17 00:00:00 2001 From: Maximilian Fischer Date: Mon, 25 May 2026 20:53:01 +0200 Subject: [PATCH 1/4] support IHateMoney QR codes --- PayForMe/Model/Project.swift | 24 ++-- PayForMe/Services/NetworkService.swift | 60 +++++++- PayForMe/Services/ProjectManager.swift | 6 +- PayForMe/Services/StorageService.swift | 8 +- PayForMe/Util/Util.swift | 104 +++++++++++--- .../Manual/AddProjectManualView.swift | 60 ++++---- .../Manual/AddProjectManualViewModel.swift | 83 +++++++---- .../QRCodes/AddProjectQRViewModel.swift | 72 ++++++---- .../ShareProjectQRCodeViewModel.swift | 2 +- PayForMeTests/BalanceCalculationTests.swift | 3 +- PayForMeTests/NetworkRequestTests.swift | 12 +- PayForMeTests/TestHelpers.swift | 12 +- PayForMeTests/UrlExtensionsTests.swift | 130 +++++++++++------- 13 files changed, 396 insertions(+), 180 deletions(-) diff --git a/PayForMe/Model/Project.swift b/PayForMe/Model/Project.swift index 0935ecd..d75a86d 100644 --- a/PayForMe/Model/Project.swift +++ b/PayForMe/Model/Project.swift @@ -19,11 +19,13 @@ class Project: Codable, Identifiable { var bills: [Bill] var me: Int? - convenience init(name: String, password: String, token: String, backend: ProjectBackend, url: URL) { - self.init(name: name, password: password, token: token, backend: backend, url: url, id: nil) + let projectId: String + + convenience init(name: String, password: String, token: String, backend: ProjectBackend, url: URL, projectId: String) { + self.init(name: name, password: password, token: token, backend: backend, url: url, id: nil, projectId: projectId) } - fileprivate init(name: String, password: String, token: String, backend: ProjectBackend, url: URL, id: Int?, me: Int? = nil) { + fileprivate init(name: String, password: String, token: String, backend: ProjectBackend, url: URL, id: Int?, me: Int? = nil, projectId: String) { self.name = name self.password = password self.token = token @@ -33,6 +35,7 @@ class Project: Codable, Identifiable { members = [:] bills = [] self.me = me + self.projectId = projectId } } @@ -49,8 +52,9 @@ struct StoredProject: Codable { let backend: ProjectBackend var id: Int? let me: Int? + let projectId: String - init(name: String, password: String, token: String, url: URL, backend: ProjectBackend) { + init(name: String, password: String, token: String, url: URL, backend: ProjectBackend, projectId: String) { self.name = name self.password = password self.token = token @@ -58,6 +62,7 @@ struct StoredProject: Codable { self.backend = backend id = nil me = nil + self.projectId = projectId } init(project: Project) { @@ -68,10 +73,11 @@ struct StoredProject: Codable { backend = project.backend id = project.id me = project.me + projectId = project.projectId } func toProject() -> Project { - Project(name: name, password: password, token: token, backend: backend, url: url, id: id!, me: me) + Project(name: name, password: password, token: token, backend: backend, url: url, id: id!, me: me, projectId: projectId) } } @@ -101,10 +107,10 @@ enum ProjectBackend: Int, Codable { } } -let previewProject = Project(name: "TestProject", password: "TestPassword", token: "asdasdas", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 0) +let previewProject = Project(name: "TestProject", password: "TestPassword", token: "asdasdas", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 0, projectId: "TestProject") let previewProjects = [ previewProject, - Project(name: "test1", password: "test23", token: "dasdasa", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 1), - Project(name: "test2", password: "test45", token: "123123122", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 2), + Project(name: "test1", password: "test23", token: "dasdasa", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 1, projectId: "test1"), + Project(name: "test2", password: "test45", token: "123123122", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 2, projectId: "test2"), ] -let demoProject = Project(name: "study-group", password: "no-pass", token: "9da50e410157dc1ca63e594af022f3a2", backend: .cospend, url: URL(string: "https://intranet.mayflower.de")!, id: 1) +let demoProject = Project(name: "study-group", password: "no-pass", token: "9da50e410157dc1ca63e594af022f3a2", backend: .cospend, url: URL(string: "https://intranet.mayflower.de")!, id: 1, projectId: "study-group") diff --git a/PayForMe/Services/NetworkService.swift b/PayForMe/Services/NetworkService.swift index b67638e..02e1465 100644 --- a/PayForMe/Services/NetworkService.swift +++ b/PayForMe/Services/NetworkService.swift @@ -91,7 +91,43 @@ class NetworkService { } let apiProject = try JSONDecoder().decode(APIProject.self, from: data) - return Project(name: apiProject.name, password: project.password, token: project.token, backend: project.backend, url: project.url) + return Project(name: apiProject.name, password: project.password, token: project.token, backend: project.backend, url: project.url, projectId: apiProject.id) + } + + func getProjectName(invite: InviteData) async throws -> Project { + var request = URLRequest(url: URL(string: invite.url + "/api/projects/" + invite.project)!) + request.httpMethod = "GET" + request.setValue( + "Bearer \(invite.token)", + forHTTPHeaderField: "Authorization" + ) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode / 100 == 2 else { + throw HTTPError.statuscode + } + let apiProject = try JSONDecoder().decode(APIProject.self, from: data) + + return Project(name: apiProject.name, password: "", token: invite.token, backend: ProjectBackend.iHateMoney, url: URL(string: invite.url)!, projectId: apiProject.id) + } + + func fetchInvite(_ invite: InviteData?) async throws { + guard let invite = invite, let url = URL(string: invite.url) else { + return + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue( + "Bearer \(invite.token)", + forHTTPHeaderField: "Authorization" + ) + + let (data, response) = try await URLSession.shared.data(for: request) + if let httpResponse = response as? HTTPURLResponse { + print("status:", httpResponse.statusCode) + } + print(String(data: data, encoding: .utf8) ?? "") } func postBillPublisher(bill: Bill) -> AnyPublisher { @@ -147,13 +183,21 @@ class NetworkService { .replaceError(with: false) .eraseToAnyPublisher() } + private func baseURLFor(_ project: Project, suffix: String) -> URL { var url = project.url .appendingPathComponent(project.backend.staticPath) - .appendingPathComponent(project.token) + if project.backend == .cospend { - url = url.appendingPathComponent(project.password) + url = url + .appendingPathComponent(project.projectId) + .appendingPathComponent(project.password) + } + + if project.backend == .iHateMoney { + url = url + .appendingPathComponent(project.projectId) } if suffix.isEmpty { return url @@ -181,8 +225,14 @@ class NetworkService { request = URLRequest(url: requestURL) if project.backend == .iHateMoney { - guard let authString = "\(project.token):\(project.password)".data(using: .utf8)?.base64EncodedString() else { fatalError("error generating authString. THIS SHOULD NOT HAPPEN") } - request.setValue("Basic \(authString)", forHTTPHeaderField: "Authorization") + if project.password.isEmpty { + request.setValue("Bearer \(project.token)", forHTTPHeaderField: "Authorization") + } else { + guard let authString = "\(project.token):\(project.password)".data(using: .utf8)?.base64EncodedString() else { + fatalError("error generating authString. THIS SHOULD NOT HAPPEN") + } + request.setValue("Basic \(authString)", forHTTPHeaderField: "Authorization") + } if !params.isEmpty { request.httpBody = try? JSONSerialization.data(withJSONObject: params) diff --git a/PayForMe/Services/ProjectManager.swift b/PayForMe/Services/ProjectManager.swift index 00a327d..8e16dc0 100644 --- a/PayForMe/Services/ProjectManager.swift +++ b/PayForMe/Services/ProjectManager.swift @@ -44,11 +44,7 @@ class ProjectManager: ObservableObject { func openedByURL(url: URL) { let data = url.decodeCospendString() - guard let _ = data.server, - let _ = data.project - else { - return - } + guard data != nil else { return } openedByURL = url } diff --git a/PayForMe/Services/StorageService.swift b/PayForMe/Services/StorageService.swift index a766417..2285f5e 100644 --- a/PayForMe/Services/StorageService.swift +++ b/PayForMe/Services/StorageService.swift @@ -44,6 +44,12 @@ class StorageService { table.add(column: "me") }) } + migrator.registerMigration("v4") { db in + try db.alter(table: "storedProject", body: { table in + table.add(column: "projectId") + }) + try db.execute(sql: "UPDATE storedProject SET projectId = name;") + } // #if DEBUG //// Speed up development by nuking the database when migrations change // migrator.eraseDatabaseOnSchemaChange = true @@ -153,6 +159,6 @@ private class OldProject: Codable, Identifiable { var bills: [Bill] func toProject() -> StoredProject { - return StoredProject(name: name, password: password, token: name, url: url, backend: backend) + return StoredProject(name: name, password: password, token: name, url: url, backend: backend, projectId: name) } } diff --git a/PayForMe/Util/Util.swift b/PayForMe/Util/Util.swift index a191874..6c40aeb 100644 --- a/PayForMe/Util/Util.swift +++ b/PayForMe/Util/Util.swift @@ -176,43 +176,115 @@ extension StringProtocol { } } -typealias ProjectData = (server: URL?, project: String?, passwd: String?) +protocol ProjectData { + var server: URL { get } + var project: String { get } +} + +struct ProjectDataWithToken: ProjectData { + var server: URL + var project: String + var token: String + + init(server: URL, project: String, token: String) { + self.server = server + self.project = project + self.token = token + } + +} + +struct ProjectDataWithPassword: ProjectData { + var server: URL + var project: String + var password: String? + + init(server: URL, project: String, password: String?) { + self.server = server + self.project = project + self.password = password + } +} extension URL { - func decodeMoneyBusterString() -> ProjectData { + func decodeMoneyBusterString() -> ProjectDataWithPassword? { guard absoluteString.hasPrefix("https://net.eneiluj.moneybuster.cospend/"), - pathComponents.count >= 3, pathComponents.count <= 4 else { return (nil, nil, nil) } - return (URL(string: "https://" + pathComponents[1]), pathComponents[2], pathComponents[safe: 3]) + pathComponents.count >= 3, pathComponents.count <= 4 else { + return nil + } + + guard let hostUrl = URL(string: "https://" + pathComponents[1]) else { + return nil + } + + let password = pathComponents[safe: 3] + + return ProjectDataWithPassword( + server: hostUrl, + project: pathComponents[2], + password: password + ) } } extension URL { - func decodeCospendString() -> ProjectData { + func decodeCospendString() -> ProjectDataWithPassword? { guard let host = host, let scheme = scheme, scheme.localizedCaseInsensitiveContains("cospend") else { - return (nil, nil, nil) + return nil } - - var hostString = "https://\(host)" - + + var hostString = host + if let port = port {hostString += ":\(port)"} if pathComponents.count > 3 { hostString += "/" + pathComponents[1..<(pathComponents.count - 2)].joined(separator: "/") } - - return (URL(string: hostString), - pathComponents[safe: pathComponents.count - 2], - pathComponents.last) + + guard + let hostUrl = URL(string: "https://" + hostString), + let project = pathComponents[safe: pathComponents.count - 2], + let password = pathComponents.last + else { return nil } + + return ProjectDataWithPassword( + server: hostUrl, + project: project, + password: password) + } +} + +extension URL { + func decodeIHateMoenyString() -> ProjectDataWithToken? { + guard var host = host, let scheme = scheme, scheme.localizedCaseInsensitiveContains("ihatemoney") else { + return nil + } + + let hostUrl = "https://" + host + + guard + let url = URL(string: hostUrl), + let project = pathComponents[safe: pathComponents.count - 3], + let token = pathComponents.last + else { return nil} + + return ProjectDataWithToken(server: url, project: project, token: token) } } extension URL { - func decodeQRCode() -> ProjectData { - guard let scheme = scheme else { return (nil, nil, nil) } - return scheme.contains("cospend") ? decodeCospendString() : decodeMoneyBusterString() + func decodeQRCode() -> ProjectData? { + guard let scheme = scheme else { return nil } + if scheme.contains("cospend") { + return decodeCospendString() + } else if scheme.contains("ihatemoney") { + return decodeIHateMoenyString() + } else { + return decodeMoneyBusterString() + } } } diff --git a/PayForMe/Views/Projects/Manual/AddProjectManualView.swift b/PayForMe/Views/Projects/Manual/AddProjectManualView.swift index edca69c..9aaf3ae 100644 --- a/PayForMe/Views/Projects/Manual/AddProjectManualView.swift +++ b/PayForMe/Views/Projects/Manual/AddProjectManualView.swift @@ -25,18 +25,16 @@ struct AddProjectManualView: View { } .pickerStyle(SegmentedPickerStyle()) .padding(EdgeInsets(top: 8, leading: 8, bottom: 0, trailing: 8)) - if viewmodel.projectType == .cospend { - if #available(iOS 16.0, *) { - PasteButton(payloadType: String.self) { strings in - pasteLink(pasteString: strings[0]) - } - .padding(.top, 10) - } else { - Button("Paste Link") { - pasteLink() - } - .padding(.top, 10) + if #available(iOS 16.0, *) { + PasteButton(payloadType: String.self) { strings in + pasteLink(pasteString: strings[0]) + } + .padding(.top, 10) + } else { + Button("Paste Link") { + pasteLink() } + .padding(.top, 10) } Form { Section( @@ -44,7 +42,7 @@ struct AddProjectManualView: View { ) { TextFieldContainer( viewmodel.projectType == .cospend - ? "https://mynextcloud.org" : "https://ihatemoney.org", + ? "https://mynextcloud.org" : "https://ihatemoney.org", text: self.$viewmodel.serverAddress ) } @@ -52,26 +50,32 @@ struct AddProjectManualView: View { Section(header: Text("Project ID & Password")) { TextField("Enter project id", text: self.$viewmodel.projectName) - .autocapitalization(.none) + .autocapitalization(.none) SecureField("Enter project password", text: self.$viewmodel.projectPassword) } + + if viewmodel.projectType == .iHateMoney { + Section(header: Text("Invite Token")) { + TextField("Enter invite url", text: self.$viewmodel.inviteUrl) + .autocapitalization(.none) + } + } + + + SlickLoadingSpinner(connectionState: viewmodel.validationProgress) + .frame(width: 50, height: 50) + FancyButton( + add: false, + action: addButton, + text: "Add Project" + ) + .disabled(viewmodel.validationProgress != .success) + if !viewmodel.errorText.isEmpty { + Text(viewmodel.errorText) + } } - .id(viewmodel.projectType == .cospend ? "cospend" : "iHateMoney") - .frame(width: UIScreen.main.bounds.width, height: 220, alignment: .center) - SlickLoadingSpinner(connectionState: viewmodel.validationProgress) - .frame(width: 50, height: 50) - FancyButton( - add: false, - action: addButton, - text: "Add Project" - ) - .disabled(viewmodel.validationProgress != .success) - if !viewmodel.errorText.isEmpty { - Text(viewmodel.errorText) - } - Spacer() - } + .id(viewmodel.projectType == .cospend ? "cospend" : "iHateMoney")} .padding(.horizontal, 20) .padding(.vertical, 50) .background(Color.PFMBackground) diff --git a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift index defa90e..52be310 100644 --- a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift +++ b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift @@ -10,6 +10,13 @@ import Foundation import SlickLoadingSpinner import UIKit +struct InviteData { + let url: String + let baseUrl: String + let token: String + let project: String +} + class AddProjectManualViewModel: ObservableObject { @Published var projectType = ProjectBackend.cospend @@ -23,19 +30,31 @@ class AddProjectManualViewModel: ObservableObject { @Published var projectPassword = "" + @Published var inviteUrl = "" + @Published var validationProgress = LoadingState.notStarted @Published var errorText = "" private var lastProjectTestedSuccessfully: Project? + private var cancellables = Set() + init() { validatedInput.map { _ in LoadingState.connecting }.assign(to: &$validationProgress) validatedServer.map { $0 == 200 ? LoadingState.success : LoadingState.failure }.assign(to: &$validationProgress) errorTextPublisher.assign(to: &$errorText) serverCheckUnsupportedProtocoll.assign(to: &$errorText) + + $inviteUrl + .filter { _ in self.projectType == .iHateMoney } + .debounce(for: .seconds(1), scheduler: DispatchQueue.main) + .sink { [weak self] token in self?.validateInviteToken(token) } + .store(in: &cancellables) } + + func reset() { serverAddress = "" projectName = "" @@ -51,34 +70,46 @@ class AddProjectManualViewModel: ObservableObject { } } - func pasteAddress(address: String) { - let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) - guard let url = URL(string: trimmedAddress) else { - return - } - - // If it is a moneybuster URL - let (mUrl, mName, mPassword) = url.decodeQRCode() - if let url = mUrl, let name = mName { - serverAddress = url.absoluteString - projectName = name - if let password = mPassword { - projectPassword = password + private func validateInviteToken(_ token: String) { + guard !token.isEmpty, !serverAddress.isEmpty, !projectName.isEmpty else { return } + let baseUrl = serverAddress.hasPrefix("https://") ? serverAddress : "https://\(serverAddress)" + let inviteData = InviteData(url: baseUrl, baseUrl: baseUrl, token: token, project: projectName) + validationProgress = .connecting + Task { @MainActor in + do { + let testedProject = try await NetworkService.shared.getProjectName(invite: inviteData) + self.lastProjectTestedSuccessfully = testedProject + self.validationProgress = .success + } catch { + print("Invite URL failed: \(error)") + self.validationProgress = .failure } - return } - // If it is another url - - let pathComponents = url.pathComponents - let pureUrl = url.deletingPathExtension().absoluteString - let trimmIndices = url.absoluteString.indices(of: "/") - if let cutIndex = trimmIndices[safe: 2] { - let trimmedUrl = pureUrl[...cutIndex] - serverAddress = String(trimmedUrl) - } else { - serverAddress = pureUrl + } + + func pasteAddress(address: String) { + let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmedAddress) else { return } + + switch url.decodeQRCode() { + case let project as ProjectDataWithPassword: + serverAddress = project.server.absoluteString + projectName = project.project + projectPassword = project.password ?? "" + case let project as ProjectDataWithToken: + projectType = .iHateMoney + serverAddress = project.server.absoluteString + projectName = project.project + inviteUrl = project.token + default: + guard url.pathComponents.contains("join"), + let scheme = url.scheme, let host = url.host, + url.pathComponents.count >= 4 else { return } + projectType = .iHateMoney + serverAddress = "\(scheme)://\(host)" + projectName = url.pathComponents[1] + inviteUrl = url.pathComponents[3] } - fillFieldsFromComponents(components: pathComponents) } var serverAddressFormatted: AnyPublisher { @@ -134,7 +165,7 @@ class AddProjectManualViewModel: ObservableObject { .compactMap { server, token, password -> Project? in if let address = server.address, address.isValidURL, !token.isEmpty, !password.isEmpty { guard let url = URL(string: address) else { return nil } - return Project(name: token, password: password, token: token, backend: server.0, url: url) + return Project(name: token, password: password, token: token, backend: server.0, url: url, projectId: self.projectName) } else { return nil } diff --git a/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift b/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift index 1456a04..620456f 100644 --- a/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift +++ b/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift @@ -50,7 +50,8 @@ class AddProjectQRViewModel: ObservableObject { ) .map { url, token, password in self.isTestingSubject.send(.connecting) - return Project(name: "", password: password, token: token, backend: .cospend, url: url) + + return Project(name: "", password: password, token: token, backend: .cospend, url: url, projectId: token) } .flatMap { project in NetworkService.shared.testProject(project) @@ -92,42 +93,59 @@ class AddProjectQRViewModel: ObservableObject { foundCode .sink { codedUrl in let projectData = codedUrl.decodeQRCode() - guard let url = projectData.server, let token = projectData.project else { return } - if let password = projectData.passwd { + + if let projectData = projectData as? ProjectDataWithPassword { + if let password = projectData.password { + self.isTestingSubject.send(.connecting) + let project = Project(name: projectData.project, password: password, token: projectData.project, backend: .cospend, url: projectData.server, projectId: projectData.project) + Task(priority: .userInitiated) { + do { + let apiProject = try await NetworkService.shared.getProjectName(project) + try ProjectManager.shared.addProject(apiProject) + self.isTestingSubject.send(.success) + } catch { + print(codedUrl) + print() + print(error) + self.isTestingSubject.send(.failure) + } + } + // NetworkService.shared.testProject(project) + // .asUIPublisher + // .sink(receiveValue: { + // project, code in + // if code == 200 { + // DispatchQueue.main.asyncAfter(deadline: DispatchTime.now().advanced(by: .seconds(1))) { + // ProjectManager.shared.addProject(project) + // } + // self.isTestingSubject.send(.success) + // } else { + // self.isTestingSubject.send(.failure) + // } + // }).store(in: &self.subscriptions) + } else { + withAnimation { + self.url = projectData.server + self.name = projectData.project + self.askForPassword.toggle() + } + } + return + } + + if let projectData = projectData as? ProjectDataWithToken { self.isTestingSubject.send(.connecting) - let project = Project(name: token, password: password, token: token, backend: .cospend, url: url) Task(priority: .userInitiated) { do { - let apiProject = try await NetworkService.shared.getProjectName(project) + let apiProject = try await NetworkService.shared.getProjectName(invite: InviteData(url: projectData.server.absoluteString, baseUrl: projectData.server.absoluteString, token: projectData.token, project: projectData.project)) try ProjectManager.shared.addProject(apiProject) self.isTestingSubject.send(.success) } catch { - print(codedUrl) - print() - print(error) self.isTestingSubject.send(.failure) } } -// NetworkService.shared.testProject(project) -// .asUIPublisher -// .sink(receiveValue: { -// project, code in -// if code == 200 { -// DispatchQueue.main.asyncAfter(deadline: DispatchTime.now().advanced(by: .seconds(1))) { -// ProjectManager.shared.addProject(project) -// } -// self.isTestingSubject.send(.success) -// } else { -// self.isTestingSubject.send(.failure) -// } -// }).store(in: &self.subscriptions) - } else { - withAnimation { - self.url = url - self.name = token - self.askForPassword.toggle() - } } } + } } diff --git a/PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift b/PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift index 28818ce..e11eeeb 100644 --- a/PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift +++ b/PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift @@ -25,7 +25,7 @@ final class ShareProjectQRCodeViewModel: ObservableObject { private func generate() { let server = project.url.relativeString .replacingOccurrences(of: "https://", with: "") - self.dataString = "cospend://\(server)/\(project.token)/\(project.password)" + self.dataString = "cospend://\(server)/\(project.projectId)/\(project.password)" self.qrCodeImage = generateQRCode(from: dataString) } diff --git a/PayForMeTests/BalanceCalculationTests.swift b/PayForMeTests/BalanceCalculationTests.swift index 034c261..a7cc990 100644 --- a/PayForMeTests/BalanceCalculationTests.swift +++ b/PayForMeTests/BalanceCalculationTests.swift @@ -35,7 +35,8 @@ class BalanceCalculationTests: XCTestCase { private func makeProject(members: [Person], bills: [Bill]) -> Project { let project = Project( name: "test", password: "pw", token: "tok", - backend: .cospend, url: URL(string: "https://test.de")! + backend: .cospend, url: URL(string: "https://test.de")!, + projectId: "" ) project.members = Dictionary(uniqueKeysWithValues: members.map { ($0.id, $0) }) project.bills = bills diff --git a/PayForMeTests/NetworkRequestTests.swift b/PayForMeTests/NetworkRequestTests.swift index a7b7037..d0e67bc 100644 --- a/PayForMeTests/NetworkRequestTests.swift +++ b/PayForMeTests/NetworkRequestTests.swift @@ -72,7 +72,7 @@ class NetworkRequestTests: XCTestCase { func testCospend_loadBills_urlEmbedsTokenAndPassword() { let project = Project.makeCospend(token: "mytoken", password: "mypass", - url: "https://cloud.example.com") + url: "https://cloud.example.com", projectId: "myproject") MockURLProtocol.requestHandler = jsonHandler() let exp = expectation(description: "request intercepted") @@ -87,13 +87,13 @@ class NetworkRequestTests: XCTestCase { // The full Cospend path must be: // /index.php/apps/cospend/api/projects/{token}/{password}/bills XCTAssertTrue( - url.contains("/index.php/apps/cospend/api/projects/mytoken/mypass/bills"), + url.contains("/index.php/apps/cospend/api/projects/myproject/mypass/bills"), "Cospend must put token and password in the URL path. Got: \(url)" ) } func testCospend_loadMembers_urlContainsMembersEndpoint() { - let project = Project.makeCospend(token: "tok", password: "pass") + let project = Project.makeCospend(token: "tok", password: "pass", projectId: "proj") MockURLProtocol.requestHandler = jsonHandler() let exp = expectation(description: "request intercepted") @@ -105,7 +105,7 @@ class NetworkRequestTests: XCTestCase { let url = MockURLProtocol.lastCapturedRequest?.url?.absoluteString ?? "" XCTAssertTrue(url.hasSuffix("/members") || url.contains("/members"), "Members endpoint must end with /members. Got: \(url)") - XCTAssertTrue(url.contains("/tok/pass/members"), + XCTAssertTrue(url.contains("/proj/pass/members"), "Cospend must embed token and password. Got: \(url)") } @@ -133,7 +133,7 @@ class NetworkRequestTests: XCTestCase { func testIHateMoney_loadBills_urlDoesNotContainPassword() { // iHateMoney uses HTTP Basic Auth — the password must NEVER appear in the URL. let project = Project.makeIHateMoney(token: "mytoken", password: "secret", - url: "https://ihatemoney.org") + url: "https://ihatemoney.org", projectId: "myproject") MockURLProtocol.requestHandler = jsonHandler() let exp = expectation(description: "request intercepted") @@ -145,7 +145,7 @@ class NetworkRequestTests: XCTestCase { let url = MockURLProtocol.lastCapturedRequest?.url?.absoluteString ?? "" XCTAssertFalse(url.contains("secret"), "iHateMoney password must NOT appear in the URL. Got: \(url)") - XCTAssertTrue(url.contains("/api/projects/mytoken/bills"), + XCTAssertTrue(url.contains("/api/projects/myproject/bills"), "iHateMoney URL must use /api/projects/{token}/bills. Got: \(url)") } diff --git a/PayForMeTests/TestHelpers.swift b/PayForMeTests/TestHelpers.swift index c7487b4..76bdddf 100644 --- a/PayForMeTests/TestHelpers.swift +++ b/PayForMeTests/TestHelpers.swift @@ -76,28 +76,32 @@ extension Project { static func makeCospend( token: String = "mytoken", password: String = "mypass", - url: String = "https://nextcloud.example.com" + url: String = "https://nextcloud.example.com", + projectId: String = "my-project" ) -> Project { Project( name: "test-project", password: password, token: token, backend: .cospend, - url: URL(string: url)! + url: URL(string: url)!, + projectId: projectId, ) } static func makeIHateMoney( token: String = "mytoken", password: String = "mypass", - url: String = "https://ihatemoney.org" + url: String = "https://ihatemoney.org", + projectId: String = "my-project" ) -> Project { Project( name: "test-project", password: password, token: token, backend: .iHateMoney, - url: URL(string: url)! + url: URL(string: url)!, + projectId: projectId ) } } diff --git a/PayForMeTests/UrlExtensionsTests.swift b/PayForMeTests/UrlExtensionsTests.swift index 8ae38bd..cebedcb 100644 --- a/PayForMeTests/UrlExtensionsTests.swift +++ b/PayForMeTests/UrlExtensionsTests.swift @@ -32,10 +32,14 @@ class UrlExtensionsTests: XCTestCase { // cospend://host/project/password → server="https://host" let url = URL(string: "cospend://myserver.de/myproject/no-pass")! - let (server, project, password) = url.decodeCospendString() - XCTAssertEqual(server?.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "no-pass") + let project = url.decodeCospendString() + XCTAssertNotNil(project) + + if let server = project?.server, let password = project?.password, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://myserver.de") + XCTAssertEqual(project, "myproject") + XCTAssertEqual(password, "no-pass") + } } func testCospendStringDecodingForSubfolders() throws { @@ -45,10 +49,14 @@ class UrlExtensionsTests: XCTestCase { // → server="https://host/folder1/folder2" let url = URL(string: "cospend://myserver.de/folder1/folder2/myproject/mypassword")! - let (server, project, password) = url.decodeCospendString() - XCTAssertEqual(server?.absoluteString, "https://myserver.de/folder1/folder2") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") + let project = url.decodeCospendString() + XCTAssertNotNil(project) + + if let server = project?.server, let password = project?.password, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://myserver.de/folder1/folder2") + XCTAssertEqual(project, "myproject") + XCTAssertEqual(password, "mypassword") + } } func testCospendStringDecodingForSubdomains() throws { @@ -56,10 +64,14 @@ class UrlExtensionsTests: XCTestCase { // server root, not just the top-level domain. let url = URL(string: "cospend://subdomain.myserver.de/myproject/mypassword")! - let (server, project, password) = url.decodeCospendString() - XCTAssertEqual(server?.absoluteString, "https://subdomain.myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") + let project = url.decodeCospendString() + XCTAssertNotNil(project) + + if let server = project?.server, let password = project?.password, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://subdomain.myserver.de") + XCTAssertEqual(project, "myproject") + XCTAssertEqual(password, "mypassword") + } } func testCospendStringDecodingForNonStandardPort() throws { @@ -68,10 +80,14 @@ class UrlExtensionsTests: XCTestCase { // reach the right endpoint. let url = URL(string: "cospend://myserver.de:1234/myproject/mypassword")! - let (server, project, password) = url.decodeCospendString() - XCTAssertEqual(server?.absoluteString, "https://myserver.de:1234") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") + let project = url.decodeCospendString() + XCTAssertNotNil(project) + + if let server = project?.server, let password = project?.password, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://myserver.de:1234") + XCTAssertEqual(project, "myproject") + XCTAssertEqual(password, "mypassword") + } } func testCospendError_wrongScheme() throws { @@ -80,10 +96,9 @@ class UrlExtensionsTests: XCTestCase { // its path looks like it might contain project data. let url = URL(string: "https://myserver/myproject/mypassword")! - let (server, project, password) = url.decodeCospendString() - XCTAssertNil(server) + let project = url.decodeCospendString() + XCTAssertNil(project) - XCTAssertNil(password) } // MARK: - MoneyBuster web link decoding @@ -94,22 +109,11 @@ class UrlExtensionsTests: XCTestCase { // https://net.eneiluj.moneybuster.cospend/{server}/{project}/{password} let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject/mypassword")! - let (server, project, password) = url.decodeMoneyBusterString() - XCTAssertEqual(server?.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") - } - - func testMoneyBusterNoPassword() throws { - // MoneyBuster omits the password component for passwordless projects. - // The decoder must return nil for password without crashing — the default - // "no-pass" value is set by AddProjectManualViewModel, not here. - let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject")! - - let (server, project, password) = url.decodeMoneyBusterString() - XCTAssertEqual(server?.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertNil(password) + let project = url.decodeMoneyBusterString() + XCTAssertNotNil(project) + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "mypassword") } func testMoneyBusterError_wrongHost() throws { @@ -117,23 +121,18 @@ class UrlExtensionsTests: XCTestCase { // nil. Without this guard, any https URL would decode as a project link. let url = URL(string: "https://myserver/myproject/mypassword")! - let (server, project, password) = url.decodeMoneyBusterString() - XCTAssertNil(server) + let project = url.decodeMoneyBusterString() XCTAssertNil(project) - XCTAssertNil(password) } func testMoneyBusterDecoding_tooManyPathComponents_returnsNil() throws { // The decoder guards: pathComponents.count must be 3 or 4. // (["", server, project] = 3, or + password = 4) // URLs with more components are malformed — the server/project boundary - // is ambiguous, so all three fields must be nil. + // is ambiguous, so nil must be returned. let url = URL(string: "https://net.eneiluj.moneybuster.cospend/server/project/password/extra")! - let (server, project, password) = url.decodeMoneyBusterString() - XCTAssertNil(server) - XCTAssertNil(project) - XCTAssertNil(password) + XCTAssertNil(url.decodeMoneyBusterString()) } // MARK: - QR code dispatcher (decodeQRCode) @@ -144,10 +143,11 @@ class UrlExtensionsTests: XCTestCase { // a direct call to decodeCospendString() for the same URL. let url = URL(string: "cospend://myserver.de/myproject/mypassword")! - let (server, project, password) = url.decodeQRCode() - XCTAssertEqual(server?.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") + let project = url.decodeQRCode() as? ProjectDataWithPassword + XCTAssertNotNil(project) + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "mypassword") } func testDecodeQRCode_httpsSchemeRoutesToMoneyBusterDecoder() throws { @@ -155,9 +155,37 @@ class UrlExtensionsTests: XCTestCase { // A MoneyBuster QR code uses https:// with the fixed MoneyBuster host prefix. let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject/mypassword")! - let (server, project, password) = url.decodeQRCode() - XCTAssertEqual(server?.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") + let project = url.decodeMoneyBusterString() + XCTAssertNotNil(project) + + if let server = project?.server, let password = project?.password, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://myserver.de") + XCTAssertEqual(password, "mypassword") + XCTAssertEqual(project, "myproject") + } + } + + func testMoneyBusterNoPassword() throws { + let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject")! + + let project = url.decodeMoneyBusterString() + XCTAssertNotNil(project) + + if let server = project?.server, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://myserver.de") + XCTAssertEqual(project, "myproject") + } + } + + func testIHateMoneyQRDecoding() throws { + let url: URL = URL(string: "ihatemoney://my-server.de/demo-project/join/WyJ0ZXN0Il0.Rt04fNMmxp9YslCRq8hB6jE9s1Q")! + + let project = url.decodeIHateMoenyString() + XCTAssertNotNil(project) + + XCTAssertEqual(project?.server.absoluteString, "https://my-server.de") + XCTAssertEqual(project?.project, "demo-project") + XCTAssertEqual(project?.token, "WyJ0ZXN0Il0.Rt04fNMmxp9YslCRq8hB6jE9s1Q") + } } From fea620e8d06ca4322f341458f3b71eb32ebac615 Mon Sep 17 00:00:00 2001 From: Maximilian Fischer Date: Wed, 24 Jun 2026 17:45:24 +0200 Subject: [PATCH 2/4] cleanup, naming and test optimizations --- PayForMe/Services/NetworkService.swift | 19 ------- PayForMe/Util/Util.swift | 29 ++-------- .../QRCodes/AddProjectQRViewModel.swift | 13 ----- PayForMeTests/UrlExtensionsTests.swift | 56 +++++-------------- 4 files changed, 20 insertions(+), 97 deletions(-) diff --git a/PayForMe/Services/NetworkService.swift b/PayForMe/Services/NetworkService.swift index 02e1465..1bf9933 100644 --- a/PayForMe/Services/NetworkService.swift +++ b/PayForMe/Services/NetworkService.swift @@ -111,25 +111,6 @@ class NetworkService { return Project(name: apiProject.name, password: "", token: invite.token, backend: ProjectBackend.iHateMoney, url: URL(string: invite.url)!, projectId: apiProject.id) } - func fetchInvite(_ invite: InviteData?) async throws { - guard let invite = invite, let url = URL(string: invite.url) else { - return - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue( - "Bearer \(invite.token)", - forHTTPHeaderField: "Authorization" - ) - - let (data, response) = try await URLSession.shared.data(for: request) - if let httpResponse = response as? HTTPURLResponse { - print("status:", httpResponse.statusCode) - } - print(String(data: data, encoding: .utf8) ?? "") - } - func postBillPublisher(bill: Bill) -> AnyPublisher { let request = buildURLRequest("bills", params: bill.paramsFor(currentProject.backend), project: currentProject, httpMethod: "POST") return sendBillPublisher(request: request) diff --git a/PayForMe/Util/Util.swift b/PayForMe/Util/Util.swift index 6c40aeb..2ebe363 100644 --- a/PayForMe/Util/Util.swift +++ b/PayForMe/Util/Util.swift @@ -185,25 +185,12 @@ struct ProjectDataWithToken: ProjectData { var server: URL var project: String var token: String - - init(server: URL, project: String, token: String) { - self.server = server - self.project = project - self.token = token - } - } struct ProjectDataWithPassword: ProjectData { var server: URL var project: String var password: String? - - init(server: URL, project: String, password: String?) { - self.server = server - self.project = project - self.password = password - } } extension URL { @@ -225,9 +212,7 @@ extension URL { password: password ) } -} -extension URL { func decodeCospendString() -> ProjectDataWithPassword? { guard let host = host, let scheme = scheme, @@ -239,7 +224,7 @@ extension URL { var hostString = host if let port = port {hostString += ":\(port)"} - + if pathComponents.count > 3 { hostString += "/" + pathComponents[1..<(pathComponents.count - 2)].joined(separator: "/") } @@ -255,11 +240,9 @@ extension URL { project: project, password: password) } -} -extension URL { - func decodeIHateMoenyString() -> ProjectDataWithToken? { - guard var host = host, let scheme = scheme, scheme.localizedCaseInsensitiveContains("ihatemoney") else { + func decodeIHateMoneyString() -> ProjectDataWithToken? { + guard let host = host, let scheme = scheme, scheme.localizedCaseInsensitiveContains("ihatemoney") else { return nil } @@ -273,23 +256,21 @@ extension URL { return ProjectDataWithToken(server: url, project: project, token: token) } -} -extension URL { func decodeQRCode() -> ProjectData? { guard let scheme = scheme else { return nil } if scheme.contains("cospend") { return decodeCospendString() } else if scheme.contains("ihatemoney") { - return decodeIHateMoenyString() + return decodeIHateMoneyString() } else { return decodeMoneyBusterString() } } } -// Why is URL an identifier but not identifiable? extension URL: Identifiable { + // Why is URL an identifier but not identifiable? public var id: URL { self } diff --git a/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift b/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift index 620456f..bbe973c 100644 --- a/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift +++ b/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift @@ -110,19 +110,6 @@ class AddProjectQRViewModel: ObservableObject { self.isTestingSubject.send(.failure) } } - // NetworkService.shared.testProject(project) - // .asUIPublisher - // .sink(receiveValue: { - // project, code in - // if code == 200 { - // DispatchQueue.main.asyncAfter(deadline: DispatchTime.now().advanced(by: .seconds(1))) { - // ProjectManager.shared.addProject(project) - // } - // self.isTestingSubject.send(.success) - // } else { - // self.isTestingSubject.send(.failure) - // } - // }).store(in: &self.subscriptions) } else { withAnimation { self.url = projectData.server diff --git a/PayForMeTests/UrlExtensionsTests.swift b/PayForMeTests/UrlExtensionsTests.swift index cebedcb..3cb5e67 100644 --- a/PayForMeTests/UrlExtensionsTests.swift +++ b/PayForMeTests/UrlExtensionsTests.swift @@ -34,12 +34,9 @@ class UrlExtensionsTests: XCTestCase { let project = url.decodeCospendString() XCTAssertNotNil(project) - - if let server = project?.server, let password = project?.password, let project = project?.project { - XCTAssertEqual(server.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "no-pass") - } + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "no-pass") } func testCospendStringDecodingForSubfolders() throws { @@ -52,11 +49,9 @@ class UrlExtensionsTests: XCTestCase { let project = url.decodeCospendString() XCTAssertNotNil(project) - if let server = project?.server, let password = project?.password, let project = project?.project { - XCTAssertEqual(server.absoluteString, "https://myserver.de/folder1/folder2") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") - } + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de/folder1/folder2") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "mypassword") } func testCospendStringDecodingForSubdomains() throws { @@ -67,11 +62,9 @@ class UrlExtensionsTests: XCTestCase { let project = url.decodeCospendString() XCTAssertNotNil(project) - if let server = project?.server, let password = project?.password, let project = project?.project { - XCTAssertEqual(server.absoluteString, "https://subdomain.myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") - } + XCTAssertEqual(project?.server.absoluteString, "https://subdomain.myserver.de") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "mypassword") } func testCospendStringDecodingForNonStandardPort() throws { @@ -83,11 +76,9 @@ class UrlExtensionsTests: XCTestCase { let project = url.decodeCospendString() XCTAssertNotNil(project) - if let server = project?.server, let password = project?.password, let project = project?.project { - XCTAssertEqual(server.absoluteString, "https://myserver.de:1234") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") - } + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de:1234") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "mypassword") } func testCospendError_wrongScheme() throws { @@ -150,37 +141,20 @@ class UrlExtensionsTests: XCTestCase { XCTAssertEqual(project?.password, "mypassword") } - func testDecodeQRCode_httpsSchemeRoutesToMoneyBusterDecoder() throws { - // For https:// QR codes, decodeQRCode() must call decodeMoneyBusterString(). - // A MoneyBuster QR code uses https:// with the fixed MoneyBuster host prefix. - let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject/mypassword")! - - let project = url.decodeMoneyBusterString() - XCTAssertNotNil(project) - - if let server = project?.server, let password = project?.password, let project = project?.project { - XCTAssertEqual(server.absoluteString, "https://myserver.de") - XCTAssertEqual(password, "mypassword") - XCTAssertEqual(project, "myproject") - } - } - func testMoneyBusterNoPassword() throws { let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject")! let project = url.decodeMoneyBusterString() XCTAssertNotNil(project) - if let server = project?.server, let project = project?.project { - XCTAssertEqual(server.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - } + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de") + XCTAssertEqual(project?.project, "myproject") } func testIHateMoneyQRDecoding() throws { let url: URL = URL(string: "ihatemoney://my-server.de/demo-project/join/WyJ0ZXN0Il0.Rt04fNMmxp9YslCRq8hB6jE9s1Q")! - let project = url.decodeIHateMoenyString() + let project = url.decodeIHateMoneyString() XCTAssertNotNil(project) XCTAssertEqual(project?.server.absoluteString, "https://my-server.de") From 2e7a1e64c7b9d1b6f7670115b4ce95e78d3cc617 Mon Sep 17 00:00:00 2001 From: Maximilian Fischer Date: Fri, 3 Jul 2026 18:47:07 +0200 Subject: [PATCH 3/4] openByURL should also handle IHM links --- PayForMe/SceneDelegate.swift | 2 +- PayForMe/Services/ProjectManager.swift | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/PayForMe/SceneDelegate.swift b/PayForMe/SceneDelegate.swift index 720c4b3..f897003 100644 --- a/PayForMe/SceneDelegate.swift +++ b/PayForMe/SceneDelegate.swift @@ -67,7 +67,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func scene(_: UIScene, openURLContexts URLContexts: Set) { guard let context = URLContexts.first, let scheme = context.url.scheme, - scheme.localizedCaseInsensitiveContains("cospend") + scheme.localizedCaseInsensitiveContains("cospend") || scheme.localizedCaseInsensitiveContains("ihatemoney") else { return } diff --git a/PayForMe/Services/ProjectManager.swift b/PayForMe/Services/ProjectManager.swift index 8e16dc0..9f0f839 100644 --- a/PayForMe/Services/ProjectManager.swift +++ b/PayForMe/Services/ProjectManager.swift @@ -43,8 +43,7 @@ class ProjectManager: ObservableObject { } func openedByURL(url: URL) { - let data = url.decodeCospendString() - guard data != nil else { return } + guard url.decodeCospendString() != nil || url.decodeIHateMoneyString() != nil else { return } openedByURL = url } From 83bced4a4c1c6b1bfb7f1cb803e8bd781be1ebe5 Mon Sep 17 00:00:00 2001 From: Maximilian Fischer Date: Fri, 3 Jul 2026 18:47:40 +0200 Subject: [PATCH 4/4] fix cospend api url --- PayForMe/Services/NetworkService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PayForMe/Services/NetworkService.swift b/PayForMe/Services/NetworkService.swift index 1bf9933..ccf408c 100644 --- a/PayForMe/Services/NetworkService.swift +++ b/PayForMe/Services/NetworkService.swift @@ -172,7 +172,7 @@ class NetworkService { if project.backend == .cospend { url = url - .appendingPathComponent(project.projectId) + .appendingPathComponent(project.token) .appendingPathComponent(project.password) }