Permission Delegate Guide



Background

Starting from version 8.22.184, StreamLayer SDK no longer calls privacy-sensitive iOS frameworks (Contacts, Photos, AVCaptureDevice, AVAudioSession.requestRecordPermission) directly from its binary. Instead, the SDK asks the host app to handle all permission prompts and sensitive data access through the SLRPermissionDelegate protocol.

Why a delegate?

Apple's App Store static analyzer flags any reference to privacy-sensitive APIs in a binary — even if the code path is never executed — and requires the corresponding NSCameraUsageDescription, NSMicrophoneUsageDescription, NSContactsUsageDescription, and NSPhotoLibraryUsageDescription keys in the host app's Info.plist.

Moving these calls out of the SDK binary gives host apps full control over:

  • Which permissions appear in the App Store review (via Info.plist keys).
  • When and how permission prompts are displayed to the user.
  • Whether privacy-gated features (Watch Party camera, chat photo input, contacts sync) are available at all.

If your integration only uses the advertising overlay and does not need camera, microphone, contacts, or photos — you don't implement the delegate at all, and your Info.plist stays clean.

When do I need to implement it?

SDK feature usedPermissions required
Advertising overlay onlyNone — skip this guide entirely
Voice chat / Watch Party (audio)Microphone
Watch Party (video)Microphone + Camera
Chat photo inputCamera + Photo Library
Contacts sync / invite friendsContacts

Implement only the delegate methods your features require. All methods have default no-op implementations, so unused permissions are simply "denied" and the relevant features degrade gracefully.


Overview

Protocol: SLRPermissionDelegate

public protocol SLRPermissionDelegate: AnyObject {
  // Permission status & requests
  func permissionStatus(for type: SLRPermissionType) -> SLRPermissionStatus
  func requestPermission(for type: SLRPermissionType, completion: @escaping (Bool) -> Void)

  // Contacts data access
  func fetchContacts(completion: @escaping ([SLRContactInfo]) -> Void)
  func fetchContact(identifier: String, completion: @escaping (SLRContactInfo?) -> Void)

  // Photo library data source
  func createPhotoLibraryDataSource() -> SLRPhotoLibraryDataSource?

  // Camera capture session
  func createCameraCaptureSession() -> SLRCameraCaptureSession?
}

All methods are optional thanks to protocol extensions with default implementations that return denied/empty/nil.

Supporting Types

public enum SLRPermissionType: Sendable {
  case camera
  case microphone
  case contacts
  case photoLibrary
}

public enum SLRPermissionStatus: Sendable {
  case authorized
  case denied
  case notDetermined
  case restricted
}

public struct SLRContactInfo: Sendable {
  public let identifier: String
  public let givenName: String
  public let familyName: String
  public let phoneNumbers: [String]
  public let thumbnailImageData: Data?
}

Integration Steps

Step 1 — Add Required Info.plist Keys

Add only the keys that correspond to features you use. Each key must include a user-facing description explaining why your app needs the data.

<!-- Only if you use Watch Party or voice chat -->
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) uses the microphone for voice chat in Watch Party.</string>

<!-- Only if you use Watch Party video or chat photo input -->
<key>NSCameraUsageDescription</key>
<string>$(PRODUCT_NAME) uses the camera for video chat and sharing photos.</string>

<!-- Only if you use contact sync / invite friends -->
<key>NSContactsUsageDescription</key>
<string>$(PRODUCT_NAME) accesses your contacts to help you find friends.</string>

<!-- Only if you use chat photo input -->
<key>NSPhotoLibraryUsageDescription</key>
<string>$(PRODUCT_NAME) accesses your photo library to share photos in chat.</string>

Step 2 — Implement SLRPermissionDelegate

Create a dedicated class that implements the protocol. Import the system frameworks corresponding to the features you support:

import UIKit
import AVFoundation   // for camera & microphone
import Contacts       // for contacts sync
import Photos         // for photo library
import StreamLayerSDK

final class PermissionDelegateImpl: NSObject, SLRPermissionDelegate {
  // ...implementations below
}

Tip: You can implement the delegate directly on your AppDelegate, but a dedicated class keeps your AppDelegate clean.

Step 3 — Wire Up the Delegate

Assign the delegate after StreamLayer.initSDK(...):

