Browse Source

Import existing sources

Many things may still be broken.
tags/v0.1.0
Lennart Grahl 3 years ago
commit
80e1905516
43 changed files with 2683 additions and 0 deletions
  1. 10
    0
      .gitignore
  2. 23
    0
      build.gradle
  3. BIN
      gradle/wrapper/gradle-wrapper.jar
  4. 6
    0
      gradle/wrapper/gradle-wrapper.properties
  5. 164
    0
      gradlew
  6. 90
    0
      gradlew.bat
  7. BIN
      libs/armeabi-v7a/libjingle_peerconnection_so.so
  8. BIN
      libs/autobahn-0.5.0.jar
  9. BIN
      libs/libjingle_peerconnection.jar
  10. BIN
      libs/x86/libjingle_peerconnection_so.so
  11. 1
    0
      settings.gradle
  12. 70
    0
      src/main/java/org/saltyrtc/client/Chunkifier.java
  13. 62
    0
      src/main/java/org/saltyrtc/client/ClientState.java
  14. 374
    0
      src/main/java/org/saltyrtc/client/DataChannel.java
  15. 16
    0
      src/main/java/org/saltyrtc/client/DataChannelMessageDispatcher.java
  16. 62
    0
      src/main/java/org/saltyrtc/client/EncryptedChannel.java
  17. 5
    0
      src/main/java/org/saltyrtc/client/ErrorStateHandler.java
  18. 29
    0
      src/main/java/org/saltyrtc/client/Handler.java
  19. 45
    0
      src/main/java/org/saltyrtc/client/InternalState.java
  20. 93
    0
      src/main/java/org/saltyrtc/client/InternalStateListener.java
  21. 161
    0
      src/main/java/org/saltyrtc/client/KeyStore.java
  22. 20
    0
      src/main/java/org/saltyrtc/client/MessageDispatcher.java
  23. 443
    0
      src/main/java/org/saltyrtc/client/PeerConnection.java
  24. 28
    0
      src/main/java/org/saltyrtc/client/PeerConnectionMessageDispatcher.java
  25. 31
    0
      src/main/java/org/saltyrtc/client/Session.java
  26. 513
    0
      src/main/java/org/saltyrtc/client/Signaling.java
  27. 56
    0
      src/main/java/org/saltyrtc/client/SignalingMessageDispatcher.java
  28. 65
    0
      src/main/java/org/saltyrtc/client/State.java
  29. 39
    0
      src/main/java/org/saltyrtc/client/StateDispatcher.java
  30. 5
    0
      src/main/java/org/saltyrtc/client/StateHandler.java
  31. 8
    0
      src/main/java/org/saltyrtc/client/StateListener.java
  32. 10
    0
      src/main/java/org/saltyrtc/client/StateType.java
  33. 101
    0
      src/main/java/org/saltyrtc/client/States.java
  34. 73
    0
      src/main/java/org/saltyrtc/client/Unchunkifier.java
  35. 7
    0
      src/main/java/org/saltyrtc/client/exceptions/ConversionException.java
  36. 19
    0
      src/main/java/org/saltyrtc/client/exceptions/CryptoException.java
  37. 7
    0
      src/main/java/org/saltyrtc/client/exceptions/CryptoFailedException.java
  38. 7
    0
      src/main/java/org/saltyrtc/client/exceptions/DispatchException.java
  39. 10
    0
      src/main/java/org/saltyrtc/client/exceptions/InvalidChunkException.java
  40. 7
    0
      src/main/java/org/saltyrtc/client/exceptions/InvalidKeyException.java
  41. 4
    0
      src/main/java/org/saltyrtc/client/exceptions/OtherKeyMissingException.java
  42. 4
    0
      src/main/java/org/saltyrtc/client/exceptions/SessionUnavailableException.java
  43. 15
    0
      src/test/java/LibraryTest.java

+ 10
- 0
.gitignore View File

@@ -0,0 +1,10 @@
# Vim
*.swp

# IntelliJ
*.iml
.idea

# Gradle
.gradle
local.properties

+ 23
- 0
build.gradle View File

@@ -0,0 +1,23 @@
// Apply the java plugin to add support for Java
apply plugin: 'java'

// In this section you declare where to find the dependencies of your project
repositories {
jcenter()
}

// In this section you declare the dependencies for your production and test code
dependencies {
// The production code uses the SLF4J logging API at compile time
compile 'org.slf4j:slf4j-api:1.7.21'

// Dependencies
compile files('libs/libjingle_peerconnection.jar')
compile files('libs/autobahn-0.5.0.jar')

// Declare the dependency for your favourite test framework you want to use in your tests.
// TestNG is also supported by the Gradle Test task. Just change the
// testCompile dependency to testCompile 'org.testng:testng:6.8.1' and add
// 'test.useTestNG()' to your build script.
testCompile 'junit:junit:4.12'
}

BIN
gradle/wrapper/gradle-wrapper.jar View File


+ 6
- 0
gradle/wrapper/gradle-wrapper.properties View File

@@ -0,0 +1,6 @@
#Mon May 23 09:10:43 CEST 2016
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-2.13-bin.zip

+ 164
- 0
gradlew View File

@@ -0,0 +1,164 @@
#!/usr/bin/env bash

##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################

# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null

APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`

# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""

# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"

warn ( ) {
echo "$*"
}

die ( ) {
echo
echo "$*"
echo
exit 1
}

# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac

CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar

# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME

Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.

Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi

# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi

# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi

# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`

# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option

if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi

# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"

exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

+ 90
- 0
gradlew.bat View File

@@ -0,0 +1,90 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

BIN
libs/armeabi-v7a/libjingle_peerconnection_so.so View File


BIN
libs/autobahn-0.5.0.jar View File


BIN
libs/libjingle_peerconnection.jar View File


BIN
libs/x86/libjingle_peerconnection_so.so View File


+ 1
- 0
settings.gradle View File

@@ -0,0 +1 @@
rootProject.name = 'saltyrtc-client-java'

+ 70
- 0
src/main/java/org/saltyrtc/client/Chunkifier.java View File

@@ -0,0 +1,70 @@
package org.saltyrtc.client;

import java.nio.ByteBuffer;
import java.util.Iterator;

