From a97a491d472264c28e8d2ba197b5de3413aecd9c Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Tue, 7 Jul 2026 15:11:12 +0300 Subject: [PATCH 1/5] Replace Device struct with the OpenAPI generated model --- Scripts/openapi_generate.sh | 2 ++ .../Payloads/CurrentUserPayloads.swift | 6 ++-- .../Endpoints/Payloads/DevicePayloads.swift | 27 --------------- .../Database/DTOs/CurrentUserDTO.swift | 11 ++++-- .../StreamChat/Database/DTOs/DeviceDTO.swift | 19 ++++++++++- .../StreamChat/Database/DatabaseSession.swift | 6 ++-- .../StreamChatModel.xcdatamodel/contents | 7 ++++ .../{DeviceResponse.swift => Device.swift} | 20 +++++------ .../OpenAPI/models/ListDevicesResponse.swift | 4 +-- .../StreamChat/Models/Device+Extensions.swift | 29 ++++++++++++++++ Sources/StreamChat/Models/Device.swift | 26 -------------- .../Workers/CurrentUserUpdater.swift | 3 +- StreamChat.xcodeproj/project.pbxproj | 1 - .../Fixtures/JSONs/Devices.json | 9 ----- .../Database/DatabaseSession_Mock.swift | 2 +- .../DummyData/CurrentUserPayload.swift | 4 +-- ...esponse+Dummy.swift => Device+Dummy.swift} | 6 ++-- .../TestData/DummyData/DevicePayloads.swift | 18 ---------- .../Payloads/DevicePayloads_Tests.swift | 21 ------------ .../Database/DTOs/CurrentUserDTO_Tests.swift | 6 ++-- .../Database/DTOs/DeviceDTO_Tests.swift | 34 ++++++++++++++++++- .../Database/DTOs/UserDTO_Tests.swift | 2 +- .../StateLayer/ConnectedUser_Tests.swift | 2 +- .../Workers/CurrentUserUpdater_Tests.swift | 15 ++++---- 24 files changed, 135 insertions(+), 145 deletions(-) delete mode 100644 Sources/StreamChat/APIClient/Endpoints/Payloads/DevicePayloads.swift rename Sources/StreamChat/Generated/OpenAPI/models/{DeviceResponse.swift => Device.swift} (79%) create mode 100644 Sources/StreamChat/Models/Device+Extensions.swift delete mode 100644 Sources/StreamChat/Models/Device.swift delete mode 100644 TestTools/StreamChatTestTools/Fixtures/JSONs/Devices.json rename TestTools/StreamChatTestTools/TestData/DummyData/{DeviceResponse+Dummy.swift => Device+Dummy.swift} (72%) delete mode 100644 TestTools/StreamChatTestTools/TestData/DummyData/DevicePayloads.swift delete mode 100644 Tests/StreamChatTests/APIClient/Endpoints/Payloads/DevicePayloads_Tests.swift diff --git a/Scripts/openapi_generate.sh b/Scripts/openapi_generate.sh index ce5b2720391..48f0fb015e5 100755 --- a/Scripts/openapi_generate.sh +++ b/Scripts/openapi_generate.sh @@ -161,6 +161,7 @@ prune_models # so allowed_models above still matches the generator's original names. rename_generated Action AttachmentActionPayload rename_generated AppResponseFields AppSettings +rename_generated DeviceResponse Device rename_generated Field AttachmentFieldPayload rename_generated FileUploadConfig UploadConfig rename_generated ImageData GiphyImageData @@ -178,6 +179,7 @@ publicize_model() { "$file" } publicize_model AppSettings +publicize_model Device publicize_model UploadConfig # 5. Format. diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/CurrentUserPayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/CurrentUserPayloads.swift index c0c12a118b5..c63cc19260e 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/CurrentUserPayloads.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/CurrentUserPayloads.swift @@ -7,7 +7,7 @@ import Foundation /// An object describing the incoming current user JSON payload. final class CurrentUserPayload: UserPayload, @unchecked Sendable { /// A list of devices. - let devices: [DevicePayload] + let devices: [Device] /// Muted users. let mutedUsers: [MutedUserPayload] /// Muted channels. @@ -37,7 +37,7 @@ final class CurrentUserPayload: UserPayload, @unchecked Sendable { teams: [TeamId] = [], language: String?, extraData: [String: RawJSON], - devices: [DevicePayload] = [], + devices: [Device] = [], mutedUsers: [MutedUserPayload] = [], mutedChannels: [MutedChannelPayload] = [], unreadCount: UnreadCountPayload? = nil, @@ -74,7 +74,7 @@ final class CurrentUserPayload: UserPayload, @unchecked Sendable { required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: UserPayloadsCodingKeys.self) - devices = try container.decodeIfPresent([DevicePayload].self, forKey: .devices) ?? [] + devices = try container.decodeIfPresent([Device].self, forKey: .devices) ?? [] mutedUsers = try container.decodeIfPresent([MutedUserPayload].self, forKey: .mutedUsers) ?? [] mutedChannels = try container.decodeIfPresent([MutedChannelPayload].self, forKey: .mutedChannels) ?? [] unreadCount = try? UnreadCountPayload(from: decoder) diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/DevicePayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/DevicePayloads.swift deleted file mode 100644 index 0ae448e7e15..00000000000 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/DevicePayloads.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -struct DevicePayload: Decodable, Equatable { - private enum CodingKeys: String, CodingKey { - case id - case createdAt = "created_at" - } - - /// Device identifier. - let id: DeviceId - /// Date the device was created for the user. - let createdAt: Date? - - init(id: DeviceId, createdAt: Date? = .init()) { - self.id = id - self.createdAt = createdAt - } -} - -struct DeviceListPayload: Decodable { - /// List of devices belonging to user. - let devices: [DevicePayload] -} diff --git a/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift b/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift index 15003352ad5..453dda54464 100644 --- a/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift +++ b/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift @@ -170,7 +170,7 @@ extension NSManagedObjectContext: CurrentUserDatabaseSession { dto.unreadChannelCountsByGroup = counts } - func saveCurrentUserDevices(_ devices: [DevicePayload], clearExisting: Bool) throws -> [DeviceDTO] { + func saveCurrentUserDevices(_ devices: [Device], clearExisting: Bool) throws -> [DeviceDTO] { invalidateCurrentUserCache() guard let currentUser = currentUser else { @@ -186,7 +186,14 @@ extension NSManagedObjectContext: CurrentUserDatabaseSession { let deviceDTOs = devices.map { device -> DeviceDTO in let dto = DeviceDTO.loadOrCreate(id: device.id, context: self) - dto.createdAt = device.createdAt?.bridgeDate + dto.createdAt = device.createdAt.bridgeDate + dto.disabled = device.disabled.map { NSNumber(value: $0) } + dto.disabledReason = device.disabledReason + dto.hardwareId = device.hardwareId + dto.pushProvider = device.pushProvider + dto.pushProviderName = device.pushProviderName + dto.userId = device.userId + dto.voip = device.voip.map { NSNumber(value: $0) } dto.user = currentUser return dto } diff --git a/Sources/StreamChat/Database/DTOs/DeviceDTO.swift b/Sources/StreamChat/Database/DTOs/DeviceDTO.swift index ddca94dc8c0..d6a17ee18a7 100644 --- a/Sources/StreamChat/Database/DTOs/DeviceDTO.swift +++ b/Sources/StreamChat/Database/DTOs/DeviceDTO.swift @@ -9,6 +9,13 @@ import Foundation class DeviceDTO: NSManagedObject { @NSManaged var id: String @NSManaged var createdAt: DBDate? + @NSManaged var disabled: NSNumber? + @NSManaged var disabledReason: String? + @NSManaged var hardwareId: String? + @NSManaged var pushProvider: String + @NSManaged var pushProviderName: String? + @NSManaged var userId: String + @NSManaged var voip: NSNumber? @NSManaged var user: CurrentUserDTO } @@ -46,6 +53,16 @@ extension DeviceDTO { extension DeviceDTO { func asModel() throws -> Device { try isNotDeleted() - return Device(id: id, createdAt: createdAt?.bridgeDate) + return Device( + createdAt: createdAt?.bridgeDate ?? Date(timeIntervalSince1970: 0), + disabled: disabled?.boolValue, + disabledReason: disabledReason, + hardwareId: hardwareId, + id: id, + pushProvider: pushProvider, + pushProviderName: pushProviderName, + userId: userId, + voip: voip?.boolValue + ) } } diff --git a/Sources/StreamChat/Database/DatabaseSession.swift b/Sources/StreamChat/Database/DatabaseSession.swift index 2e1aed992f3..c6af2f8c58c 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -65,10 +65,10 @@ protocol CurrentUserDatabaseSession { /// Adjusts `CurrentUserDTO.unreadChannelCountsByGroup[groupKey]` by `delta`, flooring at 0. func adjustUnreadChannelCount(forGroup groupKey: String, by delta: Int) - /// Updates the `CurrentUserDTO.devices` with the provided `DevicesPayload` + /// Updates the `CurrentUserDTO.devices` with the provided devices. /// If there's no current user set, an error will be thrown. @discardableResult - func saveCurrentUserDevices(_ devices: [DevicePayload], clearExisting: Bool) throws -> [DeviceDTO] + func saveCurrentUserDevices(_ devices: [Device], clearExisting: Bool) throws -> [DeviceDTO] /// Saves the `currentDevice` for current user. func saveCurrentDevice(_ deviceId: String) throws @@ -92,7 +92,7 @@ protocol CurrentUserDatabaseSession { extension CurrentUserDatabaseSession { @discardableResult - func saveCurrentUserDevices(_ devices: [DevicePayload]) throws -> [DeviceDTO] { + func saveCurrentUserDevices(_ devices: [Device]) throws -> [DeviceDTO] { try saveCurrentUserDevices(devices, clearExisting: false) } } diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents index 86bb78d5592..d4fe1ce2d8d 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -193,7 +193,14 @@ + + + + + + + diff --git a/Sources/StreamChat/Generated/OpenAPI/models/DeviceResponse.swift b/Sources/StreamChat/Generated/OpenAPI/models/Device.swift similarity index 79% rename from Sources/StreamChat/Generated/OpenAPI/models/DeviceResponse.swift rename to Sources/StreamChat/Generated/OpenAPI/models/Device.swift index b52d9cf5dd0..e76374e9624 100644 --- a/Sources/StreamChat/Generated/OpenAPI/models/DeviceResponse.swift +++ b/Sources/StreamChat/Generated/OpenAPI/models/Device.swift @@ -4,25 +4,25 @@ import Foundation -final class DeviceResponse: Sendable, Codable, JSONEncodable { +public final class Device: Sendable, Codable, JSONEncodable { /// Date/time of creation - let createdAt: Date + public let createdAt: Date /// Whether device is disabled or not - let disabled: Bool? + public let disabled: Bool? /// Reason explaining why device had been disabled - let disabledReason: String? + public let disabledReason: String? /// Stable physical device identifier used to deduplicate pushes across push providers - let hardwareId: String? + public let hardwareId: String? /// Device ID - let id: String + public let id: String /// Push provider - let pushProvider: String + public let pushProvider: String /// Push provider name - let pushProviderName: String? + public let pushProviderName: String? /// User ID - let userId: String + public let userId: String /// When true the token is for Apple VoIP push notifications - let voip: Bool? + public let voip: Bool? init(createdAt: Date, disabled: Bool? = nil, disabledReason: String? = nil, hardwareId: String? = nil, id: String, pushProvider: String, pushProviderName: String? = nil, userId: String, voip: Bool? = nil) { self.createdAt = createdAt diff --git a/Sources/StreamChat/Generated/OpenAPI/models/ListDevicesResponse.swift b/Sources/StreamChat/Generated/OpenAPI/models/ListDevicesResponse.swift index fc20511e2fa..9782a1ddbd3 100644 --- a/Sources/StreamChat/Generated/OpenAPI/models/ListDevicesResponse.swift +++ b/Sources/StreamChat/Generated/OpenAPI/models/ListDevicesResponse.swift @@ -6,10 +6,10 @@ import Foundation final class ListDevicesResponse: Sendable, Codable, JSONEncodable { /// List of devices - let devices: [DeviceResponse] + let devices: [Device] let duration: String - init(devices: [DeviceResponse], duration: String) { + init(devices: [Device], duration: String) { self.devices = devices self.duration = duration } diff --git a/Sources/StreamChat/Models/Device+Extensions.swift b/Sources/StreamChat/Models/Device+Extensions.swift new file mode 100644 index 00000000000..fef3dd67047 --- /dev/null +++ b/Sources/StreamChat/Models/Device+Extensions.swift @@ -0,0 +1,29 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// A unique identifier of a device. +public typealias DeviceId = String + +extension Data { + /// Generates a device id string from device token data. + var deviceId: DeviceId { map { String(format: "%02x", $0) }.joined() } +} + +extension Device: Identifiable {} + +extension Device: Equatable { + public static func == (lhs: Device, rhs: Device) -> Bool { + lhs.id == rhs.id + && lhs.createdAt == rhs.createdAt + && lhs.disabled == rhs.disabled + && lhs.disabledReason == rhs.disabledReason + && lhs.hardwareId == rhs.hardwareId + && lhs.pushProvider == rhs.pushProvider + && lhs.pushProviderName == rhs.pushProviderName + && lhs.userId == rhs.userId + && lhs.voip == rhs.voip + } +} diff --git a/Sources/StreamChat/Models/Device.swift b/Sources/StreamChat/Models/Device.swift deleted file mode 100644 index 2b2ce19654f..00000000000 --- a/Sources/StreamChat/Models/Device.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// A unique identifier of a device. -public typealias DeviceId = String - -extension Data { - /// Generates a device id string from device token data. - var deviceId: DeviceId { map { String(format: "%02x", $0) }.joined() } -} - -/// An object representing a device which can receive push notifications. -public struct Device: Codable, Equatable, Identifiable, Sendable { - private enum CodingKeys: String, CodingKey { - case id - case createdAt = "created_at" - } - - /// The device identifier. - public let id: DeviceId - /// The date when the device for created. - public let createdAt: Date? -} diff --git a/Sources/StreamChat/Workers/CurrentUserUpdater.swift b/Sources/StreamChat/Workers/CurrentUserUpdater.swift index 3153cfe691a..4b7f242cf52 100644 --- a/Sources/StreamChat/Workers/CurrentUserUpdater.swift +++ b/Sources/StreamChat/Workers/CurrentUserUpdater.swift @@ -137,12 +137,11 @@ class CurrentUserUpdater: Worker, @unchecked Sendable { do { nonisolated(unsafe) var devices = [Device]() let response = try result.get() - let devicePayloads = response.devices.map { DevicePayload(id: $0.id, createdAt: $0.createdAt) } self?.database.write({ (session) in // Since this call always return all device, we want' to clear the existing ones // to remove the deleted devices. devices = try session.saveCurrentUserDevices( - devicePayloads, + response.devices, clearExisting: true ) .map { try $0.asModel() } diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index dc430e1e1c3..1481b175fa6 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -758,7 +758,6 @@ StreamChatTests/APIClient/Endpoints/Payloads/ChannelReadPayload_Tests.swift, StreamChatTests/APIClient/Endpoints/Payloads/CurrentUserPayloads_Tests.swift, StreamChatTests/APIClient/Endpoints/Payloads/CustomDataHashMap_Tests.swift, - StreamChatTests/APIClient/Endpoints/Payloads/DevicePayloads_Tests.swift, StreamChatTests/APIClient/Endpoints/Payloads/DraftPayloads_Tests.swift, StreamChatTests/APIClient/Endpoints/Payloads/FileUploadPayload_Tests.swift, StreamChatTests/APIClient/Endpoints/Payloads/FlagMessagePayload_Tests.swift, diff --git a/TestTools/StreamChatTestTools/Fixtures/JSONs/Devices.json b/TestTools/StreamChatTestTools/Fixtures/JSONs/Devices.json deleted file mode 100644 index 268426f4015..00000000000 --- a/TestTools/StreamChatTestTools/Fixtures/JSONs/Devices.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "devices" : [ - { - "id" : "2552c2ec3ba609bd6ebe5d437e6926de20fde3adae2d2e7f0a5a72bc90285fa5", - "push_provider" : "apn" - } - ], - "duration" : "0.43ms" -} diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift index 207fda34bf4..65780f9de76 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift @@ -49,7 +49,7 @@ class DatabaseSession_Mock: DatabaseSession { return try underlyingSession.saveCurrentDevice(deviceId) } - func saveCurrentUserDevices(_ devices: [DevicePayload], clearExisting: Bool) throws -> [DeviceDTO] { + func saveCurrentUserDevices(_ devices: [Device], clearExisting: Bool) throws -> [DeviceDTO] { try throwErrorIfNeeded() return try underlyingSession.saveCurrentUserDevices(devices, clearExisting: clearExisting) } diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/CurrentUserPayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/CurrentUserPayload.swift index b1c363b3086..a0ad25a9388 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/CurrentUserPayload.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/CurrentUserPayload.swift @@ -22,7 +22,7 @@ extension CurrentUserPayload { teamsRole: [String: UserRole]? = nil, unreadCount: UnreadCountPayload? = .dummy, extraData: [String: RawJSON] = [:], - devices: [DevicePayload] = [], + devices: [Device] = [], mutedUsers: [MutedUserPayload] = [], teams: [TeamId] = [], language: String? = nil, @@ -57,7 +57,7 @@ extension CurrentUserPayload { static func dummy( userPayload: UserPayload, unreadCount: UnreadCountPayload? = .dummy, - devices: [DevicePayload] = [], + devices: [Device] = [], mutedUsers: [MutedUserPayload] = [], mutedChannels: [MutedChannelPayload] = [], privacySettings: UserPrivacySettingsPayload? = nil, diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/DeviceResponse+Dummy.swift b/TestTools/StreamChatTestTools/TestData/DummyData/Device+Dummy.swift similarity index 72% rename from TestTools/StreamChatTestTools/TestData/DummyData/DeviceResponse+Dummy.swift rename to TestTools/StreamChatTestTools/TestData/DummyData/Device+Dummy.swift index 8776c40b4b7..b8a2e91de75 100644 --- a/TestTools/StreamChatTestTools/TestData/DummyData/DeviceResponse+Dummy.swift +++ b/TestTools/StreamChatTestTools/TestData/DummyData/Device+Dummy.swift @@ -5,8 +5,8 @@ import Foundation @testable import StreamChat -extension DeviceResponse { - static func dummy(pushProvider: String = "apn", userId: String = .unique) -> DeviceResponse { +extension Device { + static func dummy(pushProvider: String = "apn", userId: String = .unique) -> Device { .init( createdAt: .unique, id: .unique, @@ -17,7 +17,7 @@ extension DeviceResponse { } extension ListDevicesResponse { - static func dummy(devices: [DeviceResponse] = [.dummy(), .dummy()]) -> ListDevicesResponse { + static func dummy(devices: [Device] = [.dummy(), .dummy()]) -> ListDevicesResponse { .init(devices: devices, duration: "") } } diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/DevicePayloads.swift b/TestTools/StreamChatTestTools/TestData/DummyData/DevicePayloads.swift deleted file mode 100644 index 9f7c0aa6760..00000000000 --- a/TestTools/StreamChatTestTools/TestData/DummyData/DevicePayloads.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation -@testable import StreamChat - -extension DevicePayload { - static var dummy: DevicePayload { - .init(id: .unique, createdAt: .unique) - } -} - -extension DeviceListPayload { - static var dummy: DeviceListPayload { - .init(devices: [DevicePayload.dummy, DevicePayload.dummy]) - } -} diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/DevicePayloads_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/DevicePayloads_Tests.swift deleted file mode 100644 index acdb7f2c894..00000000000 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/DevicePayloads_Tests.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -@testable import StreamChat -@testable import StreamChatTestTools -import XCTest - -final class DevicePayloads_Tests: XCTestCase { - let devicesJSON = XCTestCase.mockData(fromJSONFile: "Devices") - - func test_devicesPayload_isSerialized() throws { - let payload = try JSONDecoder.default.decode(DeviceListPayload.self, from: devicesJSON) - - XCTAssertEqual(payload.devices.count, 1) - XCTAssertEqual( - payload.devices.first?.id, - "2552c2ec3ba609bd6ebe5d437e6926de20fde3adae2d2e7f0a5a72bc90285fa5" - ) - } -} diff --git a/Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift index c8e7a452442..b8414a77dab 100644 --- a/Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift @@ -46,7 +46,7 @@ final class CurrentUserModelDTO_Tests: XCTestCase { let payload: CurrentUserPayload = .dummy( userPayload: userPayload, - devices: [DevicePayload.dummy], + devices: [Device.dummy()], mutedUsers: [ .dummy(userId: .unique), .dummy(userId: .unique), @@ -114,7 +114,7 @@ final class CurrentUserModelDTO_Tests: XCTestCase { } func test_savingCurrentUser_removesCurrentDevice() throws { - let initialDevice = DevicePayload.dummy + let initialDevice = Device.dummy() let initialCurrentUserPayload = CurrentUserPayload.dummy(userId: .unique, role: .admin, devices: [initialDevice]) // Save the payload to the db @@ -133,7 +133,7 @@ final class CurrentUserModelDTO_Tests: XCTestCase { // ..and is set to currentDevice XCTAssertNotEqual(currentUser?.currentDevice, nil) - let newCurrentUserPayload = CurrentUserPayload.dummy(userId: initialCurrentUserPayload.id, role: .admin, devices: [.dummy]) + let newCurrentUserPayload = CurrentUserPayload.dummy(userId: initialCurrentUserPayload.id, role: .admin, devices: [.dummy()]) // Save the payload to the db try database.writeSynchronously { session in diff --git a/Tests/StreamChatTests/Database/DTOs/DeviceDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/DeviceDTO_Tests.swift index 689e319ec0b..cf555407264 100644 --- a/Tests/StreamChatTests/Database/DTOs/DeviceDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/DeviceDTO_Tests.swift @@ -21,7 +21,7 @@ final class DeviceDTO_Tests: XCTestCase { } func test_deviceListPayload_isStoredAndLoadedFromDB() throws { - let dummyDevices = DeviceListPayload.dummy + let dummyDevices = ListDevicesResponse.dummy() try database.writeSynchronously { (session) in // Save a current user to db for testing @@ -41,4 +41,36 @@ final class DeviceDTO_Tests: XCTestCase { XCTAssertEqual(sortedCurrentUserDevices?.first?.id, sortedDummyDevices.first?.id) XCTAssertEqual(sortedCurrentUserDevices?.first?.createdAt, sortedDummyDevices.first?.createdAt) } + + func test_deviceAllFields_areStoredAndLoadedFromDB() throws { + let device = Device( + createdAt: .unique, + disabled: true, + disabledReason: "spam", + hardwareId: "hw-1", + id: .unique, + pushProvider: "firebase", + pushProviderName: "my-fcm", + userId: .unique, + voip: true + ) + + try database.writeSynchronously { (session) in + try session.saveCurrentUser(payload: self.dummyCurrentUser) + try session.saveCurrentUserDevices([device]) + } + + let loadedCurrentUser: CurrentChatUser? = try database.viewContext.currentUser?.asModel() + let loadedDevice = loadedCurrentUser?.devices.first + + XCTAssertEqual(loadedDevice?.id, device.id) + XCTAssertEqual(loadedDevice?.createdAt, device.createdAt) + XCTAssertEqual(loadedDevice?.disabled, device.disabled) + XCTAssertEqual(loadedDevice?.disabledReason, device.disabledReason) + XCTAssertEqual(loadedDevice?.hardwareId, device.hardwareId) + XCTAssertEqual(loadedDevice?.pushProvider, device.pushProvider) + XCTAssertEqual(loadedDevice?.pushProviderName, device.pushProviderName) + XCTAssertEqual(loadedDevice?.userId, device.userId) + XCTAssertEqual(loadedDevice?.voip, device.voip) + } } diff --git a/Tests/StreamChatTests/Database/DTOs/UserDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/UserDTO_Tests.swift index c6691b41ac8..6d4d4eb33fd 100644 --- a/Tests/StreamChatTests/Database/DTOs/UserDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/UserDTO_Tests.swift @@ -326,7 +326,7 @@ final class UserDTO_Tests: XCTestCase { userId: userId, role: .admin, extraData: [:], - devices: [DevicePayload.dummy], + devices: [Device.dummy()], mutedUsers: [ .dummy(userId: .unique) ] diff --git a/Tests/StreamChatTests/StateLayer/ConnectedUser_Tests.swift b/Tests/StreamChatTests/StateLayer/ConnectedUser_Tests.swift index 49090c158e9..6a9f1627bce 100644 --- a/Tests/StreamChatTests/StateLayer/ConnectedUser_Tests.swift +++ b/Tests/StreamChatTests/StateLayer/ConnectedUser_Tests.swift @@ -205,7 +205,7 @@ final class ConnectedUser_Tests: XCTestCase { } private func currentUserPayload(name: String = "InitialName", deviceCount: Int = 0, role: UserRole = .admin) -> CurrentUserPayload { - let devices = (0.. Date: Tue, 7 Jul 2026 15:13:15 +0300 Subject: [PATCH 2/5] Update CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2ff8aa7e07..02a99ab4f27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming +## StreamChat +### ✅ Added +- Expose additional device data on `Device`: `pushProvider`, `pushProviderName`, `userId`, `disabled`, `disabledReason`, `hardwareId`, and `voip` [#4161](https://github.com/GetStream/stream-chat-swift/pull/4161) + ### 🔄 Changed # [5.6.0](https://github.com/GetStream/stream-chat-swift/releases/tag/5.6.0) From 2297a68948426facb797ad86eec7eb1eed487856 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Tue, 7 Jul 2026 15:38:50 +0300 Subject: [PATCH 3/5] Adjust CHANGELOG entry --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a99ab4f27..dabab300995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## StreamChat ### ✅ Added -- Expose additional device data on `Device`: `pushProvider`, `pushProviderName`, `userId`, `disabled`, `disabledReason`, `hardwareId`, and `voip` [#4161](https://github.com/GetStream/stream-chat-swift/pull/4161) - -### 🔄 Changed +- Add additional data to `Device` (`pushProvider`, `pushProviderName`, `userId`, `disabled`, `disabledReason`, `hardwareId`, and `voip`) [#4161](https://github.com/GetStream/stream-chat-swift/pull/4161) # [5.6.0](https://github.com/GetStream/stream-chat-swift/releases/tag/5.6.0) _July 03, 2026_ From ad46c6e4a7d4dc873f68612503e18137c941262f Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 9 Jul 2026 09:22:52 +0300 Subject: [PATCH 4/5] Make generated Device.createdAt optional to preserve public API --- Scripts/openapi_generate.sh | 11 +++++++++++ Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift | 2 +- .../StreamChat/Generated/OpenAPI/models/Device.swift | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Scripts/openapi_generate.sh b/Scripts/openapi_generate.sh index 48f0fb015e5..c2d59227139 100755 --- a/Scripts/openapi_generate.sh +++ b/Scripts/openapi_generate.sh @@ -156,6 +156,17 @@ prune_models() { } prune_models +# Relax selected generated stored properties back to optional. Some models are +# exposed as public API where a property was historically optional (e.g. +# Device.createdAt was Date? before the OpenAPI migration). +optionalize_property() { + local file="$OUTPUT_DIR_CHAT/models/$1.swift" + sed -i '' -E \ + -e "s/^( let $2: [^?]+)$/\1?/" \ + "$file" +} +optionalize_property DeviceResponse createdAt + # 4b. Rename selected generated models for clarity and to avoid generic-name # pollution / collisions with hand-written SDK types. Runs AFTER prune_models # so allowed_models above still matches the generator's original names. diff --git a/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift b/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift index 453dda54464..9d2abc989e1 100644 --- a/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift +++ b/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift @@ -186,7 +186,7 @@ extension NSManagedObjectContext: CurrentUserDatabaseSession { let deviceDTOs = devices.map { device -> DeviceDTO in let dto = DeviceDTO.loadOrCreate(id: device.id, context: self) - dto.createdAt = device.createdAt.bridgeDate + dto.createdAt = device.createdAt?.bridgeDate dto.disabled = device.disabled.map { NSNumber(value: $0) } dto.disabledReason = device.disabledReason dto.hardwareId = device.hardwareId diff --git a/Sources/StreamChat/Generated/OpenAPI/models/Device.swift b/Sources/StreamChat/Generated/OpenAPI/models/Device.swift index e76374e9624..f893d2144eb 100644 --- a/Sources/StreamChat/Generated/OpenAPI/models/Device.swift +++ b/Sources/StreamChat/Generated/OpenAPI/models/Device.swift @@ -6,7 +6,7 @@ import Foundation public final class Device: Sendable, Codable, JSONEncodable { /// Date/time of creation - public let createdAt: Date + public let createdAt: Date? /// Whether device is disabled or not public let disabled: Bool? /// Reason explaining why device had been disabled From 89b2c29a26554e17ca2be8b5a47402bea70d8245 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 9 Jul 2026 16:44:28 +0300 Subject: [PATCH 5/5] Generate Hashable for Device --- Scripts/openapi_generate.sh | 1 + .../Generated/OpenAPI/models/Device.swift | 26 +++++++++++++++++++ .../StreamChat/Models/Device+Extensions.swift | 14 ---------- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/Scripts/openapi_generate.sh b/Scripts/openapi_generate.sh index c718c595376..86b95cca8ba 100755 --- a/Scripts/openapi_generate.sh +++ b/Scripts/openapi_generate.sh @@ -47,6 +47,7 @@ allowed_models=( # unlike allowed_models above which uses the generator's original names. allowed_hashable_models=( AppSettings + Device UploadConfig ) diff --git a/Sources/StreamChat/Generated/OpenAPI/models/Device.swift b/Sources/StreamChat/Generated/OpenAPI/models/Device.swift index f893d2144eb..9a38fa83884 100644 --- a/Sources/StreamChat/Generated/OpenAPI/models/Device.swift +++ b/Sources/StreamChat/Generated/OpenAPI/models/Device.swift @@ -48,3 +48,29 @@ public final class Device: Sendable, Codable, JSONEncodable { case voip } } + +extension Device: Hashable { + public static func == (lhs: Device, rhs: Device) -> Bool { + lhs.createdAt == rhs.createdAt && + lhs.disabled == rhs.disabled && + lhs.disabledReason == rhs.disabledReason && + lhs.hardwareId == rhs.hardwareId && + lhs.id == rhs.id && + lhs.pushProvider == rhs.pushProvider && + lhs.pushProviderName == rhs.pushProviderName && + lhs.userId == rhs.userId && + lhs.voip == rhs.voip + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(createdAt) + hasher.combine(disabled) + hasher.combine(disabledReason) + hasher.combine(hardwareId) + hasher.combine(id) + hasher.combine(pushProvider) + hasher.combine(pushProviderName) + hasher.combine(userId) + hasher.combine(voip) + } +} diff --git a/Sources/StreamChat/Models/Device+Extensions.swift b/Sources/StreamChat/Models/Device+Extensions.swift index fef3dd67047..9e92921d213 100644 --- a/Sources/StreamChat/Models/Device+Extensions.swift +++ b/Sources/StreamChat/Models/Device+Extensions.swift @@ -13,17 +13,3 @@ extension Data { } extension Device: Identifiable {} - -extension Device: Equatable { - public static func == (lhs: Device, rhs: Device) -> Bool { - lhs.id == rhs.id - && lhs.createdAt == rhs.createdAt - && lhs.disabled == rhs.disabled - && lhs.disabledReason == rhs.disabledReason - && lhs.hardwareId == rhs.hardwareId - && lhs.pushProvider == rhs.pushProvider - && lhs.pushProviderName == rhs.pushProviderName - && lhs.userId == rhs.userId - && lhs.voip == rhs.voip - } -}