/* * Copyright (C) 2015 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ package com.googlecode.eyesfree.braille.service.display; import android.app.Service; import android.bluetooth.BluetoothDevice; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.os.Handler; import android.os.IBinder; import android.os.Message; import android.os.RemoteCallbackList; import android.os.RemoteException; import android.util.Log; import com.googlecode.eyesfree.braille.display.BrailleDisplayProperties; import com.googlecode.eyesfree.braille.display.BrailleInputEvent; import com.googlecode.eyesfree.braille.display.IBrailleService; import com.googlecode.eyesfree.braille.display.IBrailleServiceCallback; import com.googlecode.eyesfree.braille.service.R; import com.googlecode.eyesfree.braille.utils.ZipResourceExtractor; import java.io.File; /** * An Andorid service that connects to braille displays and exposes a unified * interface to other Apps. * * DisplayService contains ReadThread. * ReadThread contains DriverThread and DeviceFinder. * DriverThread contains BrlttyWrapper. */ public class DisplayService extends Service implements DriverThread.OnInputEventListener { private static final String LOG_TAG = DisplayService.class.getSimpleName(); /** * Listener thread which reads from the braille device and forwards * data to the driver. */ /** Written in main thread, read in IPC threads. */ private volatile ReadThread mReadThread; /** The list of registered clients. */ private final RemoteCallbackList<IBrailleServiceCallback> mClients = new RemoteCallbackList<IBrailleServiceCallback>(); private final ServiceImpl mServiceImpl = new ServiceImpl(); private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (Intent.ACTION_SCREEN_ON.equals(action)) { // Try reconnecting to Braille device on screen on action. // This is done so that if a display was turned on // after the service is started, it will be // connected the next time the user unlocks the device. mHandler.unscheduleDisconnect(); connectBraille(); } else if (Intent.ACTION_SCREEN_OFF.equals(action)) { // Disconnect the display after the screen goes off, but // wait a bit since it takes some time to reconnect, which // is going to be frustrating if the screen went off // accidentally. mHandler.scheduleDisconnect(SCREEN_OFF_DISCONNECT_DELAY); } else if (BluetoothDevice.ACTION_BOND_STATE_CHANGED.equals( action)) { connectFromBroadcastIntent(intent); } else { Log.w(LOG_TAG, "Unexpected broadcast " + action); } } }; private MainHandler mHandler = new MainHandler(); private static final int STATE_DISCONNECTED = 0; private static final int STATE_CONNECTED = 1; private int mConnectionState = STATE_DISCONNECTED; private String mConnectionProgress = null; /** * Delay between the screen goes off and the display gets automatically * disconnected. */ private static final long SCREEN_OFF_DISCONNECT_DELAY = 7000; private BrailleDisplayProperties mDisplayProperties; private File mTablesDir; private static final int FILES_ERROR = -1; private static final int FILES_NOT_EXTRACTED = 0; private static final int FILES_EXTRACTED = 1; private int mDataFileState = FILES_NOT_EXTRACTED; /** Set to {@code true} if a connect request comes in while * we are still disconnecting. */ private boolean mConnectPending; private String mPendingBluetoothAddress; @Override public void onCreate() { super.onCreate(); mTablesDir = getDir("keytables", Context.MODE_PRIVATE); registerBroadcastReceiver(); ensureDataFiles(); Log.i(LOG_TAG, "Service started."); } @Override public void onDestroy() { super.onDestroy(); Log.i(LOG_TAG, "Destroying service."); disconnectBraille(); if (mBroadcastReceiver != null) { unregisterReceiver(mBroadcastReceiver); } } @Override public IBinder onBind(Intent intent) { Log.v(LOG_TAG, "onBind"); connectBraille(); return mServiceImpl; } @Override public boolean onUnbind(Intent intent) { disconnectBraille(); return true; } @Override public void onRebind(Intent intent) { connectBraille(); } // NOTE: The methods in this class are invoked on threads created by the // IPC system and not the main application thread. private class ServiceImpl extends IBrailleService.Stub { @Override public boolean registerCallback( final IBrailleServiceCallback callback) { if (callback == null) { Log.e(LOG_TAG, "Registering null callback"); return false; } mHandler.registerCallback(callback); return true; } @Override public void unregisterCallback(IBrailleServiceCallback callback) { if (callback == null) { Log.e(LOG_TAG, "Unregistering null callback"); return; } if (!mClients.unregister(callback)) { Log.w(LOG_TAG, "Failed to unregister callback" + callback); } } @Override public void displayDots(final byte[] patterns) { if (patterns == null) { Log.e(LOG_TAG, "null dot patterns"); } ReadThread localReadThread = mReadThread; if (localReadThread == null) { return; } DriverThread localDriverThread = localReadThread.getDriverThread(); if (localDriverThread == null) { return; } localDriverThread.writeWindow(patterns); } @Override public void poll() { mHandler.connectBraille(); } } /*package*/ void onDisplayConnected( final BrailleDisplayProperties properties) { mHandler.onDisplayConnected(properties); } /*package*/ void onDisplayDisconnected() { mHandler.onDisplayDisconnected(); } /*package*/ void setConnectionProgress(String description) { mHandler.setConnectionProgress(description); } /** * Forwards input events from the driver thread to be broadcast * from the main service thread. */ @Override public void onInputEvent(final BrailleInputEvent event) { mHandler.onInputEvent(event); } /** * Disconnects the service from the currently connected braille device, * sends notification to the clients about the state change. */ private void disconnectBraille() { Log.i(LOG_TAG, "Disconnecting braille display"); mConnectionState = STATE_DISCONNECTED; mDisplayProperties = null; broadcastConnectionState(); if (mReadThread != null) { mReadThread.disconnect(); } } /** * Tries to connect to the braille device, on success sends notification to * registered clients. */ private void connectBraille() { if (mConnectionState == STATE_CONNECTED) { return; } if (mDataFileState != FILES_EXTRACTED) { return; } if (mReadThread != null) { // We must wait until the read thread is dead before retrying // a connect, so just set the flag here. mConnectPending = true; } else { mReadThread = new ReadThread(this, mTablesDir, mPendingBluetoothAddress); mPendingBluetoothAddress = null; mReadThread.start(); } } /** * If {@code intent} indicates that a new device has been * paired, try to connect to it. */ private void connectFromBroadcastIntent(Intent intent) { if (mConnectionState == STATE_CONNECTED) { return; } int bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.BOND_NONE); if (bondState != BluetoothDevice.BOND_BONDED) { return; } BluetoothDevice bthDev = intent.getParcelableExtra( BluetoothDevice.EXTRA_DEVICE); if (bthDev == null) { return; } mPendingBluetoothAddress = bthDev.getAddress(); connectBraille(); } private void registerBroadcastReceiver() { String[] actions = new String[] { Intent.ACTION_SCREEN_ON, Intent.ACTION_SCREEN_OFF, BluetoothDevice.ACTION_BOND_STATE_CHANGED, }; IntentFilter filter = new IntentFilter(); for (String action : actions) { filter.addAction(action); } registerReceiver(mBroadcastReceiver, filter, null, null); } private void sendConnectionState(IBrailleServiceCallback callback) { try { switch (mConnectionState) { case STATE_CONNECTED: callback.onDisplayConnected(mDisplayProperties); break; case STATE_DISCONNECTED: callback.onDisplayDisconnected(); break; default: Log.e(LOG_TAG, "Unknown connection state: " + mConnectionState); break; } } catch (RemoteException ex) { // Nothing to do, the callback list will remove the callback // later. } } private void sendConnectionProgress(IBrailleServiceCallback callback) { try { callback.onConnectionChangeProgress(mConnectionProgress); } catch (RemoteException ex) { // Nothing to do, the callback list will remove the callback // later. } } private void broadcastConnectionState() { int i = mClients.beginBroadcast(); try { while (i-- > 0) { sendConnectionState(mClients.getBroadcastItem(i)); } } finally { mClients.finishBroadcast(); } } private void broadcastConnectionProgress() { int i = mClients.beginBroadcast(); try { while (i-- > 0) { sendConnectionProgress(mClients.getBroadcastItem(i)); } } finally { mClients.finishBroadcast(); } } private void broadcastInputEvent(BrailleInputEvent event) { int i = mClients.beginBroadcast(); try { while (i-- > 0) { try { mClients.getBroadcastItem(i).onInput(event); } catch (RemoteException ex) { // Nothing to do, the callback list will remove the // callback later. } } } finally { mClients.finishBroadcast(); } } /** * Returns {@code true} if there are any registered clients that * haven't died or unregistered themselves. */ private boolean haveClients() { // Unfortunately, the callback list doesn't provide this // information without doing this. int numClients = mClients.beginBroadcast(); mClients.finishBroadcast(); return numClients > 0; } private void ensureDataFiles() { if (mDataFileState != FILES_NOT_EXTRACTED) { return; } // TODO: When the zip file is larger than a few kilobytes, detect if // the data was already extracted and don't do this every time the // service starts. ZipResourceExtractor extractor = new ZipResourceExtractor( this, R.raw.keytables, mTablesDir) { @Override protected void onPostExecute(Integer result) { if (result == RESULT_OK) { mDataFileState = FILES_EXTRACTED; if (haveClients()) { connectBraille(); } } else { Log.e(LOG_TAG, "Couldn't extract data files"); // TODO: figure out a way to deal with this so a user // doesn't get stuck in this state. mDataFileState = FILES_ERROR; broadcastConnectionState(); } } }; extractor.execute(); } private class MainHandler extends Handler { private static final int MSG_REGISTER_CALLBACK = 1; private static final int MSG_ON_DISPLAY_CONNECTED = 2; private static final int MSG_ON_DISPLAY_DISCONNECTED = 3; private static final int MSG_SET_CONNECTION_PROGRESS = 4; private static final int MSG_ON_INPUT_EVENT = 5; private static final int MSG_CONNECT_BRAILLE = 6; private static final int MSG_DISCONNECT_BRAILLE = 7; public void registerCallback(IBrailleServiceCallback callback) { obtainMessage(MSG_REGISTER_CALLBACK, callback).sendToTarget(); } public void onDisplayConnected(BrailleDisplayProperties properties) { obtainMessage(MSG_ON_DISPLAY_CONNECTED, properties).sendToTarget(); } public void onDisplayDisconnected() { sendEmptyMessage(MSG_ON_DISPLAY_DISCONNECTED); } public void setConnectionProgress(String description) { obtainMessage(MSG_SET_CONNECTION_PROGRESS, description) .sendToTarget(); } public void onInputEvent(BrailleInputEvent event) { obtainMessage(MSG_ON_INPUT_EVENT, event).sendToTarget(); } public void unscheduleDisconnect() { removeMessages(MSG_DISCONNECT_BRAILLE); } public void scheduleDisconnect(long delayMillis) { sendEmptyMessageDelayed(MSG_DISCONNECT_BRAILLE, delayMillis); } public void connectBraille() { sendEmptyMessage(MSG_CONNECT_BRAILLE); } @Override public void handleMessage(Message msg) { switch (msg.what) { case MSG_REGISTER_CALLBACK: handleRegisterCallback((IBrailleServiceCallback) msg.obj); break; case MSG_ON_DISPLAY_CONNECTED: handleOnDisplayConnected( (BrailleDisplayProperties) msg.obj); break; case MSG_ON_DISPLAY_DISCONNECTED: handleOnDisplayDisconnected(); break; case MSG_SET_CONNECTION_PROGRESS: handleSetConnectionProgress((String) msg.obj); break; case MSG_ON_INPUT_EVENT: handleOnInputEvent((BrailleInputEvent) msg.obj); break; case MSG_CONNECT_BRAILLE: DisplayService.this.connectBraille(); break; case MSG_DISCONNECT_BRAILLE: disconnectBraille(); break; default: // fall out } } private void handleRegisterCallback(IBrailleServiceCallback callback) { mClients.register(callback); if (mConnectionProgress != null) { sendConnectionProgress(callback); } if (mDataFileState == FILES_NOT_EXTRACTED || (mConnectionState == STATE_DISCONNECTED && mReadThread != null)) { // Extraction or connection in progress, there will be a // broadcast of the state when it either succeeds or fails. return; } sendConnectionState(callback); } private void handleOnDisplayConnected( BrailleDisplayProperties properties) { mConnectionState = STATE_CONNECTED; mConnectionProgress = null; mDisplayProperties = properties; mConnectPending = false; broadcastConnectionState(); } private void handleOnDisplayDisconnected() { mReadThread = null; if (mConnectionState != STATE_DISCONNECTED) { mConnectionState = STATE_DISCONNECTED; broadcastConnectionState(); } if (mConnectPending) { // Don't get stuck retrying if connecting fails. mConnectPending = false; connectBraille(); } } private void handleSetConnectionProgress(String description) { if ((description == null && mConnectionProgress == null) || (description != null && description.equals( mConnectionProgress))) { return; } mConnectionProgress = description; broadcastConnectionProgress(); } private void handleOnInputEvent(BrailleInputEvent event) { broadcastInputEvent(event); } } }