Remote Sensors
The Sensor/LiveService
sample project is another variation on some of the SensorManager
samples shown elsewhere in the book. This one uses data binding to show the current ambient light reading in a simple UI. And in this case, the SensorManager
is being used by a service, to which the activity is binding by way of a LiveData
and ViewModel
set up for that work.
The AIDL
The service (LightSensorService
) is going to run in a separate process from the rest of the app. That is not required for services, but for the purposes of this example, it shows a slightly more complex scenario than having the service be in the same process.
Since we are going to use a remote bound service, we need to use AIDL to define the API between the service and its client. The project has two such AIDL files.
One is ILightReporter
, which is the AIDL interface that the service will expose as its API:
package com.commonsware.android.livedata;
import com.commonsware.android.livedata.ILightCallback;
interface ILightReporter {
void registerCallback(ILightCallback cb);
void unregisterCallback(ILightCallback cb);
}
This offers two methods, for registering and unregistering a callback to find out about new light readings. That callback is defined by the other AIDL interface, ILightCallback
:
package com.commonsware.android.livedata;
interface ILightCallback {
void onLightEvent(float value);
}
Our client will implement this callback and it will receive light sensor readings (as a single float
of the light level in lux) from the service.
Callbacks are not your only option for receiving asynchronous data updates from a service — you could use a Messenger
, ResultReceiver
, PendingIntent
, etc. Most can follow the same basic pattern shown in this example for a LiveData
wrapper.
The Service and the Process
LightSensorService
is registered in the manifest with the android:process
attribute, to have that service run in a separate private process from the rest of the app:
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.commonsware.android.livedata"
xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="1"
android:versionName="1.0">
<application
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.Apptheme">
<activity
android:name=".MainActivity"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".LightSensorService"
android:exported="false"
android:process=":light" />
</application>
</manifest>
LightSensorService
wraps a SensorManager
and has a Reporter
implementation of the ILightReporter
interface to serve as its binder:
package com.commonsware.android.livedata;
import android.app.Service;
import android.content.Intent;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.os.IBinder;
import android.os.RemoteCallbackList;
import android.os.RemoteException;
public class LightSensorService extends Service {
private SensorManager sensorManager;
private Reporter reporter=new Reporter();
@Override
public void onCreate() {
super.onCreate();
sensorManager=(SensorManager)getSystemService(SENSOR_SERVICE);
}
@Override
public IBinder onBind(Intent intent) {
return reporter;
}
private class Reporter extends ILightReporter.Stub {
private RemoteCallbackList<ILightCallback> callbacks=new RemoteCallbackList<>();
@Override
public void registerCallback(ILightCallback cb) {
callbacks.register(cb);
if (callbacks.getRegisteredCallbackCount()==1) {
sensorManager.registerListener(listener,
sensorManager.getDefaultSensor(Sensor.TYPE_LIGHT),
SensorManager.SENSOR_DELAY_UI);
}
}
@Override
public void unregisterCallback(ILightCallback cb) {
callbacks.unregister(cb);
if (callbacks.getRegisteredCallbackCount()==0) {
sensorManager.unregisterListener(listener);
}
}
final private SensorEventListener listener=new SensorEventListener() {
@Override
public void onSensorChanged(SensorEvent event) {
callbacks.beginBroadcast();
for (int i=0;i<callbacks.getRegisteredCallbackCount();i++) {
ILightCallback cb=callbacks.getBroadcastItem(i);
try {
cb.onLightEvent(event.values[0]);
}
catch (RemoteException e) {
// we tried!
}
}
callbacks.finishBroadcast();
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
// unused
}
};
}
}
Reporter
uses RemoteCallbackList
to keep track of the callback objects supplied by clients. RemoteCallbackList
helps keep track of clients that crash, removing their registered callbacks from the list.
Once we have a registered callback, we begin requesting TYPE_LIGHT
sensor readings from the SensorManager
, routing those to a SensorEventListener
. That listener iterates over the callbacks managed by that RemoteCallbackList
and calls onLightEvent()
on each. If we get a RemoteException
when calling the callback, we just move on — RemoteCallbackList
should detect that the client is no longer around and remove its callback from the list. Ideally, we would create a custom subclass of RemoteCallbackList
and override onCallbackDied()
to find out about it, so we can unregister our SensorEventListener
if we have no more callbacks — this is left as an exercise for the reader.
The net result of this work is that our clients that register callbacks find out about light sensor events via those callbacks.
The LiveData and the ViewModel
We only need to receive light sensor readings in the activity when the activity is visible. Otherwise, such readings are just a waste of time, battery, etc. This is an ideal case for a lifecycle-aware component, and LiveData
fits that bill nicely. So, we have a ServiceLiveData
that wraps up the binding and callback work with the service and emits a stream of Float
objects for the light sensor readings:
package com.commonsware.android.livedata;
import android.arch.lifecycle.LiveData;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.IBinder;
import android.os.RemoteException;
import android.util.Log;
public class ServiceLiveData extends LiveData<Float>
implements ServiceConnection {
private final Context app;
private ILightReporter reporter;
ServiceLiveData(Context ctxt) {
app=ctxt.getApplicationContext();
}
@Override
protected void onActive() {
super.onActive();
app.bindService(new Intent(app, LightSensorService.class), this, Context.BIND_AUTO_CREATE);
}
@Override
protected void onInactive() {
goAway();
app.unbindService(this);
super.onInactive();
}
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
reporter=ILightReporter.Stub.asInterface(service);
try {
reporter.registerCallback(cb);
}
catch (RemoteException e) {
Log.e(getClass().getSimpleName(), "Exception registering callback", e);
}
}
@Override
public void onServiceDisconnected(ComponentName name) {
reporter=null;
}
private void goAway() {
try {
reporter.unregisterCallback(cb);
}
catch (RemoteException e) {
Log.e(getClass().getSimpleName(), "Exception unregistering callback", e);
}
finally {
reporter=null;
}
}
private final ILightCallback cb=new ILightCallback.Stub() {
@Override
public void onLightEvent(float value) {
postValue(value);
}
};
}
We get the Application
singleton in the constructor, then use that in onActive()
and onInactive()
to bind and unbind from the service. In the case of this app, binding will start the service, and unbinding will destroy it, as there is no other reason for the service to be around. In other scenarios — such as the audio player — the lifetime of the service will be managed by startService()
and stopService()
(or stopSelf()
), and binding/unbinding merely is for the communications channel between the client and the service.
Once we are bound, in onServiceConnected()
, we register our callback. Since we have to use AIDL here for cross-process service communication, the callback is a subclass of ILightCallback.Stub
. When it is called with onLightEvent()
, it uses postData()
to update the LiveData
— postData()
works from a background thread, and we are not in control over what thread is used for onLightEvent()
.
Our ServiceViewModel
simply exposes a ServiceLiveData
named sensorLiveData
:
package com.commonsware.android.livedata;
import android.app.Application;
import android.arch.lifecycle.AndroidViewModel;
import android.hardware.Sensor;
import android.hardware.SensorManager;
import android.support.annotation.NonNull;
public class ServiceViewModel extends AndroidViewModel {
public final ServiceLiveData sensorLiveData;
public ServiceViewModel(@NonNull Application app) {
super(app);
sensorLiveData=new ServiceLiveData(app);
}
}
The Activity and the Layout
MainActivity
uses ServiceViewModel
in onCreate()
:
package com.commonsware.android.livedata;
import android.arch.lifecycle.ViewModelProviders;
import android.databinding.BindingAdapter;
import android.os.Bundle;
import android.support.v4.app.FragmentActivity;
import android.widget.TextView;
import com.commonsware.android.livedata.databinding.MainBinding;
public class MainActivity extends FragmentActivity {
@BindingAdapter("android:text")
public static void setLightReading(TextView tv, Float value) {
if (value==null) {
tv.setText(null);
}
else {
tv.setText(String.format("%f", value));
}
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
MainBinding binding=MainBinding.inflate(getLayoutInflater());
ServiceViewModel vm=ViewModelProviders.of(this).get(ServiceViewModel.class);
binding.setViewModel(vm);
binding.setLifecycleOwner(this);
setContentView(binding.getRoot());
}
}
Principally, we want to bind it to our main
layout resource:
<?xml version="1.0" encoding="utf-8"?>
<layout>
<data>
<variable
name="viewModel"
type="com.commonsware.android.livedata.ServiceViewModel" />
</data>
<android.support.constraint.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">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:text="@string/label_light"
android:textAppearance="@android:style/TextAppearance.Material.Large"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.25" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:text="@{viewModel.sensorLiveData}"
android:textAppearance="@android:style/TextAppearance.Material.Large"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.75" />
</android.support.constraint.ConstraintLayout>
</layout>
The layout uses a binding expression to find out about changes in the light sensor, by tying our ServiceLiveData
to a TextView
. The setLightReading()
BindingAdapter
in MainActivity
will wind up being used by the data binding framework to take the Float
values from ServiceLiveData
and pour them into the TextView
.
And, so that the data binding framework can use the LiveData
properly, not only do we call setViewModel()
on the code-generated MainBinding
, but we also call setLifecycleOwner()
to give the data binding framework the LifecycleOwner
to use.
Prev Table of Contents Next
This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.