/**
* Iterates over chunks of bytes and injects a 'more' flag byte.
*/
public class Chunkifier implements Iterable<ByteBuffer> {
private final byte[] bytes;
private final int chunkSize; // First byte is being used as a 'more' flag

public Chunkifier(byte[] bytes, int chunkSize) {
this.bytes = bytes;
this.chunkSize = chunkSize;
}

@Override
public Iterator<ByteBuffer> iterator() {
return new Iterator<ByteBuffer>() {
private int index = 0;

public int offset() {
return this.offset(this.index);
}

public int offset(int index) {
return index * (chunkSize - 1);
}

@Override
public boolean hasNext() {
return offset() < bytes.length;
}

public boolean hasNext(int index) {
return offset(index) < bytes.length;
}

@Override
public ByteBuffer next() {
// More chunks?
byte moreChunks;
if (hasNext(this.index + 1)) {
moreChunks = (byte) 0x01;
} else {
moreChunks = (byte) 0x00;
}

// Put more chunks indicator and bytes into buffer
// Note: 'allocateDirect' does NOT work, DO NOT CHANGE!
int offset = offset();
int length = Math.min(chunkSize, bytes.length + 1 - offset);
ByteBuffer buffer = ByteBuffer.allocate(length);
buffer.put(moreChunks);
buffer.put(bytes, offset, length - 1);

// Flip offset and remaining length for reading
buffer.flip();
this.index += 1;
return buffer;
}

@Override
public void remove() {
throw new UnsupportedOperationException();
}
};
}
}

+ 62
- 0
src/main/java/org/saltyrtc/client/ClientState.java View File

@@ -0,0 +1,62 @@
package org.saltyrtc.client;

import java.util.HashMap;

/**
* Summarises various general states to simplify recognition of the current state.
*/
public class ClientState extends State {
public class StateValue {
public final static String RESET = "reset";
public final static String TIMEOUT = "timeout";
public final static String LOST = "lost";
public final static String CONNECTED = "connected";
public final static String UNSTABLE = "unstable";
public final static String DISCONNECTED = "disconnected";
}

public ClientState(String name) {
super(name);
this.type = StateType.DANGER;
this.value = StateValue.DISCONNECTED;
}

public void update(int type, String value) {
this.type = type;
this.value = value;

// Broadcast
this.notifyListeners();
}

public void update(HashMap<String, InternalState> states) {
int weight = 0;

// Calculate client state type and value
for (InternalState state : states.values()) {
weight += state.type;
}

// Data channel open and PC at most unstable: Force warning if danger
if (weight >= StateType.DANGER
&& states.get("dc").type == StateType.SUCCESS
&& states.get("pc").type != StateType.DANGER) {
weight = StateType.WARNING;
}

// Calculate state type
if (weight < StateType.WARNING) {
this.type = StateType.SUCCESS;
this.value = StateValue.CONNECTED;
} else if (weight < StateType.DANGER) {
this.type = StateType.WARNING;
this.value = StateValue.UNSTABLE;
} else {
this.type = StateType.DANGER;
this.value = StateValue.DISCONNECTED;
}

// Broadcast
this.notifyListeners();
}
}

+ 374
- 0
src/main/java/org/saltyrtc/client/DataChannel.java View File

@@ -0,0 +1,374 @@
package org.saltyrtc.client;

import android.util.Log;

import org.saltyrtc.client.exceptions.CryptoException;
import org.saltyrtc.client.exceptions.InvalidChunkException;
import org.saltyrtc.client.Utils;

import org.json.JSONException;
import org.json.JSONObject;
import org.webrtc.DataChannel.Buffer;
import org.webrtc.DataChannel.Observer;
import org.webrtc.DataChannel.State;

import java.nio.ByteBuffer;
import java.util.ArrayList;

