Nearby Connections for Android: Getting Started

[ad_1]

Devices may not always be connected to the internet. Despite that, Nearby Connections allows Android devices within close proximity to connect in a peer-to-peer fashion enabling the exchange of data. This allows use cases such as local multiplayer gaming, offline data transfers and controlling an Android TV using a phone or tablet.

Internally, Nearby Connections combines and abstracts features, such as Bluetooth and Wi-Fi, to create an easy-to-use API. Nearby Connections enables/disables these features as needed and restores the device to its previous state once the app isn’t using the API anymore. This allows you to focus on your specific domain without the worry of integrating complex networking code.

In this tutorial, you’ll learn:

  • What Advertisers and Discoverers are.
  • About advertising your phone for Nearby Connections.
  • How to establish a connection between an advertiser and a discoverer.
  • How to send and receive payloads.
Note: This tutorial assumes you have experience developing in Kotlin. If you’re unfamiliar with the language, read our Kotlin for Android tutorial first.

Getting Started

Throughout this tutorial, you’ll work with a TicTacToe game. In one device, a player will host the match; in another, a second player will connect to the host, and the game will start. The game will let each player know whose turn it is.

Use the Download Materials button at the top or bottom of this tutorial to download the starter project.

Although you could run the starter project using an emulator, later in the tutorial, you’ll need physical devices because, currently, Nearby Connections API requires physical devices to work.

Once downloaded, open the starter project in Android Studio 2021.2.1 or newer. Build and run, and you’ll see the following screen:

Home Screen

You’ll see that you can choose to either host a match or discover an existing one. However, it doesn’t actually do either of those things, you’re going to fix that.

Hosting Screen Discovering Screen

Review the project to familiarize yourself with the files:

  • HomeScreen.kt: Let’s you choose to host or discover a game.
  • WaitingScreen.kt: You’ll find the app’s screens after choosing to host or discover.
  • GameScreen.kt: This contains screens related to the game.
  • TicTacToe.kt: Models a TicTacToe game.
  • TicTacToeRouter.kt: This allows you to navigate between screens.
  • TicTacToeViewModel.kt: This orchestrates the interactions between the screens, the game, and later, with the Nearby Connections client.

Setting Up Dependencies and Permissions

To use the Nearby Connections API, you must first add a dependency. Open your app’s build.gradle file and add the following dependency:


implementation 'com.google.android.gms:play-services-nearby:18.3.0'

Sync your project so Android Studio can download the dependency.

Now open your AndroidManifest.xml and add the following permissions:


<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />

Some of these are dangerous permissions, therefore you’ll need to request user consent. Open MainActivity and assign REQUIRED_PERMISSIONS inside the companion object as follows:


val REQUIRED_PERMISSIONS =
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
    arrayOf(
      Manifest.permission.BLUETOOTH_SCAN,
      Manifest.permission.BLUETOOTH_ADVERTISE,
      Manifest.permission.BLUETOOTH_CONNECT,
      Manifest.permission.ACCESS_FINE_LOCATION
    )
  } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
  } else {
    arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION)
  }

You’ll need these imports:


import android.Manifest
import android.os.Build

The activity already has the code to request these permissions from the user.

Now that you’ve added the needed dependency, you can start using the Nearby Connections client that you’ll look at in the next section.

Getting the Connection Client

To get the client for Nearby Connections, you can simply call:


Nearby.getConnectionsClient(context)

Because you’ll use it inside the ViewModel, open TicTacToeViewModel and update the constructor with the following:


class TicTacToeViewModel(private val connectionsClient: ConnectionsClient)

Next, open TicTacToeViewModelFactory and update it like this:


class TicTacToeViewModelFactory(
  private val connectionsClient: ConnectionsClient
) : ViewModelProvider.Factory {
  override fun <T : ViewModel> create(modelClass: Class<T>): T {
    if (modelClass.isAssignableFrom(TicTacToeViewModel::class.java)) {
      @Suppress("UNCHECKED_CAST")
      return TicTacToeViewModel(connectionsClient) as T
  ...

For both files, you’ll need to import the following:


import com.google.android.gms.nearby.connection.ConnectionsClient

Finally, open MainActivity and modify the viewModel property like this:


private val viewModel: TicTacToeViewModel by viewModels {
  TicTacToeViewModelFactory(Nearby.getConnectionsClient(applicationContext))
}

Make sure to import the following:


import com.google.android.gms.nearby.Nearby

Now your ViewModel and associated factory classes have the ConnectionsClient instance provided. You’re ready to start using it and establish a connection!

Choosing a Strategy

Now you’ll choose a connection strategy based on how the devices need to connect.

Check the following table to understand the alternatives:

Strategy Request N outgoing connections Receive M incoming connections
P2P_CLUSTER N=MANY M=MANY
P2P_STAR N=1 M=MANY
P2P_POINT_TO_POINT N=1 M=1

You would use P2P_CLUSTER when a device can both request outgoing connections to other devices and receive incoming connections from other devices. If you need a star-shaped topology where there’s a central hosting device, and the rest will connect to it, you would use P2P_STAR.

In this case, because you’ll connect between two devices, you’ll use P2P_POINT_TO_POINT. Open TicTacToeViewModel and add the following constant:


private companion object {
  ...
  val STRATEGY = Strategy.P2P_POINT_TO_POINT
}

You’ll need to import:


import com.google.android.gms.nearby.connection.Strategy

It’s important to note that both Advertiser and Discoverer, which you’ll learn about later, have to use the same strategy.

To set the strategy, update startHosting() with the following code:


fun startHosting() {
  Log.d(TAG, "Start advertising...")
  TicTacToeRouter.navigateTo(Screen.Hosting)
  val advertisingOptions = AdvertisingOptions.Builder().setStrategy(STRATEGY).build()
}

This begins the advertising code and sets the strategy to P2P type that you defined earlier. You’ll get back an options variable that you’ll use later to set up the advertising connection.

Also, update startDiscovering() with the following:


fun startDiscovering() {
  Log.d(TAG, "Start discovering...")
  TicTacToeRouter.navigateTo(Screen.Discovering)
  val discoveryOptions = DiscoveryOptions.Builder().setStrategy(STRATEGY).build()
}

Similar to the advertising code, this sets up the discovery options to use the same P2P strategy.

In the following sections, you’ll learn what Advertisers and Discoverers are and how they exchange data.

Preparing Your Devices

To start exchanging data between two devices, one of them, the Advertiser, has to advertise itself so that the other device, the Discoverer, can request a connection.

Advertising

To start advertising, update startHosting() with the following:


fun startHosting() {
  Log.d(TAG, "Start advertising...")
  TicTacToeRouter.navigateTo(Screen.Hosting)
  val advertisingOptions = AdvertisingOptions.Builder().setStrategy(STRATEGY).build()

  // 1
  connectionsClient.startAdvertising(
    localUsername, // 2
    BuildConfig.APPLICATION_ID, // 3
    connectionLifecycleCallback, // 4
    advertisingOptions // 5
  ).addOnSuccessListener {
    // 6
    Log.d(TAG, "Advertising...")
    localPlayer = 1
    opponentPlayer = 2
  }.addOnFailureListener {
    // 7
    Log.d(TAG, "Unable to start advertising")
    TicTacToeRouter.navigateTo(Screen.Home)
  }
}

Let’s see what’s going on here:

  1. Call startAdvertising() on the client.
  2. You need to pass a local endpoint name.
  3. You set BuildConfig.APPLICATION_ID for service ID because you want a Discoverer to find you with this unique id.
  4. Calls to the connectionLifecycleCallback methods occur when establishing a connection with a Discoverer.
  5. You pass the options containing the strategy previously configured.
  6. Once the client successfully starts advertising, you set the local player as player 1, and the opponent will be player 2.
  7. If the client fails to advertise, it logs to the console and returns to the home screen.

These are the imports you need:


import com.google.android.gms.nearby.connection.AdvertisingOptions
import com.yourcompany.android.tictactoe.BuildConfig

Add a property named connectionLifecycleCallback with the following content:


private val connectionLifecycleCallback = object : ConnectionLifecycleCallback() {
  override fun onConnectionInitiated(endpointId: String, info: ConnectionInfo) {
    Log.d(TAG, "onConnectionInitiated")
  }

  override fun onConnectionResult(endpointId: String, resolution: ConnectionResolution) {
    Log.d(TAG, "onConnectionResult")

    when (resolution.status.statusCode) {
      ConnectionsStatusCodes.STATUS_OK -> {
        Log.d(TAG, "ConnectionsStatusCodes.STATUS_OK")
      }
      ConnectionsStatusCodes.STATUS_CONNECTION_REJECTED -> {
        Log.d(TAG, "ConnectionsStatusCodes.STATUS_CONNECTION_REJECTED")
      }
      ConnectionsStatusCodes.STATUS_ERROR -> {
        Log.d(TAG, "ConnectionsStatusCodes.STATUS_ERROR")
      }
      else -> {
        Log.d(TAG, "Unknown status code ${resolution.status.statusCode}")
      }
    }
  }

  override fun onDisconnected(endpointId: String) {
    Log.d(TAG, "onDisconnected")
  }
}

When a Discoverer requests a connection, the Advertiser’s ConnectionLifecycleCallback.onConnectionInitiated() will fire. In a later section, you’ll add code to this method callback. When there’s a connection change that occurs, the ConnectionLifecycleCallback.onConnectionResult() fires. You’ll handle three specific connection status types: OK, rejected and error. There’s also a catch-all for the any other unknown status code that is returned.

You’ll need the following imports:


import com.google.android.gms.nearby.connection.ConnectionLifecycleCallback
import com.google.android.gms.nearby.connection.ConnectionInfo
import com.google.android.gms.nearby.connection.ConnectionResolution
import com.google.android.gms.nearby.connection.ConnectionsStatusCodes

Discovering

The Discoverer is the device that wants to discover an Advertiser to request a connection.

To start discovering, update the following method:


fun startDiscovering() {
  Log.d(TAG, "Start discovering...")
  TicTacToeRouter.navigateTo(Screen.Discovering)
  val discoveryOptions = DiscoveryOptions.Builder().setStrategy(STRATEGY).build()

  // 1
  connectionsClient.startDiscovery(
    BuildConfig.APPLICATION_ID, // 2
    endpointDiscoveryCallback, // 3
    discoveryOptions // 4
  ).addOnSuccessListener {
    // 5
    Log.d(TAG, "Discovering...")
    localPlayer = 2
    opponentPlayer = 1
  }.addOnFailureListener {
    // 6
    Log.d(TAG, "Unable to start discovering")
    TicTacToeRouter.navigateTo(Screen.Home)
  }
}

This is what’s going on:

  1. You call startDiscovery() on the client.
  2. You set BuildConfig.APPLICATION_ID for service ID because you want to find an Advertiser this unique ID.
  3. Calls to the endpointDiscoveryCallback methods occur when establishing a connection with an Advertiser.
  4. You pass the options containing the strategy previously configured.
  5. Once the client successfully starts discovering you set the local player as player 2, the opponent will be player 1.
  6. If the client fails to discover, it logs to the console and returns to the home screen.

Add this import:


import com.google.android.gms.nearby.connection.DiscoveryOptions

Add a property named endpointDiscoveryCallback with the following content:


private val endpointDiscoveryCallback = object : EndpointDiscoveryCallback() {
  override fun onEndpointFound(endpointId: String, info: DiscoveredEndpointInfo) {
    Log.d(TAG, "onEndpointFound")
  }

  override fun onEndpointLost(endpointId: String) {
    Log.d(TAG, "onEndpointLost")
  }
}

You also need to import these:


import com.google.android.gms.nearby.connection.EndpointDiscoveryCallback
import com.google.android.gms.nearby.connection.DiscoveredEndpointInfo

When a Discoverer finds an Advertiser, the Discoverer’s EndpointDiscoveryCallback.onEndpointFound() will be called. You’ll add code to this method callback in the following section.

Establishing a Connection

After finding an Advertiser, the Discoverer has to request a connection. Update EndpointDiscoveryCallback.onEndpointFound() with the following code:


override fun onEndpointFound(endpointId: String, info: DiscoveredEndpointInfo) {
  Log.d(TAG, "onEndpointFound")

  Log.d(TAG, "Requesting connection...")
  // 1
  connectionsClient.requestConnection(
    localUsername, // 2
    endpointId, // 3
    connectionLifecycleCallback // 4
  ).addOnSuccessListener {
    // 5
    Log.d(TAG, "Successfully requested a connection")
  }.addOnFailureListener {
    // 6
    Log.d(TAG, "Failed to request the connection")
  }
}

Let’s review step by step:

  1. You call requestConnection() on the client.
  2. You need to pass a local endpoint name.
  3. Pass the endpointId you’ve just found.
  4. Calls to the connectionLifecycleCallback methods occur later when the connection initiates with the Advertiser.
  5. Once the client successfully requests a connection, it logs to the console.
  6. If the client fails, it logs to the console.

The Advertiser and Discoverer need to accept the connection, both will get notified via ConnectionLifecycleCallback.onConnectionInitiated(), so update the code with this:


override fun onConnectionInitiated(endpointId: String, info: ConnectionInfo) {
  Log.d(TAG, "onConnectionInitiated")

  Log.d(TAG, "Accepting connection...")
  connectionsClient.acceptConnection(endpointId, payloadCallback)
}
Note: Here, you’re immediately accepting the connection; however, you could use an authentication mechanism. For example, instead of just accepting, you could pop a dialog showing a token on both sides, each side can accept or reject the connection. More info here.

You need to provide a payloadCallback, which contains methods that’ll execute later when the devices exchange data. For now, just create a property with the following content:


private val payloadCallback: PayloadCallback = object : PayloadCallback() {
  override fun onPayloadReceived(endpointId: String, payload: Payload) {
    Log.d(TAG, "onPayloadReceived")
  }

  override fun onPayloadTransferUpdate(endpointId: String, update: PayloadTransferUpdate) {
    Log.d(TAG, "onPayloadTransferUpdate")
  }
}

You need to import these:


import com.google.android.gms.nearby.connection.PayloadCallback
import com.google.android.gms.nearby.connection.Payload
import com.google.android.gms.nearby.connection.PayloadTransferUpdate

After accepting, ConnectionLifecycleCallback.onConnectionResult() notifies each side of the new connection. Update its code to the following:


override fun onConnectionResult(endpointId: String, resolution: ConnectionResolution) {
  Log.d(TAG, "onConnectionResult")

  when (resolution.status.statusCode) {
    ConnectionsStatusCodes.STATUS_OK -> {
      Log.d(TAG, "ConnectionsStatusCodes.STATUS_OK")

      opponentEndpointId = endpointId
      Log.d(TAG, "opponentEndpointId: $opponentEndpointId")
      newGame()
      TicTacToeRouter.navigateTo(Screen.Game)
    }
...

If the status code is STATUS_OK, you save the opponentEndpointId to send payloads later. Now you can navigate to the game screen to start playing!

Build and run the application on two physical devices, click Host on one of them and Discover on the other one. After a few seconds, you should see the game board on each device:

Game - Player 1 Game - Player 2

Using a Payload

Sending

You need to send the player position to the other device whenever you make a move. Modify sendPosition() with the following code:


private fun sendPosition(position: Pair<Int, Int>) {
  Log.d(TAG, "Sending [${position.first},${position.second}] to $opponentEndpointId")
  connectionsClient.sendPayload(
    opponentEndpointId,
    position.toPayLoad()
  )
}

Here, you’re using the opponentEndpointId you previously saved to send the position. You need to convert the position, which is a Pair to a Payload object. To do that, add the following extension to the end of the file:


fun Pair<Int, Int>.toPayLoad() = Payload.fromBytes("$first,$second".toByteArray(UTF_8))

Import this:


import kotlin.text.Charsets.UTF_8

You’ve now converted the pair into a comma separated string which is converted to a ByteArray that is finally used to create a Payload.

Note: If you need to send bigger payloads, check the documentation for more types.

Receiving

To receive this payload, update the PayloadCallback.onPayloadReceived() with this:


override fun onPayloadReceived(endpointId: String, payload: Payload) {
  Log.d(TAG, "onPayloadReceived")

  // 1
  if (payload.type == Payload.Type.BYTES) {
    // 2
    val position = payload.toPosition()
    Log.d(TAG, "Received [${position.first},${position.second}] from $endpointId")
    // 3
    play(opponentPlayer, position)
  }
}

This is what’s going on:

  1. You check if the payload type is BYTES.
  2. You convert back the Payload to a position Pair object.
  3. Instruct the game that the opponent has played this position.

Add the extension to convert a Payload to a Pair position to the end of the file:


fun Payload.toPosition(): Pair<Int, Int> {
  val positionStr = String(asBytes()!!, UTF_8)
  val positionArray = positionStr.split(",")
  return positionArray[0].toInt() to positionArray[1].toInt()
}

Build and run the application on two devices and start playing!
Gameplay

Clearing Connections

When the Advertiser and Discoverer have found each other, you should stop advertising and discovering. Add the following code to the ConnectionLifecycleCallback.onConnectionResult():


override fun onConnectionResult(endpointId: String, resolution: ConnectionResolution) {
  Log.d(TAG, "onConnectionResult")

  when (resolution.status.statusCode) {
    ConnectionsStatusCodes.STATUS_OK -> {
      Log.d(TAG, "ConnectionsStatusCodes.STATUS_OK")

      connectionsClient.stopAdvertising()
      connectionsClient.stopDiscovery()
...

You need to disconnect the client whenever one player decides to exit the game. Add the following to ensure the client is stopped whenever the ViewModel is destroyed:


override fun onCleared() {
  stopClient()
  super.onCleared()
}

Update goToHome() as follows:


fun goToHome() {
  stopClient()
  TicTacToeRouter.navigateTo(Screen.Home)
}

Add the code for stopClient() as follows:


private fun stopClient() {
  Log.d(TAG, "Stop advertising, discovering, all endpoints")
  connectionsClient.stopAdvertising()
  connectionsClient.stopDiscovery()
  connectionsClient.stopAllEndpoints()
  localPlayer = 0
  opponentPlayer = 0
  opponentEndpointId = ""
}

Here you’re also calling stopAllEndpoints() which will ensure the disconnection of the client.

If you want to disconnect from a specific endpoint you can use disconnectFromEndpoint(endpointId).

Finally, whenever an Advertiser or Discoverer executes stopAllEndpoints() (or disconnectFromEndpoint(endpointId)) the counterpart will be notified via ConnectionLifecycleCallback.onDisconnected(), so update it as follows:


override fun onDisconnected(endpointId: String) {
  Log.d(TAG, "onDisconnected")
  goToHome()
}

Build and run the app on both devices. Start a new game and press the back button on any device. You’ll notice that the game ends on both devices and takes you back to the home screen.

Congratulations! You’ve just learned the basics of Android’s Nearby Connections API.

Where to Go From Here?

You can download the final version of the project using the Download Materials button at the top or bottom of this tutorial.

As a challenge, you can let more players connect to the host and use a bigger board, the TicTacToe model already allows that. Here are a few tips that will help you:

  • You’ll need to choose another connection strategy.
  • Let the host decide the board size.
  • Because the host doesn’t know how many opponents will connect beforehand, you’ll need to set the local and opponent player numbers when the game starts.
  • Instead of immediately starting the game whenever an opponent joins, wait for more than one to join and let the host decide when to start.
  • You’ll need a new payload to signal to all the opponents that the host has started a new game.

You can find the solution in the materials.

Here are some great references to learn more about the subject:

  • You can find the official documentation here.
  • Here you’ll find the API reference.
  • If you liked this, you might want to build a ‘rock, paper and scissors’ multiplayer game, just follow along this codelab.
  • For other Nearby use cases, check this blog series.

Feel free to share your feedback and findings or ask any questions in the comments below or in the forums. We hope you enjoyed this tutorial!

[ad_2]

Source link