Share data across devices without internet access. iOS Multipeer Connectivity

Boris Bugor
6 min readAug 5, 2023
Photo by James Lewis on Unsplash

Multipeer Connectivity is an alternative to the common data exchange format. Instead of exchanging data via Wi-Fi or Cellular Network through an intermediate broker, which is usually a backend server, Multipeer Connectivity provides the ability to exchange information between multiply nearby devices without intermediaries.

The technology itself for the iPhone and iPad is based on Wi-Fi and Bluetooth, while for the MacBook and Apple TV, a similar technology was organized via Wi-Fi and Ethernet.

From here, the pros and cons of this technology immediately follow. The advantages include decentralization and, accordingly, the ability to exchange information without intermediaries.

Disadvantages — sharing is limited to Wi-Fi and Bluetooth coverage for iPhone and iPad, or Wi-Fi and Ethernet for MacBook and Apple TV. In other words, the exchange of information can be carried out in the immediate vicinity of the devices.

Integration of Multipeer Connectivity is not complicated and consists of the following steps:

  1. Project preset
  2. Setup visibility for other devices
  3. Scan for visible devices in range
  4. Creating a pair of devices for data exchange
  5. Data exchange

Let’s take a closer look at each of the above steps.

1. Project preset

At this stage, the project must be prepared for the implementation of Multipeer Connectivity. To do this, you need to obtain additional permissions from the user to be able to scan:

  • to do this, add Privacy — Local Network Usage Description to the Info.plist file with a description of the purpose of use;
  • In addition, for the possibility of information exchange, Info.plist also needs to be supplemented with the following lines:
<key>NSBonjourServices</key>
<array>
<string>_nearby-devices._tcp</string>
<string>_nearby-devices._upd</string>
</array>

It is important to note that the nearby-devices substring is used as an example in this context. In your project, this key must meet the following requirements:

1–15 characters long and valid characters include ASCII lowercase letters, numbers, and the hyphen, containing at least one letter and no adjacent hyphens.

You can read more about the requirements here.

As for communication protocols, the example uses tcp and upd (more reliable and less reliable one). If you do not know which protocol you need, you should enter both.

2. Setup visibility for other devices

Organization of device visibility for multi-peer connection is implemented by MCNearbyServiceAdvertiser. Let’s create a class that will be responsible for detecting, displaying and sharing information between devices.

import MultipeerConnectivity
import SwiftUI

class DeviceFinderViewModel: ObservableObject {
private let advertiser: MCNearbyServiceAdvertiser
private let session: MCSession
private let serviceType = "nearby-devices"

@Published var isAdvertised: Bool = false {
didSet {
isAdvertised ? advertiser.startAdvertisingPeer() : advertiser.stopAdvertisingPeer()
}
}

init() {
let peer = MCPeerID(displayName: UIDevice.current.name)
session = MCSession(peer: peer)

advertiser = MCNearbyServiceAdvertiser(
peer: peer,
discoveryInfo: nil,
serviceType: serviceType
)
}
}

The core of the multipeer is a MCSession, which will allow you to connect and exchange data between devices.

The serviceType is the key mentioned above, which was added to the Info.plist file along with the exchange protocols.

The isAdvertised property will allow you to switch the visibility of the device using Toggle.

3. Scan for visible devices in range

Device visibility scanning for a multi-peer connection is performed by MCNearbyServiceBrowser:

class DeviceFinderViewModel: NSObject, ObservableObject {
...
private let browser: MCNearbyServiceBrowser
...

@Published var peers: [PeerDevice] = []
...

override init() {
...

browser = MCNearbyServiceBrowser(peer: peer, serviceType: serviceType)

super.init()

browser.delegate = self
}

func startBrowsing() {
browser.startBrowsingForPeers()
}

func finishBrowsing() {
browser.stopBrowsingForPeers()
}
}

extension DeviceFinderViewModel: MCNearbyServiceBrowserDelegate {
func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) {
peers.append(PeerDevice(peerId: peerID))
}

func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
peers.removeAll(where: { $0.peerId == peerID })
}
}

struct PeerDevice: Identifiable, Hashable {
let id = UUID()
let peerId: MCPeerID
}

A list of all visible devices will be stored in peers. TheMCNearbyServiceBrowser delegate methods will add or remove an MCPeerID when a peer is found or lost.
The startBrowsing and finishBrowsing methods will be used to start discovering visible devices when the screen appears, or stop searching after the screen disappears.

The following View will be used as the UI:

struct ContentView: View {
@StateObject var model = DeviceFinderViewModel()

var body: some View {
NavigationStack {
List(model.peers) { peer in
HStack {
Image(systemName: "iphone.gen1")
.imageScale(.large)
.foregroundColor(.accentColor)

Text(peer.peerId.displayName)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 5)
}
.onAppear {
model.startBrowsing()
}
.onDisappear {
model.finishBrowsing()
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Toggle("Press to be discoverable", isOn: $model.isAdvertised)
.toggleStyle(.switch)
}
}
}
}
}

