Nextcloud login flow v2, Network code rewrite

This commit is contained in:
Vicnet
2023-09-20 13:25:25 +02:00
parent 0f16b164d6
commit 26dd5c34ff
16 changed files with 504 additions and 364 deletions

View File

@@ -15,10 +15,10 @@
A701719E2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719D2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift */; };
A70171A02AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719F2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift */; };
A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AC2AA8EF4700064C43 /* MainViewModel.swift */; };
A70171AF2AB2116B00064C43 /* NetworkController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AE2AB2116B00064C43 /* NetworkController.swift */; };
A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AE2AB2116B00064C43 /* NetworkHandler.swift */; };
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B02AB211DF00064C43 /* CustomError.swift */; };
A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B32AB2122900064C43 /* NetworkRequests.swift */; };
A70171B92AB399FB00064C43 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B82AB399FB00064C43 /* Extensions.swift */; };
A70171B92AB399FB00064C43 /* DateFormatterExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B82AB399FB00064C43 /* DateFormatterExtension.swift */; };
A70171BC2AB4983500064C43 /* CategoryCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BB2AB4983500064C43 /* CategoryCardView.swift */; };
A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* CategoryDetailView.swift */; };
A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeDetailView.swift */; };
@@ -28,6 +28,8 @@
A70171C92AB4CBB400064C43 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C82AB4CBB400064C43 /* OnboardingView.swift */; };
A70171CB2AB4CD1700064C43 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserDefaults.swift */; };
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; };
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; };
A703226D2ABAF90D00D7C4ED /* APIInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226C2ABAF90D00D7C4ED /* APIInterface.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -60,10 +62,10 @@
A701719D2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientUITests.swift; sourceTree = "<group>"; };
A701719F2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift; sourceTree = "<group>"; };
A70171AC2AA8EF4700064C43 /* MainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewModel.swift; sourceTree = "<group>"; };
A70171AE2AB2116B00064C43 /* NetworkController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkController.swift; sourceTree = "<group>"; };
A70171AE2AB2116B00064C43 /* NetworkHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkHandler.swift; sourceTree = "<group>"; };
A70171B02AB211DF00064C43 /* CustomError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomError.swift; sourceTree = "<group>"; };
A70171B32AB2122900064C43 /* NetworkRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkRequests.swift; sourceTree = "<group>"; };
A70171B82AB399FB00064C43 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = "<group>"; };
A70171B82AB399FB00064C43 /* DateFormatterExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatterExtension.swift; sourceTree = "<group>"; };
A70171BB2AB4983500064C43 /* CategoryCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryCardView.swift; sourceTree = "<group>"; };
A70171BD2AB4987900064C43 /* CategoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailView.swift; sourceTree = "<group>"; };
A70171BF2AB498A900064C43 /* RecipeDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDetailView.swift; sourceTree = "<group>"; };
@@ -73,6 +75,8 @@
A70171C82AB4CBB400064C43 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
A70171CA2AB4CD1700064C43 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = "<group>"; };
A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = "<group>"; };
A703226C2ABAF90D00D7C4ED /* APIInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIInterface.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -128,6 +132,7 @@
A70171BA2AB4980100064C43 /* Views */,
A70171B72AB2445700064C43 /* ViewModels */,
A70171B22AB211F000064C43 /* Network */,
A703226B2ABAF60D00D7C4ED /* Extensions */,
A70171852AA8E71F00064C43 /* Assets.xcassets */,
A70171872AA8E71F00064C43 /* Nextcloud_Cookbook_iOS_Client.entitlements */,
A70171882AA8E71F00064C43 /* Preview Content */,
@@ -163,9 +168,9 @@
A70171B22AB211F000064C43 /* Network */ = {
isa = PBXGroup;
children = (
A70171B82AB399FB00064C43 /* Extensions.swift */,
A703226C2ABAF90D00D7C4ED /* APIInterface.swift */,
A70171B32AB2122900064C43 /* NetworkRequests.swift */,
A70171AE2AB2116B00064C43 /* NetworkController.swift */,
A70171AE2AB2116B00064C43 /* NetworkHandler.swift */,
A70171B02AB211DF00064C43 /* CustomError.swift */,
);
path = Network;
@@ -203,6 +208,15 @@
path = Data;
sourceTree = "<group>";
};
A703226B2ABAF60D00D7C4ED /* Extensions */ = {
isa = PBXGroup;
children = (
A70171B82AB399FB00064C43 /* DateFormatterExtension.swift */,
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -335,9 +349,9 @@
files = (
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */,
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */,
A70171AF2AB2116B00064C43 /* NetworkController.swift in Sources */,
A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */,
A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */,
A70171B92AB399FB00064C43 /* Extensions.swift in Sources */,
A70171B92AB399FB00064C43 /* DateFormatterExtension.swift in Sources */,
A70171BC2AB4983500064C43 /* CategoryCardView.swift in Sources */,
A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */,
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */,
@@ -346,6 +360,8 @@
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
A70171842AA8E71900064C43 /* MainView.swift in Sources */,
A70171CB2AB4CD1700064C43 /* UserDefaults.swift in Sources */,
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */,
A703226D2ABAF90D00D7C4ED /* APIInterface.swift in Sources */,
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */,
A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */,
A70171C92AB4CBB400064C43 /* OnboardingView.swift in Sources */,

View File

@@ -47,7 +47,8 @@ struct RecipeDetail: Codable {
keywords: "",
dateCreated: "",
dateModified: "",
imageUrl: "", id: "",
imageUrl: "",
id: "",
prepTime: "",
cookTime: "",
totalTime: "",
@@ -68,4 +69,18 @@ struct RecipeImage {
var full: UIImage?
}
struct LoginV2Request: Codable {
let poll: LoginV2Poll
let login: String
}
struct LoginV2Poll: Codable {
let token: String
let endpoint: String
}
struct LoginV2Response: Codable {
let server: String
let loginName: String
let appPassword: String
}

View File

@@ -1,5 +1,5 @@
//
// Extensions.swift
// DateFormatterExtension.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 14.09.23.

View File

@@ -0,0 +1,33 @@
//
// JSONCoderExtension.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 20.09.23.
//
import Foundation
extension JSONDecoder {
static func safeDecode<T: Decodable>(_ data: Data) -> T? {
let decoder = JSONDecoder()
do {
print("Decoding type ", T.self, " ...")
return try decoder.decode(T.self, from: data)
} catch (let error) {
print("JSONDecoder - safeDecode(): Failed to decode data.")
print("Error: ", error)
return nil
}
}
}
extension JSONEncoder {
static func safeEncode<T: Encodable>(_ object: T) -> Data? {
do {
return try JSONEncoder().encode(object)
} catch {
print("JSONDecoder - safeEncode(): Could not encode object \(T.self)")
}
return nil
}
}

View File

@@ -0,0 +1,78 @@
//
// APIInterface.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 20.09.23.
//
import Foundation
class APIInterface {
var userSettings: UserSettings
var apiPath: String
var authString: String
let apiVersion = "1"
init(userSettings: UserSettings) {
print("Initializing NetworkController.")
self.userSettings = userSettings
self.apiPath = "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v\(apiVersion)/"
let loginString = "\(userSettings.username):\(userSettings.token)"
let loginData = loginString.data(using: String.Encoding.utf8)!
self.authString = loginData.base64EncodedString()
}
func imageDataFromServer(recipeId: Int, thumb: Bool) async -> Data? {
do {
let request = RequestWrapper.imageRequest(path: .IMAGE(recipeId: recipeId, thumb: thumb))
let (data, _): (Data?, Error?) = try await NetworkHandler.sendHTTPRequest(
request,
hostPath: apiPath,
authString: authString
)
guard let data = data else {
print("Error receiving or decoding data.")
return nil
}
return data
} catch {
print("Could not load image from server.")
}
return nil
}
}
extension APIInterface {
func sendDataRequest<D: Decodable>(_ request: RequestWrapper) async -> (D?, Error?) {
do {
let (data, error) = try await NetworkHandler.sendHTTPRequest(
request,
hostPath: apiPath,
authString: authString
)
if let data = data {
return (JSONDecoder.safeDecode(data), error)
}
return (nil, error)
} catch {
print("An unknown network error occured.")
}
return (nil, NetworkError.unknownError)
}
func sendRequest(_ request: RequestWrapper) async -> Error? {
do {
return try await NetworkHandler.sendHTTPRequest(
request,
hostPath: apiPath,
authString: authString
).1
} catch {
print("An unknown network error occured.")
}
return NetworkError.unknownError
}
}

View File

@@ -13,3 +13,15 @@ public enum NotImplementedError: Error, CustomStringConvertible {
return "Function not implemented."
}
}
public enum NetworkError: String, Error {
case missingUrl = "Missing URL."
case parametersNil = "Parameters are nil."
case encodingFailed = "Parameter encoding failed."
case redirectionError = "Redirection error"
case clientError = "Client error"
case serverError = "Server error"
case invalidRequest = "Invalid request"
case unknownError = "Unknown error"
case dataError = "Invalid data error."
}

View File

@@ -1,248 +0,0 @@
//
// NetworkController.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 13.09.23.
//
import Foundation
public enum NetworkError: String, Error {
case missingUrl = "Missing URL."
case parametersNil = "Parameters are nil."
case encodingFailed = "Parameter encoding failed."
case redirectionError = "Redirection error"
case clientError = "Client error"
case serverError = "Server error"
case invalidRequest = "Invalid request"
case unknownError = "Unknown error"
case dataError = "Invalid data error."
}
class NetworkController {
var userSettings: UserSettings
var authString: String
var cookBookUrlString: String
let apiVersion = "1"
init() {
print("Initializing NetworkController.")
self.userSettings = UserSettings()
self.cookBookUrlString = "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v\(apiVersion)/"
let loginString = "\(userSettings.username):\(userSettings.token)"
let loginData = loginString.data(using: String.Encoding.utf8)!
self.authString = loginData.base64EncodedString()
}
func fetchData(path: String) async throws -> Data? {
let url = URL(string: "\(cookBookUrlString)/\(path)")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(
"true",
forHTTPHeaderField: "OCS-APIRequest"
)
request.setValue(
"Basic \(authString)",
forHTTPHeaderField: "Authorization"
)
do {
let (data, _) = try await URLSession.shared.data(for: request)
return data
} catch {
return nil
}
}
func sendHTTPRequest(_ requestWrapper: RequestWrapper) async throws -> (Data?, NetworkError?) {
print("Sending \(requestWrapper.method.rawValue) request (path: \(requestWrapper.prepend(cookBookPath: cookBookUrlString))) ...")
let urlStringSanitized = requestWrapper.prepend(cookBookPath: cookBookUrlString).addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
let url = URL(string: urlStringSanitized!)!
var request = URLRequest(url: url)
request.setValue(
"true",
forHTTPHeaderField: "OCS-APIRequest"
)
request.setValue(
"Basic \(authString)",
forHTTPHeaderField: "Authorization"
)
request.setValue(
requestWrapper.accept.rawValue,
forHTTPHeaderField: "Accept"
)
request.httpMethod = requestWrapper.method.rawValue
switch requestWrapper.method {
case .GET: break
case .POST, .PUT:
guard let httpBody = requestWrapper.body else { return (nil, nil) }
do {
print("Encoding request ...")
request.httpBody = try JSONEncoder().encode(httpBody)
print("Request body: \(String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "nil")")
} catch {
throw error
}
case .DELETE: throw NotImplementedError.notImplemented
}
var data: Data? = nil
var response: URLResponse? = nil
do {
(data, response) = try await URLSession.shared.data(for: request)
print("Response: ", response)
return (data, nil)
} catch {
return (nil, decodeURLResponse(response: response as? HTTPURLResponse))
}
}
private func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? {
guard let response = response else {
return NetworkError.unknownError
}
switch response.statusCode {
case 200...299: return (nil)
case 300...399: return (NetworkError.redirectionError)
case 400...499: return (NetworkError.clientError)
case 500...599: return (NetworkError.serverError)
case 600: return (NetworkError.invalidRequest)
default: return (NetworkError.unknownError)
}
}
func sendDataRequest<D: Decodable>(_ request: RequestWrapper) async -> (D?, Error?) {
do {
let (data, error) = try await sendHTTPRequest(request)
if let data = data {
return (decodeData(data), error)
}
return (nil, error)
} catch {
print("An unknown network error occured.")
}
return (nil, NetworkError.unknownError)
}
func sendRequest(_ request: RequestWrapper) async -> Error? {
do {
return try await sendHTTPRequest(request).1
} catch {
print("An unknown network error occured.")
}
return NetworkError.unknownError
}
private func decodeData<D: Decodable>(_ data: Data) -> D? {
let decoder = JSONDecoder()
do {
print("Decoding type ", D.self, " ...")
return try decoder.decode(D.self, from: data)
} catch (let error) {
print("DataController - decodeData(): Failed to decode data.")
print("Error: ", error)
return nil
}
}
}
struct NetworkHandler {
static func sendHTTPRequest(_ requestWrapper: RequestWrapper, authString: String? = nil) async throws -> (Data?, NetworkError?) {
print("Sending \(requestWrapper.method.rawValue) request (path: \(requestWrapper.path)) ...")
let urlStringSanitized = requestWrapper.path.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
let url = URL(string: urlStringSanitized!)!
var request = URLRequest(url: url)
request.setValue(
"true",
forHTTPHeaderField: "OCS-APIRequest"
)
if let authString = authString {
request.setValue(
"Basic \(authString)",
forHTTPHeaderField: "Authorization"
)
}
request.setValue(
requestWrapper.accept.rawValue,
forHTTPHeaderField: "Accept"
)
request.httpMethod = requestWrapper.method.rawValue
switch requestWrapper.method {
case .GET: break
case .POST, .PUT:
guard let httpBody = requestWrapper.body else { break }
do {
print("Encoding request ...")
request.httpBody = try JSONEncoder().encode(httpBody)
print("Request body: \(String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "nil")")
} catch {
throw error
}
case .DELETE: throw NotImplementedError.notImplemented
}
var data: Data? = nil
var response: URLResponse? = nil
do {
(data, response) = try await URLSession.shared.data(for: request)
print("Response: ", response)
return (data, nil)
} catch {
return (nil, decodeURLResponse(response: response as? HTTPURLResponse))
}
}
static func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? {
guard let response = response else {
return NetworkError.unknownError
}
switch response.statusCode {
case 200...299: return (nil)
case 300...399: return (NetworkError.redirectionError)
case 400...499: return (NetworkError.clientError)
case 500...599: return (NetworkError.serverError)
case 600: return (NetworkError.invalidRequest)
default: return (NetworkError.unknownError)
}
}
static func sendDataRequest<D: Decodable>(_ request: RequestWrapper) async -> (D?, Error?) {
do {
let (data, error) = try await sendHTTPRequest(request)
if let data = data {
print(String(data: data, encoding: .utf8))
return (decodeData(data), error)
}
return (nil, error)
} catch {
print("An unknown network error occured.")
}
return (nil, NetworkError.unknownError)
}
private static func decodeData<D: Decodable>(_ data: Data) -> D? {
let decoder = JSONDecoder()
do {
print("Decoding type ", D.self, " ...")
return try decoder.decode(D.self, from: data)
} catch (let error) {
print("DataController - decodeData(): Failed to decode data.")
print("Error: ", error)
return nil
}
}
}

View File

@@ -0,0 +1,79 @@
//
// NetworkHandler.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 13.09.23.
//
import Foundation
struct NetworkHandler {
static func sendHTTPRequest(
_ requestWrapper: RequestWrapper,
hostPath: String,
authString: String?
) async throws -> (Data?, NetworkError?) {
print("Sending \(requestWrapper.getMethod()) request (path: \(requestWrapper.getPath())) ...")
// Prepare URL
let urlString = hostPath + requestWrapper.getPath()
let urlStringSanitized = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
let url = URL(string: urlStringSanitized!)!
// Create URL request
var request = URLRequest(url: url)
// Set URL method
request.httpMethod = requestWrapper.getMethod()
// Set authentication string, if needed
if let authString = authString {
request.setValue(
"Basic \(authString)",
forHTTPHeaderField: "Authorization"
)
}
// Set other header fields
for headerField in requestWrapper.getHeaderFields() {
request.setValue(
headerField.getValue(),
forHTTPHeaderField: headerField.getField()
)
}
// Set http body
if let body = requestWrapper.getBody() {
request.httpBody = body
}
print("Request:\nMethod: \(request.httpMethod)\nHeaders: \(request.allHTTPHeaderFields)\nBody: \(request.httpBody)")
// Wait for and return data and (decoded) response
var data: Data? = nil
var response: URLResponse? = nil
do {
(data, response) = try await URLSession.shared.data(for: request)
print("Response: ", response)
return (data, nil)
} catch {
return (nil, decodeURLResponse(response: response as? HTTPURLResponse))
}
}
private static func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? {
guard let response = response else {
return NetworkError.unknownError
}
switch response.statusCode {
case 200...299: return (nil)
case 300...399: return (NetworkError.redirectionError)
case 400...499: return (NetworkError.clientError)
case 500...599: return (NetworkError.serverError)
case 600: return (NetworkError.invalidRequest)
default: return (NetworkError.unknownError)
}
}
}

View File

@@ -8,47 +8,153 @@
import Foundation
enum RequestMethod: String {
case GET = "GET", POST = "POST", PUT = "PUT", DELETE = "DELETE"
case GET = "GET",
POST = "POST",
PUT = "PUT",
DELETE = "DELETE"
}
enum RequestPath: String {
case GET_CATEGORIES = "categories"
enum RequestPath {
case CATEGORIES,
RECIPE_LIST(categoryName: String),
RECIPE_DETAIL(recipeId: Int),
IMAGE(recipeId: Int, thumb: Bool)
case LOGINV2REQ,
CUSTOM(path: String),
NONE
var stringValue: String {
switch self {
case .CATEGORIES: return "categories"
case .RECIPE_LIST(categoryName: let name): return "category/\(name)"
case .RECIPE_DETAIL(recipeId: let recipeId): return "recipes/\(recipeId)"
case .IMAGE(recipeId: let recipeId, thumb: let thumb): return "recipes/\(recipeId)/image?size=\(thumb ? "thumb" : "full")"
case .LOGINV2REQ: return "/index.php/login/v2"
case .CUSTOM(path: let path): return path
case .NONE: return ""
}
}
}
enum AcceptHeader: String {
case JSON = "application/json", IMAGE = "image/jpeg"
enum ContentType: String {
case JSON = "application/json",
IMAGE = "image/jpeg",
FORM = "application/x-www-form-urlencoded"
}
struct HeaderField {
private let _field: String
private let _value: String
func getField() -> String {
return _field
}
func getValue() -> String {
return _value
}
static func accept(value: ContentType) -> HeaderField {
return HeaderField(_field: "accept", _value: value.rawValue)
}
static func ocsRequest(value: Bool) -> HeaderField {
return HeaderField(_field: "OCS-APIRequest", _value: value ? "true" : "false")
}
static func contentType(value: ContentType) -> HeaderField {
return HeaderField(_field: "Content-Type", _value: value.rawValue)
}
}
struct RequestWrapper {
let method: RequestMethod
var path: String
let accept: AcceptHeader
let body: Codable?
private let _method: RequestMethod
private let _path: RequestPath
private let _headerFields: [HeaderField]
private let _body: Data?
private let _authenticate: Bool = true
init(method: RequestMethod, path: String, body: Codable? = nil, accept: AcceptHeader = .JSON) {
self.method = method
self.path = path
self.body = body
self.accept = accept
private init(
method: RequestMethod,
path: RequestPath,
headerFields: [HeaderField] = [],
body: Data? = nil,
authenticate: Bool = true
) {
self._method = method
self._path = path
self._headerFields = headerFields
self._body = body
}
func prepend(cookBookPath: String) -> String {
return cookBookPath + self.path
func getMethod() -> String {
return self._method.rawValue
}
func getPath() -> String {
return self._path.stringValue
}
func getHeaderFields() -> [HeaderField] {
return self._headerFields
}
func getBody() -> Data? {
return _body
}
func needsAuth() -> Bool {
return _authenticate
}
}
struct LoginV2Request: Codable {
let poll: LoginV2Poll
let login: String
extension RequestWrapper {
static func customRequest(
method: RequestMethod,
path: RequestPath,
headerFields: [HeaderField] = [],
body: Data? = nil,
authenticate: Bool = true
) -> RequestWrapper {
let request = RequestWrapper(
method: method,
path: path,
headerFields: headerFields,
body: body,
authenticate: authenticate
)
return request
}
static func jsonGetRequest(path: RequestPath) -> RequestWrapper {
let headerFields = [
HeaderField.ocsRequest(value: true),
HeaderField.accept(value: .JSON)
]
let request = RequestWrapper(
method: .GET,
path: path,
headerFields: headerFields,
authenticate: true
)
return request
}
static func imageRequest(path: RequestPath) -> RequestWrapper {
let headerFields = [
HeaderField.ocsRequest(value: true),
HeaderField.accept(value: .IMAGE)
]
let request = RequestWrapper(
method: .GET,
path: path,
headerFields: headerFields,
authenticate: true
)
return request
}
}
struct LoginV2Poll: Codable {
let token: String
let endpoint: String
}
struct LoginV2Response: Codable {
let server: String
let loginName: String
let appPassword: String
}

View File

@@ -10,12 +10,16 @@ import SwiftUI
@main
struct Nextcloud_Cookbook_iOS_ClientApp: App {
@StateObject var userSettings = UserSettings()
@StateObject var mainViewModel = MainViewModel()
var body: some Scene {
WindowGroup {
MainView(userSettings: userSettings)
.fullScreenCover(isPresented: $userSettings.onboarding) {
ZStack {
if userSettings.onboarding {
OnboardingView(userSettings: userSettings)
} else {
MainView(userSettings: userSettings, viewModel: mainViewModel)
}
}.transition(.slide)
}
}
}

View File

@@ -15,20 +15,19 @@ import UIKit
private var imageCache: [Int: RecipeImage] = [:]
let dataStore: DataStore
let networkController: NetworkController
var apiInterface: APIInterface? = nil
/// The path of an image in storage
private var localImagePath: (Int, Bool) -> (String) = { recipeId, full in
return "image\(recipeId)_\(full ? "full" : "thumb")"
private var localImagePath: (Int, Bool) -> (String) = { recipeId, thumb in
return "image\(recipeId)_\(thumb ? "thumb" : "full")"
}
/// The path of an image on the server
private var networkImagePath: (Int, Bool) -> (String) = { recipeId, full in
return "recipes/\(recipeId)/image?size=\(full ? "full" : "thumb")"
private var networkImagePath: (Int, Bool) -> (String) = { recipeId, thumb in
return "recipes/\(recipeId)/image?size=\(thumb ? "thumb" : "full")"
}
init() {
self.networkController = NetworkController()
self.dataStore = DataStore()
}
@@ -36,7 +35,11 @@ import UIKit
/// - Parameters
/// - needsUpdate: If true, the recipe will be loaded from the server directly, otherwise it will be loaded from store first.
func loadCategoryList(needsUpdate: Bool = false) async {
if let categoryList: [Category] = await load(localPath: "categories.data", networkPath: "categories", needsUpdate: needsUpdate) {
if let categoryList: [Category] = await loadObject(
localPath: "categories.data",
networkPath: .CATEGORIES,
needsUpdate: needsUpdate
) {
self.categories = categoryList
}
}
@@ -46,7 +49,11 @@ import UIKit
/// - categoryName: The name of the category containing the requested list of recipes.
/// - needsUpdate: If true, the recipe will be loaded from the server directly, otherwise it will be loaded from store first.
func loadRecipeList(categoryName: String, needsUpdate: Bool = false) async {
if let recipeList: [Recipe] = await load(localPath: "category_\(categoryName).data", networkPath: "category/\(categoryName)", needsUpdate: needsUpdate) {
if let recipeList: [Recipe] = await loadObject(
localPath: "category_\(categoryName).data",
networkPath: .RECIPE_LIST(categoryName: categoryName),
needsUpdate: needsUpdate
) {
recipes[categoryName] = recipeList
}
}
@@ -62,7 +69,11 @@ import UIKit
return recipeDetail
}
}
if let recipeDetail: RecipeDetail = await load(localPath: "recipe\(recipeId).data", networkPath: "recipes/\(recipeId)", needsUpdate: needsUpdate) {
if let recipeDetail: RecipeDetail = await loadObject(
localPath: "recipe\(recipeId).data",
networkPath: .RECIPE_DETAIL(recipeId: recipeId),
needsUpdate: needsUpdate
) {
recipeDetails[recipeId] = recipeDetail
return recipeDetail
}
@@ -75,7 +86,7 @@ import UIKit
guard let recipeList = recipes[category.name] else { continue }
for recipe in recipeList {
let _ = await loadRecipeDetail(recipeId: recipe.recipe_id, needsUpdate: true)
let _ = await loadImage(recipeId: recipe.recipe_id, full: false)
let _ = await loadImage(recipeId: recipe.recipe_id, thumb: true)
}
}
}
@@ -100,17 +111,18 @@ import UIKit
/// - full: If true, load the full resolution image. Otherwise, load a thumbnail-sized image.
/// - needsUpdate: Determines wether the image should be loaded directly from the server, or if it should be loaded from cache/store first.
/// - Returns: The image if found locally or on the server, otherwise nil.
func loadImage(recipeId: Int, full: Bool, needsUpdate: Bool = false) async -> UIImage? {
print("loadImage(recipeId: \(recipeId), full: \(full), needsUpdate: \(needsUpdate))")
func loadImage(recipeId: Int, thumb: Bool, needsUpdate: Bool = false) async -> UIImage? {
print("loadImage(recipeId: \(recipeId), thumb: \(thumb), needsUpdate: \(needsUpdate))")
// If the image needs an update, request it from the server and overwrite the stored image
if needsUpdate {
if let data = await imageDataFromServer(recipeId: recipeId, full: full) {
guard let apiInterface = apiInterface else { return nil }
if let data = await apiInterface.imageDataFromServer(recipeId: recipeId, thumb: thumb) {
guard let image = UIImage(data: data) else {
imageCache[recipeId] = RecipeImage(imageExists: false)
return nil
}
await dataStore.save(data: data.base64EncodedString(), toPath: localImagePath(recipeId, full))
imageToCache(image: image, recipeId: recipeId, full: full)
await dataStore.save(data: data.base64EncodedString(), toPath: localImagePath(recipeId, thumb))
imageToCache(image: image, recipeId: recipeId, thumb: thumb)
return image
} else {
imageCache[recipeId] = RecipeImage(imageExists: false)
@@ -126,30 +138,31 @@ import UIKit
// Try to load image from cache
print("Attempting to load image from cache ...")
if let image = imageFromCache(recipeId: recipeId, full: full) {
if let image = imageFromCache(recipeId: recipeId, thumb: thumb) {
print("Image found in cache.")
return image
}
// Try to load from store
print("Attempting to load image from local storage ...")
if let image = await imageFromStore(recipeId: recipeId, full: full) {
if let image = await imageFromStore(recipeId: recipeId, thumb: thumb) {
print("Image found in local storage.")
imageToCache(image: image, recipeId: recipeId, full: full)
imageToCache(image: image, recipeId: recipeId, thumb: thumb)
return image
}
// Try to load from the server. Store if successfull.
print("Attempting to load image from server ...")
if let data = await imageDataFromServer(recipeId: recipeId, full: full) {
guard let apiInterface = apiInterface else { return nil }
if let data = await apiInterface.imageDataFromServer(recipeId: recipeId, thumb: thumb) {
print("Image data received.")
// Create empty RecipeImage for each recipe even if no image found, so that further server requests are only sent if explicitly requested.
guard let image = UIImage(data: data) else {
imageCache[recipeId] = RecipeImage(imageExists: false)
return nil
}
await dataStore.save(data: data.base64EncodedString(), toPath: localImagePath(recipeId, full))
imageToCache(image: image, recipeId: recipeId, full: full)
await dataStore.save(data: data.base64EncodedString(), toPath: localImagePath(recipeId, thumb))
imageToCache(image: image, recipeId: recipeId, thumb: thumb)
return image
}
imageCache[recipeId] = RecipeImage(imageExists: false)
@@ -170,14 +183,15 @@ import UIKit
extension MainViewModel {
private func load<D: Codable>(localPath: String, networkPath: String, needsUpdate: Bool = false) async -> D? {
private func loadObject<T: Codable>(localPath: String, networkPath: RequestPath, needsUpdate: Bool = false) async -> T? {
do {
if !needsUpdate, let data: D = try await dataStore.load(fromPath: localPath) {
if !needsUpdate, let data: T = try await dataStore.load(fromPath: localPath) {
print("Data found locally.")
return data
} else {
let request = RequestWrapper(method: .GET, path: networkPath)
let (data, error): (D?, Error?) = await networkController.sendDataRequest(request)
guard let apiInterface = apiInterface else { return nil }
let request = RequestWrapper.jsonGetRequest(path: networkPath)
let (data, error): (T?, Error?) = await apiInterface.sendDataRequest(request)
print(error as Any)
if let data = data {
await dataStore.save(data: data, toPath: localPath)
@@ -190,33 +204,33 @@ extension MainViewModel {
return nil
}
private func imageToCache(image: UIImage, recipeId: Int, full: Bool) {
private func imageToCache(image: UIImage, recipeId: Int, thumb: Bool) {
if imageCache[recipeId] == nil {
imageCache[recipeId] = RecipeImage(imageExists: true)
}
if full {
imageCache[recipeId]!.imageExists = true
imageCache[recipeId]!.full = image
} else {
if thumb {
imageCache[recipeId]!.imageExists = true
imageCache[recipeId]!.thumb = image
} else {
imageCache[recipeId]!.imageExists = true
imageCache[recipeId]!.full = image
}
}
private func imageFromCache(recipeId: Int, full: Bool) -> UIImage? {
private func imageFromCache(recipeId: Int, thumb: Bool) -> UIImage? {
if imageCache[recipeId] != nil {
if full {
return imageCache[recipeId]!.full
} else {
if thumb {
return imageCache[recipeId]!.thumb
} else {
return imageCache[recipeId]!.full
}
}
return nil
}
private func imageFromStore(recipeId: Int, full: Bool) async -> UIImage? {
private func imageFromStore(recipeId: Int, thumb: Bool) async -> UIImage? {
do {
let localPath = localImagePath(recipeId, full)
let localPath = localImagePath(recipeId, thumb)
if let data: String = try await dataStore.load(fromPath: localPath) {
guard let dataDecoded = Data(base64Encoded: data) else { return nil }
let image = UIImage(data: dataDecoded)
@@ -228,22 +242,6 @@ extension MainViewModel {
}
return nil
}
private func imageDataFromServer(recipeId: Int, full: Bool) async -> Data? {
do {
let networkPath = networkImagePath(recipeId, full)
let request = RequestWrapper(method: .GET, path: networkPath, accept: .IMAGE)
let (data, _): (Data?, Error?) = try await networkController.sendHTTPRequest(request)
guard let data = data else {
print("Error receiving or decoding data.")
return nil
}
return data
} catch {
print("Could not load image from server.")
}
return nil
}
}

View File

@@ -59,7 +59,7 @@ struct RecipeBookView: View {
for recipe in recipes {
Task {
let _ = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id)
let _ = await viewModel.loadImage(recipeId: recipe.recipe_id, full: true)
let _ = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false)
}
}
}

View File

@@ -8,9 +8,17 @@
import SwiftUI
struct MainView: View {
@StateObject var viewModel = MainViewModel()
@StateObject var userSettings: UserSettings
@ObservedObject var viewModel: MainViewModel
@ObservedObject var userSettings: UserSettings
var columns: [GridItem] = [GridItem(.adaptive(minimum: 150), spacing: 0)]
init(userSettings: UserSettings, viewModel: MainViewModel) {
self.userSettings = userSettings
self.viewModel = viewModel
self.viewModel.apiInterface = APIInterface(userSettings: userSettings)
}
var body: some View {
NavigationView {
ScrollView(.vertical, showsIndicators: false) {
@@ -60,11 +68,6 @@ struct MainView: View {
}
}
struct MainView_Previews: PreviewProvider {
static var previews: some View {
MainView(userSettings: UserSettings())
}
}

View File

@@ -141,7 +141,7 @@ struct LoginTab: View {
}
}
}
Text("Submitting will open a web browser. Please follow the login instructions provided there.\nAfter a successfull login, return to this application and press 'Done'.")
Text("Submitting will open a web browser. Please follow the login instructions provided there.\nAfter a successfull login, return to this application and press 'Validate'.")
.font(.subheadline)
.padding(.bottom)
.tint(.white)
@@ -152,10 +152,13 @@ struct LoginTab: View {
// fetch login v2 response
Task {
guard let res = await fetchLoginV2Response() else { return }
print(res.loginName)
print("Login successfull for user \(res.loginName)!")
userSettings.username = res.loginName
userSettings.token = res.appPassword
userSettings.onboarding = false
}
} label: {
Text("Done")
Text("Validate")
.foregroundColor(.white)
.font(.headline)
.padding()
@@ -188,23 +191,64 @@ struct LoginTab: View {
}
func sendLoginV2Request() async {
let request = RequestWrapper(
let hostPath = "https://\(userSettings.serverAddress)"
let headerFields: [HeaderField] = [
//HeaderField.ocsRequest(value: true),
//HeaderField.accept(value: .JSON)
]
let request = RequestWrapper.customRequest(
method: .POST,
path: "https://\(userSettings.serverAddress)/index.php/login/v2"
path: .LOGINV2REQ,
headerFields: headerFields
)
let (loginReq, _): (LoginV2Request?, Error?) = await NetworkHandler.sendDataRequest(request)
self.loginRequest = loginReq
do {
let (data, _): (Data?, Error?) = try await NetworkHandler.sendHTTPRequest(
request,
hostPath: hostPath,
authString: nil
)
guard let data = data else { return }
print("Data: \(data)")
let loginReq: LoginV2Request? = JSONDecoder.safeDecode(data)
self.loginRequest = loginReq
} catch {
print("Could not establish communication with the server.")
}
}
func fetchLoginV2Response() async -> LoginV2Response? {
guard let loginRequest = loginRequest else { return nil }
let request = RequestWrapper(
let headerFields = [
HeaderField.ocsRequest(value: true),
HeaderField.accept(value: .JSON),
HeaderField.contentType(value: .FORM)
]
let request = RequestWrapper.customRequest(
method: .POST,
path: loginRequest.poll.endpoint,
body: "token=\(loginRequest.poll.token)"
path: .NONE,
headerFields: headerFields,
body: "token=\(loginRequest.poll.token)".data(using: .utf8),
authenticate: false
)
let (loginRes, _): (LoginV2Response?, Error?) = await NetworkHandler.sendDataRequest(request)
return loginRes
var (data, error): (Data?, Error?) = (nil, nil)
do {
(data, error) = try await NetworkHandler.sendHTTPRequest(
request,
hostPath: loginRequest.poll.endpoint,
authString: nil
)
} catch {
print("Error: ", error)
}
guard let data = data else { return nil }
if let loginRes: LoginV2Response = JSONDecoder.safeDecode(data) {
return loginRes
}
print("Could not decode.")
return nil
}
}

View File

@@ -36,10 +36,10 @@ struct RecipeCardView: View {
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal)
.task {
recipeThumb = await viewModel.loadImage(recipeId: recipe.recipe_id, full: false)
recipeThumb = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: true)
}
.refreshable {
recipeThumb = await viewModel.loadImage(recipeId: recipe.recipe_id, full: false, needsUpdate: true)
recipeThumb = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: true, needsUpdate: true)
}
}
}

View File

@@ -65,11 +65,11 @@ struct RecipeDetailView: View {
.navigationTitle(showTitle ? recipe.name : "")
.task {
recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id)
recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, full: true)
recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false)
}
.refreshable {
recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id, needsUpdate: true)
recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, full: true, needsUpdate: true)
recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false, needsUpdate: true)
}
}