/**
* Handles the communication between browser and app.
* Note: Public methods can be used safely from any thread.
*/
public class DataChannel extends EncryptedChannel {
protected static final String NAME = "DataChannel";
protected static final String LABEL = "saltyrtc";
protected static final int HEARTBEAT_ACK_TIMEOUT = 10000;
protected static final int MTU = 16384;
protected State state;
protected org.webrtc.DataChannel dc;
protected ArrayList<JSONObject> cached;
protected Events events;
// Ex protected
public final StateDispatcher stateDispatcher = new StateDispatcher();
// Ex protected
public final DataChannelMessageDispatcher messageDispatcher = new DataChannelMessageDispatcher();
protected final HeartbeatAckTimer heartbeatAckTimer = new HeartbeatAckTimer();
protected String heartbeat = null;

public interface MessageListener {
void onMessage(JSONObject message);
}

/**
* Handles data channel events and dispatches messages.
* TODO: So... where are the error events?
*/
protected class Events implements Observer, Unchunkifier.Events {
private volatile boolean stopped = false;
private final Unchunkifier unchunkifier;

public Events() {
this.unchunkifier = new Unchunkifier(this);
}

public void stop() {
this.stopped = true;
}

// Note: For some reason this method is called twice when the data channel closes.
@Override
public void onStateChange() {
Handler.post(new Runnable() {
@Override
public void run() {
if (!stopped) {
setState(dc.state());
}
}
});
}

@Override
public void onMessage(Buffer buffer) {
if (this.stopped) {
return;
}
if (!buffer.binary) {
Log.w(NAME, "Ignored ASCII message");
} else {
// Note: Buffer need to be received directly as it will be disposed after return
try {
this.unchunkifier.add(buffer.data);
} catch (InvalidChunkException e) {
stateDispatcher.error("chunk", "Invalid chunk received: " + e.moreChunks);
}
}
}

@Override
public void onCompletedMessage(final ByteBuffer buffer) {
// Now that the bytes have been fetched from the buffer, we can safely dispatch
Handler.post(new Runnable() {
@Override
public void run() {
if (!stopped) {
receive(buffer);
}
}
});
}
}

protected class HeartbeatAckTimer implements Runnable {
@Override
public void run() {
Log.e(NAME, "Heartbeat ack timeout");
stateDispatcher.error("timeout", "Heartbeat ack timeout");
dc.close();
}
}

public DataChannel() {
this.reset(true);
}

// Ex protected
public void reset() {
this.reset(false);
}

// Ex protected
public void reset(boolean hard) {
// Set to unknown state
this.setState(null);

// Close and reset event instance
if (this.events != null) {
this.events.stop();
}
this.events = new Events();

// Cancel and reset heartbeat ack timer
this.cancelHeartbeatAckTimer();
// Reset heartbeat content
this.heartbeat = null;

// Close data channel instance
if (this.dc != null) {
this.dc.close();
this.dc.dispose();
this.dc = null;
}

// Hard reset?
if (!hard) {
return;
}

// Cached messages
this.cached = new ArrayList<>();
}

protected void setInstance(final org.webrtc.DataChannel dc) {
this.dc = dc;
// Register events on data channel instance
dc.registerObserver(this.events);
// Set the initial state of the data channel instance
setState(dc.state());
}

protected void setState(final State state) {
// Special case: Unknown state
if (state == null) {
// Ignore repeated state changes
if (this.state == null) {
Log.d(NAME, "Ignoring repeated state: unknown");
return;
}

// Update state and notify listeners
this.state = null;
this.stateDispatcher.state("unknown");
} else {
// Ignore repeated state changes
if (state == this.state) {
Log.d(NAME, "Ignoring repeated state: " + state.toString().toLowerCase());
return;
}

// Update state and notify listeners
this.state = state;
this.stateDispatcher.state(state.toString().toLowerCase());
}
}

// Ex protected
public boolean close() {
if (this.dc != null) {
this.dc.close();
return true;
} else {
return false;
}
}

protected void startHeartbeatAckTimer() {
Handler.postDelayed(this.heartbeatAckTimer, HEARTBEAT_ACK_TIMEOUT);
}

protected void cancelHeartbeatAckTimer() {
Handler.removeCallbacks(this.heartbeatAckTimer);
}

// Ex protected
public void sendCached() {
Log.d(NAME, "Sending " + this.cached.size() + " delayed messages");
for (JSONObject message : this.cached) {
this.send(message);
}
this.cached.clear();
}

// Ex protected
public void sendMessage(JSONObject inner) {
// Build JSON
JSONObject message = new JSONObject();
try {
// Prepare data
message.put("type", "message");
message.put("data", inner);
} catch (JSONException e) {
Log.e(NAME, "Message encode error: " + e.toString());
e.printStackTrace();
this.stateDispatcher.error("encode", e.toString());
return;
}

// Send message
this.send(message);
}

// Ex protected
public void receiveMessage(JSONObject inner) {
Log.d(NAME, "Broadcasting message");
this.messageDispatcher.message(inner);
}

protected void sendHeartbeat() {
this.sendHeartbeat(Utils.getRandomString());
}

protected void sendHeartbeat(String content) {
Log.d(NAME, "Sending heartbeat");

// Store heartbeat
this.heartbeat = content;

// Build JSON
JSONObject message = new JSONObject();
try {
// Prepare data
message.put("type", "heartbeat");
message.put("data", content);
} catch (JSONException e) {
Log.e(NAME, "Heartbeat encode error: " + e.toString());
e.printStackTrace();
this.stateDispatcher.error("encode", e.toString());
return;
}

// Start timer and send heartbeat
this.startHeartbeatAckTimer();
this.send(message);
}

protected void receiveHeartbeatAck(String content) {
// Validate heartbeat ack
if (this.heartbeat == null) {
Log.w(NAME, "Ignored heartbeat-ack that has not been sent");
return;
}
if (!content.equals(this.heartbeat)) {
Log.e(NAME, "Heartbeat-ack does not match, expected: " + this.heartbeat +
"received: " + content);
this.stateDispatcher.error("heartbeat", "Content did not match");
} else {
Log.d(NAME, "Received heartbeat-ack");
this.heartbeat = null;
// Cancel heartbeat ack timer
this.cancelHeartbeatAckTimer();
}
}

protected void receiveHeartbeat(String content) {
Log.d(NAME, "Received heartbeat");
this.sendHeartbeatAck(content);
}

protected void sendHeartbeatAck(String content) {
Log.d(NAME, "Sending heartbeat-ack");

// Build JSON
JSONObject message = new JSONObject();
try {
// Prepare data
message.put("type", "heartbeat-ack");
message.put("data", content);
} catch (JSONException e) {
this.stateDispatcher.error("encode", e.toString());
Log.e(NAME, "Heartbeat ack encode error: " + e.toString());
e.printStackTrace();
return;
}

// Send heartbeat ack
this.send(message);
}

protected void send(JSONObject message) {
// Delay sending until connected
if (this.dc != null && this.state == State.OPEN) {
KeyStore.Box box;
try {
// Encrypt data
box = this.encrypt(message.toString());
} catch (CryptoException e) {
this.stateDispatcher.error(e.getState(), e.getError());
return;
}

// Send chunks
String sizeKb = String.format("%.2f", ((float) box.getSize()) / 1024);
Log.d(NAME, "Sending message (size: " + sizeKb + " KB): " + message);
Chunkifier chunkifier = new Chunkifier(box.getBuffer().array(), MTU);
for (ByteBuffer chunk : chunkifier) {
// Wrap buffer into data channel buffer and set the 'binary' flag
Buffer buffer = new Buffer(chunk, true);

// Send buffer content
this.dc.send(buffer);
}
} else {
Log.d(NAME, "Delaying message until channel is open");
this.cached.add(message);
}
}

protected void receive(ByteBuffer buffer) {
final String data;
KeyStore.Box box = new KeyStore.Box(buffer);
String sizeKb = String.format("%.2f", ((float) box.getSize()) / 1024);

// Decrypt data
try {
data = this.decrypt(box);
} catch (CryptoException e) {
stateDispatcher.error(e.getState(), e.getError());
return;
}

try {
// Decode data
Log.d(NAME, "Received message (size: " + sizeKb + " KB): " + data);
JSONObject message = new JSONObject(data);
String type = message.getString("type");

// Relay message
//noinspection IfCanBeSwitch
if (type.equals("message")) {
JSONObject inner = message.getJSONObject("data");
receiveMessage(inner);
} else if (type.equals("heartbeat-ack")) {
String content = message.getString("data");
receiveHeartbeatAck(content);
} else if (type.equals("heartbeat")) {
String content = message.getString("data");
receiveHeartbeat(content);
} else {
Log.w(NAME, "Ignored message: " + data);
}
} catch (JSONException e) {
Log.w(NAME, "Ignored invalid message: " + data);
}
}
}

+ 16
- 0
src/main/java/org/saltyrtc/client/DataChannelMessageDispatcher.java View File

@@ -0,0 +1,16 @@
package org.saltyrtc.client;

import org.json.JSONObject;

public class DataChannelMessageDispatcher extends MessageDispatcher<DataChannel.MessageListener> {
protected void message(final JSONObject message) {
Handler.post(new Runnable() {
@Override
public void run() {
if (listener != null) {
listener.onMessage(message);
}
}
});
}
}

+ 62
- 0
src/main/java/org/saltyrtc/client/EncryptedChannel.java View File

@@ -0,0 +1,62 @@
package org.saltyrtc.client;

