Multipeer Networking and You

The goal of this prototype is to get two devices communicating via multipeer networking. If Apple's done their job right (Framework development + documentation) it should be easy, right?

Once you wrap your head around how the whole system is implemented then yes, it actually is fairly easy to get multiple devices communicating.

First off, let's lay our our user story/use case:

Two devices are connected via multipeer, each one has a button (which is activated when a connection is secured) which will increment the counter on the other device.  

Apple's Multipeer Framework page is a bit light but it does lay out the general pieces that we need.

  • Session objects handle the communications.
  • Advertiser objects tells others they're available.
  • Browser objects browse for advertised devices.
  • Peer ID's allow for unique identification.

One of the interesting things about the multipeer networks is that unlike a traditional client/server network each node could be advertising and discovering at the same time.

This lead me to one concept that I had to wrap my head around before I could really grok this concept: It's the browsing device that invites the advertiser to a session.

Initially, I was thinking of the advertiser as more of a server and the browser as a client. Wrong, wrong, wrong. Thinking like that made it even more difficult for me to really understand what was going on under the hood.

Let's describe the workflow:
- You declare a service identifier (just an NSString constant) so your app knows what to advertise/browse. - The app starts both advertising and browsing for that service at the same time. - If the app finds a peer, create a session and invite that peer to it. - If the app receives an invitation to a session, accept it. - Stop advertising or discovering if either of these things happens, restart is the peer is lost. - Start sharing that sweet, sweet data.

As a warning, it's what my app's implementing is an exceptionally simplified version of what this framework can do. It's assuming that there are only two peers that always want to connect to each other.

  • The app should be alerting users when you receive a peering invitation, allow them to decline.
  • You probably keep a list of found peers stored so that you can act when the peer is lost.

In true Apple Framework fashion, there's the 'Simple to implement but boring looking' Apple standard and the 'Handle everything yourself with base classes' approach. A lot of the examples out there is using the Apple way, which is to use MCAdvertiserAssistant to handle the advertising and MCBrowserViewController to browse for available peers.

The issue with each of these is that I was hoping to implement more of a SpaceTeam-esque method of matchmaking so I realized that I would have to roll my own.

Code - ASWASWLobbyInitViewController.m

Everything is contained inside one view controller.

Imports + Interface + Implementation stuff

@import MultipeerConnectivity;

static NSString * const HotColdServiceType = @"hotcold-service";

@interface ASWLobbyInitViewController () <MCNearbyServiceAdvertiserDelegate, MCSessionDelegate, MCNearbyServiceBrowserDelegate>

@property IBOutlet UILabel *myDeviceName;
@property IBOutlet UILabel *theirDeviceName;
@property IBOutlet UILabel *theirButtonCounter;
@property IBOutlet UIButton *incrementCounterButton;

@property MCSession *session;
@property MCNearbyServiceAdvertiser *advertiser;
@property MCNearbyServiceBrowser *browser;
@property MCPeerID *localPeerID;
@property NSMutableArray *connectedPeers;

@end


@implementation ASWLobbyInitViewController {
    NSNumber *buttonCounter;
}

HotColdServiceType is simply a string we'll be using later as the service identifier.

We declare ourselves to be able to handle MCNearbyServiceAdvertiserDelegate, MCSessionDelegate and MCNearbyServiceBrowserDelegatedelegate messages.

We have our outlets for our our labels and a few private properties to keep instances of our session, advertiser, browser, peerID and an array of connected peers.

One private variable is an NSNumber of the button counter. It's the value we'll be sending across.

viewDidLoad

- (void)viewDidLoad {
    [super viewDidLoad];

    self.localPeerID = [[MCPeerID alloc] initWithDisplayName:[[UIDevice currentDevice] name]];

    self.myDeviceName.text = @"";
    self.theirDeviceName.text = @"";
    self.theirButtonCounter.text = @"0 times";
    self.incrementCounterButton.enabled = NO;
    buttonCounter = [[NSNumber alloc] initWithInt:0];
    self.connectedPeers = [[NSMutableArray alloc] init];

    // Browser for others
    self.browser = [[MCNearbyServiceBrowser alloc] initWithPeer:self.localPeerID
                                                    serviceType:HotColdServiceType];
    self.browser.delegate = self;

    // Advertise to others
    self.advertiser = [[MCNearbyServiceAdvertiser alloc] initWithPeer:self.localPeerID
                                                        discoveryInfo:nil
                                                          serviceType:HotColdServiceType];
    self.advertiser.delegate = self;

}

Right now, I'm using the device name as the device's Peer ID. In the future I think I'll be generating something a bit more fun.

After that, we set some default values and initialize the browser and the advertiser.

We're ready to get this show on the road.

viewWillAppear

-(void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];

    self.myDeviceName.text = self.localPeerID.displayName;

    [self.browser startBrowsingForPeers];
    [self.advertiser startAdvertisingPeer];
}

We set our display name on the label and start both browsing and advertising. From here things get event based.

MCNearbyServiceAdvertiserDelegates

-(void)advertiser:(MCNearbyServiceAdvertiser *)advertiser didReceiveInvitationFromPeer:(MCPeerID *)peerID
      withContext:(NSData *)context
