/*
* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) 2014 Digi International Inc., All Rights Reserved.
*/
package com.digi.android.wva;
import android.app.Notification;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.graphics.BitmapFactory;
import android.os.Handler;
import android.os.IBinder;
import android.preference.PreferenceManager;
import android.support.v4.app.NotificationCompat;
import android.text.TextUtils;
import android.util.Log;
import android.widget.Toast;
import com.digi.android.wva.adapters.EndpointsAdapter;
import com.digi.android.wva.adapters.LogAdapter;
import com.digi.android.wva.model.EndpointConfiguration;
import com.digi.android.wva.model.LogEvent;
import com.digi.android.wva.util.MessageCourier;
import com.digi.android.wva.util.NetworkUtils;
import com.digi.android.wva.util.VehicleEndpointComparator;
import com.digi.wva.WVA;
import com.digi.wva.async.EventChannelStateListener;
import com.digi.wva.async.WvaCallback;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.util.List;
import java.util.Set;
/**
* VehicleInfoService is a self-contained service created to facilitate easy
* integration with the Digi Wi-Fi Vehicle Bus Adapter (WVA). It is intended
* to be a started service which provides constant information from the WVA
* web service.
*
* <p>In this demonstration app, this service is started when the application
* is created (in {@link WvaApplication#onCreate}) and by calling startService
* with intents containing various commands, the service can be directed to
* connect or disconnect from devices. The idea is that the service is always
* running, just not necessarily always doing useful things.</p>
*
* @author awickert
*
*/
public class VehicleInfoService extends Service {
/* Constants to be added as extras in startService calls for the service
* to specify what "command" is being passed */
// INTENT_* constants are the Intent extra names
/** Intent extra key to indicate the "command" of the intent */
public static final String INTENT_CMD = "command";
/** Intent extra key to indicate the IP address to connect to, when the
* command being used is {@link #CMD_CONNECT}.
*/
public static final String INTENT_IP = "ip_addr";
/** Intent extra key to give the basic-auth username to use with this device.
*/
public static final String INTENT_AUTH_USER = "auth_user";
/** Intent extra key to give the basic-auth password to use with this device.
*/
public static final String INTENT_AUTH_PASS = "auth_pass";
/** Intent extra key to indicate whether the HTTP connection should use HTTPS or not.
*/
public static final String INTENT_HTTPS = "https";
// CMD_* are values for INTENT_CMD extras
/** This command is only used in {@link com.digi.android.wva.WvaApplication#onCreate()},
* to initialize VehicleInfoService.
*/
public static final int CMD_APPCREATE = 0; // WvaApplication.onCreate only
/** Directs VehicleInfoService to attempt to connect to a device at the
* IP address given by the Intent extra whose key is {@link #INTENT_CMD}.
*/
public static final int CMD_CONNECT = 1; // start listening to device
/** Directs VehicleInfoService to disconnect from the device. */
public static final int CMD_DISCONNECT = 2; // stop listening to anything
private static final String TAG = "VehicleInfoService";
private static final int NOTIF_ID = 98866843; // WVANOTIF
private WVA mDevice;
private boolean isConnected = false;
private String connectIp; // IP address to connect to
private Handler mHandler;
/**
* Builds a new EventChannelStateListener specialized for use by the demo app.
*
* <p>This method is protected, rather than private, due to a bug between JaCoCo and
* the Android build tools which causes the instrumented bytecode to be invalid when this
* method is private:
* <a href="http://stackoverflow.com/questions/17603192/dalvik-transformation-using-wrong-invoke-opcode" target="_blank">see StackOverflow question.</a>
* </p>
* @return a new event channel state listener to use
*/
protected EventChannelStateListener makeStateListener() {
return new EventChannelStateListener() {
@Override
public boolean runsOnUiThread() {
return true;
}
private void log(final LogEvent event) {
LogAdapter.getInstance().add(event);
}
@Override
public void onConnected(WVA device) {
Log.d(TAG, "connectionListener -- onConnected");
MessageCourier.sendDashConnected(connectIp);
log(new LogEvent("Connected to device.", null));
// Ensure the service-running notification goes up.
isConnected = true;
showNotificationIfRunning();
}
@Override
public void onError(WVA device, IOException error) {
Log.e(TAG, "Device connection error", error);
device.disconnectEventChannel(true);
log(new LogEvent("An error occurred. Disconnecting...", null));
String msg;
if (error == null) {
msg = "Connection with the WVA device encountered some error.";
}
else if (!NetworkUtils.shouldBeAllowedToConnect(getApplicationContext())) {
Log.d(TAG, "Connection error is because the network went away.");
msg = "Your network connection has gone away.";
} else {
msg = "Connection with the WVA device encountered an error: " +
error.getMessage();
}
MessageCourier.sendError(msg);
}
@Override
public void onRemoteClose(WVA device, int port) {
Log.d(TAG, "connectionListener -- onRemoteClose");
MessageCourier.sendReconnecting(connectIp);
log(new LogEvent("Reconnecting...", null));
// this will interrupt() the EventChannel thread, but since we're
// inside that thread currently, and we're not doing any thread-blocking
// calls here, execution will continue until we leave this method.
// Then, execution returns to EventChannel, and off we go.
super.onRemoteClose(device, port);
}
@Override
public void onFailedConnection(WVA device, int port) {
Log.d(TAG, "connectionListener -- onFailedConnection");
MessageCourier.sendReconnecting(connectIp);
log(new LogEvent("Retrying connection...", null));
reconnectAfter(device, 15000, port);
}
};
}
public VehicleInfoService() {
connectIp = null;
}
@Override
public void onCreate() {
Log.d(TAG, "Vehicle service created.");
WvaApplication app = (WvaApplication)getApplication();
if (app == null) {
// This shouldn't happen in any reasonable scenario.
throw new NullPointerException("Couldn't get application in service!");
} else {
mHandler = app.getHandler();
}
super.onCreate();
}
/**
* Depending on if the service is currently set as 'connected'
* (check value of {@code isConnected} boolean), either call
* {@link #startForeground(int, Notification)} to put up the
* "service is running" notification, or call
* {@link #stopForeground(boolean) stopForeground(true)} to remove the
* notification (because the service isn't listening).
*
* <p>This method is protected, rather than private, due to a bug between JaCoCo and
* the Android build tools which causes the instrumented bytecode to be invalid when this
* method is private:
* http://stackoverflow.com/questions/17603192/dalvik-transformation-using-wrong-invoke-opcode
* </p>
*/
protected void showNotificationIfRunning() {
// First, c
WvaApplication app = (WvaApplication) getApplication();
if (app != null && app.isTesting())
return;
if (isConnected) {
NotificationCompat.Builder builder;
builder = new NotificationCompat.Builder(getApplicationContext());
PendingIntent contentIntent;
Intent intent = new Intent(VehicleInfoService.this, DashboardActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
Intent.FLAG_ACTIVITY_SINGLE_TOP);
contentIntent = PendingIntent.getActivity(VehicleInfoService.this,
0, intent, 0);
builder.setContentTitle("Digi WVA Service")
.setContentText("Connected to " + (TextUtils.isEmpty(connectIp) ? "(null)" : connectIp))
.setLargeIcon(BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher))
.setSmallIcon(R.drawable.notif_small)
.setOngoing(true)
.setContentIntent(contentIntent);
startForeground(NOTIF_ID, builder.build());
} else {
try {
stopForeground(true);
} catch (Exception e) {
// Might happen if startForeground not called before
e.printStackTrace();
}
}
}
/**
* Factory function to create the intent used when startService is called
* in {@link WvaApplication#onCreate onCreate}
*
* @param context Application context (use {@link #getApplicationContext()})
* @return intent to be used in startService call
*/
public static Intent buildCreateIntent(Context context) {
// Make new intent for VehicleInfoService with command CMD_APPCREATE
return new Intent(context, VehicleInfoService.class)
.putExtra(INTENT_CMD, CMD_APPCREATE);
}
/**
* Factory function to create the intent used in a startService call to
* tell the {@link VehicleInfoService} to "connect" to the device at
* the given IP address.
*
* @param context Application context (use {@link #getApplicationContext()})
* @param ip_addr IP address of device to connect to
* @return intent to be used in startService call
*/
public static Intent buildConnectIntent(Context context, String ip_addr) {
return buildConnectIntent(context, ip_addr, null, null, true);
}
public static Intent buildConnectIntent(Context context, String ip_addr, String auth_user, String auth_pass, boolean useHttps) {
// Make new intent with command CMD_CONNECT and the IP given
Intent intent = new Intent(context, VehicleInfoService.class);
// Add command and the ip address
intent
.putExtra(INTENT_CMD, CMD_CONNECT)
.putExtra(INTENT_IP, ip_addr)
.putExtra(INTENT_AUTH_USER, auth_user)
.putExtra(INTENT_AUTH_PASS, auth_pass)
.putExtra(INTENT_HTTPS, useHttps);
return intent;
}
/**
* Factory function to create the intent used in a startService call to
* tell the {@link VehicleInfoService} to "disconnect" from whatever
* device it's connected to currently
*
* @param context Application context (use {@link #getApplicationContext()})
* @return intent to be used in startService call
*/
public static Intent buildDisconnectIntent(Context context) {
return new Intent(context, VehicleInfoService.class)
.putExtra(INTENT_CMD, CMD_DISCONNECT);
}
/**
* Take the Intent that was used to call startService (i.e. the
* intent used to give a command to the service) and the command
* that it had as an extra and act on it accordingly.
*
* <p>This method is protected, rather than private, due to a bug between JaCoCo and
* the Android build tools which causes the instrumented bytecode to be invalid when this
* method is private:
* http://stackoverflow.com/questions/17603192/dalvik-transformation-using-wrong-invoke-opcode
* </p>
*
* @param intent the intent used to call startService
* @param command the command that the intent had as an extra
*/
protected synchronized void parseIntent(Intent intent, int command) {
boolean isConnect = false;
final WvaApplication app = (WvaApplication)getApplication();
if (app == null) {
// Based on Android sources, this would only happen if the service is
// not attached to an application... In this case, we can't know what
// is a valid thing to do here.
Log.e(TAG, "getApplication() returned null!");
return;
}
switch (command) {
case CMD_APPCREATE:
Log.i(TAG, "startService - CMD_APPCREATE");
isConnected = false;
break;
case CMD_CONNECT:
Log.i(TAG, "startService - CMD_CONNECT");
isConnect = true;
break;
case CMD_DISCONNECT:
Log.i(TAG, "startService - CMD_DISCONNECT");
isConnected = false;
if (mDevice != null) {
mDevice.disconnectEventChannel(true);
mDevice = null;
app.setDevice(null);
} else {
Log.d(TAG, "Got CMD_DISCONNECT but mDevice is null");
}
break;
default:
Log.i(TAG, "startService - unknown command " + command);
isConnected = false;
}
if (isConnect) {
String ip = intent.getStringExtra(INTENT_IP);
String username = intent.getStringExtra(INTENT_AUTH_USER);
String password = intent.getStringExtra(INTENT_AUTH_PASS);
boolean useHttps = intent.getBooleanExtra(INTENT_HTTPS, true);
if (TextUtils.isEmpty(ip)) {
Log.e(TAG, "startService given connect command with empty IP!");
isConnected = false;
}
else {
final int port = Integer.valueOf(
PreferenceManager.getDefaultSharedPreferences(this)
.getString("pref_device_port", "5000"));
connectIp = ip;
isConnected = false;
boolean autoSubscribe = PreferenceManager.getDefaultSharedPreferences(this).getBoolean("pref_auto_subscribe", false);
final int autosub = autoSubscribe
? Integer.valueOf(PreferenceManager
.getDefaultSharedPreferences(this)
.getString("pref_default_interval", "-1"))
: -1;
Log.i(TAG, "Initiating connection to " + connectIp);
// Structuring the code like this allows us to
// inject Device instances for testing. Otherwise,
// it would become difficult to unit-test
// VehicleInfoService.
mDevice = app.getDevice();
if (mDevice == null) {
mDevice = new WVA(connectIp);
// Set up authentication and HTTP(S) configuration.
// The demo app assumes default HTTP and HTTPS ports.
mDevice.useBasicAuth(username, password)
.useSecureHttp(useHttps)
.setHttpPort(80)
.setHttpsPort(443);
app.setDevice(mDevice);
}
mDevice.fetchVehicleDataEndpoints(new WvaCallback<Set<String>>() {
@Override
public void onResponse(Throwable error, Set<String> endpoints) {
Log.d(TAG, "initVehicleData onResponse...");
if (error != null) {
Log.e(TAG, "Got error starting Vehicle", error);
// Stop WVA inner threads (TCPReceiver, MessageHandler)
synchronized (VehicleInfoService.this) {
mDevice.disconnectEventChannel();
mDevice = null;
app.setDevice(null);
}
String err = error.getMessage();
if (TextUtils.isEmpty(err)) {
Throwable cause = error.getCause();
if (cause != null)
err = cause.getMessage();
else
err = error.toString();
}
MessageCourier.sendError(err);
return;
}
// Sort the endpoints set
final List<String> sortedEndpoints = VehicleEndpointComparator.asSortedList(endpoints);
Log.d(TAG, "Beginning endpoint handling");
// First, add them all to the adapter.
for (String e : sortedEndpoints) {
// To improve performance, we add the endpoint to the endpoints list here,
// but do not notify it that the data set has changed. We then notify only
// after adding all endpoints.
EndpointsAdapter.getInstance().add(new EndpointConfiguration(e), false);
}
// Update the endpoints adapter.
mHandler.postAtFrontOfQueue(new Runnable() {
@Override
public void run() {
Log.d("VIS", "Updating endpoints adapter");
EndpointsAdapter.getInstance().notifyDataSetChanged();
}
});
// Handle subscribing/unsubscribing on a separate thread.
if (autosub > 0) {
Runnable doSubscriptions = new Runnable() {
@Override
public void run() {
for (String e : sortedEndpoints) {
// Add a bit of sleep between subscribing
// to each endpoint, so as not to overload
// the main thread as it tries to keep up.
// This is happening as soon as the
// DashboardActivity is launched, after all.
try {
Thread.sleep(25);
} catch (InterruptedException ignored) {
}
if (app.getDevice() == null) {
// User backed out of DashboardActivity
// We should stop these subscriptions...
Log.d(TAG, "app.getDevice() returned null. " +
"Stopping subscriptions...");
app.clearDevice();
return;
}
final String ep = e;
boolean isPressurePro = false;
for (String s : VehicleEndpointComparator.PRESSURE_PRO_PREFIXES) {
if (ep.startsWith(s)) {
isPressurePro = true;
break;
}
}
if (!isPressurePro) {
// (Try to) subscribe to the endpoint
app.subscribeToEndpointFromService(e, autosub,
new WvaCallback<Void>() {
@Override
public void onResponse(Throwable error, Void response) {
String msg;
if (error != null) {
msg = "Failed to subscribe to " + ep;
Log.e(TAG, "Failed to subscribe to " + ep, error);
final LogEvent evt = new LogEvent(msg, null);
mHandler.post(new Runnable() {
@Override
public void run() {
LogAdapter.getInstance().add(evt);
}
});
}
}
});
}
}
}
};
// Do subscriptions on this thread when running unit tests,
// but on a separate thread when actually being used. This allows
// us to test the code in the runnable.
if (app.isTesting()) {
doSubscriptions.run();
} else {
new Thread(doSubscriptions).start();
}
}
}
});
if (mDevice == null)
return;
// Ensure that the correct state listener is used.
mDevice.setEventChannelStateListener(makeStateListener());
mDevice.connectEventChannel(port);
if (mDevice == null)
return;
// Android Studio warns that getApplicationContext() might
// return null. So we will check for null in the callbacks.
final Context toastContext = getApplicationContext();
JSONObject portJson = new JSONObject();
try {
portJson.put("port", port);
portJson.put("enable", "on");
} catch (JSONException e) {
e.printStackTrace();
return;
}
mDevice.configure("ws_events", portJson, new WvaCallback<Void>() {
@Override
public void onResponse(Throwable error, Void response) {
if (error == null) {
Log.d(TAG, "Successfully configured port.");
} else {
Log.d(TAG, "Failed to configure port", error);
if (toastContext != null) {
Toast.makeText(toastContext, "Failed to set port to " + port,
Toast.LENGTH_SHORT).show();
}
}
}
});
}
}
}
@Override
public int onStartCommand(Intent intent, int code, int startid){
// Log.d(TAG, "VIS onStartCommand, got intent? " + (intent != null));
if (intent == null) {
// Process was killed and is being restarted, and we previously
// returned START_STICKY, so this method is getting a null Intent.
// Since the app was killed we don't know who to talk to, so
// don't talk to anyone.
Log.e(TAG, "onStartCommand - null intent");
isConnected = false;
} else {
int command = intent.getIntExtra(INTENT_CMD, -1);
if (command == -1) {
Log.e(TAG, "startService called without command");
isConnected = false;
} else {
parseIntent(intent, command);
}
}
showNotificationIfRunning();
return START_STICKY;
}
/**
* Useful for unit testing, to be able to access the field and
* find out what it is set to at any given instant.
*
* @return the service's current Device reference
*/
public synchronized WVA getDevice() {
return mDevice;
}
/**
* Indicate if the VehicleInfoService is currently connected to a
* WVA device
* @return true if connected to a WVA device, false otherwise
*/
public boolean isConnected() {
return isConnected;
}
/**
* Fetch the IP address we last attempted to connect to (whether that attempt
* was successful or not)
* @return the last IP address we tried to connect to
*/
public String getConnectionIpAddress() {
return connectIp;
}
@Override
public void onDestroy() {
// Log.d(TAG, "onDestroy");
// Remove the notification
WvaApplication app = (WvaApplication)getApplication();
if (app != null && !app.isTesting())
stopForeground(true);
}
@Override
public IBinder onBind(Intent intent) {
// TODO Auto-generated method stub
return null;
}
}