import org.saltyrtc.client.exceptions.CryptoException;
import org.saltyrtc.client.exceptions.CryptoFailedException;
import org.saltyrtc.client.exceptions.OtherKeyMissingException;

import java.io.UnsupportedEncodingException;

/**
* Handles binary packing and unpacking.
* Simplifies the usage of KeyStore by handling all exceptions.
*/
public class EncryptedChannel {
protected static final String NAME = "EncryptedChannel";

protected KeyStore.Box encrypt(String message) throws CryptoException {
// Encrypt data
try {
return KeyStore.encrypt(message);
} catch (OtherKeyMissingException e) {
this.throwCryptoException(
e, "key", "Cannot encrypt, public key of recipient is missing");
} catch (UnsupportedEncodingException e) {
this.throwCryptoException(
e, "encode", "Cannot encrypt, UTF-8 encoding not supported");
} catch (CryptoFailedException e) {
this.throwCryptoException(
e, "crypto", "Cannot encrypt, invalid data or keys don't match");
}

// Unreachable section
return null;
}

protected final String decrypt(KeyStore.Box box) throws CryptoException {
// Decrypt data
try {
return KeyStore.decrypt(box);
} catch (OtherKeyMissingException e) {
this.throwCryptoException(
e, "key", "Cannot decrypt, public key of recipient is missing");
} catch (UnsupportedEncodingException e) {
this.throwCryptoException(
e, "encode", "Cannot decrypt, UTF-8 encoding not supported");
} catch (CryptoFailedException e) {
this.throwCryptoException(
e, "crypto", "Cannot decrypt, invalid data or keys don't match");
}

// Unreachable section
return null;
}

protected void throwCryptoException(
Exception e,
final String state,
final String error
) throws CryptoException {
e.printStackTrace();
throw new CryptoException(state, error);
}
}

+ 5
- 0
src/main/java/org/saltyrtc/client/ErrorStateHandler.java View File

@@ -0,0 +1,5 @@
package org.saltyrtc.client;

public interface ErrorStateHandler {
void handle(final String error);
}

+ 29
- 0
src/main/java/org/saltyrtc/client/Handler.java View File

@@ -0,0 +1,29 @@
package org.saltyrtc.client;

/**
* A globally accessible handler proxy that is used for the whole package
* to avoid the need for synchronisation.
*/
public class Handler {
protected static android.os.Handler handler;

/**
* Note: This method has to be called before any other method can be called safely.
* @param handler A handler instance.
*/
public static void setHandler(android.os.Handler handler) {
Handler.handler = handler;
}

public static boolean post(Runnable runnable) {
return handler.post(runnable);
}

public static boolean postDelayed(Runnable runnable, long delay) {
return handler.postDelayed(runnable, delay);
}

public static void removeCallbacks(Runnable runnable) {
handler.removeCallbacks(runnable);
}
}

+ 45
- 0
src/main/java/org/saltyrtc/client/InternalState.java View File

@@ -0,0 +1,45 @@
package org.saltyrtc.client;

import android.util.Log;

import java.util.HashMap;

/**
* Contains type and value of a specific state instance.
* Note: Public methods can be used safely from any thread.
*/
public class InternalState extends State {
protected final HashMap<String, Integer> rules = new HashMap<>();

public InternalState(String name) {
super(name);
this.reset();
}

protected void reset() {
this.type = StateType.DANGER;
this.value = "unknown";
}

protected void addRules(HashMap<String, Integer> rules) {
this.rules.putAll(rules);
}

protected void update(String value) {
// Find state type for value in rules
Integer type = this.rules.get(value);
if (type == null) {
Log.w(name, "Unknown state '" + value + "' for " + this.toString());
Log.d(name, "Rules: " + this.rules.toString());
type = StateType.DANGER;
value = "unknown";
}

// Update type and value
this.type = type;
this.value = value;

// Broadcast
this.notifyListeners();
}
}

+ 93
- 0
src/main/java/org/saltyrtc/client/InternalStateListener.java View File

@@ -0,0 +1,93 @@
package org.saltyrtc.client;

import android.util.Log;

import java.util.HashMap;

/**
* Listener for state change events.
*/
public abstract class InternalStateListener {
protected static final String NAME = "InternalStateListener";
protected final HashMap<String, StateHandler> stateHandler = new HashMap<>();
protected final HashMap<String, ErrorStateHandler> errorHandler = new HashMap<>();

/**
* Will be called for all state changes.
* @param state A state.
*/
public abstract void onState(final String state);

/**
* Will always be called for all error states.
* @param state An error state.
* @param error An error message.
*/
public abstract void onError(final String state, final String error);

/**
* Will pass the state to onState first.
* Checks if a state handler for a specific state exists and invokes it.
*/
protected void handleState(String state) {
this.onState(state);
if (this.stateHandler != null && this.stateHandler.containsKey(state)) {
Log.d(NAME, "Calling handler for state: " + state);
final StateHandler handler = this.stateHandler.get(state);

// Handle state in runnable
Handler.post(new Runnable() {
@Override
public void run() {
handler.handle();
}
});
}
}

/**
* Will pass the error state to onError first.
* Checks if a error state handler for a specific error state exists and invokes it.
*/
protected void handleError(String state, final String error) {
this.onError(state, error);
if (this.errorHandler != null && this.errorHandler.containsKey(state)) {
Log.d(NAME, "Calling handler for error state: " + state);
final ErrorStateHandler handler = this.errorHandler.get(state);

// Handle error state in runnable
Handler.post(new Runnable() {
@Override
public void run() {
handler.handle(error);
}
});
}
}

/**
* Add a state handler. State handlers will be executed in the event looper.
* @param state The state the handler will be applied on.
* @param handler State handler instance.
*/
protected void addStateHandler(String state, StateHandler handler) {
this.stateHandler.put(state, handler);
}

protected void removeStateHandler(String state) {
this.stateHandler.remove(state);
}

/**
* Add an error state handler. Error state handlers will be executed in the event looper.
* @param state The error state the handler will be applied on.
* @param handler Error state handler instance.
*/
protected void addErrorHandler(String state, ErrorStateHandler handler) {
this.errorHandler.put(state, handler);
}

protected void removeErrorHandler(String state) {
this.errorHandler.remove(state);
}
}

+ 161
- 0
src/main/java/org/saltyrtc/client/KeyStore.java View File

@@ -0,0 +1,161 @@
package org.saltyrtc.client;

import android.util.Log;
import org.saltyrtc.client.exceptions.CryptoFailedException;
import org.saltyrtc.client.exceptions.InvalidKeyException;
import org.saltyrtc.client.exceptions.OtherKeyMissingException;
import org.saltyrtc.client.Utils;

