Skip to main content
Back to Examples

iOS & Android Mobile Streaming

Build native mobile streaming apps with camera capture, audio handling, and network optimization. Includes complete Swift and Kotlin code examples.

Intermediate2 hoursiOS & Android

iOS (Swift)

Build streaming apps for iPhone and iPad using Swift and AVFoundation

Android (Kotlin)

Build streaming apps for Android devices using Kotlin and Camera2 API

iOS Implementation (Swift)

Step 1: Project Setup

  1. 1

    Create a new iOS App project in Xcode

    File → New → Project → iOS → App

  2. 2

    Add required capabilities in Info.plist

    <key>NSCameraUsageDescription</key>
    <string>We need camera access to stream video</string>
    <key>NSMicrophoneUsageDescription</key>
    <string>We need microphone access to stream audio</string>
  3. 3

    Install WAVE iOS SDK via CocoaPods

    # Podfile
    platform :ios, '14.0'
    
    target 'YourApp' do
      use_frameworks!
      pod 'WAVEStreaming', '~> 1.0'
    end

    Then run: pod install

Step 2: Camera Capture Setup

Configure AVFoundation camera capture

import UIKit
import AVFoundation
import WAVEStreaming

class StreamViewController: UIViewController {

    // MARK: - Properties
    private let captureSession = AVCaptureSession()
    private var videoDevice: AVCaptureDevice?
    private var audioDevice: AVCaptureDevice?
    private let previewLayer = AVCaptureVideoPreviewLayer()
    private var waveStreamer: WAVEStreamer?

    // MARK: - UI Elements
    private let startButton = UIButton()
    private let stopButton = UIButton()
    private let switchCameraButton = UIButton()

