// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit import SignalUI fileprivate extension CVComponentState { private static let unfairLock = UnfairLock() private static var groupInviteLinkAvatarCache = [String: GroupInviteLinkCachedAvatar]() private static var groupInviteLinkAvatarsInFlight = Set() private static var expiredGroupInviteLinks = Set() static func updateExpirationList(url: URL, isExpired: Bool) -> Bool { unfairLock.withLock { let alreadyExpired = expiredGroupInviteLinks.contains(url) guard alreadyExpired != isExpired else { return false } if isExpired { expiredGroupInviteLinks.insert(url) } else { expiredGroupInviteLinks.remove(url) } return true } } static func isGroupInviteLinkExpired(_ url: URL) -> Bool { unfairLock.withLock { expiredGroupInviteLinks.contains(url) } } private static func cachedGroupInviteLinkAvatar(avatarUrlPath: String) -> GroupInviteLinkCachedAvatar? { unfairLock.withLock { guard let cachedAvatar = groupInviteLinkAvatarCache[avatarUrlPath], cachedAvatar.isValid else { return nil } return cachedAvatar } } private static func loadGroupInviteLinkAvatar(avatarUrlPath: String, groupInviteLinkInfo: GroupInviteLinkInfo) -> Promise { Self.unfairLock.withLock { guard !groupInviteLinkAvatarsInFlight.contains(avatarUrlPath) else { return } groupInviteLinkAvatarsInFlight.insert(avatarUrlPath) } return firstly(on: DispatchQueue.global()) { () -> Promise in let groupV2ContextInfo = try Self.groupsV2.groupV2ContextInfo(forMasterKeyData: groupInviteLinkInfo.masterKey) return Promise.wrapAsync { try await self.groupsV2Impl.fetchGroupInviteLinkAvatar( avatarUrlPath: avatarUrlPath, groupSecretParamsData: groupV2ContextInfo.groupSecretParamsData ) } }.map(on: DispatchQueue.global()) { (avatarData: Data) -> Void in let imageMetadata = avatarData.imageMetadata(withPath: nil, mimeType: nil) let cacheFileUrl = OWSFileSystem.temporaryFileUrl(fileExtension: imageMetadata.fileExtension, isAvailableWhileDeviceLocked: true) guard imageMetadata.isValid else { let cachedAvatar = GroupInviteLinkCachedAvatar(cacheFileUrl: cacheFileUrl, imageSizePixels: imageMetadata.pixelSize, isValid: false) Self.unfairLock.withLock { Self.groupInviteLinkAvatarCache[avatarUrlPath] = cachedAvatar Self.groupInviteLinkAvatarsInFlight.remove(avatarUrlPath) } throw OWSAssertionError("Invalid group avatar.") } try avatarData.write(to: cacheFileUrl) let cachedAvatar = GroupInviteLinkCachedAvatar(cacheFileUrl: cacheFileUrl, imageSizePixels: imageMetadata.pixelSize, isValid: true) Self.unfairLock.withLock { Self.groupInviteLinkAvatarCache[avatarUrlPath] = cachedAvatar Self.groupInviteLinkAvatarsInFlight.remove(avatarUrlPath) } }.recover(on: DispatchQueue.global()) { (error) -> Promise in _ = Self.unfairLock.withLock { Self.groupInviteLinkAvatarsInFlight.remove(avatarUrlPath) } throw error } } } // MARK: - extension CVComponentState { // MARK: - Notifications static func configureGroupInviteLink(_ url: URL, message: TSMessage, groupInviteLinkInfo: GroupInviteLinkInfo) -> GroupInviteLinkViewModel { let touchMessage = { Self.databaseStorage.write { transaction in Self.databaseStorage.touch(interaction: message, shouldReindex: false, transaction: transaction) } } guard let groupInviteLinkPreview = GroupManager.cachedGroupInviteLinkPreview(groupInviteLinkInfo: groupInviteLinkInfo) else { // If there is no cached GroupInviteLinkPreview for this link, // try to do load it now. On success, touch the interaction // in order to trigger reload of the view. firstly(on: DispatchQueue.global()) { () -> Promise in let groupContextInfo = try Self.groupsV2.groupV2ContextInfo(forMasterKeyData: groupInviteLinkInfo.masterKey) return Promise.wrapAsync { try await Self.groupsV2Impl.fetchGroupInviteLinkPreview( inviteLinkPassword: groupInviteLinkInfo.inviteLinkPassword, groupSecretParamsData: groupContextInfo.groupSecretParamsData, allowCached: false ) } }.done(on: DispatchQueue.global()) { (_: GroupInviteLinkPreview) in if Self.updateExpirationList(url: url, isExpired: false) { touchMessage() } }.catch(on: DispatchQueue.global()) { (error: Error) in switch error { case GroupsV2Error.expiredGroupInviteLink, GroupsV2Error.localUserBlockedFromJoining: Logger.warn("Failed to fetch group link content: \(error)") if Self.updateExpirationList(url: url, isExpired: true) { touchMessage() } default: // TODO: Add retry? owsFailDebugUnlessNetworkFailure(error) } } return GroupInviteLinkViewModel(url: url, groupInviteLinkPreview: nil, avatar: nil, isExpired: Self.isGroupInviteLinkExpired(url)) } guard let avatarUrlPath = groupInviteLinkPreview.avatarUrlPath else { // If this group link has no avatar, there's nothing left to load. return GroupInviteLinkViewModel(url: url, groupInviteLinkPreview: groupInviteLinkPreview, avatar: nil, isExpired: false) } guard let avatar = Self.cachedGroupInviteLinkAvatar(avatarUrlPath: avatarUrlPath) else { // If there is no cached avatar for this link, // try to do load it now. On success, touch the interaction // in order to trigger reload of the view. firstly(on: DispatchQueue.global()) { Self.loadGroupInviteLinkAvatar(avatarUrlPath: avatarUrlPath, groupInviteLinkInfo: groupInviteLinkInfo) }.done(on: DispatchQueue.global()) { () in touchMessage() }.catch { error in // TODO: Add retry? owsFailDebugUnlessNetworkFailure(error) } return GroupInviteLinkViewModel(url: url, groupInviteLinkPreview: groupInviteLinkPreview, avatar: nil, isExpired: false) } return GroupInviteLinkViewModel(url: url, groupInviteLinkPreview: groupInviteLinkPreview, avatar: avatar, isExpired: false) } }