import com.neilalexander.jnacl.NaCl;

import java.io.UnsupportedEncodingException;
import java.nio.ByteBuffer;
import java.security.SecureRandom;

/**
* Handles encrypting and decrypting messages for the peers.
* Note: This class is thread safe.
*/
public class KeyStore {
protected static final String name = "KeyStore";
protected static boolean setupDone = false;
protected static final byte[] privateKey = new byte[NaCl.SECRETKEYBYTES];
protected static final byte[] publicKey = new byte[NaCl.PUBLICKEYBYTES];
protected static byte[] otherKey = null;
protected static NaCl nacl = null;
protected static final SecureRandom random = new SecureRandom();

public static class Box {
public final byte[] nonce;
public final byte[] data;

public Box(byte[] nonce, byte[] data) {
this.nonce = nonce;
this.data = data;
}

public Box(ByteBuffer buffer) {
// Unpack nonce
this.nonce = new byte[NaCl.NONCEBYTES];
buffer.get(nonce, 0, NaCl.NONCEBYTES);

// Unpack data
this.data = new byte[buffer.remaining()];
buffer.get(data);
}

public int getSize() {
return this.nonce.length + this.data.length;
}

public ByteBuffer getBuffer() {
// Pack data
// Note: 'allocateDirect' does NOT work, DO NOT CHANGE!
ByteBuffer box = ByteBuffer.allocate(this.getSize());
box.put(this.nonce);
box.put(this.data);

// Flip offset and remaining length for reading
box.flip();

// Return box as byte buffer
return box;
}
}

public synchronized static String getPublicKey() {
return NaCl.asHex(publicKey);
}

public synchronized static void setOtherKey(String otherKey) throws InvalidKeyException {
// Start setup if required
setup();
// Store binary key
KeyStore.otherKey = NaCl.getBinary(otherKey);
// Create getNaCl for encryption and decryption
try {
nacl = new NaCl(privateKey, KeyStore.otherKey);
} catch (Error e) {
throw new InvalidKeyException(e.toString());
}
}

public synchronized static NaCl getNaCl() throws OtherKeyMissingException {
if (nacl == null) {
throw new OtherKeyMissingException();
}
return nacl;
}

public synchronized static void setup() {
// Skip if already set up
if (setupDone) {
return;
}

// Generate key pair
Log.d(name, "Generating new key pair");
NaCl.genkeypair(KeyStore.publicKey, KeyStore.privateKey);
Log.d(name, "Private key: " + NaCl.asHex(KeyStore.privateKey));
Log.d(name, "Public key: " + NaCl.asHex(KeyStore.publicKey));
setupDone = true;

// Make sure encryption and decryption works properly
// TODO: Self-test
NaCl nacl = KeyStore.nacl;
KeyStore.nacl = new NaCl(privateKey, publicKey);
String expected = Utils.getRandomString();
try {
if (!decrypt(encrypt(expected)).equals(expected)) {
throw new AssertionError("Self-test failed");
}
} catch (Exception e) {
Log.e(name, "Self-test failed");
e.printStackTrace();
}
Log.d(name, "Self-test passed");
KeyStore.nacl = nacl;
}

public static Box encrypt(String message) throws
OtherKeyMissingException, UnsupportedEncodingException,
CryptoFailedException {
// Convert string to bytes
byte[] data = message.getBytes("UTF-8");

// Generate random nonce
byte[] nonce = new byte[NaCl.NONCEBYTES];
random.nextBytes(nonce);

// Encrypt data with keys and nonce
try {
data = getNaCl().encrypt(data, nonce);
} catch (Error e) {
throw new CryptoFailedException(e.toString());
}
if (data == null) {
throw new CryptoFailedException("Encrypted data is null");
}

// Return box
return new Box(nonce, data);
}

public static String decrypt(Box box) throws
OtherKeyMissingException, UnsupportedEncodingException,
CryptoFailedException {
// Decrypt data
byte[] data;
try {
data = getNaCl().decrypt(box.data, box.nonce);
} catch (Error e) {
throw new CryptoFailedException(e.toString());
}
if (data == null) {
throw new CryptoFailedException("Decrypted data is null");
}

// Return data as string
return new String(data, "UTF-8");
}
}

+ 20
- 0
src/main/java/org/saltyrtc/client/MessageDispatcher.java View File

@@ -0,0 +1,20 @@
package org.saltyrtc.client;

public class MessageDispatcher<ML> {
protected ML listener = null;

protected ML getListener() {
return listener;
}

// Ex protected
public void setListener(ML messageListener) {
this.listener = messageListener;
}

public void removeListener(ML messageListener) {
if (this.listener == messageListener) {
this.listener = null;
}
}
}

+ 443
- 0
src/main/java/org/saltyrtc/client/PeerConnection.java View File

@@ -0,0 +1,443 @@
package org.saltyrtc.client;

import android.content.Context;
import android.util.Log;
import org.webrtc.*;
import org.webrtc.PeerConnection.*;

import java.util.ArrayList;
import java.util.LinkedList;

