Refactor application structure and configuration
- Updated the application namespace and ID from "net.ypchat.app" to "de.ypchat.android" for better alignment with branding. - Increased Gradle heap size settings to optimize build performance. - Disabled dependency constraints to simplify dependency management. - Removed obsolete files related to the previous application structure, including MainActivity, YpChatApp, and various core components, streamlining the codebase. These changes collectively enhance the application's configuration and structure, improving maintainability and performance.
This commit is contained in:
493
ios/YpChat/Data/ChatRepository.swift
Normal file
493
ios/YpChat/Data/ChatRepository.swift
Normal file
@@ -0,0 +1,493 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// Entspricht `CommandTableState` in `ChatRepository.kt`.
|
||||
struct CommandTableState: Equatable, Sendable {
|
||||
var title: String
|
||||
var columns: [String]
|
||||
var rows: [[String]]
|
||||
}
|
||||
|
||||
/// Zentraler UI-Zustand wie `ChatState` in `ChatRepository.kt`.
|
||||
struct ChatState: Equatable, Sendable {
|
||||
var isConnected: Bool = false
|
||||
var isLoggedIn: Bool = false
|
||||
var expressSessionId: String?
|
||||
var currentUser: UserDto?
|
||||
var users: [UserDto] = []
|
||||
var currentConversation: String?
|
||||
var messages: [ChatMessageDto] = []
|
||||
var searchResults: [UserDto] = []
|
||||
var inboxResults: [InboxItemDto] = []
|
||||
var historyResults: [HistoryItemDto] = []
|
||||
var countries: [CountryOption] = []
|
||||
var feedbackItems: [FeedbackItemDto] = []
|
||||
var feedbackMessage: String?
|
||||
var feedbackAdminAuthenticated: Bool = false
|
||||
var feedbackAdminUserName: String?
|
||||
var feedbackAdminError: String?
|
||||
var partnerLinks: [PartnerLinkDto] = []
|
||||
var partnersError: String?
|
||||
var savedProfile: SavedProfile = SavedProfile()
|
||||
var commandLines: [String] = []
|
||||
var commandKind: String?
|
||||
var commandTable: CommandTableState?
|
||||
var awaitingLoginUsername: Bool = false
|
||||
var awaitingLoginPassword: Bool = false
|
||||
var remainingSecondsToTimeout: Int = 1800
|
||||
var isUploadingImage: Bool = false
|
||||
var imageUploadMessage: String?
|
||||
var unreadChatsCount: Int = 0
|
||||
var errorMessage: String?
|
||||
}
|
||||
|
||||
/// Analog `ChatRepository.kt`: REST + Socket + `reduce` + Timeout.
|
||||
final class ChatRepository: ObservableObject {
|
||||
@Published private(set) var state = ChatState()
|
||||
|
||||
private let api: RestAPIClient
|
||||
private let socket: SocketClient
|
||||
private let profileStore: ProfileStore
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var timeoutTickerStarted = false
|
||||
|
||||
init(api: RestAPIClient, socket: SocketClient, profileStore: ProfileStore) {
|
||||
self.api = api
|
||||
self.socket = socket
|
||||
self.profileStore = profileStore
|
||||
|
||||
socket.eventPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] event in
|
||||
self?.reduce(event)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
startTimeoutTicker()
|
||||
}
|
||||
|
||||
// MARK: - Session / Profil
|
||||
|
||||
func restoreSession() async {
|
||||
var s = state
|
||||
s.savedProfile = profileStore.read()
|
||||
state = s
|
||||
|
||||
await loadCountries()
|
||||
await loadFeedbackAdminStatus()
|
||||
|
||||
do {
|
||||
let session = try await api.sessionStatus()
|
||||
var next = state
|
||||
next.expressSessionId = session.sessionId
|
||||
next.isLoggedIn = session.loggedIn && session.user != nil
|
||||
next.currentUser = session.user
|
||||
next.errorMessage = nil
|
||||
state = next
|
||||
if session.loggedIn, session.user != nil {
|
||||
resetTimeout()
|
||||
}
|
||||
connectSocket(expressSessionId: session.sessionId)
|
||||
} catch {
|
||||
var next = state
|
||||
next.errorMessage = error.localizedDescription
|
||||
state = next
|
||||
}
|
||||
}
|
||||
|
||||
func loadCountries() async {
|
||||
do {
|
||||
let countries = try await api.countries()
|
||||
let locale = Locale.current
|
||||
let options: [CountryOption] = countries.map { englishName, code in
|
||||
let normalized = code.uppercased()
|
||||
let localized = Locale.current.localizedString(forRegionCode: normalized)
|
||||
let display = localized.flatMap { $0.isEmpty ? nil : $0 } ?? englishName
|
||||
return CountryOption(englishName: englishName, displayName: display, isoCode: code)
|
||||
}
|
||||
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||
|
||||
var next = state
|
||||
next.countries = options
|
||||
state = next
|
||||
} catch {
|
||||
var next = state
|
||||
next.errorMessage = "Country list could not be loaded: \(error.localizedDescription)"
|
||||
state = next
|
||||
}
|
||||
}
|
||||
|
||||
func login(userName: String, gender: String, age: Int, country: String) async {
|
||||
profileStore.write(SavedProfile(nickname: userName, gender: gender, age: age, country: country))
|
||||
|
||||
let session = try? await api.sessionStatus()
|
||||
let sessionId = session?.sessionId ?? state.expressSessionId
|
||||
|
||||
var next = state
|
||||
next.expressSessionId = sessionId
|
||||
state = next
|
||||
|
||||
if !socket.isConnected {
|
||||
connectSocket(expressSessionId: sessionId)
|
||||
}
|
||||
socket.login(userName: userName, gender: gender, age: age, country: country, expressSessionId: sessionId)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
func logout() async {
|
||||
try? await api.logout()
|
||||
socket.disconnect()
|
||||
clearCookies()
|
||||
var fresh = ChatState()
|
||||
fresh.savedProfile = profileStore.read()
|
||||
fresh.countries = state.countries
|
||||
state = fresh
|
||||
}
|
||||
|
||||
func connectSocket(expressSessionId: String? = nil) {
|
||||
let sid = expressSessionId ?? state.expressSessionId
|
||||
socket.connect()
|
||||
if let sid {
|
||||
socket.setSessionId(sid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chat
|
||||
|
||||
func openConversation(userName: String) {
|
||||
var next = state
|
||||
next.currentConversation = userName
|
||||
next.messages = []
|
||||
state = next
|
||||
socket.requestConversation(withUserName: userName)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
func closeConversation() {
|
||||
var next = state
|
||||
next.currentConversation = nil
|
||||
next.messages = []
|
||||
state = next
|
||||
}
|
||||
|
||||
func sendMessage(text: String) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let target = state.currentConversation
|
||||
let isCommand = trimmed.hasPrefix("/")
|
||||
if target == nil, !isCommand { return }
|
||||
|
||||
socket.sendMessage(toUserName: target, message: trimmed)
|
||||
if !isCommand {
|
||||
let msg = ChatMessageDto(
|
||||
from: state.currentUser?.userName ?? "",
|
||||
to: target,
|
||||
message: trimmed,
|
||||
messageId: nil,
|
||||
timestamp: ISO8601DateFormatter().string(from: Date()),
|
||||
read: false,
|
||||
isImage: false,
|
||||
imageType: nil,
|
||||
imageUrl: nil,
|
||||
imageCode: nil
|
||||
)
|
||||
var next = state
|
||||
next.messages.append(msg)
|
||||
state = next
|
||||
}
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
func sendImage(toUserName: String, imageCode: String, imageUrl: String) {
|
||||
let absoluteUrl: String =
|
||||
imageUrl.hasPrefix("http") ? imageUrl : AppConfig.baseURL + imageUrl
|
||||
socket.sendImage(toUserName: toUserName, imageCode: imageCode, imageUrl: absoluteUrl)
|
||||
let msg = ChatMessageDto(
|
||||
from: state.currentUser?.userName ?? "",
|
||||
to: toUserName,
|
||||
message: absoluteUrl,
|
||||
messageId: nil,
|
||||
timestamp: ISO8601DateFormatter().string(from: Date()),
|
||||
read: false,
|
||||
isImage: true,
|
||||
imageType: nil,
|
||||
imageUrl: absoluteUrl,
|
||||
imageCode: imageCode
|
||||
)
|
||||
var next = state
|
||||
next.messages.append(msg)
|
||||
state = next
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
func setImageUploadState(inProgress: Bool, message: String? = nil) {
|
||||
var next = state
|
||||
next.isUploadingImage = inProgress
|
||||
next.imageUploadMessage = message
|
||||
state = next
|
||||
}
|
||||
|
||||
func uploadImage(data: Data, fileName: String, mimeType: String) async throws -> ImageUploadResponse {
|
||||
let result = try await api.uploadImage(data: data, fileName: fileName, mimeType: mimeType)
|
||||
return result.response
|
||||
}
|
||||
|
||||
// MARK: - Feedback / Partner
|
||||
|
||||
func loadFeedback() async {
|
||||
do {
|
||||
let response = try await api.feedback()
|
||||
var next = state
|
||||
next.feedbackItems = response.items
|
||||
next.feedbackMessage = nil
|
||||
state = next
|
||||
} catch {
|
||||
var next = state
|
||||
next.feedbackMessage = error.localizedDescription
|
||||
state = next
|
||||
}
|
||||
}
|
||||
|
||||
func submitFeedback(comment: String) async {
|
||||
let trimmed = comment.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
let profile = state.savedProfile
|
||||
do {
|
||||
try await api.submitFeedback(
|
||||
FeedbackRequest(
|
||||
name: profile.nickname,
|
||||
age: profile.age,
|
||||
country: profile.country,
|
||||
gender: profile.gender,
|
||||
comment: trimmed
|
||||
)
|
||||
)
|
||||
var next = state
|
||||
next.feedbackMessage = "Feedback saved"
|
||||
state = next
|
||||
await loadFeedback()
|
||||
} catch {
|
||||
var next = state
|
||||
next.feedbackMessage = error.localizedDescription
|
||||
state = next
|
||||
}
|
||||
}
|
||||
|
||||
func loadFeedbackAdminStatus() async {
|
||||
do {
|
||||
let response = try await api.feedbackAdminStatus()
|
||||
var next = state
|
||||
next.feedbackAdminAuthenticated = response.authenticated
|
||||
next.feedbackAdminUserName = response.username
|
||||
next.feedbackAdminError = nil
|
||||
state = next
|
||||
} catch {
|
||||
var next = state
|
||||
next.feedbackAdminAuthenticated = false
|
||||
next.feedbackAdminUserName = nil
|
||||
state = next
|
||||
}
|
||||
}
|
||||
|
||||
func loginFeedbackAdmin(username: String, password: String) async {
|
||||
do {
|
||||
let response = try await api.feedbackAdminLogin(FeedbackAdminLoginRequest(username: username, password: password))
|
||||
var next = state
|
||||
next.feedbackAdminAuthenticated = true
|
||||
next.feedbackAdminUserName = response.username
|
||||
next.feedbackAdminError = nil
|
||||
state = next
|
||||
await loadFeedback()
|
||||
} catch {
|
||||
var next = state
|
||||
next.feedbackAdminError = error.localizedDescription
|
||||
state = next
|
||||
}
|
||||
}
|
||||
|
||||
func logoutFeedbackAdmin() async {
|
||||
try? await api.feedbackAdminLogout()
|
||||
var next = state
|
||||
next.feedbackAdminAuthenticated = false
|
||||
next.feedbackAdminUserName = nil
|
||||
next.feedbackAdminError = nil
|
||||
state = next
|
||||
}
|
||||
|
||||
func deleteFeedback(id: String) async {
|
||||
do {
|
||||
try await api.deleteFeedback(id: id)
|
||||
await loadFeedback()
|
||||
} catch {
|
||||
var next = state
|
||||
next.feedbackAdminError = error.localizedDescription
|
||||
state = next
|
||||
}
|
||||
}
|
||||
|
||||
func loadPartners() async {
|
||||
do {
|
||||
let links = try await api.partners()
|
||||
var next = state
|
||||
next.partnerLinks = links
|
||||
next.partnersError = nil
|
||||
state = next
|
||||
} catch {
|
||||
var next = state
|
||||
next.partnersError = error.localizedDescription
|
||||
state = next
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Socket actions
|
||||
|
||||
func search(nameIncludes: String?, minAge: Int?, maxAge: Int?, countries: [String], genders: [String]) {
|
||||
socket.userSearch(nameIncludes: nameIncludes, minAge: minAge, maxAge: maxAge, countries: countries, genders: genders)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
func requestInbox() {
|
||||
socket.requestOpenConversations()
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
func requestHistory() {
|
||||
socket.requestHistory()
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
func blockUser(userName: String) {
|
||||
socket.blockUser(userName: userName)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
func unblockUser(userName: String) {
|
||||
socket.unblockUser(userName: userName)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func clearCookies() {
|
||||
HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
|
||||
}
|
||||
|
||||
private func startTimeoutTicker() {
|
||||
guard !timeoutTickerStarted else { return }
|
||||
timeoutTickerStarted = true
|
||||
Timer.publish(every: 1, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.sink { [weak self] _ in
|
||||
self?.runTimeoutTick()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func runTimeoutTick() {
|
||||
guard state.isLoggedIn else { return }
|
||||
let nextSec = max(0, state.remainingSecondsToTimeout - 1)
|
||||
var next = state
|
||||
next.remainingSecondsToTimeout = nextSec
|
||||
state = next
|
||||
if nextSec == 0 {
|
||||
Task { await self.logout() }
|
||||
}
|
||||
}
|
||||
|
||||
private func resetTimeout() {
|
||||
guard state.isLoggedIn || state.currentUser != nil else { return }
|
||||
var next = state
|
||||
next.remainingSecondsToTimeout = 1800
|
||||
state = next
|
||||
}
|
||||
|
||||
private func reduce(_ event: SocketEvent) {
|
||||
state = Self.apply(state: state, event: event, socket: socket)
|
||||
}
|
||||
|
||||
private static func apply(state: ChatState, event: SocketEvent, socket: SocketClient) -> ChatState {
|
||||
var current = state
|
||||
switch event {
|
||||
case .connectionChanged(let connected, _):
|
||||
current.isConnected = connected
|
||||
|
||||
case .connected(let sessionId, let loggedIn, let user):
|
||||
if let sid = sessionId {
|
||||
socket.setSessionId(sid)
|
||||
}
|
||||
current.expressSessionId = sessionId ?? current.expressSessionId
|
||||
current.isLoggedIn = loggedIn || current.isLoggedIn
|
||||
current.currentUser = user ?? current.currentUser
|
||||
current.errorMessage = nil
|
||||
if current.isLoggedIn {
|
||||
current.remainingSecondsToTimeout = 1800
|
||||
}
|
||||
|
||||
case .loginSuccess(let sessionId, let user):
|
||||
current.expressSessionId = sessionId ?? current.expressSessionId
|
||||
current.isLoggedIn = true
|
||||
current.currentUser = user
|
||||
current.errorMessage = nil
|
||||
current.remainingSecondsToTimeout = 1800
|
||||
|
||||
case .userList(let users):
|
||||
current.users = users
|
||||
|
||||
case .incomingMessage(let message):
|
||||
let active = current.currentConversation == message.from
|
||||
if active {
|
||||
current.messages.append(message)
|
||||
}
|
||||
if !active {
|
||||
current.unreadChatsCount += 1
|
||||
}
|
||||
current.remainingSecondsToTimeout = 1800
|
||||
|
||||
case .messageSent:
|
||||
break
|
||||
|
||||
case .conversation(let withUserName, let messages):
|
||||
current.currentConversation = withUserName
|
||||
current.messages = messages
|
||||
current.unreadChatsCount = max(0, current.unreadChatsCount - 1)
|
||||
current.remainingSecondsToTimeout = 1800
|
||||
|
||||
case .searchResults(let results):
|
||||
current.searchResults = results
|
||||
|
||||
case .historyResults(let results):
|
||||
current.historyResults = results
|
||||
|
||||
case .inboxResults(let results):
|
||||
current.inboxResults = results
|
||||
|
||||
case .unreadChats(let count):
|
||||
current.unreadChatsCount = count
|
||||
|
||||
case .userBlocked(let userName):
|
||||
current.errorMessage = "\(userName) blocked"
|
||||
|
||||
case .userUnblocked(let userName):
|
||||
current.errorMessage = "\(userName) unblocked"
|
||||
|
||||
case .commandResult(let lines, let kind):
|
||||
current.commandLines = lines
|
||||
current.commandKind = kind
|
||||
current.commandTable = nil
|
||||
current.awaitingLoginUsername = kind == "loginPromptUsername"
|
||||
current.awaitingLoginPassword = kind == "loginPromptPassword"
|
||||
if kind == "info" || kind.hasPrefix("login") {
|
||||
current.errorMessage = lines.joined(separator: " | ")
|
||||
}
|
||||
|
||||
case .commandTable(let title, let columns, let rows):
|
||||
current.commandTable = CommandTableState(title: title, columns: columns, rows: rows)
|
||||
|
||||
case .error(let message):
|
||||
current.errorMessage = message
|
||||
}
|
||||
return current
|
||||
}
|
||||
}
|
||||
105
ios/YpChat/Data/Models.swift
Normal file
105
ios/YpChat/Data/Models.swift
Normal file
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Session / User (RestApi.kt / Models.kt)
|
||||
|
||||
struct UserDto: Codable, Equatable, Sendable {
|
||||
var sessionId: String?
|
||||
var userName: String = ""
|
||||
var gender: String = ""
|
||||
var age: Int = 0
|
||||
var country: String = ""
|
||||
var isoCountryCode: String = ""
|
||||
}
|
||||
|
||||
struct SessionResponse: Codable, Equatable, Sendable {
|
||||
var loggedIn: Bool = false
|
||||
var sessionId: String?
|
||||
var user: UserDto?
|
||||
}
|
||||
|
||||
struct LogoutResponse: Codable, Equatable, Sendable {
|
||||
var success: Bool = false
|
||||
}
|
||||
|
||||
// MARK: - Chat / Listen (Socket / Models.kt)
|
||||
|
||||
struct ChatMessageDto: Codable, Equatable, Sendable {
|
||||
var from: String = ""
|
||||
var to: String?
|
||||
var message: String = ""
|
||||
var messageId: String?
|
||||
var timestamp: String = ""
|
||||
var read: Bool = false
|
||||
var isImage: Bool = false
|
||||
var imageType: String?
|
||||
var imageUrl: String?
|
||||
var imageCode: String?
|
||||
}
|
||||
|
||||
struct HistoryItemDto: Codable, Equatable, Sendable {
|
||||
var userName: String = ""
|
||||
var lastMessage: ChatMessageDto?
|
||||
}
|
||||
|
||||
struct InboxItemDto: Codable, Equatable, Sendable {
|
||||
var userName: String = ""
|
||||
var unreadCount: Int = 0
|
||||
}
|
||||
|
||||
// MARK: - REST (Models.kt)
|
||||
|
||||
struct CountryOption: Equatable, Sendable {
|
||||
var englishName: String
|
||||
var displayName: String
|
||||
var isoCode: String
|
||||
}
|
||||
|
||||
struct FeedbackItemDto: Codable, Equatable, Sendable {
|
||||
var id: String = ""
|
||||
var name: String?
|
||||
var age: Int?
|
||||
var country: String?
|
||||
var gender: String?
|
||||
var comment: String = ""
|
||||
var createdAt: String = ""
|
||||
}
|
||||
|
||||
struct FeedbackResponse: Codable, Equatable, Sendable {
|
||||
var items: [FeedbackItemDto] = []
|
||||
var admin: Bool = false
|
||||
}
|
||||
|
||||
struct FeedbackAdminStatusResponse: Codable, Equatable, Sendable {
|
||||
var authenticated: Bool = false
|
||||
var username: String?
|
||||
}
|
||||
|
||||
struct FeedbackRequest: Codable, Equatable, Sendable {
|
||||
var name: String = ""
|
||||
var age: Int?
|
||||
var country: String = ""
|
||||
var gender: String = ""
|
||||
var comment: String = ""
|
||||
}
|
||||
|
||||
struct FeedbackAdminLoginRequest: Codable, Equatable, Sendable {
|
||||
var username: String = ""
|
||||
var password: String = ""
|
||||
}
|
||||
|
||||
struct PartnerLinkDto: Codable, Equatable, Sendable {
|
||||
var pageName: String = ""
|
||||
var url: String = ""
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case pageName = "Page Name"
|
||||
case url
|
||||
}
|
||||
}
|
||||
|
||||
struct ImageUploadResponse: Codable, Equatable, Sendable {
|
||||
var success: Bool = false
|
||||
var code: String?
|
||||
var url: String?
|
||||
var error: String?
|
||||
}
|
||||
182
ios/YpChat/Data/RestAPIClient.swift
Normal file
182
ios/YpChat/Data/RestAPIClient.swift
Normal file
@@ -0,0 +1,182 @@
|
||||
import Foundation
|
||||
|
||||
enum RestAPIError: Error, LocalizedError {
|
||||
case invalidURL(String)
|
||||
case badStatus(Int, String?)
|
||||
case decoding(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL(let s): return "Ungültige URL: \(s)"
|
||||
case .badStatus(let code, let body): return "HTTP \(code): \(body ?? "")"
|
||||
case .decoding(let e): return "JSON: \(e.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// REST-Schicht analog `RestApi.kt`.
|
||||
final class RestAPIClient: @unchecked Sendable {
|
||||
private let baseURLString: String
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
private let encoder: JSONEncoder
|
||||
|
||||
init(baseURLString: String, session: URLSession) {
|
||||
self.baseURLString = baseURLString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
self.session = session
|
||||
self.decoder = JSONDecoder()
|
||||
self.encoder = JSONEncoder()
|
||||
}
|
||||
|
||||
func sessionStatus() async throws -> SessionResponse {
|
||||
try await request(path: "api/session", method: "GET", body: nil)
|
||||
}
|
||||
|
||||
func logout() async throws -> LogoutResponse {
|
||||
let url = try url(for: "api/logout")
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw RestAPIError.badStatus(-1, nil)
|
||||
}
|
||||
guard (200 ... 299).contains(http.statusCode) else {
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
throw RestAPIError.badStatus(http.statusCode, text)
|
||||
}
|
||||
if data.isEmpty {
|
||||
return LogoutResponse(success: true)
|
||||
}
|
||||
do {
|
||||
return try decoder.decode(LogoutResponse.self, from: data)
|
||||
} catch {
|
||||
throw RestAPIError.decoding(error)
|
||||
}
|
||||
}
|
||||
|
||||
func countries() async throws -> [String: String] {
|
||||
try await request(path: "api/countries", method: "GET", body: nil)
|
||||
}
|
||||
|
||||
func feedback() async throws -> FeedbackResponse {
|
||||
try await request(path: "api/feedback", method: "GET", body: nil)
|
||||
}
|
||||
|
||||
func feedbackAdminStatus() async throws -> FeedbackAdminStatusResponse {
|
||||
try await request(path: "api/feedback/admin-status", method: "GET", body: nil)
|
||||
}
|
||||
|
||||
func submitFeedback(_ requestBody: FeedbackRequest) async throws {
|
||||
let data = try encoder.encode(requestBody)
|
||||
try await requestVoid(path: "api/feedback", method: "POST", body: data)
|
||||
}
|
||||
|
||||
func feedbackAdminLogin(_ requestBody: FeedbackAdminLoginRequest) async throws -> FeedbackAdminStatusResponse {
|
||||
let data = try encoder.encode(requestBody)
|
||||
return try await request(path: "api/feedback/admin-login", method: "POST", body: data)
|
||||
}
|
||||
|
||||
func feedbackAdminLogout() async throws {
|
||||
try await requestVoid(path: "api/feedback/admin-logout", method: "POST", body: Data())
|
||||
}
|
||||
|
||||
func deleteFeedback(id: String) async throws {
|
||||
let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
|
||||
try await requestVoid(path: "api/feedback/\(encoded)", method: "DELETE", body: nil)
|
||||
}
|
||||
|
||||
func partners() async throws -> [PartnerLinkDto] {
|
||||
try await request(path: "api/partners", method: "GET", body: nil)
|
||||
}
|
||||
|
||||
/// Multipart-Feld `image` wie OkHttp `MultipartBody.Part`.
|
||||
func uploadImage(data: Data, fileName: String, mimeType: String) async throws -> (response: ImageUploadResponse, httpStatus: Int) {
|
||||
let url = try url(for: "api/upload-image")
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
var body = Data()
|
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
body.append(
|
||||
"Content-Disposition: form-data; name=\"image\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!
|
||||
)
|
||||
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(data)
|
||||
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
req.httpBody = body
|
||||
|
||||
let (respData, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw RestAPIError.badStatus(-1, nil)
|
||||
}
|
||||
guard (200 ... 299).contains(http.statusCode) else {
|
||||
let text = String(data: respData, encoding: .utf8)
|
||||
throw RestAPIError.badStatus(http.statusCode, text)
|
||||
}
|
||||
do {
|
||||
let decoded = try decoder.decode(ImageUploadResponse.self, from: respData)
|
||||
return (decoded, http.statusCode)
|
||||
} catch {
|
||||
throw RestAPIError.decoding(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Request
|
||||
|
||||
private func url(for path: String) throws -> URL {
|
||||
let trimmed = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let url = URL(string: "\(baseURLString)/\(trimmed)") else {
|
||||
throw RestAPIError.invalidURL("\(baseURLString)/\(trimmed)")
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private func requestVoid(path: String, method: String, body: Data?) async throws {
|
||||
let url = try url(for: path)
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.httpBody = body
|
||||
if let body, !body.isEmpty {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw RestAPIError.badStatus(-1, nil)
|
||||
}
|
||||
guard (200 ... 299).contains(http.statusCode) else {
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
throw RestAPIError.badStatus(http.statusCode, text)
|
||||
}
|
||||
}
|
||||
|
||||
private func request<T: Decodable>(path: String, method: String, body: Data?) async throws -> T {
|
||||
let url = try url(for: path)
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.httpBody = body
|
||||
if let body, !body.isEmpty {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw RestAPIError.badStatus(-1, nil)
|
||||
}
|
||||
guard (200 ... 299).contains(http.statusCode) else {
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
throw RestAPIError.badStatus(http.statusCode, text)
|
||||
}
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw RestAPIError.decoding(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
348
ios/YpChat/Data/SocketClient.swift
Normal file
348
ios/YpChat/Data/SocketClient.swift
Normal file
@@ -0,0 +1,348 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import SocketIO
|
||||
|
||||
/// Entspricht `SocketClient.kt`: gleiche Events, `setSessionId`, `login`, Emits.
|
||||
final class SocketClient: @unchecked Sendable {
|
||||
private let baseURL: String
|
||||
private var manager: SocketManager?
|
||||
private var socket: SocketIOClient?
|
||||
private var pendingExpressSessionId: String?
|
||||
|
||||
private let eventSubject = PassthroughSubject<SocketEvent, Never>()
|
||||
|
||||
/// Ereignisse vom Server (analog `SharedFlow<SocketEvent>`).
|
||||
var eventPublisher: AnyPublisher<SocketEvent, Never> {
|
||||
eventSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
var isConnected: Bool {
|
||||
socket?.status == .connected
|
||||
}
|
||||
|
||||
init(baseURL: String) {
|
||||
self.baseURL = baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
}
|
||||
|
||||
func connect() {
|
||||
disconnect()
|
||||
|
||||
guard let url = URL(string: baseURL) else {
|
||||
notify(.error("Ungültige Socket-Basis-URL"))
|
||||
return
|
||||
}
|
||||
|
||||
var config: SocketIOClientConfiguration = [
|
||||
.log(false),
|
||||
.compress,
|
||||
.reconnects(true),
|
||||
.reconnectAttempts(-1),
|
||||
.reconnectWait(1),
|
||||
.reconnectWaitMax(30),
|
||||
.version(.three),
|
||||
]
|
||||
|
||||
if let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty {
|
||||
config.insert(.cookies(cookies))
|
||||
}
|
||||
|
||||
let manager = SocketManager(socketURL: url, config: config)
|
||||
self.manager = manager
|
||||
let socket = manager.defaultSocket
|
||||
self.socket = socket
|
||||
|
||||
socket.on(clientEvent: .connect) { [weak self] _, _ in
|
||||
self?.handleSocketConnect()
|
||||
}
|
||||
socket.on(clientEvent: .disconnect) { [weak self] data, _ in
|
||||
let reason = data.first.map { String(describing: $0) }
|
||||
self?.notify(.connectionChanged(connected: false, reason: reason))
|
||||
}
|
||||
socket.on(clientEvent: .error) { [weak self] data, _ in
|
||||
let msg = data.first.map { String(describing: $0) } ?? ""
|
||||
self?.notify(.error("Socket-Verbindung fehlgeschlagen: \(msg)"))
|
||||
}
|
||||
|
||||
registerServerEvents(socket)
|
||||
manager.connect()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
socket?.removeAllHandlers()
|
||||
manager?.disconnect()
|
||||
socket = nil
|
||||
manager = nil
|
||||
}
|
||||
|
||||
func setSessionId(_ expressSessionId: String) {
|
||||
pendingExpressSessionId = expressSessionId
|
||||
guard isConnected else { return }
|
||||
socket?.emit("setSessionId", ["expressSessionId": expressSessionId])
|
||||
}
|
||||
|
||||
func login(userName: String, gender: String, age: Int, country: String, expressSessionId: String?) {
|
||||
var payload: [String: Any] = [
|
||||
"userName": userName,
|
||||
"gender": gender,
|
||||
"age": age,
|
||||
"country": country,
|
||||
]
|
||||
if let expressSessionId {
|
||||
payload["expressSessionId"] = expressSessionId
|
||||
} else {
|
||||
payload["expressSessionId"] = NSNull()
|
||||
}
|
||||
socket?.emit("login", payload)
|
||||
}
|
||||
|
||||
func sendMessage(toUserName: String?, message: String, messageId: String = "\(Int(Date().timeIntervalSince1970 * 1000))") {
|
||||
var payload: [String: Any] = [
|
||||
"message": message.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
"messageId": messageId,
|
||||
]
|
||||
if let toUserName, !toUserName.isEmpty {
|
||||
payload["toUserName"] = toUserName
|
||||
}
|
||||
socket?.emit("message", payload)
|
||||
}
|
||||
|
||||
func sendImage(toUserName: String, imageCode: String, imageUrl: String, messageId: String = "\(Int(Date().timeIntervalSince1970 * 1000))") {
|
||||
socket?.emit(
|
||||
"message",
|
||||
[
|
||||
"toUserName": toUserName,
|
||||
"message": imageCode,
|
||||
"messageId": messageId,
|
||||
"isImage": true,
|
||||
"imageUrl": imageUrl,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func requestConversation(withUserName: String) {
|
||||
socket?.emit("requestConversation", ["withUserName": withUserName])
|
||||
}
|
||||
|
||||
func userSearch(nameIncludes: String?, minAge: Int?, maxAge: Int?, countries: [String], genders: [String]) {
|
||||
var payload: [String: Any] = [
|
||||
"countries": countries,
|
||||
"genders": genders,
|
||||
]
|
||||
payload["nameIncludes"] = nameIncludes ?? NSNull()
|
||||
payload["minAge"] = minAge.map { $0 as Any } ?? NSNull()
|
||||
payload["maxAge"] = maxAge.map { $0 as Any } ?? NSNull()
|
||||
socket?.emit("userSearch", payload)
|
||||
}
|
||||
|
||||
func requestHistory() {
|
||||
socket?.emit("requestHistory")
|
||||
}
|
||||
|
||||
func requestOpenConversations() {
|
||||
socket?.emit("requestOpenConversations")
|
||||
}
|
||||
|
||||
func blockUser(userName: String) {
|
||||
socket?.emit("blockUser", ["userName": userName])
|
||||
}
|
||||
|
||||
func unblockUser(userName: String) {
|
||||
socket?.emit("unblockUser", ["userName": userName])
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func handleSocketConnect() {
|
||||
if let sid = pendingExpressSessionId {
|
||||
socket?.emit("setSessionId", ["expressSessionId": sid])
|
||||
}
|
||||
// Reihenfolge wie `SocketClient.kt` (EVENT_CONNECT): zuerst setSessionId, dann ConnectionChanged.
|
||||
notify(.connectionChanged(connected: true, reason: nil))
|
||||
}
|
||||
|
||||
private func notify(_ event: SocketEvent) {
|
||||
eventSubject.send(event)
|
||||
}
|
||||
|
||||
private func registerServerEvents(_ socket: SocketIOClient) {
|
||||
socket.on("connected") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
self?.notify(
|
||||
.connected(
|
||||
sessionId: json.stringOrNil("sessionId"),
|
||||
loggedIn: json.bool("loggedIn", default: false),
|
||||
user: json.userObject("user")
|
||||
)
|
||||
)
|
||||
}
|
||||
socket.on("loginSuccess") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
self?.notify(.loginSuccess(sessionId: json.stringOrNil("sessionId"), user: json.userObject("user")))
|
||||
}
|
||||
socket.on("userList") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data),
|
||||
let users = json["users"] as? [[String: Any]]
|
||||
else { return }
|
||||
self?.notify(.userList(users: users.map { UserDto(json: $0) }))
|
||||
}
|
||||
socket.on("message") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
self?.notify(.incomingMessage(ChatMessageDto(json: json)))
|
||||
}
|
||||
socket.on("messageSent") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
self?.notify(.messageSent(messageId: json.stringOrNil("messageId"), to: json.stringOrNil("to")))
|
||||
}
|
||||
socket.on("conversation") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
let withName = json.string("with")
|
||||
let rawMessages = json["messages"] as? [[String: Any]] ?? []
|
||||
let messages = rawMessages.map { ChatMessageDto(json: $0) }
|
||||
self?.notify(.conversation(withUserName: withName, messages: messages))
|
||||
}
|
||||
socket.on("searchResults") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data),
|
||||
let results = json["results"] as? [[String: Any]]
|
||||
else { return }
|
||||
self?.notify(.searchResults(results.map { UserDto(json: $0) }))
|
||||
}
|
||||
socket.on("historyResults") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data),
|
||||
let results = json["results"] as? [[String: Any]]
|
||||
else { return }
|
||||
self?.notify(.historyResults(results.map { HistoryItemDto(json: $0) }))
|
||||
}
|
||||
socket.on("inboxResults") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data),
|
||||
let results = json["results"] as? [[String: Any]]
|
||||
else { return }
|
||||
self?.notify(.inboxResults(results.map { InboxItemDto(json: $0) }))
|
||||
}
|
||||
socket.on("unreadChats") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
self?.notify(.unreadChats(count: json.int("count", default: 0)))
|
||||
}
|
||||
socket.on("userBlocked") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
self?.notify(.userBlocked(json.string("userName")))
|
||||
}
|
||||
socket.on("userUnblocked") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
self?.notify(.userUnblocked(json.string("userName")))
|
||||
}
|
||||
socket.on("commandResult") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
let lines = (json["lines"] as? [Any])?.map { String(describing: $0) } ?? []
|
||||
let kind = json.string("kind", default: "info")
|
||||
self?.notify(.commandResult(lines: lines, kind: kind))
|
||||
}
|
||||
socket.on("commandTable") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
let title = json.string("title", default: "Ausgabe")
|
||||
let columns = (json["columns"] as? [Any])?.map { String(describing: $0) } ?? []
|
||||
let rows = Self.nestedStringRows(json["rows"])
|
||||
self?.notify(.commandTable(title: title, columns: columns, rows: rows))
|
||||
}
|
||||
socket.on("error") { [weak self] data, _ in
|
||||
let json = Self.firstDictionary(from: data)
|
||||
let message =
|
||||
json?.stringOrNil("message")
|
||||
?? data.first.map { String(describing: $0) }
|
||||
?? "Unbekannter Socket-Fehler"
|
||||
self?.notify(.error(message))
|
||||
}
|
||||
}
|
||||
|
||||
private static func firstDictionary(from data: [Any]) -> [String: Any]? {
|
||||
guard let first = data.first else { return nil }
|
||||
if let d = first as? [String: Any] { return d }
|
||||
if let n = first as? NSDictionary { return n as? [String: Any] }
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func nestedStringRows(_ any: Any?) -> [[String]] {
|
||||
guard let outer = any as? [Any] else { return [] }
|
||||
return outer.map { row in
|
||||
(row as? [Any])?.map { String(describing: $0) } ?? []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSON helpers (JSONObject-Äquivalent)
|
||||
|
||||
private extension Dictionary where Key == String, Value == Any {
|
||||
func stringOrNil(_ key: String) -> String? {
|
||||
guard let v = self[key], !(v is NSNull) else { return nil }
|
||||
if let s = v as? String { return s }
|
||||
return String(describing: v)
|
||||
}
|
||||
|
||||
func string(_ key: String, default def: String = "") -> String {
|
||||
stringOrNil(key) ?? def
|
||||
}
|
||||
|
||||
func bool(_ key: String, default def: Bool) -> Bool {
|
||||
guard let v = self[key], !(v is NSNull) else { return def }
|
||||
if let b = v as? Bool { return b }
|
||||
if let n = v as? NSNumber { return n.boolValue }
|
||||
return def
|
||||
}
|
||||
|
||||
func int(_ key: String, default def: Int) -> Int {
|
||||
guard let v = self[key], !(v is NSNull) else { return def }
|
||||
if let i = v as? Int { return i }
|
||||
if let n = v as? NSNumber { return n.intValue }
|
||||
return def
|
||||
}
|
||||
|
||||
func userObject(_ key: String) -> UserDto? {
|
||||
guard let nested = self[key] as? [String: Any] else { return nil }
|
||||
return UserDto(json: nested)
|
||||
}
|
||||
}
|
||||
|
||||
private extension UserDto {
|
||||
init(json: [String: Any]) {
|
||||
self.init(
|
||||
sessionId: json.stringOrNil("sessionId"),
|
||||
userName: json.string("userName"),
|
||||
gender: json.string("gender"),
|
||||
age: json.int("age", default: 0),
|
||||
country: json.string("country"),
|
||||
isoCountryCode: json.string("isoCountryCode")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatMessageDto {
|
||||
init(json: [String: Any]) {
|
||||
self.init(
|
||||
from: json.string("from"),
|
||||
to: json.stringOrNil("to"),
|
||||
message: json.string("message"),
|
||||
messageId: json.stringOrNil("messageId"),
|
||||
timestamp: json.string("timestamp"),
|
||||
read: json.bool("read", default: false),
|
||||
isImage: json.bool("isImage", default: false),
|
||||
imageType: json.stringOrNil("imageType"),
|
||||
imageUrl: json.stringOrNil("imageUrl"),
|
||||
imageCode: json.stringOrNil("imageCode")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension HistoryItemDto {
|
||||
init(json: [String: Any]) {
|
||||
let last: ChatMessageDto? = {
|
||||
guard let nested = json["lastMessage"] as? [String: Any] else { return nil }
|
||||
return ChatMessageDto(json: nested)
|
||||
}()
|
||||
self.init(userName: json.string("userName"), lastMessage: last)
|
||||
}
|
||||
}
|
||||
|
||||
private extension InboxItemDto {
|
||||
init(json: [String: Any]) {
|
||||
self.init(userName: json.string("userName"), unreadCount: json.int("unreadCount", default: 0))
|
||||
}
|
||||
}
|
||||
21
ios/YpChat/Data/SocketEvent.swift
Normal file
21
ios/YpChat/Data/SocketEvent.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
/// Entspricht `SocketEvent.kt` (Android).
|
||||
enum SocketEvent: Sendable {
|
||||
case connected(sessionId: String?, loggedIn: Bool, user: UserDto?)
|
||||
case loginSuccess(sessionId: String?, user: UserDto?)
|
||||
case userList(users: [UserDto])
|
||||
case incomingMessage(ChatMessageDto)
|
||||
case messageSent(messageId: String?, to: String?)
|
||||
case conversation(withUserName: String, messages: [ChatMessageDto])
|
||||
case searchResults([UserDto])
|
||||
case historyResults([HistoryItemDto])
|
||||
case inboxResults([InboxItemDto])
|
||||
case unreadChats(count: Int)
|
||||
case userBlocked(String)
|
||||
case userUnblocked(String)
|
||||
case commandResult(lines: [String], kind: String)
|
||||
case commandTable(title: String, columns: [String], rows: [[String]])
|
||||
case error(String)
|
||||
case connectionChanged(connected: Bool, reason: String?)
|
||||
}
|
||||
Reference in New Issue
Block a user