// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import GRDB import SignalCoreKit import SessionUtilitiesKit public struct Profile: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, CustomStringConvertible { public static var databaseTableName: String { "profile" } internal static let interactionForeignKey = ForeignKey([Columns.id], to: [Interaction.Columns.authorId]) internal static let contactForeignKey = ForeignKey([Columns.id], to: [Contact.Columns.id]) public static let groupMembers = hasMany(GroupMember.self, using: GroupMember.profileForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { case id case name = "displayName" case nickname case profilePictureUrl = "profilePictureURL" case profilePictureFileName case profileEncryptionKey } /// The id for the user that owns the profile (Note: This could be a sessionId, a blindedId or some future variant) public let id: String /// The name of the contact. Use this whenever you need the "real", underlying name of a user (e.g. when sending a message). public let name: String /// A custom name for the profile set by the current user public let nickname: String? /// The URL from which to fetch the contact's profile picture. public let profilePictureUrl: String? /// The file name of the contact's profile picture on local storage. public let profilePictureFileName: String? /// The key with which the profile is encrypted. public let profileEncryptionKey: OWSAES256Key? // MARK: - Initialization public init( id: String, name: String, nickname: String? = nil, profilePictureUrl: String? = nil, profilePictureFileName: String? = nil, profileEncryptionKey: OWSAES256Key? = nil ) { self.id = id self.name = name self.nickname = nickname self.profilePictureUrl = profilePictureUrl self.profilePictureFileName = profilePictureFileName self.profileEncryptionKey = profileEncryptionKey } // MARK: - Description public var description: String { """ Profile( displayName: \(name), profileKey: \(profileEncryptionKey?.keyData.description ?? "null"), profilePictureURL: \(profilePictureUrl ?? "null") ) """ } // MARK: - PersistableRecord public func save(_ db: Database) throws { let oldProfile: Profile? = try? Profile.fetchOne(db, id: id) try performSave(db) db.afterNextTransactionCommit { db in // Delete old profile picture if needed if let oldProfilePictureFileName: String = oldProfile?.profilePictureFileName, oldProfilePictureFileName != profilePictureFileName { let path: String = OWSUserProfile.profileAvatarFilepath(withFilename: oldProfilePictureFileName) DispatchQueue.global(qos: .default).async { OWSFileSystem.deleteFileIfExists(path) } } // Since it's possible this profile is currently being displayed, send notifications // indicating that it has been updated NotificationCenter.default.post(name: .profileUpdated, object: id) if id == getUserHexEncodedPublicKey(db) { NotificationCenter.default.post(name: .localProfileDidChange, object: nil) } else { let userInfo = [ Notification.Key.profileRecipientId.rawValue: id ] NotificationCenter.default.post(name: .otherUsersProfileDidChange, object: nil, userInfo: userInfo) } } } } // MARK: - Codable public extension Profile { init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) var profileKey: OWSAES256Key? var profilePictureUrl: String? // If we have both a `profileKey` and a `profilePicture` then the key MUST be valid if let profileKeyData: Data = try? container.decode(Data.self, forKey: .profileEncryptionKey), let profilePictureUrlValue: String = try? container.decode(String.self, forKey: .profilePictureUrl) { guard let validProfileKey: OWSAES256Key = OWSAES256Key(data: profileKeyData) else { owsFailDebug("Failed to make profile key for key data") throw GRDBStorageError.decodingFailed } profileKey = validProfileKey profilePictureUrl = profilePictureUrlValue } self = Profile( id: try container.decode(String.self, forKey: .id), name: try container.decode(String.self, forKey: .name), nickname: try? container.decode(String.self, forKey: .nickname), profilePictureUrl: profilePictureUrl, profilePictureFileName: try? container.decode(String.self, forKey: .profilePictureFileName), profileEncryptionKey: profileKey ) } func encode(to encoder: Encoder) throws { var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encode(name, forKey: .name) try container.encode(nickname, forKey: .nickname) try container.encode(profilePictureUrl, forKey: .profilePictureUrl) try container.encode(profilePictureFileName, forKey: .profilePictureFileName) try container.encode(profileEncryptionKey?.keyData, forKey: .profileEncryptionKey) } } // MARK: - Protobuf public extension Profile { static func fromProto(_ proto: SNProtoDataMessage, id: String) -> Profile? { guard let profileProto = proto.profile, let displayName = profileProto.displayName else { return nil } var profileKey: OWSAES256Key? var profilePictureUrl: String? // If we have both a `profileKey` and a `profilePicture` then the key MUST be valid if let profileKeyData: Data = proto.profileKey, profileProto.profilePicture != nil { guard let validProfileKey: OWSAES256Key = OWSAES256Key(data: profileKeyData) else { owsFailDebug("Failed to make profile key for key data") return nil } profileKey = validProfileKey profilePictureUrl = profileProto.profilePicture } return Profile( id: id, name: displayName, nickname: nil, profilePictureUrl: profilePictureUrl, profilePictureFileName: nil, profileEncryptionKey: profileKey ) } func toProto() -> SNProtoDataMessage? { let dataMessageProto = SNProtoDataMessage.builder() let profileProto = SNProtoDataMessageLokiProfile.builder() profileProto.setDisplayName(name) if let profileKey: OWSAES256Key = profileEncryptionKey, let profilePictureUrl: String = profilePictureUrl { dataMessageProto.setProfileKey(profileKey.keyData) profileProto.setProfilePicture(profilePictureUrl) } do { dataMessageProto.setProfile(try profileProto.build()) return try dataMessageProto.build() } catch { SNLog("Couldn't construct profile proto from: \(self).") return nil } } } // MARK: - Mutation public extension Profile { func with( name: String? = nil, nickname: Updatable = .existing, profilePictureUrl: Updatable = .existing, profilePictureFileName: Updatable = .existing, profileEncryptionKey: Updatable = .existing ) -> Profile { return Profile( id: id, name: (name ?? self.name), nickname: (nickname ?? self.nickname), profilePictureUrl: (profilePictureUrl ?? self.profilePictureUrl), profilePictureFileName: (profilePictureFileName ?? self.profilePictureFileName), profileEncryptionKey: (profileEncryptionKey ?? self.profileEncryptionKey) ) } } // MARK: - GRDB Interactions public extension Profile { static func displayName(_ db: Database? = nil, id: ID, thread: SessionThread, customFallback: String? = nil) -> String { return displayName( db, id: id, context: (thread.variant == .openGroup ? .openGroup : .regular), customFallback: customFallback ) } static func displayName(_ db: Database? = nil, id: ID, context: Context = .regular, customFallback: String? = nil) -> String { guard let db: Database = db else { return GRDBStorage.shared .read { db in displayName(db, id: id, context: context, customFallback: customFallback) } .defaulting(to: (customFallback ?? id)) } let existingDisplayName: String? = (try? Profile.fetchOne(db, id: id))? .displayName(for: context) return (existingDisplayName ?? (customFallback ?? id)) } static func displayNameNoFallback(_ db: Database? = nil, id: ID, thread: SessionThread) -> String? { return displayName( db, id: id, context: (thread.variant == .openGroup ? .openGroup : .regular) ) } static func displayNameNoFallback(_ db: Database? = nil, id: ID, context: Context = .regular) -> String? { guard let db: Database = db else { return GRDBStorage.shared.read { db in displayNameNoFallback(db, id: id, context: context) } } return (try? Profile.fetchOne(db, id: id))? .displayName(for: context) } // MARK: - Fetch or Create private static func defaultFor(_ id: String) -> Profile { return Profile( id: id, name: id, nickname: nil, profilePictureUrl: nil, profilePictureFileName: nil, profileEncryptionKey: nil ) } static func fetchOrCreateCurrentUser() -> Profile { var userPublicKey: String = "" let exisingProfile: Profile? = GRDBStorage.shared.read { db in userPublicKey = getUserHexEncodedPublicKey(db) return try Profile.fetchOne(db, id: userPublicKey) } return (exisingProfile ?? defaultFor(userPublicKey)) } static func fetchOrCreateCurrentUser(_ db: Database) -> Profile { let userPublicKey: String = getUserHexEncodedPublicKey(db) return ( (try? Profile.fetchOne(db, id: userPublicKey)) ?? defaultFor(userPublicKey) ) } static func fetchOrCreate(id: String) -> Profile { let exisingProfile: Profile? = GRDBStorage.shared.read { db in try Profile.fetchOne(db, id: id) } return (exisingProfile ?? defaultFor(id)) } static func fetchOrCreate(_ db: Database, id: String) -> Profile { return ( (try? Profile.fetchOne(db, id: id)) ?? defaultFor(id) ) } } // MARK: - Convenience public extension Profile { // MARK: - Context @objc enum Context: Int { case regular case openGroup } // MARK: - Truncation enum Truncation { case start case middle case end } /// A standardised mechanism for truncating a user id for a given thread static func truncated(id: String, thread: SessionThread) -> String { switch thread.variant { case .openGroup: return truncated(id: id, truncating: .start) default: return truncated(id: id, truncating: .middle) } } /// A standardised mechanism for truncating a user id static func truncated(id: String, truncating: Truncation = .start) -> String { guard id.count > 8 else { return id } switch truncating { case .start: return "...\(id.suffix(8))" case .middle: return "\(id.prefix(4))...\(id.suffix(4))" case .end: return "\(id.prefix(8))..." } } /// The name to display in the UI for a given thread variant func displayName(for threadVariant: SessionThread.Variant) -> String { return displayName( for: (threadVariant == .openGroup ? .openGroup : .regular) ) } /// The name to display in the UI func displayName(for context: Context = .regular) -> String { if let nickname: String = nickname { return nickname } switch context { case .regular: return name case .openGroup: // In open groups, where it's more likely that multiple users have the same name, // we display a bit of the Session ID after a user's display name for added context return "\(name) (\(Profile.truncated(id: id, truncating: .start)))" } } } // MARK: - Objective-C Support @objc(SMKProfile) public class SMKProfile: NSObject { var id: String @objc var name: String @objc var nickname: String? init(id: String, name: String, nickname: String?) { self.id = id self.name = name self.nickname = nickname } @objc public static func fetchCurrentUserName() -> String { let existingProfile: Profile? = GRDBStorage.shared.read { db in Profile.fetchOrCreateCurrentUser(db) } return (existingProfile?.name ?? "") } @objc public static func fetchOrCreate(id: String) -> SMKProfile { let profile: Profile = Profile.fetchOrCreate(id: id) return SMKProfile( id: id, name: profile.name, nickname: profile.nickname ) } @objc public static func saveProfile(_ profile: SMKProfile) { GRDBStorage.shared.write { db in try? Profile .fetchOrCreate(db, id: profile.id) .with(nickname: .updateTo(profile.nickname)) .save(db) } } @objc public static func displayName(id: String) -> String { return Profile.displayName(id: id) } @objc public static func displayName(id: String, customFallback: String) -> String { return Profile.displayName(id: id, customFallback: customFallback) } @objc public static func displayName(id: String, context: Profile.Context = .regular) -> String { let existingProfile: Profile? = GRDBStorage.shared.read { db in Profile.fetchOrCreateCurrentUser(db) } return (existingProfile?.name ?? id) } public static func displayName(id: String, thread: SessionThread) -> String { return Profile.displayName(id: id, thread: thread) } @objc public static var localProfileKey: OWSAES256Key? { Profile.fetchOrCreateCurrentUser().profileEncryptionKey } }