/**
* The connection between the peers (obviously). Creates the data channel and handles connection
* events.
* Public methods can be used safely from any thread.
*/
public class PeerConnection {
protected static final String NAME = "PeerConnection";
protected PeerConnectionFactory factory;
protected String state = null;
protected org.webrtc.PeerConnection pc;
protected final org.saltyrtc.client.DataChannel dc;
protected MediaConstraints constraints = new MediaConstraints();
protected LinkedList<IceServer> iceServers = new LinkedList<>();
protected boolean descriptionsExchanged;
protected ArrayList<IceCandidate> localCandidates;
protected ArrayList<IceCandidate> remoteCandidates;
protected Events events;
protected LocalDescriptionEvents localDescriptionEvents;
protected RemoteDescriptionEvents remoteDescriptionEvents;
// Ex protected
public final StateDispatcher stateDispatcher = new StateDispatcher();
// Ex protected
public final PeerConnectionMessageDispatcher messageDispatcher = new PeerConnectionMessageDispatcher();

/**
* Listener for message (answer and candidate) dispatch request events.
*/
public interface MessageListener {
void onAnswer(SessionDescription description);
void onCandidate(IceCandidate candidate);
}

/**
* Handles signaling server events and dispatches messages.
*/
protected class Events implements Observer {
private volatile boolean stopped = false;

public void stop() {
this.stopped = true;
}

@Override
public void onRenegotiationNeeded() {
if (!this.stopped) {
Log.w(NAME, "Ignored renegotiation request");
}
}

@Override
public void onIceCandidate(final IceCandidate iceCandidate) {
// Note: This check might not be necessary but the browser does it as well
if (iceCandidate != null) {
Handler.post(new Runnable() {
@Override
public void run() {
if (!stopped) {
sendCandidate(iceCandidate);
}
}
});
}
}

@Override
public void onSignalingChange(SignalingState signalingState) {
if (!this.stopped) {
Log.d(NAME, "Ignored signaling state change to: " + signalingState.toString());
}
}

@Override
public void onAddStream(MediaStream mediaStream) {
if (!this.stopped) {
Log.w(NAME, "Ignored incoming media stream");
}
}

@Override
public void onRemoveStream(MediaStream mediaStream) {
if (!this.stopped) {
Log.w(NAME, "Ignored media stream removal");
}
}

@Override
public void onIceConnectionChange(IceConnectionState iceConnectionState) {
final String state = iceConnectionState.toString().toLowerCase();
// Set state
Handler.post(new Runnable() {
@Override
public void run() {
if (!stopped) {
setState(state);
}
}
});
}

@Override
public void onIceGatheringChange(IceGatheringState iceGatheringState) {
if (this.stopped) {
return;
}
Log.d(NAME, "Ignored ICE gathering state change to: " + iceGatheringState.toString());
}

@Override
public void onDataChannel(final org.webrtc.DataChannel incomingDc) {
// Validate label
if (incomingDc.label().equals(org.saltyrtc.client.DataChannel.LABEL)) {
// Set incoming data channel instance
Handler.post(new Runnable() {
@Override
public void run() {
if (stopped) {
return;
}
Log.i(NAME, "Received channel");
dc.setInstance(incomingDc);
}
});
} else {
Log.w(NAME, "Ignored channel with label: " + incomingDc.label());
}
}
}

/**
* Handles session description events that have been emitted by the local side.
*/
protected class LocalDescriptionEvents implements SdpObserver {
private volatile boolean stopped = false;

public void stop() {
this.stopped = true;
}

@Override
public void onCreateSuccess(final SessionDescription originalDescription) {
// Set local description after creation
Handler.post(new Runnable() {
@Override
public void run() {
if (stopped) {
return;
}
// Dirty hack to increase the application specific bandwidth
// Note: This setting exists because the current SCTP over DTLS implementation
// lacks a flow control mechanism. However, 30 kbps is really not what
// we want here, so we increase the allowed bandwidth.
final SessionDescription modifiedDescription;
SessionDescription.Type type = originalDescription.type;
String[] parts = originalDescription.description.split("b=AS:30");
if (parts.length == 2) {
Log.d(NAME, "Overriding bandwidth setting to 100 Mbps");
modifiedDescription = new SessionDescription(
type, (parts[0] + "b=AS:102400" + parts[1]));
} else {
Log.w(NAME, "Couldn't override bandwidth setting");
modifiedDescription = originalDescription;
}
pc.setLocalDescription(localDescriptionEvents, modifiedDescription);
}
});
}

@Override
public void onSetSuccess() {
Log.d(NAME, "Local description set");
Handler.post(new Runnable() {
@Override
public void run() {
if (stopped) {
return;
}
// Answer created
SessionDescription description = pc.getLocalDescription();
// Note: So this thing can be null for some idiotic reason...
if (description == null) {
stateDispatcher.error("local", "Local description was null");
return;
}
// Send the answer
messageDispatcher.answer(description);
// Send the local candidates and set the remote candidates
handleCachedCandidates();
}
});
}

@Override
public void onCreateFailure(String error) {
if (this.stopped) {
return;
}
Log.e(NAME, "Creating answer failed: " + error);
stateDispatcher.error("create", error);
}

@Override
public void onSetFailure(String error) {
if (this.stopped) {
return;
}
Log.e(NAME, "Setting local description failed: " + error);
stateDispatcher.error("local", error);
}
}

/**
* Handles session description events that have been emitted by the remote side.
*/
protected class RemoteDescriptionEvents implements SdpObserver {
private volatile boolean stopped = false;

public void stop() {
this.stopped = true;
}

@Override
public void onCreateSuccess(SessionDescription description) {
if (this.stopped) {
return;
}
// Note: Not used, should never trigger
Log.w(NAME, "Ignored remote description create event");
}

@Override
public void onSetSuccess() {
Log.d(NAME, "Remote description set");
Handler.post(new Runnable() {
@Override
public void run() {
if (stopped) {
return;
}
// Offer received: Send answer
sendAnswer();
}
});
}

@Override
public void onCreateFailure(String error) {
if (this.stopped) {
return;
}
// Note: Not used, should never trigger
Log.w(NAME, "Ignored remote description creation failure: " + error);
}

@Override
public void onSetFailure(String error) {
if (this.stopped) {
return;
}
Log.e(NAME, "Setting remote description failed: " + error);
stateDispatcher.error("remote", error);
}
}

/**
* TODO: Description
*
* @param dc The used data channel (wrapper) instance.
* @param context Required because WebRTC init stuff... dunno, just don't ask
*/
public PeerConnection(org.saltyrtc.client.DataChannel dc, Context context) {
this.dc = dc;

// Set initial state
this.setState("unknown");

// Some init stuff... no idea why it's required and what it's doing exactly but otherwise
// the app crashes...
PeerConnectionFactory.initializeFieldTrials(null);

// For some shitty reason we need to initialise audio here...
// See: https://code.google.com/p/webrtc/issues/detail?id=3416
if (!PeerConnectionFactory.initializeAndroidGlobals(
context, true, false, false, null
)) {
Log.e(NAME, "Initialising Android globals failed!");
this.stateDispatcher.error("init", "Initialising Android globals failed");
return;
}

// Now we can safely create the factory... hopefully...
this.factory = new PeerConnectionFactory();

// Session description constraints
this.constraints.mandatory.add(new MediaConstraints.KeyValuePair(
"OfferToReceiveVideo", "false"
));
this.constraints.mandatory.add(new MediaConstraints.KeyValuePair(
"OfferToReceiveAudio", "false"
));

// Set ice servers
this.iceServers.add(new IceServer(
"turn:example.org",
"user",
"pass"
));
}

protected void setState(String state) {
// Ignore repeated state changes
if (state.equals(this.state)) {
Log.d(NAME, "Ignoring repeated state: " + state);
return;
}

// Update state and notify listeners
this.state = state;
this.stateDispatcher.state(state);
}

// Ex protected
public void reset() {
this.setState("unknown");

// Close and reset event instances
if (this.events != null) {
this.events.stop();
this.localDescriptionEvents.stop();
this.remoteDescriptionEvents.stop();
}
this.events = new Events();
this.localDescriptionEvents = new LocalDescriptionEvents();
this.remoteDescriptionEvents = new RemoteDescriptionEvents();

// Close peer connection instance
if (this.pc != null) {
Log.d(NAME, "Closing");
this.pc.close();
this.pc.dispose();
this.pc = null;
}
this.descriptionsExchanged = false;

// Cached ICE candidates
this.localCandidates = new ArrayList<>();
this.remoteCandidates = new ArrayList<>();
}

// Ex protected
public void create() {
this.create(null, null);
}

protected void create(MediaConstraints constraints, LinkedList<IceServer> iceServers) {
// Override defaults
if (constraints != null) {
this.constraints = constraints;
}
if (iceServers != null) {
this.iceServers = iceServers;
}

// Enable data channel communication with Firefox and Chromium
// Note: This shouldn't be necessary anymore but it doesn't do any harm either
MediaConstraints peerConstraints = new MediaConstraints();
peerConstraints.optional.add(new MediaConstraints.KeyValuePair(
"DtlsSrtpKeyAgreement", "true"
));

// Create peer connection
this.setState("init");
this.pc = this.factory.createPeerConnection(
this.iceServers, peerConstraints, this.events
);
Log.d(NAME, "Peer Connection created");
}

// Ex protected
public void receiveOffer(SessionDescription description) {
Log.i(NAME, "Received offer");
this.pc.setRemoteDescription(this.remoteDescriptionEvents, description);
}

protected void sendAnswer() {
Log.i(NAME, "Creating answer");
this.pc.createAnswer(this.localDescriptionEvents, this.constraints);
}

protected void sendCandidate(IceCandidate candidate) {
if (this.descriptionsExchanged) {
// Send candidate
Log.d(NAME, "Broadcasting candidate");
this.messageDispatcher.candidate(candidate);
} else {
// Cache candidates if no answer has been received yet
this.localCandidates.add(candidate);
}
}

// Ex protected
public void receiveCandidate(IceCandidate candidate) {
Log.d(NAME, "Received candidate");
if (!this.descriptionsExchanged) {
// Queue candidates if not connected
// Note: This is required because the app will crash if a candidate is added
// before the local description has been set.
Log.d(NAME, "Delaying setting remote candidate until descriptions have been exchanged");
this.remoteCandidates.add(candidate);
} else {
// Note: A weird freeze occurred here... if this happens again, you're fucked!
this.pc.addIceCandidate(candidate);
Log.d(NAME, "Candidate set");
}
}

protected void handleCachedCandidates() {
this.descriptionsExchanged = true;

// Send cached local candidates
Log.d(NAME, "Sending " + this.localCandidates.size() + " delayed local candidates");
for (IceCandidate candidate : this.localCandidates) {
this.sendCandidate(candidate);
}
this.localCandidates.clear();

// Set cached remote candidates
Log.d(NAME, "Setting " + this.remoteCandidates.size() + " delayed remote candidates");
for (IceCandidate candidate : this.remoteCandidates) {
this.pc.addIceCandidate(candidate);
}
this.remoteCandidates.clear();
}
}

