package com.door43.translationstudio.service;
import android.content.Intent;
import android.os.Binder;
import android.os.Bundle;
import android.os.IBinder;
import com.door43.tools.reporting.Logger;
import com.door43.translationstudio.AppContext;
import com.door43.translationstudio.core.TargetTranslationMigrator;
import com.door43.translationstudio.core.Translator;
import com.door43.translationstudio.device2device.SocketMessages;
import com.door43.translationstudio.network.Connection;
import com.door43.translationstudio.network.Peer;
import com.door43.util.RSAEncryption;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.DataInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.math.BigInteger;
import java.net.InetAddress;
import java.net.Socket;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* This class provides an importing service (effectively a client) that can
* communicate with an exporting service (server) to browse and retrieve translations
*/
public class ClientService extends NetworkService {
private static final String PARAM_PUBLIC_KEY = "param_public_key";
private static final String PARAM_PRIVATE_KEY = "param_private_key";
private static final String PARAM_DEVICE_ALIAS = "param_device_alias";
private final IBinder binder = new LocalBinder();
private OnClientEventListener listener;
private Map<String, Connection> serverConnections = new HashMap<>();
private PrivateKey privateKey;
private String publicKey;
private static Boolean isRunning = false;
private String deviceAlias;
private Map<UUID, Request> requests = new HashMap<>();
/**
* Sets whether or not the service is running
* @param running
*/
protected void setRunning(Boolean running) {
isRunning = running;
}
/**
* Checks if the service is currently running
* @return
*/
public static boolean isRunning() {
return isRunning;
}
@Override
public IBinder onBind(Intent intent) {
return binder;
}
public void setOnClientEventListener(OnClientEventListener callback) {
listener = callback;
if(isRunning() && listener != null) {
listener.onClientServiceReady();
}
}
@Override
public int onStartCommand(Intent intent, int flags, int startid) {
if(intent != null) {
Bundle args = intent.getExtras();
if (args != null && args.containsKey(PARAM_PRIVATE_KEY) && args.containsKey(PARAM_PUBLIC_KEY) && args.containsKey(PARAM_DEVICE_ALIAS)) {
privateKey = (PrivateKey) args.get(PARAM_PRIVATE_KEY);
publicKey = args.getString(PARAM_PUBLIC_KEY);
deviceAlias = args.getString(PARAM_DEVICE_ALIAS);
if (listener != null) {
listener.onClientServiceReady();
}
setRunning(true);
return START_STICKY;
}
}
Logger.e(this.getClass().getName(), "Import service requires arguments");
stopService();
return START_NOT_STICKY;
}
/**
* Establishes a TCP connection with the server.
* Once this connection has been made the cleanup thread won't identify the server as lost unless the tcp connection is also disconnected.
* @param server the server we will connect to
*/
public void connectToServer(Peer server) {
if(!serverConnections.containsKey(server.getIpAddress())) {
ServerThread serverThread = new ServerThread(server);
new Thread(serverThread).start();
}
}
/**
* Stops the service
*/
public void stopService() {
Logger.i(this.getClass().getName(), "Stopping client service");
// close sockets
for(String key: serverConnections.keySet()) {
serverConnections.get(key).close();
}
setRunning(false);
}
@Override
public void onDestroy() {
stopService();
}
/**
* Sends a message to the peer
* @param server the client to which the message will be sent
* @param message the message being sent to the client
*/
private void sendMessage(Peer server, String message) {
if (serverConnections.containsKey(server.getIpAddress())) {
if(server.isSecure()) {
// encrypt message
PublicKey key = RSAEncryption.getPublicKeyFromString(server.keyStore.getString(PeerStatusKeys.PUBLIC_KEY));
if(key != null) {
message = encryptMessage(key, message);
} else {
Logger.w(this.getClass().getName(), "Missing the server's public key");
message = SocketMessages.MSG_EXCEPTION;
}
}
serverConnections.get(server.getIpAddress()).write(message);
}
}
/**
* Requests a list of projects from the server
* @param server the server that will give the project list
* @param preferredLanguages the languages preferred by the client
*/
public void requestProjectList(Peer server, List<String> preferredLanguages) {
JSONArray languagesJson = new JSONArray();
for(String l:preferredLanguages) {
languagesJson.put(l);
}
sendMessage(server, SocketMessages.MSG_PROJECT_LIST + ":" + languagesJson);
}
/**
* Requests a target translation from the server
* @param server
* @param targetTranslationSlug
*/
public void requestTargetTranslation(Peer server, String targetTranslationSlug) {
JSONObject json = new JSONObject();
try {
json.put("target_translation_id", targetTranslationSlug);
Request request = new Request(Request.Type.TargetTranslation, json);
sendRequest(server, request);
} catch (JSONException e) {
if(listener != null) {
listener.onClientServiceError(e);
}
}
}
/**
* Handles the initial handshake and authorization
* @param server
* @param message
*/
private void onMessageReceived(Peer server, String message) {
if(server.isSecure() && server.hasIdentity()) {
message = decryptMessage(privateKey, message);
if(message != null) {
try {
Request request = Request.parse(message);
onRequestReceived(server, request);
} catch (JSONException e) {
if(listener != null) {
listener.onClientServiceError(e);
} else {
Logger.e(this.getClass().getName(), "Failed to parse request", e);
}
}
} else if(listener != null) {
listener.onClientServiceError(new Exception("Message descryption failed"));
}
} else if(!server.isSecure()){
// receive the key
try {
JSONObject json = new JSONObject(message);
server.keyStore.add(PeerStatusKeys.PUBLIC_KEY, json.getString("key"));
server.setIsSecure(true);
} catch (JSONException e) {
Logger.w(this.getClass().getName(), "Invalid request: " + message, e);
// sendMessage(server, SocketMessages.MSG_INVALID_REQUEST);
}
// send public key
try {
JSONObject json = new JSONObject();
json.put("key", publicKey);
// TRICKY: manually write to server so we don't encrypt it
if(serverConnections.containsKey(server.getIpAddress())) {
serverConnections.get(server.getIpAddress()).write(json.toString());
}
} catch (JSONException e) {
if(listener != null) {
listener.onClientServiceError(e);
}
}
// send identity
if(server.isSecure()) {
try {
JSONObject json = new JSONObject();
json.put("name", deviceAlias);
if(AppContext.isTablet()) {
json.put("device", "tablet");
} else {
json.put("device", "phone");
}
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(AppContext.udid().getBytes("UTF-8"));
byte[] digest = md.digest();
BigInteger bigInt = new BigInteger(1, digest);
String hash = bigInt.toString();
json.put("id", hash);
sendMessage(server, json.toString());
} catch (Exception e){
Logger.w(this.getClass().getName(), "Failed to prepare response ", e);
if(listener != null) {
listener.onClientServiceError(e);
}
}
}
} else if(!server.hasIdentity()) {
// receive identity
message = decryptMessage(privateKey, message);
try {
JSONObject json = new JSONObject(message);
server.setName(json.getString("name"));
server.setDevice(json.getString("device"));
server.setId(json.getString("id"));
server.setHasIdentity(true);
if(listener != null) {
listener.onServerConnectionChanged(server);
}
} catch (JSONException e) {
Logger.w(this.getClass().getName(), "Invalid request: " + message, e);
}
}
}
/**
* Sends a request to a peer.
* Requests are stored for reference when the client responds to the request
* @param client
* @param request
*/
private void sendRequest(Peer client, Request request) {
if(serverConnections.containsKey(client.getIpAddress()) && client.isSecure()) {
// remember request
this.requests.put(request.uuid, request);
// send request
sendMessage(client, request.toString());
}
}
/**
* Handles commands sent from the server
* @param server
* @param request
*/
private void onRequestReceived(final Peer server, Request request) {
JSONObject contextJson = request.context;
switch(request.type) {
case AlertTargetTranslation:
queueRequest(server, request);
break;
case TargetTranslation:
if(requests.containsKey(request.uuid)) {
requests.remove(request.uuid);
// receive file download details
int port;
final long size;
final String name;
try {
port = contextJson.getInt("port");
size = contextJson.getLong("size");
name = contextJson.getString("name");
} catch (JSONException e) {
if(listener != null) {
listener.onClientServiceError(e);
} else {
Logger.e(this.getClass().getName(), "Invalid context", e);
}
break;
}
// open download socket
openReadSocket(server, port, new OnSocketEventListener() {
@Override
public void onOpen(Connection connection) {
connection.setOnCloseListener(new Connection.OnCloseListener() {
@Override
public void onClose() {
if (listener != null) {
listener.onClientServiceError(new Exception("Socket was closed before download completed"));
}
}
});
File file = null;
try {
file = File.createTempFile("p2p", name);
// download archive
DataInputStream in = new DataInputStream(connection.getSocket().getInputStream());
file.getParentFile().mkdirs();
file.createNewFile();
OutputStream out = new FileOutputStream(file.getAbsolutePath());
byte[] buffer = new byte[8 * 1024];
int totalCount = 0;
int count;
while ((count = in.read(buffer)) > 0) {
totalCount += count;
server.keyStore.add(PeerStatusKeys.PROGRESS, totalCount / ((int) size) * 100);
if (listener != null) {
listener.onServerConnectionChanged(server);
}
out.write(buffer, 0, count);
}
server.keyStore.add(PeerStatusKeys.PROGRESS, 0);
if (listener != null) {
listener.onServerConnectionChanged(server);
}
out.close();
in.close();
// import the target translation
Translator translator = AppContext.getTranslator();
// TODO: 11/23/2015 perform a diff first
try {
String[] targetTranslationSlugs = translator.importArchive(file);
if(listener != null) {
listener.onReceivedTargetTranslations(server, targetTranslationSlugs);
}
} catch (Exception e) {
e.printStackTrace();
}
file.delete();
} catch (IOException e) {
Logger.e(this.getClass().getName(), "Failed to download the file", e);
if(file != null) {
file.delete();
}
if (listener != null) {
listener.onClientServiceError(e);
}
}
}
});
} else {
// the server is trying to send the target translation without asking
// TODO: 12/1/2015 accept according to user configuration
}
break;
default:
Logger.i(this.getClass().getName(), "received invalid request from " + server.getIpAddress() + ": " + request.toString());
}
}
/**
* Queues a request to be reviewed by the user
*
* @param server
* @param request
*/
private void queueRequest(Peer server, Request request) {
server.queueRequest(request);
if(this.listener != null) {
this.listener.onReceivedRequest(server, request);
}
}
/**
* Interface for communication with service clients.
*/
public interface OnClientEventListener {
void onClientServiceReady();
void onServerConnectionLost(Peer peer);
void onServerConnectionChanged(Peer peer);
void onClientServiceError(Throwable e);
// void onReceivedProjectList(Peer server, Model[] models);
// void onReceivedProject(Peer server, ProjectImport[] importStatuses);
void onReceivedTargetTranslations(Peer server, String[] targetTranslations);
void onReceivedRequest(Peer peer, Request request);
}
/**
* Class to retrieve instance of service
*/
public class LocalBinder extends Binder {
public ClientService getServiceInstance() {
return ClientService.this;
}
}
/**
* Manages a single server connection on it's own thread
*/
private class ServerThread implements Runnable {
private Connection mConnection;
private Peer mServer;
public ServerThread(Peer server) {
mServer = server;
}
@Override
public void run() {
// set up sockets
try {
InetAddress serverAddr = InetAddress.getByName(mServer.getIpAddress());
mConnection = new Connection(new Socket(serverAddr, mServer.getPort()));
mConnection.setOnCloseListener(new Connection.OnCloseListener() {
@Override
public void onClose() {
Thread.currentThread().interrupt();
}
});
// we store references to all connections so we can access them later
if(!serverConnections.containsKey(mConnection.getIpAddress())) {
addPeer(mServer);
serverConnections.put(mConnection.getIpAddress(), mConnection);
} else {
// we already have a connection to this server
mConnection.close();
return;
}
} catch (Exception e) {
// the connection could not be established
if(mConnection != null) {
mConnection.close();
}
if(listener != null) {
listener.onClientServiceError(e);
}
return;
}
// begin listening to server
while (!Thread.currentThread().isInterrupted()) {
String message = mConnection.readLine();
if(message == null) {
Thread.currentThread().interrupt();
} else {
onMessageReceived(mServer, message);
}
}
// close the connection
mConnection.close();
// remove all instances of the peer
if(serverConnections.containsKey(mConnection.getIpAddress())) {
serverConnections.remove(mConnection.getIpAddress());
}
removePeer(mServer);
if(listener != null) {
listener.onServerConnectionLost(mServer);
}
}
}
}