Device visibility will be enabled/disabled by the Toggle.
As a result, at this stage, the detection and display of devices should work correctly.

4. Creating a pair of devices for data exchange

The delegate method MCNearbyServiceAdvertiser didReceiveInvitationFromPeer is responsible for sending an invitation between a pair of devices. Both of them must be capable of handling this request.


class DeviceFinderViewModel: NSObject, ObservableObject {
...

@Published var permissionRequest: PermitionRequest?

@Published var selectedPeer: PeerDevice? {
didSet {
connect()
}
}

...

@Published var joinedPeer: [PeerDevice] = []

override init() {
...

advertiser.delegate = self
}

func startBrowsing() {
browser.startBrowsingForPeers()
}

func finishBrowsing() {
browser.stopBrowsingForPeers()
}

func show(peerId: MCPeerID) {
guard let first = peers.first(where: { $0.peerId == peerId }) else {
return
}

joinedPeer.append(first)
}

private func connect() {
guard let selectedPeer else {
return
}

if session.connectedPeers.contains(selectedPeer.peerId) {
joinedPeer.append(selectedPeer)
} else {
browser.invitePeer(selectedPeer.peerId, to: session, withContext: nil, timeout: 60)
}
}
}

extension DeviceFinderViewModel: MCNearbyServiceAdvertiserDelegate {
func advertiser(
_ advertiser: MCNearbyServiceAdvertiser,
didReceiveInvitationFromPeer peerID: MCPeerID,
withContext context: Data?,
invitationHandler: @escaping (Bool, MCSession?) -> Void
) {
permissionRequest = PermitionRequest(
peerId: peerID,
onRequest: { [weak self] permission in
invitationHandler(permission, permission ? self?.session : nil)
}
)
}
}

struct PermitionRequest: Identifiable {
let id = UUID()
let peerId: MCPeerID
let onRequest: (Bool) -> Void
}

When the selectedPeer is set, the connect method fires. If this peer is in the list of existing peers, it will be added to the joinedPeer array. In the future, this property will be processed by the UI.

In the absence of this peer in the session, the browser will invite this device to create a pair.

After that, the didReceiveInvitationFromPeer method will be processed for the invited device. In our case, after the start of didReceiveInvitationFromPeer, a permissionRequest is created with a delayed callback, which will be shown as an alert on the invited device:

struct ContentView: View {
@StateObject var model = DeviceFinderViewModel()

var body: some View {
NavigationStack {
...
.alert(item: $model.permissionRequest, content: { request in
Alert(
title: Text("Do you want to join \(request.peerId.displayName)"),
primaryButton: .default(Text("Yes"), action: {
request.onRequest(true)
model.show(peerId: request.peerId)
}),
secondaryButton: .cancel(Text("No"), action: {
request.onRequest(false)
})
)
})
...
}
}
}

In the case of an approve, didReceiveInvitationFromPeer will return the device sending the invitation, permission and session if permission was succeed.
As a result, after successfully accepting the invitation, a pair will be created:

5. Data exchange

After creating a pair, MCSession is responsible for the exchange of data:

import MultipeerConnectivity
import Combine

class DeviceFinderViewModel: NSObject, ObservableObject {
...

@Published var messages: [String] = []
let messagePublisher = PassthroughSubject<String, Never>()
var subscriptions = Set<AnyCancellable>()

func send(string: String) {
guard let data = string.data(using: .utf8) else {
return
}

try? session.send(data, toPeers: [joinedPeer.last!.peerId], with: .reliable)

messagePublisher.send(string)
}

override init() {


...

session.delegate = self

messagePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] in
self?.messages.append($0)
}
.store(in: &subscriptions)
}
}


extension DeviceFinderViewModel: MCSessionDelegate {
func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
guard let last = joinedPeer.last, last.peerId == peerID, let message = String(data: data, encoding: .utf8) else {
return
}

messagePublisher.send(message)
}
}

Method func send(_ data: Data, toPeers peerIDs: [MCPeerID], with mode: MCSessionSendDataMode) throws helps send data between peers.

The delegate method func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) is triggered on the device that received the message.

Also, an intermediate publisher messagePublisher is used to receive messages, since the MCSession delegate methods fire in the DispatchQueue global().

More details on the Multipeer Connectivity integration prototype can be found in this repository. As an example, this technology has provided the ability to exchange messages between devices.

Don’t hesitate to contact me on Twitter if you have any questions. Also, you can always buy me a coffee.

--

--

Boris Bugor

Senior iOS Developer | Co-founder of VideoEditor: Reels & Stories | Founder of Flow: Diary, Calendar, Gallery | #ObjC | #Swift | #UIKit | #SwiftUI