+ 28
- 0
src/main/java/org/saltyrtc/client/PeerConnectionMessageDispatcher.java View File

@@ -0,0 +1,28 @@
package org.saltyrtc.client;

import org.webrtc.IceCandidate;
import org.webrtc.SessionDescription;

public class PeerConnectionMessageDispatcher extends MessageDispatcher<PeerConnection.MessageListener> {
protected void answer(final SessionDescription description) {
Handler.post(new Runnable() {
@Override
public void run() {
if (listener != null) {
listener.onAnswer(description);
}
}
});
}

protected void candidate(final IceCandidate candidate) {
Handler.post(new Runnable() {
@Override
public void run() {
if (listener != null) {
listener.onCandidate(candidate);
}
}
});
}
}

+ 31
- 0
src/main/java/org/saltyrtc/client/Session.java View File

@@ -0,0 +1,31 @@
package org.saltyrtc.client;

import android.util.Log;
import org.saltyrtc.client.exceptions.SessionUnavailableException;

public class Session {
protected static final String NAME = "Session";
protected static String id = null;

protected synchronized static boolean equals(String otherId) {
return id != null && id.equals(otherId);
}

protected synchronized static String get() throws SessionUnavailableException {
if (id == null) {
throw new SessionUnavailableException();
}
return id;
}

protected synchronized static void set(String id) {
Log.d(NAME, "New: " + id);
Session.id = id;
}

// Ex protected
public synchronized static void reset() {
id = null;
Log.d(NAME, "Reset");
}
}

+ 513
- 0
src/main/java/org/saltyrtc/client/Signaling.java View File

@@ -0,0 +1,513 @@
package org.saltyrtc.client;


import android.util.Log;

import org.saltyrtc.client.exceptions.CryptoException;
import org.saltyrtc.client.exceptions.SessionUnavailableException;
import de.tavendo.autobahn.WebSocketConnection;
import de.tavendo.autobahn.WebSocketException;
import de.tavendo.autobahn.WebSocketHandler;
import de.tavendo.autobahn.WebSocketOptions;
import org.json.JSONException;
import org.json.JSONObject;
import org.webrtc.IceCandidate;
import org.webrtc.SessionDescription;

import java.nio.ByteBuffer;
import java.util.ArrayList;

