How to Share
The EmbedClient
sample module in the book’s sample project, along with the EmbedServer
module, demonstrate how to work with SurfaceControlViewHost
.
In the terminology being used in this chapter:
- The “server” is the app with the view hierarchy
- The “client” is the app that is displaying the UI from that view hierarchy
Client Setup
The first thing that the client needs is a SurfaceView
, which will be where the embedded UI will be rendered. EmbedClient
has an activity_main
layout resource that consists of a SurfaceView
and a Button
:
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="8dp"
tools:context=".MainActivity">
<SurfaceView
android:id="@+id/surface"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/connect"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/connect"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/connect"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Android 11 adds a getHostToken()
method to SurfaceView
, returning an IBinder
that represents the SurfaceView
. The client needs to get that “host token”, along with the ID of the Display
used for that SurfaceView
, and the dimensions of that SurfaceView
over to the server app. MainActivity
delegates this to a MainMotor
viewmodel, by calling a bind()
function when the user clicks that “Connect to Server” button:
package com.commonsware.android.r.embed.client
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.observe
import com.commonsware.android.r.embed.client.databinding.ActivityMainBinding
import org.koin.androidx.viewmodel.ext.android.viewModel
class MainActivity : AppCompatActivity() {
private val motor: MainMotor by viewModel()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.surface.setZOrderOnTop(true)
motor.surfacePackage.observe(this) {
binding.surface.setChildSurfacePackage(it)
}
binding.connect.setOnClickListener {
motor.bind(
binding.surface.hostToken,
binding.surface.display.displayId,
binding.surface.width,
binding.surface.height
)
}
}
}
IBinder
can go into a Bundle
, and the Int
values for the display ID, width, and height can all be transferred easily between processes. We want to ensure that the server process runs as long as our client needs it, so EmbedClient
and EmbedServer
use the bound service pattern, with EmbedServer
hosting the service. In particular, EmbedServer
will expose a Messenger
as its Binder
, so EmbedClient
can send a Message
to it with the IBinder
and Int
values. So, MainMotor
has a bindToService()
function that uses suspendCoroutine
to make the asynchronous act of binding to a service and getting the Messenger
appear synchronous:
private suspend fun bindToService(): MessengerConnection {
return withContext(Dispatchers.Default) {
suspendCoroutine<MessengerConnection> { continuation ->
context.bindService(
Intent().setClassName(
"com.commonsware.android.r.embed.server",
"com.commonsware.android.r.embed.server.ViewService"
),
MessengerConnection { if (isActive) continuation.resume(it) },
Context.BIND_AUTO_CREATE
)
}
}
}
}
private class MessengerConnection(private val onConnected: (MessengerConnection) -> Unit) :
ServiceConnection {
var messenger: Messenger? = null
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
messenger = Messenger(binder)
onConnected(this)
}
override fun onServiceDisconnected(name: ComponentName?) {
messenger = null
}
}
The bind()
function that MainActivity
calls then binds to the service and sends a Message
with our four pieces of data:
fun bind(
hostToken: IBinder?,
displayId: Int,
width: Int,
height: Int
) {
viewModelScope.launch {
conn = bindToService()
conn?.messenger?.send(Message.obtain().apply {
data = bundleOf(
KEY_HOST_TOKEN to hostToken,
KEY_DISPLAY_ID to displayId,
KEY_WIDTH to width,
KEY_HEIGHT to height
)
replyTo = messenger
})
}
}
Setting up the Bundle
is a bit clunky, because the bundleOf()
implementation in androidx.core:core-ktx:1.2.0
does not support IBinder
, the data type of our “host token”. So, we have to add that via a separate call to putBinder()
.
Also note that our Message
includes another Messenger
in the replyTo
property. This Messenger
will be used by the server to send data back to the client. We will look more at that part of the process later in this chapter.
Also, as was seen in an earlier chapter, we need to whitelist the server app in order to be able to bind to it:
<queries>
<package android:name="com.commonsware.android.r.embed.server" />
</queries>
Otherwise, any bindService()
call will fail, even with a valid Intent
.
Server Setup
The job of the server is to set up the SurfaceControlViewHost
and the UI to be displayed in the client.
All of that is handled by the ViewService
being bound to by the client. While the app has an activity, that is simply for convenience when launching this sample from the IDE — the activity plays no role in the UI being served up.
The UI in question consists of a really big Button
, plus a TextView
:
<?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"
android:background="@android:color/white"
android:padding="8dp">
<Button
android:id="@+id/button"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/time"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
In onCreate()
of ViewService
, we use view binding to inflate()
that layout and configure the widgets:
class ViewService : Service() {
private lateinit var messenger: Messenger
private val handlerThread = HandlerThread("ViewService")
private lateinit var binding: EmbeddedBinding
override fun onCreate() {
super.onCreate()
handlerThread.start()
binding = EmbeddedBinding.inflate(LayoutInflater.from(this))
var count = 0
binding.button.text = getString(R.string.caption, count)
binding.button.setOnClickListener {
Log.d("ViewService", "button clicked")
count += 1
binding.button.text = getString(R.string.caption, count)
}
binding.time.text = Date().toString()
messenger = Messenger(ViewHandler(this, binding, handlerThread.looper))
Log.d("ViewService", "onCreate() finished")
}
override fun onBind(p0: Intent?): IBinder = messenger.binder
}
The Message
from EmbedClient
will be received by handleMessage()
on the ViewHandler
implementation of Handler
. Our job is to process that message and, for the first message, set up the SurfaceControlViewHost
:
private class ViewHandler(
private val context: Context,
private val binding: EmbeddedBinding,
looper: Looper
) : Handler(looper) {
private var host: SurfaceControlViewHost? = null
override fun handleMessage(msg: Message) {
Log.d("ViewService", "handleMessage() called")
msg.data.apply {
if (host == null) {
val hostToken = getBinder(KEY_HOST_TOKEN)
val displayId = getInt(KEY_DISPLAY_ID)
val width = getInt(KEY_WIDTH)
val height = getInt(KEY_HEIGHT)
val display = context.getSystemService(DisplayManager::class.java)
.getDisplay(displayId)
host = SurfaceControlViewHost(context, display, hostToken).apply {
setView(binding.root, width, height)
val pkg = surfacePackage
msg.replyTo.send(Message.obtain().apply {
data = bundleOf(KEY_SURFACE_PACKAGE to pkg)
})
}
} else {
binding.time.text = Date().toString()
}
}
}
}
If we have not set up the host
previously, we grab the values out of the Message
and obtain the Display
object for our display ID. We then:
- Create the
SurfaceControlViewHost
, passing the “host token” andSurfaceView
dimensions to the constructor - Call
setView()
to attach our inflated layout to the host - Call
getSurfacePackage()
on thehost
and send that back to the client via thereplyTo
Messenger
If, on the other hand, we already have the host
set up from before, we just update the TextView
to show the now-current Date
.
Client Completion
The replyTo
Messenger
that we attached to the outbound message is set up in the init
block of MainMotor
:
class MainMotor(private val context: Context) : ViewModel() {
private var conn: MessengerConnection? = null
private val _surfacePackage =
MutableLiveData<SurfaceControlViewHost.SurfacePackage>()
val surfacePackage: LiveData<SurfaceControlViewHost.SurfacePackage> =
_surfacePackage
private val handlerThread = HandlerThread("EmbedClient")
private val handler: Handler
private val messenger: Messenger
init {
handlerThread.start()
handler = PackageHandler(handlerThread.looper) {
_surfacePackage.postValue(it)
}
messenger = Messenger(handler)
}
The PackageHandler
simply calls the supplied callback upon receipt of a Message
, extracting out the SurfacePackage
sent by the server:
private class PackageHandler(
looper: Looper,
private val onPackageReceipt: (SurfaceControlViewHost.SurfacePackage) -> Unit
) : Handler(looper) {
override fun handleMessage(msg: Message) {
val pkg = msg.data.getParcelable<SurfaceControlViewHost.SurfacePackage>(
KEY_SURFACE_PACKAGE
)
pkg?.let { onPackageReceipt(it) }
}
}
The SurfacePackage
is supplied to MainActivity
via LiveData
, and MainActivity
calls setChildSurfacePackage()
on the SurfaceView
to attach it:
motor.surfacePackage.observe(this) {
binding.surface.setChildSurfacePackage(it)
}
The Results
If you install both apps, then launch EmbedClient
and click the “Connect to Server” button, you will see the EmbedServer
-supplied UI in what had been the big open area of the SurfaceView
:
If you click the “Connect to Server” button again, the TextView
text will show the now-current date, illustrating that the connection between client and server is live. All the server is doing on these subsequent button clicks is updating the text in the TextView
— it is not doing anything else to “push” a new rendition of the UI to the client. That is handled by SurfaceControlViewHost
and the underlying shared Surface
.
Prev Table of Contents Next
This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.