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.Library;
import com.door43.translationstudio.core.SourceTranslation;
import com.door43.translationstudio.core.TargetTranslation;
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.JSONException;
import org.json.JSONObject;
import java.io.BufferedInputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.math.BigInteger;
import java.net.ServerSocket;
import java.net.Socket;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
/**
* This class provides an exporting service (effectively a server) from which
* other devices may browse and retreive translations
*/
public class ServerService extends NetworkService {
public static final String PARAM_PRIVATE_KEY = "param_private_key";
public static final String PARAM_PUBLIC_KEY = "param_public_key";
public static final String PARAM_DEVICE_ALIAS = "param_device_alias";
private static Boolean mIsRunning = false;
private final IBinder mBinder = new LocalBinder();
private OnServerEventListener listener;
private int mPort = 0;
private Thread mServerThread;
private Map<String, Connection> mClientConnections = new HashMap<>();
private PrivateKey privateKey;
private String mPublicKey;
private ServerSocket mServerSocket;
private String deviceAlias;
private Map<UUID, Request> requests = new HashMap<>();
@Override
public IBinder onBind(Intent intent) {
return mBinder;
}
/**
* Sets whether or not the service is running
* @param running
*/
protected void setRunning(Boolean running) {
mIsRunning = running;
}
/**
* Checks if the service is currently running
* @return
*/
public static boolean isRunning() {
return mIsRunning;
}
public void setOnServerEventListener(OnServerEventListener callback) {
listener = callback;
if(isRunning() && listener != null) {
listener.onServerServiceReady(mPort);
}
}
@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);
mPublicKey = args.getString(PARAM_PUBLIC_KEY);
deviceAlias = args.getString(PARAM_DEVICE_ALIAS);
mServerThread = new Thread(new ServerRunnable());
mServerThread.start();
return START_STICKY;
}
}
Logger.e(this.getClass().getName(), "Export service requires arguments");
stopService();
return START_NOT_STICKY;
}
@Override
public void onDestroy() {
stopService();
}
/**
* Stops the service
*/
public void stopService() {
Logger.i(this.getClass().getName(), "Stopping export service");
if(mServerThread != null) {
mServerThread.interrupt();
}
if(mServerSocket != null) {
try {
mServerSocket.close();
} catch (IOException e) {
Logger.e(this.getClass().getName(), "Failed to close server socket", e);
}
}
Connection[] clients = mClientConnections.values().toArray(new Connection[mClientConnections.size()]);
for(Connection c:clients) {
c.close();
}
mClientConnections.clear();
setRunning(false);
}
/**
* Sends a message to the peer
* @param client the client to which the message will be sent
* @param message the message being sent to the client
*/
private void sendMessage(Peer client, String message) {
if (mClientConnections.containsKey(client.getIpAddress())) {
if(client.isSecure()) {
// encrypt message
PublicKey key = RSAEncryption.getPublicKeyFromString(client.keyStore.getString(PeerStatusKeys.PUBLIC_KEY));
if(key != null) {
message = encryptMessage(key, message);
} else {
Logger.w(this.getClass().getName(), "Missing the client's public key");
message = SocketMessages.MSG_EXCEPTION;
}
}
mClientConnections.get(client.getIpAddress()).write(message);
}
}
/**
* 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(mClientConnections.containsKey(client.getIpAddress()) && client.isSecure()) {
// remember request
this.requests.put(request.uuid, request);
// send request
sendMessage(client, request.toString());
}
}
/**
* Accepts a client connection
* @param peer
*/
public void acceptConnection(Peer peer) {
peer.setIsAuthorized(true);
// send public key
try {
JSONObject json = new JSONObject();
json.put("key", mPublicKey);
// TRICKY: we manually write to peer so we don't encrypt it
if(mClientConnections.containsKey(peer.getIpAddress())) {
mClientConnections.get(peer.getIpAddress()).write(json.toString());
}
} catch (JSONException e) {
Logger.w(this.getClass().getName(), "Failed to prepare response ", e);
if(listener != null) {
listener.onServerServiceError(e);
}
}
}
/**
* Handles the initial handshake and authorization
* @param client
* @param message
*/
private void onMessageReceived(Peer client, String message) {
if(client.isAuthorized()) {
if(client.isSecure() && client.hasIdentity()) {
message = decryptMessage(privateKey, message);
if(message != null) {
try {
Request request = Request.parse(message);
onRequestReceived(client, request);
} catch (JSONException e) {
if(listener != null) {
listener.onServerServiceError(e);
} else {
Logger.e(this.getClass().getName(), "Failed to parse request", e);
}
}
} else if(listener != null) {
listener.onServerServiceError(new Exception("Message descryption failed"));
}
} else if(!client.isSecure()){
// receive the key
try {
JSONObject json = new JSONObject(message);
client.keyStore.add(PeerStatusKeys.PUBLIC_KEY, json.getString("key"));
client.setIsSecure(true);
} catch (JSONException e) {
Logger.w(this.getClass().getName(), "Invalid request: " + message, e);
}
// send identity
if(client.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(client, json.toString());
} catch (Exception e){
Logger.w(this.getClass().getName(), "Failed to prepare response ", e);
if(listener != null) {
listener.onServerServiceError(e);
}
}
}
} else if(!client.hasIdentity()) {
// receive identity
message = decryptMessage(privateKey, message);
try {
JSONObject json = new JSONObject(message);
client.setName(json.getString("name"));
client.setDevice(json.getString("device"));
client.setId(json.getString("id"));
client.setHasIdentity(true);
if(listener != null) {
listener.onClientChanged(client);
}
} catch (JSONException e) {
Logger.w(this.getClass().getName(), "Invalid request: " + message, e);
}
}
} else {
Logger.w(this.getClass().getName(), "The client is not authorized");
sendMessage(client, SocketMessages.MSG_AUTHORIZATION_ERROR);
}
}
/**
* Handles commands sent from the client
* @param client
* @param request
*/
private void onRequestReceived(Peer client, Request request) {
JSONObject contextJson = request.context;
switch(request.type) {
case TargetTranslation:
String targetTranslationSlug = null;
try {
targetTranslationSlug = contextJson.getString("target_translation_id");
} catch (JSONException e) {
Logger.e(this.getClass().getName(), "invalid context", e);
break;
}
final File exportFile;
try {
exportFile = File.createTempFile(targetTranslationSlug, "." + Translator.ARCHIVE_EXTENSION);
} catch (IOException e) {
Logger.e(this.getClass().getName(), "Could not create a temp file", e);
break;
}
Translator translator = AppContext.getTranslator();
TargetTranslation targetTranslation = translator.getTargetTranslation(targetTranslationSlug);
if(targetTranslation != null) {
try {
targetTranslation.setDefaultContributor(AppContext.getProfile().getNativeSpeaker());
translator.exportArchive(targetTranslation, exportFile);
if(exportFile.exists()) {
ServerSocket fileSocket = openWriteSocket(new OnSocketEventListener() {
@Override
public void onOpen(Connection connection) {
try {
DataOutputStream out = new DataOutputStream(connection.getSocket().getOutputStream());
DataInputStream in = new DataInputStream(new BufferedInputStream(new FileInputStream(exportFile)));
byte[] buffer = new byte[8 * 1024];
int count;
while ((count = in.read(buffer)) > 0) {
out.write(buffer, 0, count);
}
out.close();
in.close();
} catch (IOException e) {
Logger.e(ServerService.class.getName(), "Failed to send the target translation", e);
}
}
});
// send file details
JSONObject targetTranslationContext = new JSONObject();
targetTranslationContext.put("port", fileSocket.getLocalPort());
targetTranslationContext.put("name", exportFile.getName());
targetTranslationContext.put("size", exportFile.length());
Request reply = request.makeReply(targetTranslationContext);
sendRequest(client, reply);
}
} catch (Exception e) {
// export failed
Logger.e(this.getClass().getName(), "Failed to export the archive", e);
}
} else {
// we don't have it
}
break;
case TargetTranslationList:
Logger.i(this.getClass().getName(), "received project list request from " + client.getIpAddress());
// send the project list to the client
// TODO: we shouldn't use the project manager here because this may be running in the background (eventually)
// read preferred source languages (for better readability on the client)
//// List<Language> preferredLanguages = new ArrayList<>();
// try {
//// JSONArray preferredLanguagesJson = contextJson.getJSONArray("preferred_source_language_ids");
//// for(int i = 0; i < preferredLanguagesJson.length(); i ++) {
//// Language lang = null;//AppContext.projectManager().getLanguage(preferredLanguagesJson.getString(i));
//// if(lang != null) {
//// preferredLanguages.add(lang);
//// }
//// }
// } catch (JSONException e) {
// Logger.e(this.getClass().getName(), "failed to parse preferred language list", e);
// }
// generate project library
// TODO: identifying the projects that have changes could be expensive if there are lots of clients and lots of projects. We might want to cache this
String library = null;//Sharing.generateLibrary(AppContext.projectManager().getProjectSlugs(), preferredLanguages);
sendMessage(client, SocketMessages.MSG_PROJECT_LIST + ":" + library);
break;
default:
Logger.i(this.getClass().getName(), "received invalid request from " + client.getIpAddress() + ": " + request.toString());
}
}
/**
* Offers a target translation to the peer
* @param client
* @param targetTranslationSlug
*/
public void offerTargetTranslation(Peer client, String targetTranslationSlug) {
Library library = AppContext.getLibrary();
TargetTranslation targetTranslation = AppContext.getTranslator().getTargetTranslation(targetTranslationSlug);
if(targetTranslation != null) {
SourceTranslation sourceTranslation = library.getDefaultSourceTranslation(targetTranslation.getProjectId(), Locale.getDefault().getLanguage());
if(sourceTranslation != null) {
try {
JSONObject json = new JSONObject();
json.put("target_translation_id", targetTranslation.getId());
json.put("package_version", TargetTranslation.PACKAGE_VERSION);
json.put("project_name", sourceTranslation.getProjectTitle());
json.put("target_language_name", targetTranslation.getTargetLanguageName());
json.put("progress", library.getTranslationProgress(targetTranslation));
Request request = new Request(Request.Type.AlertTargetTranslation, json);
sendRequest(client, request);
} catch (JSONException e) {
if (listener != null) {
listener.onServerServiceError(e);
}
}
} else {
// invalid project
}
} else {
// invalid target translation
}
}
/**
* Class to retrieve instance of service
*/
public class LocalBinder extends Binder {
public ServerService getServiceInstance() {
return ServerService.this;
}
}
/**
* Interface for communication with service clients.
*/
public interface OnServerEventListener {
void onServerServiceReady(int port);
void onClientConnected(Peer peer);
void onClientLost(Peer peer);
void onClientChanged(Peer peer);
void onServerServiceError(Throwable e);
}
/**
* Manage the server instance on it's own thread
*/
private class ServerRunnable implements Runnable {
public void run() {
Socket socket;
// set up sockets
try {
mServerSocket = new ServerSocket(0);
} catch (Exception e) {
if(listener != null) {
listener.onServerServiceError(e);
}
return;
}
mPort = mServerSocket.getLocalPort();
if(listener != null) {
listener.onServerServiceReady(mPort);
}
setRunning(true);
// begin listening for connections
while (!Thread.currentThread().isInterrupted()) {
try {
socket = mServerSocket.accept();
ClientRunnable clientRunnable = new ClientRunnable(socket);
new Thread(clientRunnable).start();
} catch (Exception e) {
if(!Thread.currentThread().isInterrupted()) {
Logger.e(this.getClass().getName(), "failed to accept socket", e);
}
}
}
try {
mServerSocket.close();
} catch (Exception e) {
Logger.e(this.getClass().getName(), "failed to shutdown the server socket", e);
}
}
}
/**
* Manages a single client connection on it's own thread
*/
private class ClientRunnable implements Runnable {
private Connection mConnection;
private Peer mClient;
public ClientRunnable(Socket clientSocket) {
// set up socket
try {
mConnection = new Connection(clientSocket);
mConnection.setOnCloseListener(new Connection.OnCloseListener() {
@Override
public void onClose() {
Thread.currentThread().interrupt();
}
});
// we store a reference to all connections so we can access them later
mClientConnections.put(mConnection.getIpAddress(), mConnection);
} catch (Exception e) {
if(listener != null) {
listener.onServerServiceError(e);
}
Thread.currentThread().interrupt();
}
// create a new peer
mClient = new Peer(clientSocket.getInetAddress().toString().replace("/", ""), clientSocket.getPort());
if(addPeer(mClient)) {
if(listener != null) {
listener.onClientConnected(mClient);
}
}
}
public void run() {
while (!Thread.currentThread().isInterrupted()) {
String message = mConnection.readLine();
if (message == null ){
Thread.currentThread().interrupt();
} else {
onMessageReceived(mClient, message);
}
}
// close the connection
mConnection.close();
// remove all instances of the peer
if(mClientConnections.containsKey(mConnection.getIpAddress())) {
mClientConnections.remove(mConnection.getIpAddress());
}
removePeer(mClient);
if(listener != null) {
listener.onClientLost(mClient);
}
}
}
}