func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions: [...]) -> Bool {
  StreamLayer.initSDK(with: apiKey,
                      isDebug: false,
                      delegate: self,
                      loggerDelegate: self)

  // Assign permission delegate
  StreamLayer.shared.permissionDelegate = permissionDelegate

  return true
}

private let permissionDelegate = PermissionDelegateImpl()

Important: permissionDelegate is declared weak on StreamLayer.shared. Retain your delegate instance as a property on your AppDelegate or Scene coordinator so it isn't deallocated.


Feature-by-Feature Implementation

Permission Status Checks

Map iOS system status enums to SLRPermissionStatus:

func permissionStatus(for type: SLRPermissionType) -> SLRPermissionStatus {
  switch type {
  case .camera:
    switch AVCaptureDevice.authorizationStatus(for: .video) {
    case .authorized: return .authorized
    case .denied: return .denied
    case .notDetermined: return .notDetermined
    case .restricted: return .restricted
    @unknown default: return .denied
    }
  case .microphone:
    switch AVAudioSession.sharedInstance().recordPermission {
    case .granted: return .authorized
    case .denied: return .denied
    case .undetermined: return .notDetermined
    @unknown default: return .denied
    }
  case .contacts:
    switch CNContactStore.authorizationStatus(for: .contacts) {
    case .authorized: return .authorized
    case .denied: return .denied
    case .notDetermined: return .notDetermined
    case .restricted: return .restricted
    @unknown default: return .denied
    }
  case .photoLibrary:
    switch PHPhotoLibrary.authorizationStatus() {
    case .authorized, .limited: return .authorized
    case .denied: return .denied
    case .notDetermined: return .notDetermined
    case .restricted: return .restricted
    @unknown default: return .denied
    }
  }
}

Permission Requests

Forward the request to the system API and call completion on the main queue:

func requestPermission(for type: SLRPermissionType,
                       completion: @escaping (Bool) -> Void) {
  switch type {
  case .camera:
    AVCaptureDevice.requestAccess(for: .video) { granted in
      DispatchQueue.main.async { completion(granted) }
    }
  case .microphone:
    AVAudioSession.sharedInstance().requestRecordPermission { granted in
      DispatchQueue.main.async { completion(granted) }
    }
  case .contacts:
    CNContactStore().requestAccess(for: .contacts) { granted, _ in
      DispatchQueue.main.async { completion(granted) }
    }
  case .photoLibrary:
    PHPhotoLibrary.requestAuthorization { status in
      DispatchQueue.main.async {
        completion(status == .authorized || status == .limited)
      }
    }
  }
}

Contacts Data Access

The SDK needs contact info for address book sync and invite flows. Provide contacts as SLRContactInfo structs — not CNContact objects.

func fetchContacts(completion: @escaping ([SLRContactInfo]) -> Void) {
  DispatchQueue.global(qos: .userInitiated).async {
    let store = CNContactStore()
    let keys: [CNKeyDescriptor] = [
      CNContactIdentifierKey as CNKeyDescriptor,
      CNContactGivenNameKey as CNKeyDescriptor,
      CNContactFamilyNameKey as CNKeyDescriptor,
      CNContactPhoneNumbersKey as CNKeyDescriptor,
      CNContactThumbnailImageDataKey as CNKeyDescriptor
    ]
    let request = CNContactFetchRequest(keysToFetch: keys)
    var result: [SLRContactInfo] = []

    do {
      try store.enumerateContacts(with: request) { cnContact, _ in
        result.append(SLRContactInfo(
          identifier: cnContact.identifier,
          givenName: cnContact.givenName,
          familyName: cnContact.familyName,
          phoneNumbers: cnContact.phoneNumbers.map { $0.value.stringValue },
          thumbnailImageData: cnContact.thumbnailImageData
        ))
      }
    } catch {
      print("Failed to fetch contacts: \(error)")
    }

    DispatchQueue.main.async { completion(result) }
  }
}

