Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

### πŸ”„ Changed
## StreamChat
### βœ… Added
- 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_
Expand Down
14 changes: 14 additions & 0 deletions Scripts/openapi_generate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ allowed_models=(
# unlike allowed_models above which uses the generator's original names.
allowed_hashable_models=(
AppSettings
Device
UploadConfig
)

Expand Down Expand Up @@ -164,11 +165,23 @@ 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
Comment on lines +168 to +177

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In v6 this can be removed. Because Device is now public model, but our current model has createdAt as optional value, I need to change the generated model to match the current public model for avoiding API breaking changes in v5. Of course, in v6 we delete this and make a small breaking change.


# 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.
rename_generated Action AttachmentActionPayload
rename_generated AppResponseFields AppSettings
rename_generated DeviceResponse Device
rename_generated Field AttachmentFieldPayload
rename_generated FileUploadConfig UploadConfig
rename_generated ImageData GiphyImageData
Expand All @@ -189,6 +202,7 @@ publicize_model() {
"$file"
}
publicize_model AppSettings
publicize_model Device

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OpenAPI models is not used as public model for Device type

publicize_model UploadConfig

# 4d. Strip the generated Hashable conformance from every model not in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down

This file was deleted.

9 changes: 8 additions & 1 deletion Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -187,6 +187,13 @@ 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.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
}
Expand Down
19 changes: 18 additions & 1 deletion Sources/StreamChat/Database/DTOs/DeviceDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import Foundation
class DeviceDTO: NSManagedObject {
@NSManaged var id: String
@NSManaged var createdAt: DBDate?
@NSManaged var disabled: NSNumber?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Represents Bool? in the generated model. Not much to do here if we wanna switch to OpenAPI model directly without any additional wrapper.

@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
}
Expand Down Expand Up @@ -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
)
}
}
6 changes: 3 additions & 3 deletions Sources/StreamChat/Database/DatabaseSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,14 @@
</entity>
<entity name="DeviceDTO" representedClassName="DeviceDTO" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="disabled" optional="YES" attributeType="Boolean" usesScalarValueType="NO"/>
<attribute name="disabledReason" optional="YES" attributeType="String"/>
<attribute name="hardwareId" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="pushProvider" attributeType="String" defaultValueString=""/>
<attribute name="pushProviderName" optional="YES" attributeType="String"/>
<attribute name="userId" attributeType="String" defaultValueString=""/>
<attribute name="voip" optional="YES" attributeType="Boolean" usesScalarValueType="NO"/>
<relationship name="relationship" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CurrentUserDTO" inverseName="currentDevice" inverseEntity="CurrentUserDTO"/>
<relationship name="user" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CurrentUserDTO" inverseName="devices" inverseEntity="CurrentUserDTO"/>
<uniquenessConstraints>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Comment thread
laevandus marked this conversation as resolved.
/// 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
Expand All @@ -48,3 +48,29 @@ final class DeviceResponse: 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
15 changes: 15 additions & 0 deletions Sources/StreamChat/Models/Device+Extensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// 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 {}
26 changes: 0 additions & 26 deletions Sources/StreamChat/Models/Device.swift

This file was deleted.

3 changes: 1 addition & 2 deletions Sources/StreamChat/Workers/CurrentUserUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
1 change: 0 additions & 1 deletion StreamChat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 0 additions & 9 deletions TestTools/StreamChatTestTools/Fixtures/JSONs/Devices.json

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: "")
}
}
Loading
Loading