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:
Torsten Schulz (local)
2026-05-12 14:25:55 +02:00
parent ec567b32eb
commit 810b084e10
45 changed files with 3656 additions and 66 deletions

View 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
}
}

View 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?
}

View 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)
}
}
}

View 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))
}
}

View 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?)
}