    // MARK: - Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        setupCamera()
        setupWAVEStreaming()
    }

    // MARK: - Camera Setup
    private func setupCamera() {
        captureSession.beginConfiguration()

        // Set session preset for quality
        if captureSession.canSetSessionPreset(.hd1920x1080) {
            captureSession.sessionPreset = .hd1920x1080
        }

        // Add video input
        guard let videoDevice = AVCaptureDevice.default(
            .builtInWideAngleCamera,
            for: .video,
            position: .back
        ) else {
            print("Failed to get video device")
            return
        }

        self.videoDevice = videoDevice

        do {
            let videoInput = try AVCaptureDeviceInput(device: videoDevice)
            if captureSession.canAddInput(videoInput) {
                captureSession.addInput(videoInput)
            }
        } catch {
            print("Error adding video input: \(error)")
            return
        }

        // Add audio input
        guard let audioDevice = AVCaptureDevice.default(for: .audio) else {
            print("Failed to get audio device")
            return
        }

        self.audioDevice = audioDevice

        do {
            let audioInput = try AVCaptureDeviceInput(device: audioDevice)
            if captureSession.canAddInput(audioInput) {
                captureSession.addInput(audioInput)
            }
        } catch {
            print("Error adding audio input: \(error)")
            return
        }

        // Setup preview layer
        previewLayer.session = captureSession
        previewLayer.videoGravity = .resizeAspectFill
        previewLayer.frame = view.bounds
        view.layer.insertSublayer(previewLayer, at: 0)

        captureSession.commitConfiguration()

        // Start preview
        DispatchQueue.global(qos: .userInitiated).async { [weak self] in
            self?.captureSession.startRunning()
        }
    }

    // MARK: - WAVE Streaming Setup
    private func setupWAVEStreaming() {
        let config = WAVEStreamerConfig(
            streamKey: "your-stream-key-here",
            serverURL: "rtmp://ingest.wave.stream/live",
            videoBitrate: 6000000, // 6 Mbps for 1080p
            audioBitrate: 128000,  // 128 Kbps
            videoWidth: 1920,
            videoHeight: 1080,
            fps: 30
        )

        waveStreamer = WAVEStreamer(config: config)
        waveStreamer?.delegate = self
    }

    // MARK: - Actions
    @objc private func startStreaming() {
        waveStreamer?.connect(captureSession: captureSession)
        startButton.isEnabled = false
        stopButton.isEnabled = true
    }

    @objc private func stopStreaming() {
        waveStreamer?.disconnect()
        startButton.isEnabled = true
        stopButton.isEnabled = false
    }

    @objc private func switchCamera() {
        captureSession.beginConfiguration()

        // Remove current video input
        if let currentInput = captureSession.inputs.first(
            where: { ($0 as? AVCaptureDeviceInput)?.device.hasMediaType(.video) == true }
        ) {
            captureSession.removeInput(currentInput)
        }

        // Get opposite camera
        let newPosition: AVCaptureDevice.Position = videoDevice?.position == .back ? .front : .back

        guard let newVideoDevice = AVCaptureDevice.default(
            .builtInWideAngleCamera,
            for: .video,
            position: newPosition
        ) else {
            captureSession.commitConfiguration()
            return
        }

        do {
            let newVideoInput = try AVCaptureDeviceInput(device: newVideoDevice)
            if captureSession.canAddInput(newVideoInput) {
                captureSession.addInput(newVideoInput)
                videoDevice = newVideoDevice
            }
        } catch {
            print("Error switching camera: \(error)")
        }

        captureSession.commitConfiguration()
    }

    // MARK: - UI Setup
    private func setupUI() {
        view.backgroundColor = .black

        // Configure buttons
        startButton.setTitle("Start Streaming", for: .normal)
        startButton.backgroundColor = .systemGreen
        startButton.layer.cornerRadius = 8
        startButton.addTarget(self, action: #selector(startStreaming), for: .touchUpInside)

        stopButton.setTitle("Stop Streaming", for: .normal)
        stopButton.backgroundColor = .systemRed
        stopButton.layer.cornerRadius = 8
        stopButton.isEnabled = false
        stopButton.addTarget(self, action: #selector(stopStreaming), for: .touchUpInside)

        switchCameraButton.setTitle("Switch Camera", for: .normal)
        switchCameraButton.backgroundColor = .systemBlue
        switchCameraButton.layer.cornerRadius = 8
        switchCameraButton.addTarget(self, action: #selector(switchCamera), for: .touchUpInside)

        // Add to view and layout
        let stackView = UIStackView(arrangedSubviews: [startButton, stopButton, switchCameraButton])
        stackView.axis = .horizontal
        stackView.distribution = .fillEqually
        stackView.spacing = 12
        stackView.translatesAutoresizingMaskIntoConstraints = false

        view.addSubview(stackView)

        NSLayoutConstraint.activate([
            stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
            stackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20),
            stackView.heightAnchor.constraint(equalToConstant: 50)
        ])
    }
}

// MARK: - WAVE Streamer Delegate
extension StreamViewController: WAVEStreamerDelegate {
    func streamerDidConnect(_ streamer: WAVEStreamer) {
        print("✅ Connected to WAVE")
        DispatchQueue.main.async {
            // Update UI
        }
    }

    func streamer(_ streamer: WAVEStreamer, didDisconnectWithError error: Error?) {
        print("❌ Disconnected: \(error?.localizedDescription ?? "Unknown error")")
        DispatchQueue.main.async {
            self.startButton.isEnabled = true
            self.stopButton.isEnabled = false
        }
    }

    func streamer(_ streamer: WAVEStreamer, didUpdateStats stats: WAVEStreamStats) {
        print("📊 Bitrate: \(stats.currentBitrate / 1000) Kbps, FPS: \(stats.currentFPS)")
    }
}

Step 3: Network Optimization

Handle network changes and optimize for cellular

import Network

class NetworkMonitor {
    private let monitor = NWPathMonitor()
    private let queue = DispatchQueue(label: "NetworkMonitor")

    var onNetworkChange: ((NWPath) -> Void)?

    func startMonitoring() {
        monitor.pathUpdateHandler = { [weak self] path in
            self?.onNetworkChange?(path)

            if path.status == .satisfied {
                if path.usesInterfaceType(.wifi) {
                    print("📶 Connected via WiFi - using high quality")
                    self?.adjustQualityForWiFi()
                } else if path.usesInterfaceType(.cellular) {
                    print("📱 Connected via Cellular - optimizing bitrate")
                    self?.adjustQualityForCellular()
                }
            } else {
                print("❌ No network connection")
            }
        }

        monitor.start(queue: queue)
    }

    private func adjustQualityForWiFi() {
        // Use full 1080p quality
        let config = WAVEStreamerConfig(
            streamKey: "your-key",
            serverURL: "rtmp://ingest.wave.stream/live",
            videoBitrate: 6000000,  // 6 Mbps
            audioBitrate: 128000,
            videoWidth: 1920,
            videoHeight: 1080,
            fps: 30
        )
        // Update streamer config
    }

    private func adjustQualityForCellular() {
        // Reduce to 720p to save data
        let config = WAVEStreamerConfig(
            streamKey: "your-key",
            serverURL: "rtmp://ingest.wave.stream/live",
            videoBitrate: 3000000,  // 3 Mbps
            audioBitrate: 96000,
            videoWidth: 1280,
            videoHeight: 720,
            fps: 30
        )
        // Update streamer config
    }

    func stopMonitoring() {
        monitor.cancel()
    }
}

Step 4: Battery Optimization

Background Mode

Enable background audio in Xcode project settings:

Target → Signing & Capabilities → Background Modes → Audio, AirPlay, and Picture in Picture

Power Management

// Prevent screen from sleeping during stream
UIApplication.shared.isIdleTimerDisabled = true

// Monitor battery level
UIDevice.current.isBatteryMonitoringEnabled = true

NotificationCenter.default.addObserver(
    self,
    selector: #selector(batteryLevelDidChange),
    name: UIDevice.batteryLevelDidChangeNotification,
    object: nil
)

@objc func batteryLevelDidChange() {
    let batteryLevel = UIDevice.current.batteryLevel

    if batteryLevel < 0.2 {
        // Battery below 20% - reduce quality
        print("⚠️ Low battery - reducing stream quality")
        adjustQualityForLowBattery()
    }
}

Android Implementation (Kotlin)

Step 1: Project Setup

  1. 1

    Add permissions to AndroidManifest.xml

    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />
    
    <uses-feature android:name="android.hardware.camera" android:required="true" />
    <uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
  2. 2

    Add WAVE SDK dependency

    // build.gradle (app)
    dependencies {
        implementation 'com.wave:streaming-sdk:1.0.0'
        implementation 'androidx.camera:camera-core:1.3.0'
        implementation 'androidx.camera:camera-camera2:1.3.0'
        implementation 'androidx.camera:camera-lifecycle:1.3.0'
        implementation 'androidx.camera:camera-view:1.3.0'
    }

Step 2: Camera Implementation

Complete streaming activity with Camera2 API

package com.yourapp.streaming

import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.content.ContextCompat
import com.wave.streaming.*
import kotlinx.android.synthetic.main.activity_stream.*
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors

class StreamActivity : AppCompatActivity(), WAVEStreamerListener {

    private var camera: Camera? = null
    private var preview: Preview? = null
    private var videoCapture: VideoCapture? = null
    private lateinit var cameraExecutor: ExecutorService
    private var waveStreamer: WAVEStreamer? = null
    private var isStreaming = false

    // Permission launcher
    private val requestPermissionLauncher = registerForActivityResult(
        ActivityResultContracts.RequestMultiplePermissions()
    ) { permissions ->
        if (permissions.all { it.value }) {
            startCamera()
        } else {
            Toast.makeText(this, "Permissions required", Toast.LENGTH_SHORT).show()
            finish()
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_stream)

        cameraExecutor = Executors.newSingleThreadExecutor()

        // Request permissions
        if (allPermissionsGranted()) {
            startCamera()
        } else {
            requestPermissionLauncher.launch(REQUIRED_PERMISSIONS)
        }

        // Setup buttons
        btnStartStream.setOnClickListener { startStreaming() }
        btnStopStream.setOnClickListener { stopStreaming() }
        btnSwitchCamera.setOnClickListener { switchCamera() }

        // Initialize WAVE Streamer
        setupWAVEStreaming()
    }

    private fun startCamera() {
        val cameraProviderFuture = ProcessCameraProvider.getInstance(this)

        cameraProviderFuture.addListener({
            val cameraProvider = cameraProviderFuture.get()

            // Preview
            preview = Preview.Builder()
                .setTargetResolution(android.util.Size(1920, 1080))
                .build()
                .also {
                    it.setSurfaceProvider(viewFinder.surfaceProvider)
                }

            // Video capture
            val recorder = Recorder.Builder()
                .setQualitySelector(QualitySelector.from(Quality.FHD))
                .build()

            videoCapture = VideoCapture.withOutput(recorder)

            // Select back camera by default
            val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

            try {
                // Unbind previous use cases
                cameraProvider.unbindAll()

                // Bind use cases to camera
                camera = cameraProvider.bindToLifecycle(
                    this,
                    cameraSelector,
                    preview,
                    videoCapture
                )

            } catch (exc: Exception) {
                exc.printStackTrace()
            }

        }, ContextCompat.getMainExecutor(this))
    }

    private fun setupWAVEStreaming() {
        val config = WAVEStreamerConfig(
            streamKey = "your-stream-key-here",
            serverURL = "rtmp://ingest.wave.stream/live",
            videoBitrate = 6000000, // 6 Mbps for 1080p
            audioBitrate = 128000,  // 128 Kbps
            videoWidth = 1920,
            videoHeight = 1080,
            fps = 30
        )

        waveStreamer = WAVEStreamer(this, config)
        waveStreamer?.setListener(this)
    }

    private fun startStreaming() {
        if (isStreaming) return

        videoCapture?.let { capture ->
            waveStreamer?.connect(capture)
            isStreaming = true
            btnStartStream.isEnabled = false
            btnStopStream.isEnabled = true
            Toast.makeText(this, "Streaming started", Toast.LENGTH_SHORT).show()
        }
    }

    private fun stopStreaming() {
        if (!isStreaming) return

        waveStreamer?.disconnect()
        isStreaming = false
        btnStartStream.isEnabled = true
        btnStopStream.isEnabled = false
        Toast.makeText(this, "Streaming stopped", Toast.LENGTH_SHORT).show()
    }

    private fun switchCamera() {
        // Implementation for switching between front and back camera
        val cameraProvider = ProcessCameraProvider.getInstance(this).get()
        val newCameraSelector = if (camera?.cameraInfo?.lensFacing == CameraSelector.LENS_FACING_BACK) {
            CameraSelector.DEFAULT_FRONT_CAMERA
        } else {
            CameraSelector.DEFAULT_BACK_CAMERA
        }

        try {
            cameraProvider.unbindAll()
            camera = cameraProvider.bindToLifecycle(
                this,
                newCameraSelector,
                preview,
                videoCapture
            )
        } catch (exc: Exception) {
            exc.printStackTrace()
        }
    }

    private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
        ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED
    }

    // MARK: - WAVE Streamer Listener
    override fun onStreamerConnected(streamer: WAVEStreamer) {
        runOnUiThread {
            Toast.makeText(this, "✅ Connected to WAVE", Toast.LENGTH_SHORT).show()
        }
    }

    override fun onStreamerDisconnected(streamer: WAVEStreamer, error: Throwable?) {
        runOnUiThread {
            Toast.makeText(
                this,
                "❌ Disconnected: ${error?.message ?: "Unknown error"}",
                Toast.LENGTH_SHORT
            ).show()
            isStreaming = false
            btnStartStream.isEnabled = true
            btnStopStream.isEnabled = false
        }
    }

    override fun onStreamerStatsUpdated(streamer: WAVEStreamer, stats: WAVEStreamStats) {
        runOnUiThread {
            tvBitrate.text = "Bitrate: ${stats.currentBitrate / 1000} Kbps"
            tvFPS.text = "FPS: ${stats.currentFPS}"
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        cameraExecutor.shutdown()
        waveStreamer?.disconnect()
    }

    companion object {
        private val REQUIRED_PERMISSIONS = arrayOf(
            Manifest.permission.CAMERA,
            Manifest.permission.RECORD_AUDIO
        )
    }
}

Step 3: Layout XML

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.camera.view.PreviewView
        android:id="@+id/viewFinder"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@+id/controlsLayout"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

    <LinearLayout
        android:id="@+id/statsLayout"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:padding="12dp"
        android:background="#80000000"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintEnd_toEndOf="parent">

        <TextView
            android:id="@+id/tvBitrate"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Bitrate: 0 Kbps"
            android:textColor="@android:color/white"
            android:layout_marginEnd="16dp" />

        <TextView
            android:id="@+id/tvFPS"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="FPS: 0"
            android:textColor="@android:color/white" />
    </LinearLayout>

    <LinearLayout
        android:id="@+id/controlsLayout"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:padding="16dp"
        android:gravity="center"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent">

        <Button
            android:id="@+id/btnStartStream"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="Start Streaming"
            android:layout_marginEnd="8dp"
            android:backgroundTint="#4CAF50" />

        <Button
            android:id="@+id/btnStopStream"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="Stop Streaming"
            android:enabled="false"
            android:layout_marginStart="8dp"
            android:layout_marginEnd="8dp"
            android:backgroundTint="#F44336" />

        <Button
            android:id="@+id/btnSwitchCamera"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="Switch Camera"
            android:layout_marginStart="8dp"
            android:backgroundTint="#2196F3" />
    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>
Examples - Code Samples & Implementation Guides | WAVE | WAVE