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 LiveDatapostData() 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.