Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
workflow_dispatch:

jobs:
build:
Expand Down
4 changes: 4 additions & 0 deletions PayForMe.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
objects = {

/* Begin PBXBuildFile section */
C34C21CC3AD72833B6C06C9D /* LoadErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD167A293B2F0746C0A09C2E /* LoadErrorTests.swift */; };
481FB4FB23E964C8003BD108 /* ProjectManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 481FB4FA23E964C8003BD108 /* ProjectManager.swift */; };
481FB4FD23EAD78F003BD108 /* AddProjectManualViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 481FB4FC23EAD78F003BD108 /* AddProjectManualViewModel.swift */; };
489995DA23F6EC6F008B7E38 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 489995D923F6EC6F008B7E38 /* OnboardingView.swift */; };
Expand Down Expand Up @@ -88,6 +89,7 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
CD167A293B2F0746C0A09C2E /* LoadErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadErrorTests.swift; sourceTree = "<group>"; };
0865D2DAF541B24078D1DC41 /* TestHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
143F41EE78329905894C8A29 /* BalanceCalculationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BalanceCalculationTests.swift; sourceTree = "<group>"; };
316FBA7C0CE4ABB51EC2A121 /* NetworkRequestTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NetworkRequestTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -228,6 +230,7 @@
isa = PBXGroup;
children = (
654754E32528B29F00A82EB6 /* AddProjectManuallyTests.swift */,
CD167A293B2F0746C0A09C2E /* LoadErrorTests.swift */,
654754E52528B29F00A82EB6 /* Info.plist */,
6523FC4B25580EEF00BCD843 /* UrlExtensionsTests.swift */,
0865D2DAF541B24078D1DC41 /* TestHelpers.swift */,
Expand Down Expand Up @@ -544,6 +547,7 @@
buildActionMask = 2147483647;
files = (
654754E42528B29F00A82EB6 /* AddProjectManuallyTests.swift in Sources */,
C34C21CC3AD72833B6C06C9D /* LoadErrorTests.swift in Sources */,
6523FC4C25580EEF00BCD843 /* UrlExtensionsTests.swift in Sources */,
57BC8D25B91CE40F4669650C /* TestHelpers.swift in Sources */,
D279295CFA25D9CC69C6F27C /* BillTests.swift in Sources */,
Expand Down
76 changes: 62 additions & 14 deletions PayForMe/Services/NetworkService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,49 @@
import Combine
import Foundation

/// Why loading a project's data failed.
///
/// The load publishers used to declare `Never` as their failure type, which
/// left everything upstream unable to tell "this project is empty" from "the
/// server rejected us". Changing a project's password on the server therefore
/// showed empty lists and no explanation at all (#37).
enum LoadError: Error, Equatable, Identifiable {
/// Credentials rejected. Usually the project password was changed on the
/// server after it was added here.
case unauthorized
case notFound
case http(Int)
/// No usable connection, or a response that was not HTTP at all.
case connection
/// The server answered, but not with something we could decode.
case invalidResponse

var id: String { String(describing: self) }

init(statusCode: Int) {
switch statusCode {
case 401, 403:
self = .unauthorized
case 404:
self = .notFound
default:
self = .http(statusCode)
}
}

init(_ error: Error) {
switch error {
case let serverError as LoadError:
self = serverError
case is URLError:
self = .connection
default:
// Decoding failed: the server answered, we just could not read it.
self = .invalidResponse
}
}
}

class NetworkService {
static let shared = NetworkService()

Expand All @@ -34,16 +77,25 @@ class NetworkService {

let networkActivityPublisher = PassthroughSubject<Bool, Never>()

func loadBillsPublisher(_ project: Project) -> AnyPublisher<[Bill], Never> {
/// Turns a response into its body or into a typed failure. Both cases used
/// to collapse into "no value emitted", which is precisely what made a
/// rejected password indistinguishable from an empty project.
private static func validate(_ data: Data, _ response: URLResponse) throws -> Data {
guard let httpResponse = response as? HTTPURLResponse else {
throw LoadError.connection
}
guard httpResponse.statusCode == 200 else {
throw LoadError(statusCode: httpResponse.statusCode)
}
return data
}

func loadBillsPublisher(_ project: Project) -> AnyPublisher<[Bill], LoadError> {
let request = buildURLRequest("bills", params: [:], project: project)
return URLSession.shared.dataTaskPublisher(for: request)
.compactMap { data, response -> Data? in
guard let httpResponse = response as? HTTPURLResponse else { print("Network Error"); return nil }
guard httpResponse.statusCode == 200 else { print("Network Error: Status code: \(httpResponse.statusCode) \(httpResponse.description)"); return nil }
return data
}
.tryMap { data, response in try NetworkService.validate(data, response) }
.decode(type: [Bill].self, decoder: decoder)
.replaceError(with: [])
.mapError { LoadError($0) }
.map {
$0.sorted {
if let l1 = $0.lastchanged,
Expand All @@ -57,16 +109,12 @@ class NetworkService {
.eraseToAnyPublisher()
}

func loadMembersPublisher(_ project: Project) -> AnyPublisher<[Int: Person], Never> {
func loadMembersPublisher(_ project: Project) -> AnyPublisher<[Int: Person], LoadError> {
let request = buildURLRequest("members", params: [:], project: project)
return URLSession.shared.dataTaskPublisher(for: request)
.compactMap { data, response -> Data? in
guard let httpResponse = response as? HTTPURLResponse else { print("Network Error"); return nil }
guard httpResponse.statusCode == 200 else { print("Network Error: Status code: \(httpResponse.statusCode) \(httpResponse.description)"); return nil }
return data
}
.tryMap { data, response in try NetworkService.validate(data, response) }
.decode(type: [Person].self, decoder: decoder)
.replaceError(with: [])
.mapError { LoadError($0) }
.map {
members in
let filtered = members.filter {
Expand Down
14 changes: 13 additions & 1 deletion PayForMe/Services/ProjectManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ class ProjectManager: ObservableObject {

@Published var openedByURL: URL?

/// The last failure while loading bills and members, or nil after a load
/// that worked. Set so the UI can say something instead of showing lists
/// that are empty for no visible reason (#37).
@Published var loadingError: LoadError?

private init() {
print("init")
projects = storageService.loadProjects()
Expand Down Expand Up @@ -80,8 +85,15 @@ class ProjectManager: ObservableObject {
.receive(on: DispatchQueue.main)
.handleEvents(receiveCancel: invokeCompletionOnce)
.sink(
receiveCompletion: { _ in invokeCompletionOnce() },
receiveCompletion: { [weak self] result in
if case let .failure(error) = result {
self?.loadingError = error
}
invokeCompletionOnce()
},
receiveValue: { [weak self] project in
// A load that worked clears whatever went wrong last time.
self?.loadingError = nil
self?.currentProject = project
invokeCompletionOnce()
}
Expand Down
7 changes: 7 additions & 0 deletions PayForMe/Strings/cs.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,10 @@
"member_error_forbidden" = "To obvykle vyžaduje práva správce projektu. Sdílené heslo projektu může umožňovat jen přístup účastníka.";
"member_error_network" = "Není spojení se serverem. Zkontrolujte síť a zkuste to znovu.";
"member_error_generic" = "Server odmítl požadavek (HTTP %d).";

"Could not load project" = "Projekt se nepodařilo načíst";
"load_error_unauthorized" = "Server odmítl uložené heslo. Pokud se heslo projektu změnilo, přidej projekt znovu s novým heslem.";
"load_error_not_found" = "Server tento projekt nezná. Možná byl smazán nebo je špatné ID projektu.";
"load_error_connection" = "Žádné spojení se serverem. Zkontroluj síť a zkus to znovu.";
"load_error_invalid_response" = "Odpověď serveru nelze přečíst. Možná nejde o server Cospend ani iHateMoney.";
"load_error_generic" = "Server projekt neodeslal (HTTP %d).";
7 changes: 7 additions & 0 deletions PayForMe/Strings/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,10 @@
"member_error_forbidden" = "Für den Erfolg sind meist Maintainer-Rechte am Projekt nötig. Du kannst diese Berechtigungen jederzeit erteilen, indem du die Einstellungen des Freigabelinks in Cospend (deiner Nextcloud-App) änderst.";
"member_error_network" = "Keine Verbindung zum Server. Bitte Netzwerk prüfen und erneut versuchen.";
"member_error_generic" = "Der Server hat die Anfrage abgelehnt (HTTP %d).";

"Could not load project" = "Projekt konnte nicht geladen werden";
"load_error_unauthorized" = "Der Server hat das gespeicherte Passwort abgelehnt. Wurde das Projektpasswort geändert, füge das Projekt mit dem neuen Passwort erneut hinzu.";
"load_error_not_found" = "Der Server kennt dieses Projekt nicht. Möglicherweise wurde es gelöscht oder die Projekt-ID stimmt nicht.";
"load_error_connection" = "Keine Verbindung zum Server. Bitte prüfe dein Netzwerk und versuche es erneut.";
"load_error_invalid_response" = "Die Antwort des Servers war nicht lesbar. Möglicherweise ist es kein Cospend- oder iHateMoney-Server.";
"load_error_generic" = "Der Server hat das Projekt nicht herausgegeben (HTTP %d).";
7 changes: 7 additions & 0 deletions PayForMe/Strings/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,10 @@
"member_error_forbidden" = "This usually requires maintainer rights for the project. You can grant those at any time by changing the share-link's settings in Cospend (your Nextcloud-App).";
"member_error_network" = "No connection to the server. Please check your network and try again.";
"member_error_generic" = "The server rejected the request (HTTP %d).";

"Could not load project" = "Could not load project";
"load_error_unauthorized" = "The server rejected the stored password. If the project password was changed, add the project again with the new one.";
"load_error_not_found" = "The server does not know this project. It may have been deleted, or the project id may be wrong.";
"load_error_connection" = "No connection to the server. Please check your network and try again.";
"load_error_invalid_response" = "The server's answer could not be read. It may not be a Cospend or iHateMoney server.";
"load_error_generic" = "The server refused to send the project (HTTP %d).";
7 changes: 7 additions & 0 deletions PayForMe/Strings/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,10 @@
"Enter invite link" = "Introducir el enlace de invitación";
"invite_link_hint" = "Pega el enlace de invitación completo. El servidor y el proyecto se detectan automáticamente.";
"Server returned an invalid HTTP response" = "El servidor devolvió una respuesta HTTP no válida";

"Could not load project" = "No se pudo cargar el proyecto";
"load_error_unauthorized" = "El servidor rechazó la contraseña guardada. Si se cambió la contraseña del proyecto, añádelo de nuevo con la nueva.";
"load_error_not_found" = "El servidor no conoce este proyecto. Puede que se haya eliminado o que el identificador sea incorrecto.";
"load_error_connection" = "Sin conexión con el servidor. Comprueba tu red e inténtalo de nuevo.";
"load_error_invalid_response" = "No se pudo leer la respuesta del servidor. Puede que no sea un servidor Cospend o iHateMoney.";
"load_error_generic" = "El servidor se negó a enviar el proyecto (HTTP %d).";
7 changes: 7 additions & 0 deletions PayForMe/Strings/fr.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,10 @@
"Enter invite link" = "Saisir le lien d'invitation";
"invite_link_hint" = "Collez le lien d'invitation complet. Le serveur et le projet sont détectés automatiquement.";
"Server returned an invalid HTTP response" = "Le serveur a renvoyé une réponse HTTP non valide";

"Could not load project" = "Impossible de charger le projet";
"load_error_unauthorized" = "Le serveur a refusé le mot de passe enregistré. Si le mot de passe du projet a changé, ajoutez à nouveau le projet avec le nouveau.";
"load_error_not_found" = "Le serveur ne connaît pas ce projet. Il a peut-être été supprimé, ou l'identifiant est incorrect.";
"load_error_connection" = "Pas de connexion au serveur. Vérifiez votre réseau et réessayez.";
"load_error_invalid_response" = "La réponse du serveur est illisible. Ce n'est peut-être pas un serveur Cospend ou iHateMoney.";
"load_error_generic" = "Le serveur a refusé d'envoyer le projet (HTTP %d).";
7 changes: 7 additions & 0 deletions PayForMe/Strings/ru.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,10 @@
"member_error_forbidden" = "Обычно для этого нужны права сопровождающего проекта. Пароль общего доступа может давать только права участника.";
"member_error_network" = "Нет соединения с сервером. Проверьте сеть и повторите попытку.";
"member_error_generic" = "Сервер отклонил запрос (HTTP %d).";

"Could not load project" = "Не удалось загрузить проект";
"load_error_unauthorized" = "Сервер отклонил сохранённый пароль. Если пароль проекта изменился, добавьте проект заново с новым паролем.";
"load_error_not_found" = "Сервер не знает этот проект. Возможно, он удалён или указан неверный идентификатор.";
"load_error_connection" = "Нет соединения с сервером. Проверьте сеть и попробуйте снова.";
"load_error_invalid_response" = "Не удалось прочитать ответ сервера. Возможно, это не сервер Cospend или iHateMoney.";
"load_error_generic" = "Сервер отказался передать проект (HTTP %d).";
22 changes: 22 additions & 0 deletions PayForMe/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,28 @@ struct ContentView: View {
manager.loadBillsAndMembers()
}
}
// Without this the app just shows empty lists when the server turns us
// away — the complaint behind #37.
.alert(item: $manager.loadingError) { error in
Alert(title: Text("Could not load project"),
message: Text(Self.message(for: error)),
dismissButton: .default(Text("OK")))
}
}

static func message(for error: LoadError) -> String {
switch error {
case .unauthorized:
return NSLocalizedString("load_error_unauthorized", comment: "Loading failed because the credentials were rejected")
case .notFound:
return NSLocalizedString("load_error_not_found", comment: "Loading failed because the project does not exist on the server")
case .connection:
return NSLocalizedString("load_error_connection", comment: "Loading failed because the server could not be reached")
case .invalidResponse:
return NSLocalizedString("load_error_invalid_response", comment: "Loading failed because the server's answer could not be read")
case let .http(code):
return String(format: NSLocalizedString("load_error_generic", comment: "Loading failed with an HTTP status code"), code)
}
}

var tabBar: some View {
Expand Down
Loading