/**
* The signaling channel used to exchange metadata of the peers.
* Note: Public methods can be used safely from any thread.
*/
public class Signaling extends EncryptedChannel {
protected static final String NAME = "Signaling";
protected static final String DEFAULT_URL = "ws://127.0.0.1:8765/";
protected static final int CONNECT_MAX_RETRIES = 10;
protected static final int CONNECT_RETRY_INTERVAL = 10000;
protected String path;
protected String url;
protected String state;
protected final WebSocketOptions options;
protected WebSocketConnection ws = null;
protected int connectTries;
protected ArrayList<CachedItem> cached;
protected Events events;
// Ex protected
public final StateDispatcher stateDispatcher = new StateDispatcher();
// Ex protected
public final SignalingMessageDispatcher messageDispatcher = new SignalingMessageDispatcher();
protected final ConnectTimer connectTimer = new ConnectTimer();

public Signaling() {
// Set timeout option
this.options = new WebSocketOptions();
this.options.setSocketConnectTimeout(CONNECT_RETRY_INTERVAL);

// Store own public key for announcement
this.reset(true);
}

/**
* Listener for message (answer and candidate) dispatch request events.
*/
public interface MessageListener {
void onReset();
void onSendError();
void onOffer(SessionDescription description);
void onCandidate(IceCandidate candidate);
}

protected class CachedItem {
private final JSONObject message;
private final boolean encrypt;

public CachedItem(JSONObject message, boolean encrypt) {
this.message = message;
this.encrypt = encrypt;
}
}

protected class ConnectTimer implements Runnable {
@Override
public void run() {
connectTries += 1;
Log.w(NAME, "Connect timeout, retry " +
connectTries + "/" + CONNECT_MAX_RETRIES);
connect(path, url);
}
}

/**
* Handles signaling events and dispatches messages.
* Note: It is vital that messages are always processed with the same amount of post calls
* to the event loop. This will avoid message reordering while processing.
*/
protected class Events extends WebSocketHandler {
private volatile boolean stopped = false;

public void stop() {
this.stopped = true;
}

@Override
public void onOpen() {
// Web socket connection is ready for sending and receiving
Handler.post(new Runnable() {
@Override
public void run() {
if (!stopped) {
setState("open");
}
}
});
}

@Override
public void onClose(final int code, String reason) {
Log.d(NAME, "Connection closed with code: " + code + ", reason: " + reason);
// Web Socket connection has been closed
Handler.post(new Runnable() {
@Override
public void run() {
if (stopped) {
return;
}
// Note: We don't need a timer like in the browser version here
if (code == CLOSE_CANNOT_CONNECT) {
reconnect(0);
} else {
setState("closed");
}
}
});
}

@Override
public void onTextMessage(final String payload) {
// A message has been received
Handler.post(new Runnable() {
@Override
public void run() {
if (!stopped) {
receiveText(payload);
}
}
});
}

@Override
public void onRawTextMessage(byte[] payload) {
if (this.stopped) {
return;
}
Log.w(NAME, "Ignored raw text message");
}

@Override
public void onBinaryMessage(final byte[] payload) {
if (this.stopped) {
return;
}

// Note: Bytes need to be received directly as they might be disposed after return
final String data;
try {
data = decrypt(new KeyStore.Box(ByteBuffer.wrap(payload)));
} catch (CryptoException e) {
stateDispatcher.error(e.getState(), e.getError());
return;
}

// Now that the bytes have been fetched from the buffer, we can safely dispatch the
// data (if it could be decrypted)
Handler.post(new Runnable() {
@Override
public void run() {
if (stopped) {
return;
}
receiveBinary(data);
}
});
}
}

protected void setState(String state) {
// Ignore repeated state changes
if (state.equals(this.state)) {
Log.d(NAME, "Ignoring repeated state: " + state);
return;
}

// Update state and notify listeners
this.state = state;
this.stateDispatcher.state(state);

// Open?
if (state.equals("open")) {
// Reset connect counter
this.connectTries = 0;
}
}

public synchronized void reset() {
this.reset(false);
}

// Ex protected
public synchronized void reset(boolean hard) {
this.setState("unknown");

// Close and reset event instance
if (this.events != null) {
this.events.stop();
}
this.events = new Events();

// Close web socket instance
if (this.ws != null && this.ws.isConnected()) {
Log.d(NAME, "Disconnecting");
this.ws.disconnect();
}
// Note: This is required because the web socket can't disconnect reliably, thus
// a new instance is required.
this.ws = null;

// Hard reset?
if (!hard) {
return;
}

// Reset connect counter
this.connectTries = 0;

// Clear cached messages
this.clear();
}

// Ex protected
public void clear() {
this.cached = new ArrayList<>();
}

// Ex protected
public void connect(String path) {
this.connect(path, DEFAULT_URL);
}

protected void connect(String path, String url) { // TODO: Use WSS
// Store path and URL
this.path = path;
this.url = url;

// Give up?
if (this.connectTries == CONNECT_MAX_RETRIES) {
this.connectTries = 0;
Log.e(NAME, "Connecting failed");
this.setState("failed");
return;
}

// Reset and create web socket instance
this.reset();
this.ws = new WebSocketConnection();
Log.d(NAME, "Created");
this.setState("connecting");

// Connect
Log.d(NAME, "Connecting to path: " + path);
try {
this.ws.connect(url + path, this.events, this.options);
} catch (WebSocketException e) {
Log.e(NAME, "Connect error: " + e.toString());
this.stateDispatcher.error("connect", e.toString());
}
}

public void reconnect() {
this.reconnect(CONNECT_RETRY_INTERVAL);
}

public void reconnect(int delay) {
this.restartConnectTimer(delay);
}

public void sendHello() {
Log.d(NAME, "Sending hello");

// Build JSON
String type = "hello-server";
JSONObject message = new JSONObject();
try {
// Prepare data
message.put("type", type);
message.put("key", KeyStore.getPublicKey());
} catch (JSONException e) {
Log.e(NAME, "Hello encode error: " + e.toString());
this.stateDispatcher.error("encode", e.toString());
return;
}

// Send hello
this.send(message, false);
}

// Ex protected
public void sendReset() {
Log.d(NAME, "Sending reset");

// Build JSON
String type = "reset";
JSONObject message = new JSONObject();
try {
// Prepare data
message.put("type", type);
} catch (JSONException e) {
Log.e(NAME, "Reset encode error: " + e.toString());
e.printStackTrace();
this.stateDispatcher.error("encode", e.toString());
return;
}

// Send reset
this.send(message, false);
}

protected void receiveReset() {
Log.d(NAME, "Broadcasting reset");
this.messageDispatcher.reset();
}

protected void receiveSendError() {
Log.d(NAME, "Broadcasting send error");
this.messageDispatcher.sendError();
}

protected void receiveOffer(SessionDescription offer, String session) {
Log.d(NAME, "Broadcasting offer");
this.messageDispatcher.offer(offer, session);
}

// Ex protected
public void sendAnswer(SessionDescription description) {
Log.d(NAME, "Sending answer");

// Build JSON
String type = "answer";
JSONObject message = new JSONObject();
JSONObject payload = new JSONObject();
try {
// Prepare payload
payload.put("type", type);
payload.put("sdp", description.description);

// Prepare data
message.put("type", type);
message.put("session", Session.get());
message.put("data", payload);
} catch (JSONException e) {
Log.e(NAME, "Answer encode error: " + e.toString());
e.printStackTrace();