diff --git a/Podfile b/Podfile index 5452d4e81..5430437b7 100644 --- a/Podfile +++ b/Podfile @@ -12,6 +12,7 @@ target 'Session' do pod 'PureLayout', '~> 3.1.8', :inhibit_warnings => true pod 'Reachability', :inhibit_warnings => true pod 'Sodium', '~> 0.8.0', :inhibit_warnings => true + pod 'WebRTC', '~> 63.11', :inhibit_warnings => true pod 'YapDatabase/SQLCipher', :git => 'https://github.com/loki-project/session-ios-yap-database.git', branch: 'signal-release', :inhibit_warnings => true pod 'YYImage', git: 'https://github.com/signalapp/YYImage', :inhibit_warnings => true pod 'ZXingObjC', :inhibit_warnings => true diff --git a/Podfile.lock b/Podfile.lock index 804a148c7..d849d28fd 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -51,6 +51,7 @@ PODS: - SQLCipher/standard (4.4.0): - SQLCipher/common - SwiftProtobuf (1.5.0) + - WebRTC (63.11.20455) - YapDatabase/SQLCipher (3.1.1): - YapDatabase/SQLCipher/Core (= 3.1.1) - YapDatabase/SQLCipher/Extensions (= 3.1.1) @@ -135,6 +136,7 @@ DEPENDENCIES: - SignalCoreKit (from `https://github.com/signalapp/SignalCoreKit.git`) - Sodium (~> 0.8.0) - SwiftProtobuf (~> 1.5.0) + - WebRTC (~> 63.11) - YapDatabase/SQLCipher (from `https://github.com/loki-project/session-ios-yap-database.git`, branch `signal-release`) - YYImage (from `https://github.com/signalapp/YYImage`) - ZXingObjC @@ -154,6 +156,7 @@ SPEC REPOS: - Sodium - SQLCipher - SwiftProtobuf + - WebRTC - ZXingObjC EXTERNAL SOURCES: @@ -204,10 +207,11 @@ SPEC CHECKSUMS: Sodium: 63c0ca312a932e6da481689537d4b35568841bdc SQLCipher: e434ed542b24f38ea7b36468a13f9765e1b5c072 SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2 + WebRTC: f2a6203584745fe53532633397557876b5d71640 YapDatabase: b418a4baa6906e8028748938f9159807fd039af4 YYImage: 6db68da66f20d9f169ceb94dfb9947c3867b9665 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 50e6a35c838ba28d2ee02bc6018fdd297c04e55f +PODFILE CHECKSUM: 70c56fb65241e2064eb7999b6647a36be5031fe3 COCOAPODS: 1.10.1 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index b62d57cc0..c8d27b3e1 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -233,6 +233,10 @@ B882A79526AE878300B5AB69 /* IndividualCallService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B882A77526AE878300B5AB69 /* IndividualCallService.swift */; }; B882A79626AE878300B5AB69 /* OutboundIndividualCallInitiator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B882A77626AE878300B5AB69 /* OutboundIndividualCallInitiator.swift */; }; B882A79726AE878300B5AB69 /* IndividualCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = B882A77726AE878300B5AB69 /* IndividualCall.swift */; }; + B882A79926AE897E00B5AB69 /* Atomics.swift in Sources */ = {isa = PBXBuildFile; fileRef = B882A79826AE897E00B5AB69 /* Atomics.swift */; }; + B882A79C26AE89F200B5AB69 /* UnfairLock.m in Sources */ = {isa = PBXBuildFile; fileRef = B882A79B26AE89F200B5AB69 /* UnfairLock.m */; }; + B882A79D26AE8A1700B5AB69 /* UnfairLock.h in Headers */ = {isa = PBXBuildFile; fileRef = B882A79A26AE89E300B5AB69 /* UnfairLock.h */; settings = {ATTRIBUTES = (Public, ); }; }; + B882A79F26AE8A2200B5AB69 /* UnfairLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B882A79E26AE8A2200B5AB69 /* UnfairLock.swift */; }; B8856CA8256F0F42001CE70E /* OWSBackupFragment.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB07255A580700E217F9 /* OWSBackupFragment.m */; }; B8856CB1256F0F47001CE70E /* OWSBackupFragment.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAEA255A580500E217F9 /* OWSBackupFragment.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856CEE256F1054001CE70E /* OWSAudioPlayer.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */; }; @@ -1252,6 +1256,10 @@ B882A77526AE878300B5AB69 /* IndividualCallService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IndividualCallService.swift; sourceTree = ""; }; B882A77626AE878300B5AB69 /* OutboundIndividualCallInitiator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutboundIndividualCallInitiator.swift; sourceTree = ""; }; B882A77726AE878300B5AB69 /* IndividualCall.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IndividualCall.swift; sourceTree = ""; }; + B882A79826AE897E00B5AB69 /* Atomics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomics.swift; sourceTree = ""; }; + B882A79A26AE89E300B5AB69 /* UnfairLock.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UnfairLock.h; sourceTree = ""; }; + B882A79B26AE89F200B5AB69 /* UnfairLock.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UnfairLock.m; sourceTree = ""; }; + B882A79E26AE8A2200B5AB69 /* UnfairLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnfairLock.swift; sourceTree = ""; }; B8856D5F256F129B001CE70E /* OWSAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSAlerts.swift; sourceTree = ""; }; B885D5F52334A32100EE0D8E /* UIView+Constraints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Constraints.swift"; sourceTree = ""; }; B886B4A62398B23E00211ABE /* QRCodeVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeVC.swift; sourceTree = ""; }; @@ -2409,6 +2417,7 @@ C33FDB8A255A581200E217F9 /* AppContext.h */, C33FDB85255A581100E217F9 /* AppContext.m */, C3C2A5D12553860800C340D1 /* Array+Description.swift */, + B882A79826AE897E00B5AB69 /* Atomics.swift */, C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */, B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */, B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */, @@ -2433,6 +2442,9 @@ C33FDB14255A580800E217F9 /* OWSMath.h */, C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */, C33FDB3F255A580C00E217F9 /* String+SSK.swift */, + B882A79A26AE89E300B5AB69 /* UnfairLock.h */, + B882A79B26AE89F200B5AB69 /* UnfairLock.m */, + B882A79E26AE8A2200B5AB69 /* UnfairLock.swift */, C3C2AC2D2553CBEB00C340D1 /* String+Trimming.swift */, C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */, C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */, @@ -3785,6 +3797,7 @@ C352A3772557864000338F3E /* NSTimer+Proxying.h in Headers */, C32C5B51256DC219003C73A2 /* NSNotificationCenter+OWS.h in Headers */, C3C2A67D255388CC00C340D1 /* SessionUtilitiesKit.h in Headers */, + B882A79D26AE8A1700B5AB69 /* UnfairLock.h in Headers */, C32C6018256E07F9003C73A2 /* NSUserDefaults+OWS.h in Headers */, B8856D8D256F1502001CE70E /* UIView+OWS.h in Headers */, ); @@ -4361,6 +4374,7 @@ "${BUILT_PRODUCTS_DIR}/Reachability/Reachability.framework", "${BUILT_PRODUCTS_DIR}/SQLCipher/SQLCipher.framework", "${BUILT_PRODUCTS_DIR}/Sodium/Sodium.framework", + "${PODS_ROOT}/WebRTC/WebRTC.framework", "${BUILT_PRODUCTS_DIR}/YYImage/YYImage.framework", "${BUILT_PRODUCTS_DIR}/YapDatabase/YapDatabase.framework", "${BUILT_PRODUCTS_DIR}/ZXingObjC/ZXingObjC.framework", @@ -4383,6 +4397,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Reachability.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SQLCipher.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sodium.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/WebRTC.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/YYImage.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/YapDatabase.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZXingObjC.framework", @@ -4720,6 +4735,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B882A79926AE897E00B5AB69 /* Atomics.swift in Sources */, C3AABDDF2553ECF00042FF4C /* Array+Description.swift in Sources */, C32C5A47256DB8F0003C73A2 /* ECKeyPair+Hexadecimal.swift in Sources */, C3D9E41525676C320040E4F3 /* Storage.swift in Sources */, @@ -4737,6 +4753,7 @@ C32C5DD2256DD9E5003C73A2 /* LRUCache.swift in Sources */, C3A7211A2558BCA10043A11F /* DiffieHellman.swift in Sources */, C32C5FA1256DFED5003C73A2 /* NSArray+Functional.m in Sources */, + B882A79F26AE8A2200B5AB69 /* UnfairLock.swift in Sources */, C3A7225E2558C38D0043A11F /* Promise+Retaining.swift in Sources */, C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */, C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Description.swift in Sources */, @@ -4764,6 +4781,7 @@ C3D9E4F4256778AF0040E4F3 /* NSData+Image.m in Sources */, C32C5E0C256DDAFA003C73A2 /* NSRegularExpression+SSK.swift in Sources */, C3BBE0A92554D4DE0050F1E3 /* HTTP.swift in Sources */, + B882A79C26AE89F200B5AB69 /* UnfairLock.m in Sources */, B8856D23256F116B001CE70E /* Weak.swift in Sources */, C32C5A48256DB8F0003C73A2 /* BuildConfiguration.swift in Sources */, B87EF18126377A1D00124B3C /* Features.swift in Sources */, diff --git a/Session/Calls/CallService.swift b/Session/Calls/CallService.swift index 78dd07ade..d900019c0 100644 --- a/Session/Calls/CallService.swift +++ b/Session/Calls/CallService.swift @@ -5,6 +5,7 @@ import Foundation import SignalRingRTC import PromiseKit +import SessionUtilitiesKit // All Observer methods will be invoked from the main thread. @objc(OWSCallServiceObserver) @@ -45,12 +46,12 @@ public final class CallService: NSObject { // Prevent device from sleeping while we have an active call. if oldValue != newValue { if let oldValue = oldValue { - DeviceSleepManager.shared.removeBlock(blockObject: oldValue) + DeviceSleepManager.sharedInstance.removeBlock(blockObject: oldValue) } if let newValue = newValue { assert(calls.contains(newValue)) - DeviceSleepManager.shared.addBlock(blockObject: newValue) + DeviceSleepManager.sharedInstance.addBlock(blockObject: newValue) if newValue.isIndividualCall { individualCallService.startCallTimer() } } else { diff --git a/SessionUtilitiesKit/General/Atomics.swift b/SessionUtilitiesKit/General/Atomics.swift new file mode 100644 index 000000000..ee6302e07 --- /dev/null +++ b/SessionUtilitiesKit/General/Atomics.swift @@ -0,0 +1,319 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation + +@objc +public enum AtomicError: Int, Error { + case invalidTransition +} + +// MARK: - + +private class Atomics { + fileprivate static let fairQueue = DispatchQueue(label: "Atomics") + fileprivate static let unfairLock = UnfairLock() + + // Never instantiate this class. + private init() {} + + class func perform(isFair: Bool = false, _ block: () throws -> T) rethrows -> T { + if isFair { + return try fairQueue.sync(execute: block) + } else { + return try unfairLock.withLock(block) + } + } +} + +// MARK: - + +// Provides Objective-C compatibility for the most common atomic value type. +@objc +public class AtomicBool: NSObject { + private let value = AtomicValue(false) + + @objc(initWithValue:) + public required init(_ value: Bool) { + self.value.set(value) + } + + @objc + public func get() -> Bool { + return value.get() + } + + @objc + public func set(_ value: Bool) { + self.value.set(value) + } + + // Sets value to "toValue" IFF it currently has "fromValue", + // otherwise throws. + private func transition(from fromValue: Bool, to toValue: Bool) throws { + return try value.transition(from: fromValue, to: toValue) + } + + @objc + public func tryToSetFlag() -> Bool { + do { + try transition(from: false, to: true) + return true + } catch { + return false + } + } + + @objc + public func tryToClearFlag() -> Bool { + do { + try transition(from: true, to: false) + return true + } catch { + return false + } + } +} + +// MARK: - + +@objc +public class AtomicUInt: NSObject { + private let value = AtomicValue(0) + + @objc + public required init(_ value: UInt = 0) { + self.value.set(value) + } + + @objc + public func get() -> UInt { + return value.get() + } + + @objc + public func set(_ value: UInt) { + self.value.set(value) + } + + @discardableResult + @objc + public func increment() -> UInt { + return value.map { $0 + 1 } + } + + @discardableResult + @objc + public func decrementOrZero() -> UInt { + return value.map { max($0, 1) - 1 } + } + + @discardableResult + @objc + public func add(_ delta: UInt) -> UInt { + return value.map { $0 + delta } + } +} + +// MARK: - + +public final class AtomicValue { + private var value: T + + public required convenience init(_ value: T) { + self.init(value, allowOptionalType: false) + } + + fileprivate init(_ value: T, allowOptionalType: Bool) { + self.value = value + } + + public func get() -> T { + Atomics.perform { + return self.value + } + } + + public func set(_ value: T) { + Atomics.perform { + self.value = value + } + } + + // Transform the current value using a block. + @discardableResult + public func map(_ block: @escaping (T) -> T) -> T { + Atomics.perform { + let newValue = block(self.value) + self.value = newValue + return newValue + } + } +} + +// MARK: - + +extension AtomicValue: Codable where T: Codable { + public convenience init(from decoder: Decoder) throws { + let singleValueContainer = try decoder.singleValueContainer() + self.init(try singleValueContainer.decode(T.self)) + } + + public func encode(to encoder: Encoder) throws { + var singleValueContainer = encoder.singleValueContainer() + try singleValueContainer.encode(value) + } +} + +// MARK: - + +extension AtomicValue where T: Equatable { + // Sets value to "toValue" IFF it currently has "fromValue", + // otherwise throws. + public func transition(from fromValue: T, to toValue: T) throws { + try Atomics.perform { + guard self.value == fromValue else { + throw AtomicError.invalidTransition + } + self.value = toValue + } + } +} + +// MARK: - + +public final class AtomicOptional { + fileprivate let value = AtomicValue(nil, allowOptionalType: true) + + public required init(_ value: T?) { + self.value.set(value) + } + + public func get() -> T? { + return value.get() + } + + public func set(_ value: T?) { + self.value.set(value) + } +} + +extension AtomicOptional: Codable where T: Codable { + public convenience init(from decoder: Decoder) throws { + let singleValueContainer = try decoder.singleValueContainer() + + if singleValueContainer.decodeNil() { + self.init(nil) + } else { + self.init(try singleValueContainer.decode(T.self)) + } + } + + public func encode(to encoder: Encoder) throws { + var singleValueContainer = encoder.singleValueContainer() + try singleValueContainer.encode(value) + } +} + +extension AtomicOptional where T: Equatable { + // Sets value to "toValue" IFF it currently has "fromValue", + // otherwise throws. + public func transition(from fromValue: T, to toValue: T) throws { + try value.transition(from: fromValue, to: toValue) + } +} + +// MARK: - + +public class AtomicArray { + + private var values: [T] + + public required init(_ values: [T] = []) { + self.values = values + } + + public func get() -> [T] { + Atomics.perform { + values + } + } + + public func set(_ values: [T]) { + Atomics.perform { + self.values = values + } + } + + public func append(_ value: T) { + Atomics.perform { + values.append(value) + } + } + + public var first: T? { + Atomics.perform { + values.first + } + } + + public var popHead: T? { + Atomics.perform { + values.removeFirst() + } + } + + public func pushTail(_ value: T) { + append(value) + } +} + +extension AtomicArray where T: Equatable { + public func remove(_ valueToRemove: T) { + Atomics.perform { + self.values = self.values.filter { (value: T) -> Bool in + valueToRemove != value + } + } + } +} + +// MARK: - + +public class AtomicDictionary { + private var values: [Key: Value] + + public required init(_ values: [Key: Value] = [:]) { + self.values = values + } + + public subscript(_ key: Key) -> Value? { + set { Atomics.perform { self.values[key] = newValue } } + get { Atomics.perform { self.values[key] } } + } + + public func get() -> [Key: Value] { + Atomics.perform { self.values } + } + + public func set(_ values: [Key: Value]) { + Atomics.perform { self.values = values } + } +} + +// MARK: - + +public class AtomicSet { + private var values = Set() + + public required init() {} + + public func insert(_ value: T) { + Atomics.perform { _ = self.values.insert(value) } + } + + public func contains(_ value: T) -> Bool { + Atomics.perform { self.values.contains(value) } + } +} diff --git a/SessionUtilitiesKit/General/UnfairLock.h b/SessionUtilitiesKit/General/UnfairLock.h new file mode 100644 index 000000000..9ebe88bac --- /dev/null +++ b/SessionUtilitiesKit/General/UnfairLock.h @@ -0,0 +1,43 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// An Objective-C wrapper around os_unfair_lock. This is a non-FIFO, priority preserving lock. See: os/lock.h +/// +/// @discussion Why is this necessary? os_unfair_lock has some unexpected behavior in Swift. These problems arise +/// from Swift's handling of inout C structs. Passing the underlying struct as an inout parameter results in +/// surprising Law of Exclusivity violations. There are two ways to work around this: Manually allocate heap storage +/// in Swift or bridge to Objective-C. I figured bridging a simple struct is a bit easier to read. +/// +/// Note: Errors with unfair lock are fatal and will terminate the process. +NS_SWIFT_NAME(UnfairLock) +@interface UnfairLock : NSObject + +/// Locks the lock. Blocks if the lock is held by another thread. +/// Forwards to os_unfair_lock_lock() defined in os/lock.h +- (void)lock; + +/// Unlocks the lock. Fatal error if the lock is owned by another thread. +/// Forwards to os_unfair_lock_unlock() defined in os/lock.h +- (void)unlock; + +/// Attempts to lock the lock. Returns YES if the lock was successfully acquired. +/// Forwards to os_unfair_lock_trylock() defined in os/lock.h +- (BOOL)tryLock NS_SWIFT_NAME(tryLock()); +// Note: NS_SWIFT_NAME is required to prevent bridging from renaming to `try()`. + +/// Fatal assert that the lock is owned by the current thread. +/// Forwards to os_unfair_lock_assert_owner defined in os/lock.h +- (void)assertOwner; + +/// Fatal assert that the lock is not owned by the current thread. +/// Forwards to os_unfair_lock_assert_not_owner defined in os/lock.h +- (void)assertNotOwner; + +@end + +NS_ASSUME_NONNULL_END diff --git a/SessionUtilitiesKit/General/UnfairLock.m b/SessionUtilitiesKit/General/UnfairLock.m new file mode 100644 index 000000000..a3e0bc71e --- /dev/null +++ b/SessionUtilitiesKit/General/UnfairLock.m @@ -0,0 +1,46 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +#import +#import + +@implementation UnfairLock { + os_unfair_lock _lock; +} + +- (instancetype)init +{ + self = [super init]; + if (self) { + _lock = OS_UNFAIR_LOCK_INIT; + } + return self; +} + +- (void)lock +{ + os_unfair_lock_lock(&_lock); +} + +- (void)unlock +{ + os_unfair_lock_unlock(&_lock); +} + +- (BOOL)tryLock +{ + return os_unfair_lock_trylock(&_lock); +} + +- (void)assertOwner +{ + os_unfair_lock_assert_owner(&_lock); +} + +- (void)assertNotOwner +{ + os_unfair_lock_assert_not_owner(&_lock); +} + +@end diff --git a/SessionUtilitiesKit/General/UnfairLock.swift b/SessionUtilitiesKit/General/UnfairLock.swift new file mode 100644 index 000000000..1b6be528f --- /dev/null +++ b/SessionUtilitiesKit/General/UnfairLock.swift @@ -0,0 +1,51 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation + +public extension UnfairLock { + + /// Acquires and releases the lock around the provided closure. Blocks the current thread until the lock can be + /// acquired. + @objc + @available(swift, obsoleted: 1.0) + final func withLockObjc(_ criticalSection: () -> Void) { + withLock(criticalSection) + } + + /// Acquires and releases the lock around the provided closure. Blocks the current thread until the lock can be + /// acquired. + final func withLock(_ criticalSection: () throws -> T) rethrows -> T { + lock() + defer { unlock() } + + return try criticalSection() + } + + /// Acquires and releases the lock around the provided closure. Returns without performing the closure if the lock + /// can not be acquired. + /// - Returns: `true` if the lock was acquired and the closure was invoked. `false` if the lock could not be + /// acquired. + @discardableResult + final func tryWithLock(_ criticalSection: () throws -> Void) rethrows -> Bool { + guard tryLock() else { return false } + defer { unlock() } + + try criticalSection() + return true + } + + /// Acquires and releases the lock around the provided closure. Returns without performing the closure if the lock + /// can not be acquired. + /// - Returns: nil if the lock could not be acquired. Otherwise, returns the returns the result of the provided + /// closure + @discardableResult + final func tryWithLock(_ criticalSection: () throws -> T) rethrows -> T? { + guard tryLock() else { return nil } + defer { unlock() } + + return try criticalSection() + } + +} diff --git a/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h b/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h index c4aca0f08..0f375add2 100644 --- a/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h +++ b/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h @@ -18,6 +18,7 @@ FOUNDATION_EXPORT const unsigned char SessionUtilitiesKitVersionString[]; #import #import #import +#import #import #import