diff --git a/README.md b/README.md
index 2b74b50..e6cbd39 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,8 @@ time due to CPU limitations on iOS devices.
subcribed video stream.
**FrameMetadata** -- This project shows how to set metadata (limited to 32 bytes) to a video frame, as well as how to read metadata from a video frame.
+
+**Sender Stats** -- This project shows how to send & display bitrate statistics of the sender in 3 simple steps. It allows launching multiparty connections to see the actual stats of the sender.
## Obtaining OpenTok Credentials
@@ -98,4 +100,4 @@ We love to hear from you so if you have questions, comments or find a bug in the
## Further Reading
-- Check out the Developer Documentation at
\ No newline at end of file
+- Check out the Developer Documentation at
diff --git a/Sender-Stats/OTXCFramework.podspec b/Sender-Stats/OTXCFramework.podspec
new file mode 100644
index 0000000..e999d2d
--- /dev/null
+++ b/Sender-Stats/OTXCFramework.podspec
@@ -0,0 +1,26 @@
+Pod::Spec.new do |s|
+ build = "2401"
+ s.name = "OTXCFramework"
+ s.version = "2.32.0-preview.#{build}"
+ s.summary = "OpenTok lets you weave interactive live WebRTC video streaming right into your application"
+ s.description = <<-DESC
+ The OpenTok iOS SDK lets you use WebRTC video sessions in apps you build for iPad,
+ iPhone, and iPod touch devices.
+ DESC
+ s.homepage = "https://tokbox.com/developer/sdks/ios/"
+ s.license = { :type => "Commercial", :text => "https://tokbox.com/support/tos" }
+ s.author = { "TokBox, Inc." => "support@tokbox.com" }
+
+ s.platform = :ios
+ s.ios.deployment_target = '15.0'
+ s.source = { :http => "https://s3.us-east-1.amazonaws.com/artifact.tokbox.com/pr/otkit-ios-sdk-xcframework/2401/OpenTok-iOS-2.32.0-preview.2401.zip"}
+ s.resource_bundles = {
+ 'OTPrivacyResources' => ['OpenTok.xcframework/ios-arm64/**/OpenTok.framework/PrivacyInfo.xcprivacy']
+ }
+ s.vendored_frameworks = "OpenTok.xcframework"
+ s.frameworks = "Foundation", "AVFoundation", "AudioToolbox", "CoreFoundation", "CoreGraphics",
+ "CoreMedia", "CoreTelephony", "CoreVideo", "GLKit", "OpenGLES", "QuartzCore",
+ "SystemConfiguration", "UIKit", "VideoToolbox", "Network", "Accelerate", "MetalKit"
+ s.libraries = "c++"
+ s.requires_arc = false
+end
\ No newline at end of file
diff --git a/Sender-Stats/Podfile b/Sender-Stats/Podfile
new file mode 100644
index 0000000..707b3ce
--- /dev/null
+++ b/Sender-Stats/Podfile
@@ -0,0 +1,8 @@
+require_relative '../OpenTokSDKVersion'
+
+platform :ios, MinIosSdkVersion
+use_frameworks!
+
+target 'Sender-Stats' do
+ pod 'OTXCFramework', :podspec => 'OTXCFramework.podspec'
+end
\ No newline at end of file
diff --git a/Sender-Stats/README.md b/Sender-Stats/README.md
new file mode 100644
index 0000000..931f6bb
--- /dev/null
+++ b/Sender-Stats/README.md
@@ -0,0 +1,37 @@
+Sender Stats Sample App
+========================================
+
+This sample app demonstrates how to get and show sender stats to the user. It shows how to get the `OTSenderStats` object that contains information such as maxBitrate and currentBitrate in bps. More information on sender stats can be read [here](https://tokbox.com/developer/guides/sender-stats).
+
+*Important:* To use this application, follow the instructions in the
+[Quick Start](../README.md#quick-start) section of the main README file
+for this repository.
+
+
+
+## Setting up the statistis
+
+The following steps are needed to implement the stats monitoring. These are also highlighted in the code sample within `ChatViewController`, along with specific usage example.
+
+### Step 1
+
+First, you need to set the `senderStatisticsTrack` of the Publisher to `true` to start sending the statistics.
+
+### Step 2
+
+Next it is important to set the `subscriber.networkStatsDelegate` on the subscriber - to start getting the delegate callbacks, that contain the statistics.
+
+### Step 3
+
+Add conformance to the `OTSubscriberKitNetworkStatsDelegate` protocol, to be able to read `OTSenderStats`, when the callbacks get triggered.
+
+```swift
+ func subscriber(_ subscriber: OTSubscriberKit, videoNetworkStatsUpdated stats: OTSubscriberKitVideoNetworkStats) {
+ // here you can use stats.senderStats - which gives the access to the `OTSenderStats`.
+ }
+```
+### Final
+
+Now we have the stats - however to see the proper values for currentBitrate, we'll need to make a connection. To do that, simply join the same session with 2 clients (could be a web client + iOS device, or just 2 separate iOS simulators running simultaneously). When a publisher and subscriber are connected within one session, then we should see the actual bitrates within the sender stats property.
+
+
diff --git a/Sender-Stats/Sender-Stats.xcodeproj/project.pbxproj b/Sender-Stats/Sender-Stats.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..ad6446d
--- /dev/null
+++ b/Sender-Stats/Sender-Stats.xcodeproj/project.pbxproj
@@ -0,0 +1,414 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 46;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ A05376091EB1638C00645696 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A05376001EB1638C00645696 /* AppDelegate.swift */; };
+ A053760A1EB1638C00645696 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A05376011EB1638C00645696 /* Assets.xcassets */; };
+ A053760B1EB1638C00645696 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A05376021EB1638C00645696 /* LaunchScreen.storyboard */; };
+ A053760C1EB1638C00645696 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A05376041EB1638C00645696 /* Main.storyboard */; };
+ A053760D1EB1638C00645696 /* ChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A05376061EB1638C00645696 /* ChatViewController.swift */; };
+ A053760F1EB1638C00645696 /* MultipartyLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A05376081EB1638C00645696 /* MultipartyLayout.swift */; };
+ BB7221482E5DD0DC001542F1 /* OTCustomSession.m in Sources */ = {isa = PBXBuildFile; fileRef = BB7221452E5DD0DC001542F1 /* OTCustomSession.m */; };
+ E1D5943D18978E51B788E8FA /* Pods_Sender_Stats.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5F1AF8E25BC2CB94F73C384 /* Pods_Sender_Stats.framework */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 1D1B78097ABBBF4D2DFFB42D /* Pods-Sender-Stats.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sender-Stats.release.xcconfig"; path = "Target Support Files/Pods-Sender-Stats/Pods-Sender-Stats.release.xcconfig"; sourceTree = ""; };
+ A05376001EB1638C00645696 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ A05376011EB1638C00645696 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ A05376031EB1638C00645696 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ A05376051EB1638C00645696 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ A05376061EB1638C00645696 /* ChatViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatViewController.swift; sourceTree = ""; };
+ A05376071EB1638C00645696 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ A05376081EB1638C00645696 /* MultipartyLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MultipartyLayout.swift; sourceTree = ""; };
+ A77325387048E38B94B87194 /* Pods-Sender-Stats.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sender-Stats.debug.xcconfig"; path = "Target Support Files/Pods-Sender-Stats/Pods-Sender-Stats.debug.xcconfig"; sourceTree = ""; };
+ B5F1AF8E25BC2CB94F73C384 /* Pods_Sender_Stats.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Sender_Stats.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ BB7221452E5DD0DC001542F1 /* OTCustomSession.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OTCustomSession.m; sourceTree = ""; };
+ BB7221492E5DD0DD001542F1 /* Sender-Stats-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Sender-Stats-Bridging-Header.h"; sourceTree = ""; };
+ BB72214A2E5DD0FA001542F1 /* OTCustomSession.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OTCustomSession.h; sourceTree = ""; };
+ F852CCBF1EA4D88200ADB206 /* Sender-Stats.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Sender-Stats.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ F852CCBC1EA4D88200ADB206 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ E1D5943D18978E51B788E8FA /* Pods_Sender_Stats.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 0EDFB131BD17164A150F41CC /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ A77325387048E38B94B87194 /* Pods-Sender-Stats.debug.xcconfig */,
+ 1D1B78097ABBBF4D2DFFB42D /* Pods-Sender-Stats.release.xcconfig */,
+ );
+ path = Pods;
+ sourceTree = "";
+ };
+ 8B93B7B6FA15385562552A2A /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ B5F1AF8E25BC2CB94F73C384 /* Pods_Sender_Stats.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+ A05375FF1EB1638C00645696 /* Sender-Stats */ = {
+ isa = PBXGroup;
+ children = (
+ A05376001EB1638C00645696 /* AppDelegate.swift */,
+ BB7221452E5DD0DC001542F1 /* OTCustomSession.m */,
+ BB72214A2E5DD0FA001542F1 /* OTCustomSession.h */,
+ A05376011EB1638C00645696 /* Assets.xcassets */,
+ A05376021EB1638C00645696 /* LaunchScreen.storyboard */,
+ A05376041EB1638C00645696 /* Main.storyboard */,
+ A05376061EB1638C00645696 /* ChatViewController.swift */,
+ A05376071EB1638C00645696 /* Info.plist */,
+ A05376081EB1638C00645696 /* MultipartyLayout.swift */,
+ BB7221492E5DD0DD001542F1 /* Sender-Stats-Bridging-Header.h */,
+ );
+ path = "Sender-Stats";
+ sourceTree = "";
+ };
+ F852CCB61EA4D88200ADB206 = {
+ isa = PBXGroup;
+ children = (
+ A05375FF1EB1638C00645696 /* Sender-Stats */,
+ F852CCC01EA4D88200ADB206 /* Products */,
+ 0EDFB131BD17164A150F41CC /* Pods */,
+ 8B93B7B6FA15385562552A2A /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ F852CCC01EA4D88200ADB206 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ F852CCBF1EA4D88200ADB206 /* Sender-Stats.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ F852CCBE1EA4D88200ADB206 /* Sender-Stats */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = F852CCD11EA4D88200ADB206 /* Build configuration list for PBXNativeTarget "Sender-Stats" */;
+ buildPhases = (
+ 59684ABF8BC0862BE14FCAFB /* [CP] Check Pods Manifest.lock */,
+ F852CCBB1EA4D88200ADB206 /* Sources */,
+ F852CCBC1EA4D88200ADB206 /* Frameworks */,
+ F852CCBD1EA4D88200ADB206 /* Resources */,
+ 0D75AFDDBF54809B9F2B02B0 /* [CP] Copy Pods Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = "Sender-Stats";
+ productName = "7.Sender-Stats";
+ productReference = F852CCBF1EA4D88200ADB206 /* Sender-Stats.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ F852CCB71EA4D88200ADB206 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ LastSwiftUpdateCheck = 0830;
+ LastUpgradeCheck = 0930;
+ ORGANIZATIONNAME = tokbox;
+ TargetAttributes = {
+ F852CCBE1EA4D88200ADB206 = {
+ CreatedOnToolsVersion = 8.3.1;
+ LastSwiftMigration = 1640;
+ ProvisioningStyle = Manual;
+ };
+ };
+ };
+ buildConfigurationList = F852CCBA1EA4D88200ADB206 /* Build configuration list for PBXProject "Sender-Stats" */;
+ compatibilityVersion = "Xcode 3.2";
+ developmentRegion = English;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ English,
+ en,
+ Base,
+ );
+ mainGroup = F852CCB61EA4D88200ADB206;
+ productRefGroup = F852CCC01EA4D88200ADB206 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ F852CCBE1EA4D88200ADB206 /* Sender-Stats */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ F852CCBD1EA4D88200ADB206 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A053760C1EB1638C00645696 /* Main.storyboard in Resources */,
+ A053760A1EB1638C00645696 /* Assets.xcassets in Resources */,
+ A053760B1EB1638C00645696 /* LaunchScreen.storyboard in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 0D75AFDDBF54809B9F2B02B0 /* [CP] Copy Pods Resources */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Sender-Stats/Pods-Sender-Stats-resources.sh",
+ "${PODS_CONFIGURATION_BUILD_DIR}/OTXCFramework/OTPrivacyResources.bundle",
+ );
+ name = "[CP] Copy Pods Resources";
+ outputPaths = (
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/OTPrivacyResources.bundle",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sender-Stats/Pods-Sender-Stats-resources.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 59684ABF8BC0862BE14FCAFB /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Sender-Stats-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ F852CCBB1EA4D88200ADB206 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ BB7221482E5DD0DC001542F1 /* OTCustomSession.m in Sources */,
+ A053760F1EB1638C00645696 /* MultipartyLayout.swift in Sources */,
+ A053760D1EB1638C00645696 /* ChatViewController.swift in Sources */,
+ A05376091EB1638C00645696 /* AppDelegate.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXVariantGroup section */
+ A05376021EB1638C00645696 /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ A05376031EB1638C00645696 /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+ A05376041EB1638C00645696 /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ A05376051EB1638C00645696 /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ F852CCCF1EA4D88200ADB206 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 10.3;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ F852CCD01EA4D88200ADB206 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 10.3;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ F852CCD21EA4D88200ADB206 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = A77325387048E38B94B87194 /* Pods-Sender-Stats.debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ INFOPLIST_FILE = "$(SRCROOT)/Sender-Stats/Info.plist";
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+ PRODUCT_BUNDLE_IDENTIFIER = "com.tokbox.--Sender-Stats";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Sender-Stats/Sender-Stats-Bridging-Header.h";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 4.2;
+ };
+ name = Debug;
+ };
+ F852CCD31EA4D88200ADB206 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 1D1B78097ABBBF4D2DFFB42D /* Pods-Sender-Stats.release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ INFOPLIST_FILE = "$(SRCROOT)/Sender-Stats/Info.plist";
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+ PRODUCT_BUNDLE_IDENTIFIER = "com.tokbox.--Sender-Stats";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_OBJC_BRIDGING_HEADER = "Sender-Stats/Sender-Stats-Bridging-Header.h";
+ SWIFT_VERSION = 4.2;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ F852CCBA1EA4D88200ADB206 /* Build configuration list for PBXProject "Sender-Stats" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ F852CCCF1EA4D88200ADB206 /* Debug */,
+ F852CCD01EA4D88200ADB206 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ F852CCD11EA4D88200ADB206 /* Build configuration list for PBXNativeTarget "Sender-Stats" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ F852CCD21EA4D88200ADB206 /* Debug */,
+ F852CCD31EA4D88200ADB206 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = F852CCB71EA4D88200ADB206 /* Project object */;
+}
diff --git a/Sender-Stats/Sender-Stats.xcodeproj/xcshareddata/xcschemes/Sender-Stats.xcscheme b/Sender-Stats/Sender-Stats.xcodeproj/xcshareddata/xcschemes/Sender-Stats.xcscheme
new file mode 100644
index 0000000..d28e349
--- /dev/null
+++ b/Sender-Stats/Sender-Stats.xcodeproj/xcshareddata/xcschemes/Sender-Stats.xcscheme
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Sender-Stats/Sender-Stats/AppDelegate.swift b/Sender-Stats/Sender-Stats/AppDelegate.swift
new file mode 100644
index 0000000..23e2232
--- /dev/null
+++ b/Sender-Stats/Sender-Stats/AppDelegate.swift
@@ -0,0 +1,37 @@
+//
+// AppDelegate.swift
+// Sender-Stats
+//
+// Created by Artur Osinski on 27/08/2025.
+// Copyright © 2025 vonage. All rights reserved.
+//
+
+import UIKit
+
+@UIApplicationMain
+class AppDelegate: UIResponder, UIApplicationDelegate {
+
+ var window: UIWindow?
+
+
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ return true
+ }
+
+ func applicationWillResignActive(_ application: UIApplication) {
+ }
+
+ func applicationDidEnterBackground(_ application: UIApplication) {
+ }
+
+ func applicationWillEnterForeground(_ application: UIApplication) {
+ }
+
+ func applicationDidBecomeActive(_ application: UIApplication) {
+ }
+
+ func applicationWillTerminate(_ application: UIApplication) {
+ }
+
+}
+
diff --git a/Sender-Stats/Sender-Stats/Assets.xcassets/AccentColor.colorset/Contents.json b/Sender-Stats/Sender-Stats/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/Sender-Stats/Sender-Stats/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sender-Stats/Sender-Stats/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sender-Stats/Sender-Stats/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..8121323
--- /dev/null
+++ b/Sender-Stats/Sender-Stats/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,53 @@
+{
+ "images" : [
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "20x20"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "20x20"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "29x29"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "40x40"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "40x40"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "60x60"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "60x60"
+ },
+ {
+ "idiom" : "ios-marketing",
+ "scale" : "1x",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sender-Stats/Sender-Stats/Assets.xcassets/Contents.json b/Sender-Stats/Sender-Stats/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/Sender-Stats/Sender-Stats/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sender-Stats/Sender-Stats/Base.lproj/LaunchScreen.storyboard b/Sender-Stats/Sender-Stats/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..fdf3f97
--- /dev/null
+++ b/Sender-Stats/Sender-Stats/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Sender-Stats/Sender-Stats/Base.lproj/Main.storyboard b/Sender-Stats/Sender-Stats/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..bdf64dc
--- /dev/null
+++ b/Sender-Stats/Sender-Stats/Base.lproj/Main.storyboard
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Sender-Stats/Sender-Stats/ChatViewController.swift b/Sender-Stats/Sender-Stats/ChatViewController.swift
new file mode 100644
index 0000000..ecb2b0f
--- /dev/null
+++ b/Sender-Stats/Sender-Stats/ChatViewController.swift
@@ -0,0 +1,302 @@
+//
+// ViewController.swift
+// Sender-Stats
+//
+// Created by Artur Osinski on 27/08/2025.
+// Copyright © 2025 vonage. All rights reserved.
+//
+
+import UIKit
+import OpenTok
+import Foundation
+//import
+
+// *** Fill the following variables using your own Project info ***
+// *** https://tokbox.com/account/#/ ***
+// Replace with your OpenTok API key
+let kApiKey = ""
+// Replace with your generated session ID
+let kSessionId = ""
+// Replace with your generated token
+let kToken = ""
+let kApiRootURL = URL(string: "https://api.dev.opentok.com")
+class ChatViewController: UICollectionViewController {
+
+ enum Constant {
+ static let maxBitrateText = "Max Bitrate (bps): "
+ static let currentBitrateText = "Current Bitrate (bps): "
+ static let showStatsText = "Show stats"
+ static let hideStatsText = "Hide stats"
+ }
+
+ lazy var session: OTCustomSession = {
+ guard let session = OTCustomSession(apiKey: kApiKey, sessionId: kSessionId, delegate: self) else {
+ fatalError("Please fill in the kApiKey, kSessionId & kToken")
+ }
+ return session
+ }()
+
+ /**
+ * **Sender stats step 1**
+ * OTPublisher object, needs setting senderStatisticsTrack to true
+ * in order to be able to send sender stats
+ */
+ lazy var publisher: OTPublisher = {
+ let settings = OTPublisherSettings()
+ settings.senderStatisticsTrack = true
+ settings.name = UIDevice.current.name
+
+ return OTPublisher(delegate: self, settings: settings)!
+ }()
+
+ var subscribers: [OTSubscriber] = []
+
+ private lazy var statsButton: UIButton = {
+ let statsButton = UIButton()
+ statsButton.translatesAutoresizingMaskIntoConstraints = false
+ statsButton.setTitle(
+ Constant.showStatsText,
+ for: .normal
+ )
+ statsButton.setTitleColor(.systemBlue, for: .normal)
+ return statsButton
+ }()
+
+ private lazy var statsView: UIView = {
+ let statsView = UIView()
+ statsView.translatesAutoresizingMaskIntoConstraints = false
+ statsView.backgroundColor = .lightText
+ statsView.isHidden = true
+ return statsView
+ }()
+
+ private lazy var maxBitrateLabel: UILabel = {
+ let maxBitrateLabel = UILabel()
+ maxBitrateLabel.translatesAutoresizingMaskIntoConstraints = false
+ maxBitrateLabel.text = Constant.maxBitrateText + "N/A"
+ return maxBitrateLabel
+ }()
+
+ private lazy var currentBitrateLabel: UILabel = {
+ let currentBitrateLabel = UILabel()
+ currentBitrateLabel.translatesAutoresizingMaskIntoConstraints = false
+ currentBitrateLabel.text = Constant.currentBitrateText + "N/A"
+ return currentBitrateLabel
+ }()
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ session.setApiRootURL(kApiRootURL!)
+ setupView()
+ doConnect()
+ }
+
+ /**
+ * Asynchronously begins the session connect process. Some time later, we will
+ * expect a delegate method to call us back with the results of this action.
+ */
+ fileprivate func doConnect() {
+ var error: OTError?
+ defer {
+ processError(error)
+ }
+ session.connect(withToken: kToken, error: &error)
+ }
+
+ /**
+ * Sets up an instance of OTPublisher to use with this session. OTPubilsher
+ * binds to the device camera and microphone, and will provide A/V streams
+ * to the OpenTok session.
+ */
+ fileprivate func doPublish() {
+ var error: OTError?
+ defer {
+ processError(error)
+ }
+ session.publish(publisher, error: &error)
+
+ collectionView?.reloadData()
+ }
+
+ /**
+ * Instantiates a subscriber for the given stream and asynchronously begins the
+ * process to begin receiving A/V content for this stream. Unlike doPublish,
+ * this method does not add the subscriber to the view hierarchy. Instead, we
+ * add the subscriber only after it has connected and begins receiving data.
+ */
+ fileprivate func doSubscribe(_ stream: OTStream) {
+ var error: OTError?
+ defer {
+ processError(error)
+ }
+ guard let subscriber = OTSubscriber(stream: stream, delegate: self)
+ else {
+ print("Error while subscribing")
+ return
+ }
+
+ /**
+ * **Sender stats step 2**
+ * Set the networkStatsDelegate to start receiving
+ * OTSubscriberKitNetworkStatsDelegate callbacks for sender stats
+ */
+ subscriber.networkStatsDelegate = self
+ session.subscribe(subscriber, error: &error)
+ subscribers.append(subscriber)
+ collectionView?.reloadData()
+ }
+
+ fileprivate func cleanupSubscriber(_ stream: OTStream) {
+ subscribers = subscribers.filter { $0.stream?.streamId != stream.streamId }
+ collectionView?.reloadData()
+ }
+
+ fileprivate func processError(_ error: OTError?) {
+ guard let error else { return }
+ showAlert(errorStr: error.localizedDescription)
+ }
+
+ fileprivate func showAlert(errorStr err: String) {
+ DispatchQueue.main.async {
+ let controller = UIAlertController(title: "Error", message: err, preferredStyle: .alert)
+ controller.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
+ self.present(controller, animated: true, completion: nil)
+ }
+ }
+
+ private func setupView() {
+ view.addSubview(statsView)
+ NSLayoutConstraint.activate([
+ statsView.heightAnchor.constraint(equalToConstant: 80),
+ statsView.widthAnchor.constraint(equalToConstant: 280),
+ statsView.topAnchor.constraint(equalTo: view.topAnchor, constant: 100),
+ statsView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20)
+ ])
+
+ statsView.addSubview(maxBitrateLabel)
+ statsView.addSubview(currentBitrateLabel)
+
+ NSLayoutConstraint.activate([
+ maxBitrateLabel.heightAnchor.constraint(equalToConstant: 25),
+ maxBitrateLabel.topAnchor.constraint(equalTo: statsView.topAnchor, constant: 10),
+ maxBitrateLabel.leadingAnchor.constraint(equalTo: statsView.leadingAnchor, constant: 10),
+ maxBitrateLabel.trailingAnchor.constraint(equalTo: statsView.trailingAnchor, constant: 10)
+ ])
+
+ NSLayoutConstraint.activate([
+ currentBitrateLabel.heightAnchor.constraint(equalToConstant: 25),
+ currentBitrateLabel.topAnchor.constraint(equalTo: maxBitrateLabel.bottomAnchor, constant: 10),
+ currentBitrateLabel.leadingAnchor.constraint(equalTo: statsView.leadingAnchor, constant: 10),
+ currentBitrateLabel.trailingAnchor.constraint(equalTo: statsView.trailingAnchor, constant: 10)
+ ])
+
+ view.addSubview(statsButton)
+ NSLayoutConstraint.activate([
+ statsButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 60),
+ statsButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 30)
+ ])
+ statsButton.addTarget(self, action: #selector(toggleStatsView), for: .touchUpInside)
+ }
+
+ @objc private func toggleStatsView() {
+ statsView.isHidden.toggle()
+ statsButton.setTitle(
+ statsView.isHidden ? Constant.showStatsText : Constant.hideStatsText,
+ for: .normal
+ )
+ }
+
+ // MARK: - UICollectionView methods
+ override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
+ return subscribers.count + 1
+ }
+
+ override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
+ let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "videoCell", for: indexPath)
+ let videoView: UIView? = {
+ if (indexPath.row == 0) {
+ return publisher.view
+ } else {
+ let sub = subscribers[indexPath.row - 1]
+ return sub.view
+ }
+ }()
+
+ if let viewToAdd = videoView {
+ viewToAdd.frame = cell.bounds
+ cell.addSubview(viewToAdd)
+ }
+ return cell
+ }
+}
+
+// MARK: - OTSession delegate callbacks
+extension ChatViewController: OTSessionDelegate {
+ func sessionDidConnect(_ session: OTSession) {
+ print("Session connected")
+ doPublish()
+ }
+
+ func sessionDidDisconnect(_ session: OTSession) {
+ print("Session disconnected")
+ }
+
+ func session(_ session: OTSession, streamCreated stream: OTStream) {
+ print("Session streamCreated: \(stream.streamId)")
+ doSubscribe(stream)
+ }
+
+ func session(_ session: OTSession, streamDestroyed stream: OTStream) {
+ print("Session streamDestroyed: \(stream.streamId)")
+ cleanupSubscriber(stream)
+ }
+
+ func session(_ session: OTSession, didFailWithError error: OTError) {
+ print("session Failed to connect: \(error.localizedDescription)")
+ }
+}
+
+// MARK: - OTPublisher delegate callbacks
+extension ChatViewController: OTPublisherDelegate {
+ func publisher(_ publisher: OTPublisherKit, streamCreated stream: OTStream) {
+ }
+
+ func publisher(_ publisher: OTPublisherKit, streamDestroyed stream: OTStream) {
+ }
+
+ func publisher(_ publisher: OTPublisherKit, didFailWithError error: OTError) {
+ print("Publisher failed: \(error.localizedDescription)")
+ }
+}
+
+// MARK: - OTSubscriber delegate callbacks
+extension ChatViewController: OTSubscriberDelegate {
+ func subscriberDidConnect(toStream subscriberKit: OTSubscriberKit) {
+ print("Subscriber connected")
+ }
+
+ func subscriber(_ subscriber: OTSubscriberKit, didFailWithError error: OTError) {
+ print("Subscriber failed: \(error.localizedDescription)")
+ }
+
+ func subscriberVideoDataReceived(_ subscriber: OTSubscriber) {
+ }
+}
+
+// MARK: - OTSubscriberKitNetworkStatsDelegate callbacks
+extension ChatViewController: OTSubscriberKitNetworkStatsDelegate {
+
+ /**
+ * **Sender stats step 3**
+ * videoNetworkStatsUpdated method needs to be implemented
+ * to capture information about sender stats as follows.
+ *
+ * The stats could still be nil sometimes due to network issues or latency,
+ * even if the publisher’s sender stats track is enabled.
+ */
+ func subscriber(_ subscriber: OTSubscriberKit, videoNetworkStatsUpdated stats: OTSubscriberKitVideoNetworkStats) {
+ guard let senderStats = stats.senderStats else { return }
+ maxBitrateLabel.text = Constant.maxBitrateText + "\(senderStats.maxBitrate)"
+ currentBitrateLabel.text = Constant.currentBitrateText + "\(senderStats.currentBitrate)"
+ }
+}
diff --git a/Sender-Stats/Sender-Stats/Info.plist b/Sender-Stats/Sender-Stats/Info.plist
new file mode 100644
index 0000000..eea164f
--- /dev/null
+++ b/Sender-Stats/Sender-Stats/Info.plist
@@ -0,0 +1,40 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSRequiresIPhoneOS
+
+ NSCameraUsageDescription
+
+ NSMicrophoneUsageDescription
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UIRequiredDeviceCapabilities
+
+ armv7
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+
+
+
diff --git a/Sender-Stats/Sender-Stats/MultipartyLayout.swift b/Sender-Stats/Sender-Stats/MultipartyLayout.swift
new file mode 100644
index 0000000..984b4ad
--- /dev/null
+++ b/Sender-Stats/Sender-Stats/MultipartyLayout.swift
@@ -0,0 +1,155 @@
+//
+// MultipartyLayout.swift
+// Sender-Stats
+//
+// Created by Artur Osinski on 27/08/2025.
+// Copyright © 2025 vonage. All rights reserved.
+//
+
+import UIKit
+
+extension Int {
+ var isEven: Bool {
+ return self % 2 == 0
+ }
+}
+
+class MultipartyLayout: UICollectionViewLayout {
+ fileprivate var cache = [UICollectionViewLayoutAttributes]()
+ fileprivate var cachedNumberOfViews = 0
+
+ override func prepare() {
+ guard let views = collectionView?.numberOfItems(inSection: 0)
+ else {
+ cache.removeAll()
+ return
+ }
+
+ if views != cachedNumberOfViews {
+ cache.removeAll()
+ }
+
+ if cache.isEmpty {
+ cachedNumberOfViews = views
+ let attribs: [UICollectionViewLayoutAttributes] = {
+ switch views {
+ case 1:
+ return attributesForPublisherFullScreen()
+ case 2:
+ return attributesForPublisherAndOneSubscriber()
+ case let x where x > 2 && x.isEven:
+ return attributesForAllViewsTwoByTwo(withNumberOfViews: x)
+ case let x where x > 2 && !x.isEven:
+ return attributesForPublisherOnTopAndSubscribersTwoByTwo(withNumberOfViews: x)
+ default:
+ return []
+ }
+ }()
+
+ cache.append(contentsOf: attribs)
+ }
+ }
+
+ fileprivate func attributesForPublisherFullScreen() -> [UICollectionViewLayoutAttributes] {
+ var attribs = [UICollectionViewLayoutAttributes]()
+ let ip = IndexPath(item: 0, section: 0)
+ let attr = UICollectionViewLayoutAttributes(forCellWith: ip)
+ attr.frame = collectionView?.superview?.bounds ?? CGRect()
+ attribs.append(attr)
+
+ return attribs
+ }
+
+ // Will layout publisher view over subscriber view
+ fileprivate func attributesForPublisherAndOneSubscriber() -> [UICollectionViewLayoutAttributes] {
+ var attribs = [UICollectionViewLayoutAttributes]()
+ let height = (collectionView?.superview?.bounds.size.height ?? 0) / 2
+ let width = collectionView?.superview?.bounds.size.width ?? 0
+
+ let pubIp = IndexPath(item: 0, section: 0)
+ let pubAttribs = UICollectionViewLayoutAttributes(forCellWith: pubIp)
+ pubAttribs.frame = CGRect(x: 0, y: 0, width: width, height: height)
+ attribs.append(pubAttribs)
+
+ let subIp = IndexPath(item: 1, section: 0)
+ let subAttribs = UICollectionViewLayoutAttributes(forCellWith: subIp)
+ subAttribs.frame = CGRect(x: 0, y:height, width: width, height: height)
+ attribs.append(subAttribs)
+
+ return attribs
+ }
+
+ fileprivate func attributesForPublisherOnTopAndSubscribersTwoByTwo(withNumberOfViews views: Int)
+ -> [UICollectionViewLayoutAttributes]
+ {
+ var attribs = [UICollectionViewLayoutAttributes]()
+ let rows = CGFloat(((views - 1) / 2) + 1)
+ let height = (collectionView?.superview?.bounds.size.height ?? 0) / CGFloat(rows)
+ let width = (collectionView?.superview?.bounds.size.width ?? 0) / 2
+
+ let pubIp = IndexPath(item: 0, section: 0)
+ let pubAttribs = UICollectionViewLayoutAttributes(forCellWith: pubIp)
+ pubAttribs.frame = CGRect(x: 0, y: 0, width: collectionView?.superview?.bounds.size.width ?? 0, height: height)
+ attribs.append(pubAttribs)
+ attribs.append(contentsOf: attributesForViewsInRows(initialYOffset: height,
+ totalNumberOfViews: views,
+ viewSize: CGSize(width: width, height: height),
+ viewOffset: 1))
+ return attribs
+ }
+
+ fileprivate func attributesForAllViewsTwoByTwo(withNumberOfViews views: Int)
+ -> [UICollectionViewLayoutAttributes]
+ {
+ var attribs = [UICollectionViewLayoutAttributes]()
+ let rows = views / 2
+ let height = (collectionView?.superview?.bounds.size.height ?? 0) / CGFloat(rows)
+ let width = (collectionView?.superview?.bounds.size.width ?? 0) / 2
+
+ attribs.append(contentsOf: attributesForViewsInRows(initialYOffset: 0,
+ totalNumberOfViews: views,
+ viewSize: CGSize(width: width, height: height),
+ viewOffset: 0))
+ return attribs
+ }
+
+ fileprivate func attributesForViewsInRows(initialYOffset: CGFloat,
+ totalNumberOfViews views: Int,
+ viewSize: CGSize,
+ viewOffset: Int)
+ -> [UICollectionViewLayoutAttributes]
+ {
+ var attribs = [UICollectionViewLayoutAttributes]()
+ var yOffset = initialYOffset
+
+ let newLineCondition : (Int) -> Bool = {
+ if viewOffset == 0 {
+ return !$0.isEven
+ } else {
+ return $0.isEven
+ }
+ }
+
+ for item in viewOffset.. viewOffset && newLineCondition(item) {
+ yOffset += viewSize.height
+ }
+ }
+
+ return attribs
+ }
+
+ override var collectionViewContentSize: CGSize {
+ return collectionView?.superview?.bounds.size ?? CGSize()
+ }
+
+ override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
+ return cache
+ }
+
+}
diff --git a/Sender-Stats/Sender-Stats/OTCustomSession.h b/Sender-Stats/Sender-Stats/OTCustomSession.h
new file mode 100644
index 0000000..b06fe8a
--- /dev/null
+++ b/Sender-Stats/Sender-Stats/OTCustomSession.h
@@ -0,0 +1,18 @@
+//
+// OTCustomSession.h
+// VonageMeetApp
+//
+// Created by Jerónimo Valli on 7/10/23.
+//
+
+#ifndef OTCustomSession_h
+#define OTCustomSession_h
+
+#import
+#import
+
+@interface OTCustomSession : OTSession
+- (void)setApiRootURL:(NSURL*)aURL;
+@end
+
+#endif /* OTCustomSession_h */
diff --git a/Sender-Stats/Sender-Stats/OTCustomSession.m b/Sender-Stats/Sender-Stats/OTCustomSession.m
new file mode 100644
index 0000000..4c403b3
--- /dev/null
+++ b/Sender-Stats/Sender-Stats/OTCustomSession.m
@@ -0,0 +1,18 @@
+//
+// OTCustomSession.m
+// VonageMeetApp
+//
+// Created by Jerónimo Valli on 7/10/23.
+//
+
+#import "OTCustomSession.h"
+
+@interface OTSession ()
+- (void)setApiRootURL:(NSURL*)aURL;
+@end
+
+@implementation OTCustomSession
+- (void)setApiRootURL:(NSURL *)aURL {
+ [super setApiRootURL:aURL];
+}
+@end
diff --git a/Sender-Stats/Sender-Stats/Sender-Stats-Bridging-Header.h b/Sender-Stats/Sender-Stats/Sender-Stats-Bridging-Header.h
new file mode 100644
index 0000000..4c634a8
--- /dev/null
+++ b/Sender-Stats/Sender-Stats/Sender-Stats-Bridging-Header.h
@@ -0,0 +1,5 @@
+//
+// Use this file to import your target's public headers that you would like to expose to Swift.
+//
+
+#import "OTCustomSession.h"
diff --git a/Sender-Stats/readme-images/senderStatsExample1.png b/Sender-Stats/readme-images/senderStatsExample1.png
new file mode 100644
index 0000000..2b73df3
Binary files /dev/null and b/Sender-Stats/readme-images/senderStatsExample1.png differ