invitationHandler:(void (^)(BOOL, MCSession *))invitationHandler {  
    // Creates a session anytime someone connects using the service.
    NSLog(@"Received Invitation from %@", peerID.displayName);

    if (!self.session) {
        self.session = [[MCSession alloc] initWithPeer:self.localPeerID
                                      securityIdentity:nil
                                  encryptionPreference:MCEncryptionNone];
        self.session.delegate = self;
        invitationHandler(YES, self.session);

        [self.advertiser stopAdvertisingPeer];
        [self.browser stopBrowsingForPeers];
    }

}

When a browsing peer sends you an invitation, this method will fire.

Locking it down with an if (!self.session) means that we won't respond if we've already connected to a session. Since we're purposefully locking this down to 2 peers, we're good.

When we initialize session property we pass it our local peer id this caused me a great deal of confusion. I assumed that you create your session with the peerID value being passed in by your peer.

The docs are actually pretty clear about this:

Create an MCPeerID object that represents the local peer, and use it to initialize the session object.  

After setting the delegate for the session to self, we then have to call the handler block with a true boolean and the session object as parameters and then stop both advertising and browsing.

MCNearbyServiceBrowserDelegates

-(void)browser:(MCNearbyServiceBrowser *)browser foundPeer:(MCPeerID *)peerID withDiscoveryInfo:(NSDictionary *)info {
    NSLog(@"FOUND PEER %@", peerID.displayName);

    if (!self.session){
        self.session = [[MCSession alloc] initWithPeer:self.localPeerID
                                      securityIdentity:nil
                                  encryptionPreference:MCEncryptionNone];
        self.session.delegate = self;

        [browser invitePeer:peerID toSession:self.session withContext:nil timeout:5];

        [self.advertiser stopAdvertisingPeer];
        [self.browser stopBrowsingForPeers];
    }
}

-(void)browser:(MCNearbyServiceBrowser *)browser lostPeer:(MCPeerID *)peerID {
    NSLog(@"LOST PEER %@", peerID);

    // Kill the session
    self.session = nil;

    // Start looking again.
    [self.advertiser startAdvertisingPeer];
    [self.browser startBrowsingForPeers];
}

On the browsing side, we're simply looking for any advertised services, inviting them to a session (Notice that on this end we're also initializing it with our local session ID) setting our delegate, and stopping both advertising and browsing once one is found.

Now, if we lose the peer after finding it, we're simply restarting the advertising and browsing services.

#pragma mark - MCSessionDelegate

-(void)session:(MCSession *)session didReceiveData:(NSData *)data fromPeer:(MCPeerID *)peerID {
    NSString *message = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
    NSLog(@"Message received: %@", message);
    dispatch_async(dispatch_get_main_queue(),^ {
        self.theirButtonCounter.text = message;
    });
}

-(void)session:(MCSession *)session didReceiveStream:(NSInputStream *)stream
      withName:(NSString *)streamName
      fromPeer:(MCPeerID *)peerID {

}

-(void)session:(MCSession *)session didStartReceivingResourceWithName:(NSString *)resourceName
      fromPeer:(MCPeerID *)peerID
  withProgress:(NSProgress *)progress {
}

-(void)session:(MCSession *)session didFinishReceivingResourceWithName:(NSString *)resourceName
      fromPeer:(MCPeerID *)peerID
         atURL:(NSURL *)localURL
     withError:(NSError *)error {
}

-(void)session:(MCSession *)session peer:(MCPeerID *)peerID didChangeState:(MCSessionState)state {
    NSArray *stateStringRepresentation = @[@"MCSessionStateNotConnected", @"MCSessionStateConnecting", @"MCSessionStateConnected" ];

    NSLog(@"SESSION STATE CHANGE: %@", stateStringRepresentation[state] );

    if (state == MCSessionStateConnected) {
        NSLog(@"Connected to %@", peerID.displayName);

        [self.connectedPeers addObject:peerID];

        dispatch_async(dispatch_get_main_queue(),^ {
            self.theirDeviceName.text = peerID.displayName;
            self.incrementCounterButton.enabled = YES;
        });

        [self.view setNeedsDisplay];
    }
}

The two important delegate events are didReceiveData and didChangeState.

didReceiveData is simply called when some data is returned from your peer, It's sent as NSData, so you have to change it back to a NSString.

You also see that I'm setting the text on the label in the main thread using the main queue. If you want the UI to be updated immediately you need to make sure that you call those on the main thread.

Once a session has been created, you need to watch for MCSessionStateConnected to be returned in the didChangeState method. This means that you're good to go to begin sending information across.

I'm enabling the button and setting the peer label on the main queue once we're connected.

'Press This' Button Action

- (IBAction)incrementCounterAndSend:(UIButton *)sender {
    buttonCounter = @([buttonCounter intValue] + 1);
    NSString *message = [NSString stringWithFormat:@"%d times", [buttonCounter integerValue]];
    NSData *data = [message dataUsingEncoding:NSUTF8StringEncoding];
    NSError *error = nil;
    if (![self.session sendData:data
                        toPeers:self.connectedPeers
                       withMode:MCSessionSendDataReliable
                          error:&error]) {
        NSLog(@"[Error] %@", error);
    }
}

Pretty simple stuff. Just incrementing the button integer by one, creating a 'X times' string and sending that across the wire.

That's it! You end up with a 2 peer system as soon as you arrive on the view controller. I haven't tested it with more than 2 though.

Now I think I'll rewrite this in Swift.