400 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			400 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
| //
 | |
| //  NetworkService.swift
 | |
| //  WatchRunner Watch App
 | |
| //
 | |
| //  Created by LittleSheep on 2025/10/29.
 | |
| //
 | |
| 
 | |
| import Foundation
 | |
| 
 | |
| // MARK: - Network Service
 | |
| 
 | |
| class NetworkService {
 | |
|     private let session = URLSession.shared
 | |
|     
 | |
|     func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> ActivityResponse {
 | |
|         guard let baseURL = URL(string: serverUrl) else {
 | |
|             throw URLError(.badURL)
 | |
|         }
 | |
|         var components = URLComponents(url: baseURL.appendingPathComponent("/sphere/activities"), resolvingAgainstBaseURL: false)!
 | |
|         var queryItems = [URLQueryItem(name: "take", value: "20")]
 | |
|         if filter.lowercased() != "explore" {
 | |
|             queryItems.append(URLQueryItem(name: "filter", value: filter.lowercased()))
 | |
|         }
 | |
|         if let cursor = cursor {
 | |
|             queryItems.append(URLQueryItem(name: "cursor", value: cursor))
 | |
|         }
 | |
|         components.queryItems = queryItems
 | |
|         
 | |
|         var request = URLRequest(url: components.url!)
 | |
|         request.httpMethod = "GET"
 | |
|         request.setValue("application/json", forHTTPHeaderField: "Accept")
 | |
|         
 | |
|         request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | |
|         request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | |
|         
 | |
|         let (data, _) = try await session.data(for: request)
 | |
|         
 | |
|         let decoder = JSONDecoder()
 | |
|         decoder.dateDecodingStrategy = .iso8601
 | |
|         decoder.keyDecodingStrategy = .convertFromSnakeCase
 | |
|         
 | |
|         let activities = try decoder.decode([SnActivity].self, from: data)
 | |
|         
 | |
|         let hasMore = (activities.first?.type ?? "empty") != "empty"
 | |
|         let nextCursor = activities.isEmpty ? nil : activities.map { $0.createdAt }.min()?.ISO8601Format()
 | |
|         
 | |
|         return ActivityResponse(activities: activities, hasMore: hasMore, nextCursor: nextCursor)
 | |
|     }
 | |
|     
 | |
|     func createPost(title: String, content: String, token: String, serverUrl: String) async throws {
 | |
|         guard let baseURL = URL(string: serverUrl) else {
 | |
|             throw URLError(.badURL)
 | |
|         }
 | |
|         let url = baseURL.appendingPathComponent("/sphere/posts")
 | |
|         
 | |
|         var request = URLRequest(url: url)
 | |
|         request.httpMethod = "POST"
 | |
|         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 | |
|         request.setValue("application/json", forHTTPHeaderField: "Accept")
 | |
|         request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | |
|         request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | |
|         
 | |
|         let body: [String: Any] = ["title": title, "content": content]
 | |
|         request.httpBody = try JSONSerialization.data(withJSONObject: body)
 | |
|         
 | |
|         let (data, response) = try await session.data(for: request)
 | |
|         
 | |
|         if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 201 {
 | |
|             let responseBody = String(data: data, encoding: .utf8) ?? ""
 | |
|             print("[watchOS] createPost failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
 | |
|             throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     func fetchNotifications(offset: Int = 0, take: Int = 20, token: String, serverUrl: String) async throws -> NotificationResponse {
 | |
|         guard let baseURL = URL(string: serverUrl) else {
 | |
|             throw URLError(.badURL)
 | |
|         }
 | |
|         var components = URLComponents(url: baseURL.appendingPathComponent("/ring/notifications"), resolvingAgainstBaseURL: false)!
 | |
|         var queryItems = [URLQueryItem(name: "offset", value: String(offset)), URLQueryItem(name: "take", value: String(take))]
 | |
|         components.queryItems = queryItems
 | |
|         
 | |
|         var request = URLRequest(url: components.url!)
 | |
|         request.httpMethod = "GET"
 | |
|         request.setValue("application/json", forHTTPHeaderField: "Accept")
 | |
|         request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | |
|         request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | |
|         
 | |
|         let (data, response) = try await session.data(for: request)
 | |
|         
 | |
|         let decoder = JSONDecoder()
 | |
|         decoder.dateDecodingStrategy = .iso8601
 | |
|         decoder.keyDecodingStrategy = .convertFromSnakeCase
 | |
|         
 | |
|         let notifications = try decoder.decode([SnNotification].self, from: data)
 | |
|         
 | |
|         let httpResponse = response as? HTTPURLResponse
 | |
|         let total = Int(httpResponse?.value(forHTTPHeaderField: "X-Total") ?? "0") ?? 0
 | |
|         let hasMore = offset + notifications.count < total
 | |
|         
 | |
|         return NotificationResponse(notifications: notifications, total: total, hasMore: hasMore)
 | |
|     }
 | |
|     
 | |
|     func fetchUserProfile(token: String, serverUrl: String) async throws -> SnAccount {
 | |
|         guard let baseURL = URL(string: serverUrl) else {
 | |
|             throw URLError(.badURL)
 | |
|         }
 | |
|         let url = baseURL.appendingPathComponent("/pass/accounts/me")
 | |
|         
 | |
|         var request = URLRequest(url: url)
 | |
|         request.httpMethod = "GET"
 | |
|         request.setValue("application/json", forHTTPHeaderField: "Accept")
 | |
|         request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | |
|         request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | |
|         
 | |
|         let (data, _) = try await session.data(for: request)
 | |
|         
 | |
|         let decoder = JSONDecoder()
 | |
|         decoder.dateDecodingStrategy = .iso8601
 | |
|         decoder.keyDecodingStrategy = .convertFromSnakeCase
 | |
|         
 | |
|         return try decoder.decode(SnAccount.self, from: data)
 | |
|     }
 | |
|     
 | |
|     func fetchAccountStatus(token: String, serverUrl: String) async throws -> SnAccountStatus? {
 | |
|         guard let baseURL = URL(string: serverUrl) else {
 | |
|             throw URLError(.badURL)
 | |
|         }
 | |
|         let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses")
 | |
|         
 | |
|         var request = URLRequest(url: url)
 | |
|         request.httpMethod = "GET"
 | |
|         request.setValue("application/json", forHTTPHeaderField: "Accept")
 | |
|         request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | |
|         request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | |
|         
 | |
|         let (data, response) = try await session.data(for: request)
 | |
|         
 | |
|         if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 404 {
 | |
|             return nil
 | |
|         }
 | |
|         
 | |
|         let decoder = JSONDecoder()
 | |
|         decoder.dateDecodingStrategy = .iso8601
 | |
|         decoder.keyDecodingStrategy = .convertFromSnakeCase
 | |
|         
 | |
|         return try decoder.decode(SnAccountStatus.self, from: data)
 | |
|     }
 | |
|     
 | |
|     func createOrUpdateStatus(attitude: Int, isInvisible: Bool, isNotDisturb: Bool, label: String?, token: String, serverUrl: String) async throws -> SnAccountStatus {
 | |
|         // Check if there's already a customized status
 | |
|         let existingStatus = try? await fetchAccountStatus(token: token, serverUrl: serverUrl)
 | |
|         let method = (existingStatus?.isCustomized == true) ? "PATCH" : "POST"
 | |
|         
 | |
|         guard let baseURL = URL(string: serverUrl) else {
 | |
|             throw URLError(.badURL)
 | |
|         }
 | |
|         let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses")
 | |
|         
 | |
|         var request = URLRequest(url: url)
 | |
|         request.httpMethod = method
 | |
|         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 | |
|         request.setValue("application/json", forHTTPHeaderField: "Accept")
 | |
|         request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | |
|         request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | |
|         
 | |
|         var body: [String: Any] = [
 | |
|             "attitude": attitude,
 | |
|             "is_invisible": isInvisible,
 | |
|             "is_not_disturb": isNotDisturb
 | |
|         ]
 | |
|         
 | |
|         if let label = label, !label.isEmpty {
 | |
|             body["label"] = label
 | |
|         }
 | |
|         
 | |
|         request.httpBody = try JSONSerialization.data(withJSONObject: body)
 | |
|         
 | |
|         let (data, response) = try await session.data(for: request)
 | |
|         
 | |
|         if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 201 && httpResponse.statusCode != 200 {
 | |
|             let responseBody = String(data: data, encoding: .utf8) ?? ""
 | |
|             print("[watchOS] createOrUpdateStatus failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
 | |
|             throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
 | |
|         }
 | |
|         
 | |
|         let decoder = JSONDecoder()
 | |
|         decoder.dateDecodingStrategy = .iso8601
 | |
|         decoder.keyDecodingStrategy = .convertFromSnakeCase
 | |
|         
 | |
|         return try decoder.decode(SnAccountStatus.self, from: data)
 | |
|     }
 | |
|     
 | |
|     func clearStatus(token: String, serverUrl: String) async throws {
 | |
|         guard let baseURL = URL(string: serverUrl) else {
 | |
|             throw URLError(.badURL)
 | |
|         }
 | |
|         let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses")
 | |
|         
 | |
|         var request = URLRequest(url: url)
 | |
|         request.httpMethod = "DELETE"
 | |
|         request.setValue("application/json", forHTTPHeaderField: "Accept")
 | |
|         request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | |
|         request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | |
|         
 | |
|         let (data, response) = try await session.data(for: request)
 | |
|         
 | |
|         if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 204 {
 | |
|             let responseBody = String(data: data, encoding: .utf8) ?? ""
 | |
|             print("[watchOS] clearStatus failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
 | |
|             throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     // MARK: - Chat API Methods
 | |
|     
 | |
|     func fetchChatRooms(token: String, serverUrl: String) async throws -> ChatRoomsResponse {
 | |
|         guard let baseURL = URL(string: serverUrl) else {
 | |
|             throw URLError(.badURL)
 | |
|         }
 | |
|         let url = baseURL.appendingPathComponent("/sphere/chat")
 | |
|         
 | |
|         var request = URLRequest(url: url)
 | |
|         request.httpMethod = "GET"
 | |
|         request.setValue("application/json", forHTTPHeaderField: "Accept")
 | |
|         request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | |
|         request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | |
|         
 | |
|         let (data, _) = try await session.data(for: request)
 | |
|         
 | |
|         let decoder = JSONDecoder()
 | |
|         decoder.dateDecodingStrategy = .iso8601
 | |
|         decoder.keyDecodingStrategy = .convertFromSnakeCase
 | |
|         
 | |
|         let rooms = try decoder.decode([SnChatRoom].self, from: data)
 | |
|         return ChatRoomsResponse(rooms: rooms)
 | |
|     }
 | |
|     
 | |
|     func fetchChatRoom(identifier: String, token: String, serverUrl: String) async throws -> SnChatRoom {
 | |
|         guard let baseURL = URL(string: serverUrl) else {
 | |
|             throw URLError(.badURL)
 | |
|         }
 | |
|         let url = baseURL.appendingPathComponent("/sphere/chat/\(identifier)")
 | |
|         
 | |
|         var request = URLRequest(url: url)
 | |
|         request.httpMethod = "GET"
 | |
|         request.setValue("application/json", forHTTPHeaderField: "Accept")
 | |
|         request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | |
|         request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | |
|         
 | |
|         let (data, response) = try await session.data(for: request)
 | |
|         
 | |
|         if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 404 {
 | |
|             throw URLError(.resourceUnavailable)
 | |
|         }
 | |
|         
 | |
|         let decoder = JSONDecoder()
 | |
|         decoder.dateDecodingStrategy = .iso8601
 | |
|         decoder.keyDecodingStrategy = .convertFromSnakeCase
 | |
|         
 | |
|         return try decoder.decode(SnChatRoom.self, from: data)
 | |
|     }
 | |
|     
 | |
|     func fetchChatInvites(token: String, serverUrl: String) async throws -> ChatInvitesResponse {
 | |
|         guard let baseURL = URL(string: serverUrl) else {
 | |
|             throw URLError(.badURL)
 | |
|         }
 | |
|         let url = baseURL.appendingPathComponent("/sphere/chat/invites")
 | |
|         
 | |
|         var request = URLRequest(url: url)
 | |
|         request.httpMethod = "GET"
 | |
|         request.setValue("application/json", forHTTPHeaderField: "Accept")
 | |
|         request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | |
|         request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | |
|         
 | |
|         let (data, _) = try await session.data(for: request)
 | |
|         
 | |
|         let decoder = JSONDecoder()
 | |
|         decoder.dateDecodingStrategy = .iso8601
 | |
|         decoder.keyDecodingStrategy = .convertFromSnakeCase
 | |
|         
 | |
|         let invites = try decoder.decode([SnChatMember].self, from: data)
 | |
|         return ChatInvitesResponse(invites: invites)
 | |
|     }
 | |
|     
 | |
|     func acceptChatInvite(chatRoomId: String, token: String, serverUrl: String) async throws {
 | |
|         guard let baseURL = URL(string: serverUrl) else {
 | |
|             throw URLError(.badURL)
 | |
|         }
 | |
|         let url = baseURL.appendingPathComponent("/sphere/chat/invites/\(chatRoomId)/accept")
 | |
|         
 | |
|         var request = URLRequest(url: url)
 | |
|         request.httpMethod = "POST"
 | |
|         request.setValue("application/json", forHTTPHeaderField: "Accept")
 | |
|         request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | |
|         request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | |
|         
 | |
|         let (data, response) = try await session.data(for: request)
 | |
|         
 | |
|         if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
 | |
|             let responseBody = String(data: data, encoding: .utf8) ?? ""
 | |
|             print("[watchOS] acceptChatInvite failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
 | |
|             throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     func declineChatInvite(chatRoomId: String, token: String, serverUrl: String) async throws {
 | |
|         guard let baseURL = URL(string: serverUrl) else {
 | |
|             throw URLError(.badURL)
 | |
|         }
 | |
|         let url = baseURL.appendingPathComponent("/sphere/chat/invites/\(chatRoomId)/decline")
 | |
|         
 | |
|         var request = URLRequest(url: url)
 | |
|         request.httpMethod = "POST"
 | |
|         request.setValue("application/json", forHTTPHeaderField: "Accept")
 | |
|         request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | |
|         request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | |
|         
 | |
|         let (data, response) = try await session.data(for: request)
 | |
|         
 | |
|         if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
 | |
|             let responseBody = String(data: data, encoding: .utf8) ?? ""
 | |
|             print("[watchOS] declineChatInvite failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
 | |
|             throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     // MARK: - Message API Methods
 | |
|     
 | |
|     func fetchChatMessages(chatRoomId: String, token: String, serverUrl: String, before: Date? = nil, take: Int = 50) async throws -> [SnChatMessage] {
 | |
|         guard let baseURL = URL(string: serverUrl) else {
 | |
|             throw URLError(.badURL)
 | |
|         }
 | |
|         
 | |
|         // Try a different pattern: /sphere/chat/messages with roomId as query param
 | |
|         var components = URLComponents(
 | |
|             url: baseURL.appendingPathComponent("/sphere/chat/\(chatRoomId)/messages"),
 | |
|             resolvingAgainstBaseURL: false
 | |
|         )!
 | |
|         var queryItems = [
 | |
|             URLQueryItem(name: "take", value: String(take))
 | |
|         ]
 | |
|         if let before = before {
 | |
|             queryItems.append(URLQueryItem(name: "before", value: ISO8601DateFormatter().string(from: before)))
 | |
|         }
 | |
|         components.queryItems = queryItems
 | |
|         
 | |
|         var request = URLRequest(url: components.url!)
 | |
|         request.httpMethod = "GET"
 | |
|         request.setValue("application/json", forHTTPHeaderField: "Accept")
 | |
|         request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | |
|         request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | |
|         
 | |
|         let (data, response) = try await session.data(for: request)
 | |
| 
 | |
|         if let httpResponse = response as? HTTPURLResponse {
 | |
|             _ = String(data: data, encoding: .utf8) ?? "Unable to decode response body"
 | |
| 
 | |
|             if httpResponse.statusCode != 200 {
 | |
|                 print("[watchOS] fetchChatMessages failed with status \(httpResponse.statusCode)")
 | |
|                 throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // Check if data is empty
 | |
|         if data.isEmpty {
 | |
|             print("[watchOS] fetchChatMessages received empty response data")
 | |
|             return []
 | |
|         }
 | |
| 
 | |
|         let decoder = JSONDecoder()
 | |
|         decoder.dateDecodingStrategy = .iso8601
 | |
|         decoder.keyDecodingStrategy = .convertFromSnakeCase
 | |
| 
 | |
|         do {
 | |
|             let messages = try decoder.decode([SnChatMessage].self, from: data)
 | |
|             print("[watchOS] fetchChatMessages successfully decoded \(messages.count) messages")
 | |
|             return messages
 | |
|         } catch DecodingError.dataCorrupted(let context) {
 | |
|             print(context)
 | |
|             return []
 | |
|         } catch DecodingError.keyNotFound(let key, let context) {
 | |
|             print("[watchOS] Message decode failed: Key '\(key)' not found:", context.debugDescription)
 | |
|             print("[watchOS] Message decode failed: codingPath:", context.codingPath)
 | |
|             return []
 | |
|         } catch DecodingError.valueNotFound(let value, let context) {
 | |
|             print("[watchOS] Message decode failed: Value '\(value)' not found:", context.debugDescription)
 | |
|             print("[watchOS] Message decode failed: codingPath:", context.codingPath)
 | |
|             return []
 | |
|         } catch DecodingError.typeMismatch(let type, let context) {
 | |
|             print("[watchOS] Message decode failed: Type '\(type)' mismatch:", context.debugDescription)
 | |
|             print("[watchOS] Message decode failed: codingPath:", context.codingPath)
 | |
|             return []
 | |
|         } catch {
 | |
|             print("error: ", error)
 | |
|             throw error
 | |
|         }
 | |
|     }
 | |
| }
 |