func fetchContact(identifier: String,
                  completion: @escaping (SLRContactInfo?) -> Void) {
  DispatchQueue.global(qos: .userInitiated).async {
    let store = CNContactStore()
    let keys: [CNKeyDescriptor] = [
      CNContactIdentifierKey as CNKeyDescriptor,
      CNContactGivenNameKey as CNKeyDescriptor,
      CNContactFamilyNameKey as CNKeyDescriptor,
      CNContactPhoneNumbersKey as CNKeyDescriptor,
      CNContactThumbnailImageDataKey as CNKeyDescriptor
    ]
    do {
      let cn = try store.unifiedContact(withIdentifier: identifier,
                                        keysToFetch: keys)
      let info = SLRContactInfo(
        identifier: cn.identifier,
        givenName: cn.givenName,
        familyName: cn.familyName,
        phoneNumbers: cn.phoneNumbers.map { $0.value.stringValue },
        thumbnailImageData: cn.thumbnailImageData
      )
      DispatchQueue.main.async { completion(info) }
    } catch {
      DispatchQueue.main.async { completion(nil) }
    }
  }
}

Photo Library Data Source

The SDK uses a data source for browsing photos in the chat photo picker. Implement SLRPhotoLibraryDataSource backed by PHAsset and PHCachingImageManager:

public protocol SLRPhotoLibraryDataSource: AnyObject {
  var delegate: SLRPhotoLibraryDataSourceDelegate? { get set }
  var count: Int { get }

  @discardableResult
  func requestPreviewImage(at index: Int, targetSize: CGSize,
                           completion: @escaping (Result<UIImage, Error>) -> Void)
    -> SLRPhotoRequestHandle

  @discardableResult
  func requestFullImage(at index: Int, progressHandler: ((Double) -> Void)?,
                        completion: @escaping (Result<UIImage, Error>) -> Void)
    -> SLRPhotoRequestHandle

  func fullImageRequest(at index: Int) -> SLRPhotoRequestHandle?
}

Return an instance from createPhotoLibraryDataSource():

func createPhotoLibraryDataSource() -> SLRPhotoLibraryDataSource? {
  return PhotoLibraryDataSourceImpl()
}

A complete reference implementation backed by PHAsset/PHCachingImageManager is provided in the example app at:

Example iOS/Example iOS/SLRPermission/SLRPhotoLibraryDataSourceImpl.swift

Copy it as-is if your integration only needs standard photo library browsing.

Camera Capture Session

For live camera preview in the chat input, implement SLRCameraCaptureSession backed by AVCaptureSession:

public protocol SLRCameraCaptureSession: AnyObject {
  var previewLayer: CALayer? { get }
  var isInitialized: Bool { get }
  var isCapturing: Bool { get }
  func startCapturing(_ completion: @escaping () -> Void)
  func stopCapturing(_ completion: @escaping () -> Void)
}

Return an instance from createCameraCaptureSession():

func createCameraCaptureSession() -> SLRCameraCaptureSession? {
  return CameraCaptureSessionImpl()
}

Minimal Integrations (Advertising-Only)

If your integration only uses the advertising overlay:

  1. Do not add any of the four privacy Info.plist keys.
  2. Do not assign StreamLayer.shared.permissionDelegate.
  3. Do not import Contacts, Photos, AVFoundation (except for video playback in your own player).

The SDK will:

  • Treat every permission as .denied.
  • Skip privacy-gated features (Watch Party camera/mic, chat photo input, contacts sync) without crashing.
  • Ship a binary with no references to CNContactStore, PHPhotoLibrary, or AVCaptureDevice — App Store Connect will not require the privacy keys.

Troubleshooting

Q: App Store Connect still complains about missing privacy descriptions. Your host app code, not the SDK, is likely referencing these frameworks. Search your project for import Contacts, import Photos, AVCaptureDevice, or AVAudioSession.requestRecordPermission. If you implement SLRPermissionDelegate, these references belong only in your delegate implementation file(s) and you must add the matching Info.plist keys.

Q: My delegate method isn't being called. Ensure you've assigned the delegate after StreamLayer.initSDK(...) and that your delegate instance is retained (held as a strong property somewhere).

Q: The feature (camera preview / photo picker) is blank. Confirm createPhotoLibraryDataSource() / createCameraCaptureSession() returns a non-nil instance, and that permissions were granted via requestPermission(for:).

Q: How do I test the "no delegate" path? Temporarily comment out the line StreamLayer.shared.permissionDelegate = .... Verify the advertising overlay still works and that privacy-gated features simply do not appear (no crashes, no prompts).