package io.homeassistant.android;
import android.app.Service;
import android.content.Intent;
import android.os.Binder;
import android.os.Handler;
import android.os.IBinder;
import android.support.annotation.Nullable;
import android.util.Log;
import android.util.SparseArray;
import com.afollestad.ason.Ason;
import java.lang.ref.SoftReference;
import java.net.ProtocolException;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
import java.util.Objects;
import java.util.Queue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLPeerUnverifiedException;
import io.homeassistant.android.api.HassUtils;
import io.homeassistant.android.api.requests.AuthRequest;
import io.homeassistant.android.api.requests.StatesRequest;
import io.homeassistant.android.api.requests.SubscribeEventsRequest;
import io.homeassistant.android.api.results.Entity;
import io.homeassistant.android.api.results.EventResult;
import io.homeassistant.android.api.results.RequestResult;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
import okhttp3.internal.tls.OkHostnameVerifier;
import static io.homeassistant.android.CommunicationHandler.FAILURE_REASON_GENERIC;
import static io.homeassistant.android.CommunicationHandler.FAILURE_REASON_SSL_MISMATCH;
import static io.homeassistant.android.CommunicationHandler.FAILURE_REASON_WRONG_PASSWORD;
import static io.homeassistant.android.CommunicationHandler.MESSAGE_LOGIN_FAILED;
import static io.homeassistant.android.CommunicationHandler.MESSAGE_LOGIN_SUCCESS;
import static io.homeassistant.android.CommunicationHandler.MESSAGE_STATES_AVAILABLE;
public class HassService extends Service {
public static final String EXTRA_ACTION_COMMAND = "extra_action_command";
public static final int AUTH_STATE_NOT_AUTHENTICATED = 0;
public static final int AUTH_STATE_AUTHENTICATING = 1;
public static final int AUTH_STATE_AUTHENTICATED = 2;
private static final String TAG = HassService.class.getSimpleName();
private final HassBinder binder = new HassBinder();
private final Map<String, Entity> entityMap = new HashMap<>();
public AtomicBoolean connecting = new AtomicBoolean(false);
public AtomicBoolean connected = new AtomicBoolean(false);
public AtomicInteger authenticationState = new AtomicInteger(AUTH_STATE_NOT_AUTHENTICATED);
private WebSocket hassSocket;
private WebSocketListener socketListener = new HassSocketListener();
private AtomicInteger lastId = new AtomicInteger(0);
private Handler activityHandler;
private SparseArray<SoftReference<RequestResult.OnRequestResultListener>> requests = new SparseArray<>(3);
private Queue<String> actionsQueue = new LinkedList<>();
private AtomicBoolean handlingQueue = new AtomicBoolean(false);
private Handler stopServiceHandler = new Handler();
@Override
public void onCreate() {
connect();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String command = intent.getStringExtra(EXTRA_ACTION_COMMAND);
if (command != null) {
actionsQueue.add(command);
if (authenticationState.get() == AUTH_STATE_AUTHENTICATED)
handleActionsQueue();
return START_NOT_STICKY;
}
stopSelf();
return START_NOT_STICKY;
}
@Override
public IBinder onBind(Intent intent) {
return binder;
}
@Override
public void onDestroy() {
disconnect();
}
public void setActivityHandler(Handler handler) {
activityHandler = handler;
}
public void connect() {
// Don't try to connect if already connecting
if (!connecting.compareAndSet(false, true))
return;
// Check if already connected
if (hassSocket != null) {
if (connected.get()) {
connecting.set(false);
// Still connected, reload states
if (activityHandler != null) {
loadStates();
}
return;
} else disconnect();
}
// Connect to WebSocket
String url = Utils.getUrl(this);
// Don't connect if no url or password is set - instances without password have their password set to Common.NO_PASSWORD
if (!url.isEmpty() && !Utils.getPassword(this).isEmpty()) {
Log.d("Home Assistant URL", url = url.concat("/api/websocket"));
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.hostnameVerifier((hostname, session) -> {
if (OkHostnameVerifier.INSTANCE.verify(hostname, session) || Utils.getAllowedHostMismatches(HassService.this).contains(hostname)) {
return true;
}
loginMessage(false, FAILURE_REASON_SSL_MISMATCH);
return false;
}).build();
hassSocket = client.newWebSocket(new Request.Builder().url(HttpUrl.parse(url)).build(), socketListener);
} else connecting.set(false);
}
private void authenticate() {
if (!authenticationState.compareAndSet(AUTH_STATE_NOT_AUTHENTICATED, AUTH_STATE_AUTHENTICATING)) {
return;
}
String password = Utils.getPassword(this);
if (password.length() > 0)
send(new AuthRequest(password), null);
}
private void loginMessage(boolean success, int reason) {
authenticationState.set(success ? AUTH_STATE_AUTHENTICATED : AUTH_STATE_NOT_AUTHENTICATED);
if (activityHandler != null) {
activityHandler.obtainMessage(success ? MESSAGE_LOGIN_SUCCESS : MESSAGE_LOGIN_FAILED, reason, 0).sendToTarget();
}
}
public void subscribeEvents() {
SubscribeEventsRequest eventSubscribe = new SubscribeEventsRequest("state_changed");
send(eventSubscribe, (success, result) -> {
Log.i(TAG, "Subscribed to events");
});
}
public void loadStates() {
if (authenticationState.get() != AUTH_STATE_AUTHENTICATED) {
authenticate();
return;
}
send(new StatesRequest(), (success, result) -> {
if (success && HassUtils.extractEntitiesFromStateResult(result, entityMap)) {
activityHandler.obtainMessage(MESSAGE_STATES_AVAILABLE).sendToTarget();
}
});
}
public Map<String, Entity> getEntityMap() {
return entityMap;
}
public boolean send(Ason message, @Nullable RequestResult.OnRequestResultListener resultListener) {
if (!(message instanceof AuthRequest)) {
int rId = lastId.incrementAndGet();
message.put("id", rId);
if (resultListener != null) {
requests.append(rId, new SoftReference<>(resultListener));
}
}
return hassSocket != null && hassSocket.send(message.toString());
}
private void handleActionsQueue() {
if (handlingQueue.compareAndSet(false, true)) {
// Automatically stop the service after 30 seconds, queue should be empty by then and service not needed anymore
stopServiceHandler.postDelayed(this::stopSelf, 30 * 1000);
runNextAction();
}
}
private void runNextAction() {
if (actionsQueue.peek() != null) {
Log.d(TAG, "Sending action command " + actionsQueue.peek());
send(new Ason(actionsQueue.remove()), (success, result) -> runNextAction());
} else handlingQueue.set(false);
}
public void disconnect() {
if (hassSocket != null) {
hassSocket.close(1001, "Application closed");
hassSocket = null;
} else {
connected.set(false);
}
authenticationState.set(AUTH_STATE_NOT_AUTHENTICATED);
}
public class HassBinder extends Binder {
public HassService getService() {
return HassService.this;
}
}
private class HassSocketListener extends WebSocketListener {
@Override
public void onOpen(WebSocket webSocket, Response response) {
connecting.set(false);
connected.set(true);
}
@Override
public void onMessage(WebSocket webSocket, String text) {
try {
Ason message = new Ason(text);
String type = message.getString("type", "");
switch (type != null ? type : "") {
case "auth_required":
Log.d(TAG, "Authenticating..");
authenticate();
break;
case "auth_failed":
case "auth_invalid":
Log.w(TAG, "Authentication failed!");
loginMessage(false, FAILURE_REASON_WRONG_PASSWORD);
break;
case "auth_ok":
Log.d(TAG, "Authenticated.");
loginMessage(true, 0);
// Automatically load current states if bound to Activity
if (activityHandler != null) {
subscribeEvents();
loadStates();
} else handleActionsQueue();
break;
case "event":
EventResult eventRequest = Ason.deserialize(message, EventResult.class);
Entity updated;
if ((updated = HassUtils.updateEntityFromEventResult(eventRequest.event.data, entityMap)) != null) {
Log.d(TAG, "Updated " + updated.id);
activityHandler.post(updated::notifyObservers);
}
break;
case "result":
RequestResult res = Ason.deserialize(message, RequestResult.class);
Log.d(TAG, String.format(
"Request %1$d %2$s\nResult: %3$s\nError : %4$s", res.id, res.success ? "successful" : "failed",
res.result != null && res.result.getClass().isArray() ? Arrays.toString((Object[]) res.result) : Objects.toString(res.result),
String.valueOf(res.error)
));
RequestResult.OnRequestResultListener resultListener = requests.get(res.id, new SoftReference<>(null)).get();
if (resultListener != null) {
resultListener.onRequestResult(res.success, res.result);
requests.remove(res.id);
}
break;
}
} catch (Throwable t) { // Catch everything that it doesn't get passed to onFailure
Log.e(TAG, "Error in onMessage()", t);
}
}
@Override
public void onClosed(WebSocket webSocket, int code, String reason) {
connected.set(false);
}
@Override
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
connecting.set(false);
connected.set(false);
if (t instanceof SocketException || t instanceof ProtocolException || t instanceof SSLException || t instanceof UnknownHostException) {
Log.e(TAG, "Error while connecting to Socket, going to try again: " + t.getClass().getSimpleName());
disconnect();
if (!(t instanceof SSLPeerUnverifiedException))
loginMessage(false, FAILURE_REASON_GENERIC);
return;
}
Log.e(TAG, "Error from onFailure()", t);
}
}
}