Added module for TimeCraftersConfigurationTool to support adding server to FtcRobotController activity

This commit is contained in:
2020-09-25 09:25:22 -05:00
parent 70f315a35a
commit 86bfa02e6e
41 changed files with 1750 additions and 1 deletions

2
.idea/misc.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">

View File

@@ -21,6 +21,10 @@ android {
}
}
dependencies {
implementation project(path: ':TimeCraftersConfigurationTool')
}
repositories {
maven { url = "https://dl.bintray.com/first-tech-challenge/ftcsdk/" }

View File

@@ -121,6 +121,7 @@ import org.firstinspires.ftc.robotcore.internal.ui.UILocation;
import org.firstinspires.ftc.robotcore.internal.webserver.RobotControllerWebInfo;
import org.firstinspires.ftc.robotserver.internal.programmingmode.ProgrammingModeManager;
import org.firstinspires.inspection.RcInspectionActivity;
import org.timecrafters.TimeCraftersConfigurationTool.backend.Backend;
import java.util.List;
import java.util.Queue;
@@ -379,6 +380,12 @@ public class FtcRobotControllerActivity extends Activity
}
FtcAboutActivity.setBuildTimeFromBuildConfig(BuildConfig.BUILD_TIME);
// TODO: Allow disabling TAC Server when in a competition
if (true) {
new Backend();
Backend.instance().startServer();
}
}
protected UpdateUI createUpdateUI() {
@@ -458,6 +465,8 @@ public class FtcRobotControllerActivity extends Activity
if (preferencesHelper != null) preferencesHelper.getSharedPreferences().unregisterOnSharedPreferenceChangeListener(sharedPreferencesListener);
RobotLog.cancelWriteLogcatToDisk();
Backend.instance().stopServer();
}
protected void bindToService() {

View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,42 @@
apply plugin: 'com.android.library'
android {
compileSdkVersion 29
buildToolsVersion "30.0.1"
defaultConfig {
minSdkVersion 23
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation fileTree(dir: "libs", include: ["*.jar"])
// NOTE: Keep RobotCore version in sync with FtcRobotController modules version
implementation 'org.firstinspires.ftc:RobotCore:6.0.1'
implementation 'androidx.appcompat:appcompat:1.1.0'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}
repositories {
maven { url = "https://dl.bintray.com/first-tech-challenge/ftcsdk/" }
flatDir {
dirs '../libs'
}
}

View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,26 @@
package org.timecrafters.TimeCraftersConfigurationTool;
import android.content.Context;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("org.timecrafters.TimeCraftersConfigurationTool.test", appContext.getPackageName());
}
}

View File

@@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.timecrafters.TimeCraftersConfigurationTool">
/
</manifest>

View File

@@ -0,0 +1,394 @@
package org.timecrafters.TimeCraftersConfigurationTool.backend;
import android.content.Context;
import android.media.AudioAttributes;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.media.SoundPool;
import android.net.Uri;
import android.os.Build;
import android.util.Log;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import org.json.JSONException;
import org.timecrafters.TimeCraftersConfigurationTool.R;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Action;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Configuration;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Group;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Presets;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Variable;
import org.timecrafters.TimeCraftersConfigurationTool.serializers.ActionDeserializer;
import org.timecrafters.TimeCraftersConfigurationTool.serializers.ActionSerializer;
import org.timecrafters.TimeCraftersConfigurationTool.serializers.ConfigDeserializer;
import org.timecrafters.TimeCraftersConfigurationTool.serializers.ConfigSerializer;
import org.timecrafters.TimeCraftersConfigurationTool.serializers.ConfigurationDeserializer;
import org.timecrafters.TimeCraftersConfigurationTool.serializers.ConfigurationSerializer;
import org.timecrafters.TimeCraftersConfigurationTool.serializers.GroupDeserializer;
import org.timecrafters.TimeCraftersConfigurationTool.serializers.GroupSerializer;
import org.timecrafters.TimeCraftersConfigurationTool.serializers.PresetsDeserializer;
import org.timecrafters.TimeCraftersConfigurationTool.serializers.PresetsSerializer;
import org.timecrafters.TimeCraftersConfigurationTool.serializers.SettingsDeserializer;
import org.timecrafters.TimeCraftersConfigurationTool.serializers.SettingsSerializer;
import org.timecrafters.TimeCraftersConfigurationTool.serializers.VariableDeserializer;
import org.timecrafters.TimeCraftersConfigurationTool.serializers.VariableSerializer;
import org.timecrafters.TimeCraftersConfigurationTool.tacnet.PacketHandler;
import org.timecrafters.TimeCraftersConfigurationTool.tacnet.Server;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.FilenameFilter;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
public class Backend {
private static final String TAG = "Backend";
static private HashMap<String, Object> storage = new HashMap<>();
static private Backend instance;
public Context applicationContext;
private TACNET tacnet;
private Server server;
private Exception lastServerError;
private Config config;
private Settings settings;
private boolean configChanged, settingsChanged;
private MediaPlayer mediaPlayer;
public static HashMap<String, Object> getStorage() {
return storage;
}
public Backend() {
if (Backend.instance() != null) {
throw(new RuntimeException("Backend instance already exists!"));
} else {
instance = this;
}
loadSettings();
if (!settings.config.isEmpty()) {
loadConfig(settings.config);
}
tacnet = new TACNET();
configChanged = false;
settingsChanged = false;
}
static public Backend instance() {
return instance;
}
public TACNET tacnet() {
return tacnet;
}
public Server getServer() {
return server;
}
public void startServer() {
try {
server = new Server(settings.port);
server.start();
} catch (IOException error) {
lastServerError = error;
}
}
public void stopServer() {
if (server != null) {
try {
server.stop();
server = null;
} catch (IOException error) {
lastServerError = error;
}
}
}
public Exception getLastServerError() {
return lastServerError;
}
public Config getConfig() {
return config;
}
public Settings getSettings() {
return settings;
}
public void configChanged() {
config.getConfiguration().updatedAt = new Date();
config.getConfiguration().revision += 1;
configChanged = true;
saveConfig();
/* Automatically upload whole config to server
* TODO: Implement a more atomic remote config updating
* */
if (config != null && tacnet.isConnected()) {
String json = gsonForConfig().toJson(config);
tacnet.puts(PacketHandler.packetUploadConfig(config.getName(), json).toString());
}
}
public boolean hasConfigChanged() { return configChanged; }
public String configPath(String name) {
return TAC.CONFIGS_PATH + File.separator + name + ".json";
}
public void loadConfig(String name) {
if (name.equals("")) {
config = null;
return;
}
String path = configPath(name);
File file = new File(path);
if (file.exists() && file.isFile()) {
config = gsonForConfig().fromJson(readFromFile(path), Config.class);
config.setName(name);
}
}
public Config loadConfigWithoutMutatingBackend(String name) {
if (name.equals("")) {
return null;
}
String path = configPath(name);
File file = new File(path);
if (file.exists() && file.isFile()) {
Config config = gsonForConfig().fromJson(readFromFile(path), Config.class);
config.setName(name);
return config;
}
return null;
}
public boolean isConfigValid(String json) {
try {
gsonForConfig().fromJson(json, Config.class);
return true;
} catch (JsonSyntaxException ignored) {
return false;
}
}
public boolean saveConfig() {
if (config == null) { return false; }
final String path = configPath(getConfig().getName());
configChanged = false;
return writeToFile(path, gsonForConfig().toJson(config));
}
public boolean moveConfig(String oldName, String newName) {
final String oldPath = configPath(oldName);
final String newPath = configPath(newName);
final File oldFile = new File(oldPath);
final File newFile = new File(newPath);
if (!oldFile.exists() || !oldFile.isFile()) {
Log.e(TAG, "moveConfig: Can not move config file \"" + oldPath + "\" does not exists!");
return false;
}
if (newFile.exists() && newFile.isFile()) {
Log.e(TAG, "moveConfig: Config file \"" + newPath + "\" already exists!");
return false;
}
return oldFile.renameTo(newFile);
}
public boolean deleteConfig(String name) {
File file = new File(configPath(name));
return file.delete();
}
public void writeNewConfig(String name) {
String path = configPath(name);
File file = new File(path);
Config config = new Config(name);
Gson gson = new Gson();
try {
FileWriter fileWriter = new FileWriter(file);
fileWriter.write(gsonForConfig().toJson(config));
fileWriter.close();
} catch (IOException error) {
/* TODO */
Log.d(TAG, "writeNewConfig: IO Error: " + error.toString());
}
}
public ArrayList<String> configsList() {
ArrayList<String> list = new ArrayList<>();
File directory = new File(TAC.CONFIGS_PATH);
FilenameFilter filter = new FilenameFilter() {
@Override
public boolean accept(File dir, String name) {
return name.endsWith(".json");
}
};
File fileList[] = directory.listFiles(filter);
for (File file : fileList) {
list.add(file.getName().replace(".json", ""));
}
Collections.sort(list, new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return o1.toLowerCase().compareTo(o2.toLowerCase());
}
});
return list;
}
public Gson gsonForConfig() {
return new GsonBuilder()
.registerTypeAdapter(Config.class, new ConfigSerializer())
.registerTypeAdapter(Config.class, new ConfigDeserializer())
.registerTypeAdapter(Configuration.class, new ConfigurationSerializer())
.registerTypeAdapter(Configuration.class, new ConfigurationDeserializer())
.registerTypeAdapter(Group.class, new GroupSerializer())
.registerTypeAdapter(Group.class, new GroupDeserializer())
.registerTypeAdapter(Action.class, new ActionSerializer())
.registerTypeAdapter(Action.class, new ActionDeserializer())
.registerTypeAdapter(Variable.class, new VariableSerializer())
.registerTypeAdapter(Variable.class, new VariableDeserializer())
.registerTypeAdapter(Presets.class, new PresetsSerializer())
.registerTypeAdapter(Presets.class, new PresetsDeserializer())
.create();
}
public void settingsChanged() {
settingsChanged = true;
}
public boolean haveSettingsChanged() {
return settingsChanged;
}
public void loadSettings() {
File settingsFile = new File(TAC.SETTINGS_PATH);
if (!settingsFile.exists()) {
Log.i(TAG, "Writing default settings.json");
writeDefaultSettings();
}
try {
settings = gsonForSettings().fromJson(new FileReader(settingsFile), Settings.class);
} catch (FileNotFoundException e) {
// TODO
Log.e(TAG, "Unable to load settings.json");
}
}
public void saveSettings() {
Log.i(TAG, "Settings: " + gsonForSettings().toJson(settings));
writeToFile(TAC.SETTINGS_PATH, gsonForSettings().toJson(settings));
}
public void writeDefaultSettings() {
settings = new Settings(TACNET.DEFAULT_HOSTNAME, TACNET.DEFAULT_PORT, "");
saveSettings();
}
public Gson gsonForSettings() {
return new GsonBuilder()
.registerTypeAdapter(Settings.class, new SettingsSerializer())
.registerTypeAdapter(Settings.class, new SettingsDeserializer())
.create();
}
public String readFromFile(String path) {
StringBuilder text = new StringBuilder();
try {
BufferedReader br = new BufferedReader( new FileReader(path) );
String line;
while((line = br.readLine()) != null) {
text.append(line);
text.append("\n");
}
br.close();
} catch (IOException e) {
// TODO
}
return text.toString();
}
public boolean writeToFile(String filePath, String content) {
try {
if (filePath.startsWith(TAC.ROOT_PATH)) {
createFolders(filePath);
FileWriter writer = new FileWriter(filePath);
writer.write(content);
writer.close();
return true;
} else {
Log.e(TAG, "writeToFile disallowed path: " + filePath);
return false;
}
} catch (IOException e) {
Log.e(TAG, e.getLocalizedMessage());
return false;
}
}
private void createFolders(String filePath) throws IOException {
File rootPath = new File(TAC.ROOT_PATH);
File configsPath = new File(TAC.CONFIGS_PATH);
if (!rootPath.exists()) {
rootPath.mkdir();
}
if (!configsPath.exists()) {
configsPath.mkdir();
}
}
}

View File

@@ -0,0 +1,106 @@
package org.timecrafters.TimeCraftersConfigurationTool.backend;
import android.os.SystemClock;
import android.util.Log;
import org.timecrafters.TimeCraftersConfigurationTool.tacnet.Client;
import org.timecrafters.TimeCraftersConfigurationTool.tacnet.Connection;
import java.io.IOException;
public class TACNET {
private final static String TAG = "TACNET|TACNET";
public static final String DEFAULT_HOSTNAME = "192.168.49.1";
public static final int DEFAULT_PORT = 8962;
public static final int SYNC_INTERVAL = 250; // ms
public static final int HEARTBEAT_INTERVAL = 1_500; // ms
public enum Status {
CONNECTED,
CONNECTING,
CONNECTION_ERROR,
NOT_CONNECTED,
}
private Connection connection;
public void connect(String hostname, int port) {
if (connection != null && connection.isConnected()) {
return;
}
connection = new Connection(hostname, port);
connection.connect(new Runnable() {
@Override
public void run() {
Log.d(TAG, "run: " + connection.lastSocketError());
}
});
}
public Status status() {
if (isConnecting()) {
return Status.CONNECTING;
} else if (isConnectionError()) {
return Status.CONNECTION_ERROR;
} else if (isConnected()) {
return Status.CONNECTED;
} else {
return Status.NOT_CONNECTED;
}
}
public boolean isConnected() {
return connection != null && connection.isConnected();
}
public boolean isConnecting() {
return connection != null && !connection.isConnected() && !connection.socketError();
}
public boolean isConnectionError() {
return connection != null && connection.socketError();
}
public void close() {
if (connection != null) {
try {
connection.close();
} catch (IOException e) {}
connection = null;
}
}
public Client getClient() {
if (isConnected()) {
return connection.getClient();
} else {
return null;
}
}
public Connection getConnection() {
return connection;
}
public void puts(String message) {
if (isConnected()) {
connection.puts(message);
}
}
public String gets() {
if (isConnected()) {
return connection.gets();
} else {
return null;
}
}
public static long milliseconds() {
return SystemClock.elapsedRealtime();
}
}

View File

@@ -1,5 +1,9 @@
package org.timecrafters.TimeCraftersConfigurationTool.backend.config;
import android.util.Log;
import java.util.Arrays;
public class Variable {
public String name;
private String value;

View File

@@ -7,6 +7,7 @@ import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Action;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Group;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Variable;
import java.lang.reflect.Type;

View File

@@ -7,6 +7,7 @@ import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Action;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Group;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Variable;
import java.lang.reflect.Type;

View File

@@ -7,6 +7,7 @@ import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Configuration;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Variable;
import java.lang.reflect.Type;
import java.text.SimpleDateFormat;

View File

@@ -6,8 +6,11 @@ import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import org.timecrafters.TimeCraftersConfigurationTool.backend.Config;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Action;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Configuration;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Group;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Presets;
import java.lang.reflect.Type;
import java.util.ArrayList;

View File

@@ -6,7 +6,9 @@ import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import org.timecrafters.TimeCraftersConfigurationTool.backend.Config;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Action;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Configuration;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Group;
import java.lang.reflect.Type;

View File

@@ -2,6 +2,7 @@ package org.timecrafters.TimeCraftersConfigurationTool.serializers;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;

View File

@@ -1,7 +1,10 @@
package org.timecrafters.TimeCraftersConfigurationTool.serializers;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;

View File

@@ -6,9 +6,14 @@ import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Action;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Group;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Variable;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class VariableDeserializer implements JsonDeserializer<Variable> {
@Override

View File

@@ -0,0 +1,252 @@
package org.timecrafters.TimeCraftersConfigurationTool.tacnet;
import android.util.Log;
import org.timecrafters.TimeCraftersConfigurationTool.backend.Backend;
import org.timecrafters.TimeCraftersConfigurationTool.backend.TACNET;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
public class Client {
private Socket socket;
private BufferedReader bufferedReader;
private BufferedWriter bufferedWriter;
private String uuid;
private ArrayList<String> readQueue;
private ArrayList<String> writeQueue;
final private Object readQueueLock = new Object();
final private Object writeQueueLock = new Object();
private long syncInterval = TACNET.SYNC_INTERVAL;
private int packetsSent, packetsReceived = 0;
private long dataSent, dataReceived = 0;
private String TAG = "TACNET|Client";
private boolean socketError = false;
private String lastSocketError;
public Client() {
this.uuid = (UUID.randomUUID()).toString();
this.readQueue = new ArrayList<>();
this.writeQueue = new ArrayList<>();
}
public void setSyncInterval(long milliseconds) {
syncInterval = milliseconds;
}
public void setSocket(Socket socket) throws IOException {
this.socket = socket;
// This socket is for a "Connection" thus set a connect timeout
if (!this.socket.isBound()) {
this.socket.connect(new InetSocketAddress(Backend.instance().getSettings().hostname, Backend.instance().getSettings().port), 1500);
}
this.bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
this.bufferedWriter = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
startReader();
startWriter();
}
public ArrayList<String> readQueue() {
return readQueue;
}
public ArrayList<String> writeQueue() {
return writeQueue;
}
private void startReader() {
new Thread(new Runnable() {
@Override
public void run() {
while(!socket.isClosed()) {
// READER
try {
String message = read();
if (!message.equals("")) {
Log.i(TAG, "Read: " + message);
synchronized (readQueueLock) {
readQueue.add(message);
packetsReceived++;
dataReceived += message.length();
}
}
} catch (IOException e) {
Log.e(TAG, "Read error: " + e.getLocalizedMessage());
}
try {
TimeUnit.MILLISECONDS.sleep(syncInterval);
} catch (InterruptedException e) {}
}
}
}).start();
}
private void startWriter() {
new Thread(new Runnable() {
@Override
public void run() {
while(!socket.isClosed()) {
// WRITER
String message;
synchronized (writeQueueLock) {
for (Iterator itr = writeQueue.iterator(); itr.hasNext(); ) {
try {
message = (String) itr.next();
write(message);
packetsSent++;
dataSent += message.length();
Log.i(TAG, "Write: " + message);
itr.remove();
} catch (IOException e) {
Log.e(TAG, "Write error: " + e.getLocalizedMessage());
socketError = true;
lastSocketError = e.getLocalizedMessage();
try {
socket.close();
} catch (IOException k) {
Log.e(TAG, "Failed to close socket: " + e.getLocalizedMessage());
}
}
}
}
try {
TimeUnit.MILLISECONDS.sleep(syncInterval);
} catch (InterruptedException e) {}
}
}
}).start();
}
public void sync(Runnable runner) {
runner.run();
}
public void handleReadQueue() {
String message = this.gets();
while (message != null) {
Log.i(TAG, "Writing to Queue: " + message);
this.puts(message);
message = this.gets();
}
}
public String uuid() {
return this.uuid;
}
public boolean isConnected() {
return this.socket != null && !this.socket.isClosed();
}
public boolean isBound() {
return this.socket == null || this.socket.isBound();
}
public boolean isClosed() {
return this.socket == null || this.socket.isClosed();
}
public void write(String message) throws IOException {
bufferedWriter.write(message + "\r\n\n");
bufferedWriter.flush();
}
public String read() throws IOException {
String message = "";
String readLine;
while((readLine = bufferedReader.readLine()) != null) {
message+=readLine;
if (readLine.isEmpty()) { break; }
}
return message;
}
public void puts(String message) {
synchronized (writeQueueLock) {
writeQueue.add(message);
}
}
public String gets() {
String message = null;
synchronized (readQueueLock) {
if (readQueue.size() > 0) {
message = readQueue.get(0);
readQueue.remove(0);
}
}
return message;
}
public String encode(String message) {
return message;
}
public String decode(String blob) {
return blob;
}
public int getPacketsSent() { return packetsSent; }
public int getPacketsReceived() { return packetsReceived; }
public long getDataSent() { return dataSent; }
public long getDataReceived() { return dataReceived; }
public boolean socketError() {
return socketError;
}
public String lastSocketError() {
return lastSocketError;
}
public void flush() throws IOException {
this.bufferedWriter.flush();
}
public void close(String reason) throws IOException {
write(reason);
this.socket.close();
}
public void close() throws IOException {
if (this.socket != null) {
this.socket.close();
}
}
}

View File

@@ -0,0 +1,126 @@
package org.timecrafters.TimeCraftersConfigurationTool.tacnet;
import android.util.Log;
import org.timecrafters.TimeCraftersConfigurationTool.backend.TACNET;
import java.io.IOException;
import java.net.Socket;
public class Connection {
private PacketHandler packetHandler;
private Client client;
private String hostname;
private int port;
private String lastSocketError = null;
private boolean socketError = false;
private long lastSyncTime = 0;
private long syncInterval = TACNET.SYNC_INTERVAL;
private Runnable connectionHandlingRunner;
private long lastHeartBeatSent = 0;
private long heartBeatInterval = TACNET.HEARTBEAT_INTERVAL;
private String TAG = "TACNET|Connection";
public Connection(String hostname, int port) {
this.hostname = hostname;
this.port = port;
this.packetHandler = new PacketHandler(true);
this.connectionHandlingRunner = new Runnable() {
@Override
public void run() {
handleConnection();
}
};
}
public void connect(final Runnable errorCallback) {
if (client != null) {
return;
}
client = new Client();
new Thread(new Runnable() {
@Override
public void run() {
try {
client.setSocket(new Socket());
Log.i(TAG, "Connected to: " + hostname + ":" + port);
while(client != null && !client.isClosed()) {
if (System.currentTimeMillis() > lastSyncTime + syncInterval) {
lastSyncTime = System.currentTimeMillis();
client.sync(connectionHandlingRunner);
}
}
} catch (IOException e) {
socketError = true;
lastSocketError = e.getLocalizedMessage();
errorCallback.run();
Log.e(TAG, e.toString());
}
}
}).start();
}
private void handleConnection() {
if (client != null && !client.isClosed()) {
String message = client.gets();
if (message != null) {
packetHandler.handle(message);
}
if (System.currentTimeMillis() > lastHeartBeatSent + heartBeatInterval) {
lastHeartBeatSent = System.currentTimeMillis();
client.puts(PacketHandler.packetHeartBeat().toString());
}
try {
Thread.sleep(syncInterval);
} catch (InterruptedException e) {
// Failed to sleep I suppose.
}
} else {
client = null;
}
}
public void puts(String message) {
this.client.puts(message);
}
public String gets() {
return this.client.gets();
}
public Client getClient() {
return client;
}
public boolean isClosed() {
return this.client == null || this.client.isClosed();
}
public boolean isConnected() {
return this.client != null && this.client.isConnected();
}
public boolean socketError() {
return socketError ? socketError : client.socketError();
}
public String lastSocketError() {
return lastSocketError != null ? lastSocketError : client.lastSocketError();
}
public void close() throws IOException {
this.client.close();
}
}

View File

@@ -0,0 +1,152 @@
package org.timecrafters.TimeCraftersConfigurationTool.tacnet;
import android.util.Log;
import java.util.Arrays;
public class Packet {
final static public String PROTOCOL_VERSION = "1";
final static public String PROTOCOL_SEPERATOR = "|";
final static public String PROTOCOL_HEARTBEAT = "heartbeat";
private static final String TAG = "TACNET|Packet";
// NOTE: PacketType is cast to a char, no more than 255 packet types can exist unless
// header is updated.
public enum PacketType {
HANDSHAKE(0),
HEARTBEAT(1),
ERROR(2),
DOWNLOAD_CONFIG(10),
UPLOAD_CONFIG(11),
LIST_CONFIGS(12),
SELECT_CONFIG(13),
ADD_CONFIG(14),
UPDATE_CONFIG(15),
DELETE_CONFIG(16),
ADD_GROUP(20),
UPDATE_GROUP(21),
DELETE_GROUP(22),
ADD_ACTION(30),
UPDATE_ACTION(31),
DELETE_ACTION(32),
ADD_VARIABLE(40),
UPDATE_VARIABLE(41),
DELETE_VARIABLE(42);
private int id;
final public int getId() {
return id;
}
PacketType(int id) {
this.id = id;
}
}
private String protocolVersion;
private PacketType packetType;
private int contentLength;
private String content;
String rawMessage;
Packet(String protocolVersion, PacketType packetType, int contentLength, String content) {
this.protocolVersion = protocolVersion;
this.packetType = packetType;
this.contentLength = contentLength;
this.content = content;
}
static public Packet fromStream(String message) {
String version;
PacketType type = null;
int length;
String body;
String[] slice = message.split("\\|", 4);
if (slice.length < 4) {
Log.i(TAG, "Failed to split packet along first 4 " + PROTOCOL_SEPERATOR + ". Raw return: " + Arrays.toString(slice));
return null;
}
if (!slice[0].equals(PROTOCOL_VERSION)) {
Log.i(TAG, "Incompatible protocol version received, expected: " + PROTOCOL_VERSION + " got: " + slice[0]);
return null;
}
version = slice[0];
// type = PacketType.values()[Integer.parseInt(slice[1])];
length = Integer.parseInt(slice[2]);
body = slice[slice.length - 1];
int typeId = Integer.parseInt(slice[1]);
for (PacketType packetType : PacketType.values()) {
if (packetType.getId() == typeId) {
type = packetType;
break;
}
}
if (type == null) {
return null;
}
return new Packet(version, type, length, body);
}
static public Packet create(PacketType packetType, String message) {
return new Packet(PROTOCOL_VERSION, packetType, message.length(), message);
}
public boolean isValid() {
if (rawMessage == null) {
return true;
}
String[] parts = rawMessage.split(PROTOCOL_SEPERATOR);
return parts[0].equals(PROTOCOL_VERSION) &&
isPacketTypeValid( Integer.parseInt(parts[1]));
}
public boolean isPacketTypeValid(int rawPacketType) {
return PacketType.values().length >= rawPacketType && PacketType.values()[rawPacketType] != null;
}
public String encodeHeader() {
String string = "";
string += PROTOCOL_VERSION;
string += PROTOCOL_SEPERATOR;
string += packetType.getId();
string += PROTOCOL_SEPERATOR;
string += contentLength;
string += PROTOCOL_SEPERATOR;
return string;
}
public String toString() {
return ("" + encodeHeader() + content);
}
public String getProtocolVersion() {
return protocolVersion;
}
public PacketType getPacketType() {
return packetType;
}
public int getContentLength() {
return contentLength;
}
public String getContent() {
return content;
}
}

View File

@@ -0,0 +1,377 @@
package org.timecrafters.TimeCraftersConfigurationTool.tacnet;
import android.util.Log;
import org.timecrafters.TimeCraftersConfigurationTool.backend.Backend;
import org.timecrafters.TimeCraftersConfigurationTool.backend.Config;
import org.timecrafters.TimeCraftersConfigurationTool.backend.TAC;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Action;
import org.timecrafters.TimeCraftersConfigurationTool.backend.config.Group;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
public class PacketHandler {
private static final String TAG = "TACNET|PacketHandler";
private boolean hostIsAConnection = false;
public PacketHandler(boolean isHostAConnection) {
this.hostIsAConnection = isHostAConnection;
}
public void handle(String message) {
Packet packet = Packet.fromStream(message);
if (packet != null && packet.isValid()) {
Log.i(TAG, "Received packet of type: " + packet.getPacketType());
handOff(packet);
} else {
if (packet == null) {
Log.i(TAG, "Rejected raw packet: " + message);
} else {
Log.i(TAG, "Rejected packet: " + packet.toString());
}
}
}
public void handOff(Packet packet) {
switch(packet.getPacketType()) {
case HANDSHAKE: {
handleHandShake(packet);
return;
}
case HEARTBEAT: {
handleHeartBeat(packet);
return;
}
case ERROR: {
handleError(packet);
return;
}
case DOWNLOAD_CONFIG: {
handleDownloadConfig(packet);
return;
}
case UPLOAD_CONFIG: {
handleUploadConfig(packet);
return;
}
case LIST_CONFIGS: {
handleListConfigs(packet);
return;
}
case ADD_CONFIG: {
handleAddConfig(packet);
return;
}
case UPDATE_CONFIG: {
handleUpdateConfig(packet);
return;
}
case DELETE_CONFIG: {
handleDeleteConfig(packet);
return;
}
// case CHANGE_ACTION: {
// handleChangeAction(packet);
// return;
// }
default: {
}
}
}
// NO-OP
private void handleHandShake(Packet packet) {}
// NO-OP
private void handleHeartBeat(Packet packet) {}
// NO-OP
private void handleError(Packet packet) {}
private void handleUploadConfig(Packet packet) {
String[] split = packet.getContent().split("\\" + Packet.PROTOCOL_SEPERATOR, 2);
final String configName = split[0];
final String json = split[1];
if (configName.length() == 0 && !Backend.instance().isConfigValid(json)) {
return;
}
final String path = TAC.CONFIGS_PATH + File.separator + configName + ".json";
Backend.instance().writeToFile(path, json);
if (Backend.instance().getConfig().getName().equals(configName)) {
Backend.instance().loadConfig(configName);
}
}
private void handleDownloadConfig(Packet packet) {
final String configName = packet.getContent();
Log.i(TAG, "Got request for config: " + packet.getContent());
Packet pkt;
if (Backend.instance().configsList().contains(configName)) {
final String path = TAC.CONFIGS_PATH + File.separator + configName + ".json";
String content = Backend.instance().readFromFile(path);
pkt = packetUploadConfig(configName, content);
} else { // Error
pkt = packetError("Remote config not found", "The requested config " + configName + " does not exist over here.");
}
if (hostIsAConnection) {
Backend.instance().tacnet().puts(pkt.toString());
} else {
Backend.instance().getServer().getActiveClient().puts(pkt.toString());
}
}
// TODO: reply with config_name,456|other_config,10 (config name,revision)
private void handleListConfigs(Packet packet) {
if (hostIsAConnection) {
final String[] remoteConfigs = packet.getContent().split("\\" + Packet.PROTOCOL_SEPERATOR);
ArrayList<String> diff = Backend.instance().configsList();
for (String part : remoteConfigs) {
final String[] configInfo = part.split(",", 2);
final String name = configInfo[0];
final int revision = Integer.parseInt(configInfo[1]);
diff.remove(name);
File file = new File(Backend.instance().configPath(name));
if (file.exists()) {
final Config config = Backend.instance().loadConfigWithoutMutatingBackend(name);
if (config.getConfiguration().revision < revision) {
Log.i(TAG, "handleListConfigs: requesting config: " + name + " since local " + config.getName() + " is @ " + config.getConfiguration().revision);
Backend.instance().tacnet().puts(PacketHandler.packetDownloadConfig(name).toString());
} else if (config.getConfiguration().revision > revision) {
Log.i(TAG, "handleListConfigs: sending config: " + name + " since local " + config.getName() + " is @ " + config.getConfiguration().revision);
Backend.instance().tacnet().puts(PacketHandler.packetUploadConfig(name, Backend.instance().gsonForConfig().toJson(config)).toString());
}
} else {
Log.i(TAG, "handleListConfigs: requesting config: " + name + " since there is no local file with that name");
Backend.instance().tacnet().puts( PacketHandler.packetDownloadConfig(name).toString() );
}
}
for (String name : diff) {
final Config config = Backend.instance().loadConfigWithoutMutatingBackend(name);
Backend.instance().tacnet().puts(PacketHandler.packetUploadConfig(name, Backend.instance().gsonForConfig().toJson(config)).toString());
}
} else {
Backend.instance().getServer().getActiveClient().puts(PacketHandler.packetListConfigs().toString());
}
}
private void handleSelectConfig(Packet packet) {
final String configName = packet.getContent();
Backend.instance().getSettings().config = configName;
Backend.instance().saveSettings();
Backend.instance().loadConfig(configName);
}
private void handleAddConfig(Packet packet) {
final String configName = packet.getContent();
if (Backend.instance().configsList().contains(configName)) {
if (!hostIsAConnection) {
Backend.instance().getServer().getActiveClient().puts(
packetError("Config already exists!", "A config with the name " +
configName + " already exists here.").toString()
);
}
} else {
Backend.instance().writeNewConfig(configName);
}
}
private void handleUpdateConfig(Packet packet) {
final String[] split = packet.getContent().split("\\" + Packet.PROTOCOL_SEPERATOR, 2);
final String oldConfigName = split[0];
final String newConfigName = split[1];
if (Backend.instance().configsList().contains(newConfigName)) {
if (!hostIsAConnection) {
Backend.instance().getServer().getActiveClient().puts(
packetError("Config already exists!", "A config with the name " +
newConfigName + " already exists here.").toString()
);
}
} else {
Backend.instance().moveConfig(oldConfigName, newConfigName);
}
}
private void handleDeleteConfig(Packet packet) {
final String configName = packet.getContent();
Backend.instance().deleteConfig(configName);
}
private void handleAddGroup(Packet packet) {
final String[] split = packet.getContent().split("\\" + Packet.PROTOCOL_SEPERATOR, 2);
final String configName = packet.getContent();
final String groupName = packet.getContent();
if (Backend.instance().getConfig().getName().equals(configName)) {
if (Group.nameIsUnique(Backend.instance().getConfig().getGroups(), groupName)) {
Group group = new Group(groupName, new ArrayList<Action>());
Backend.instance().getConfig().getGroups().add(group);
} else {
Backend.instance().getServer().getActiveClient().puts(
packetError("Group name collision", "A group with the name " + groupName + " already exists").toString()
);
}
} else {
Backend.instance().getServer().getActiveClient().puts(
packetError("Active config mismatch", "Active config is not " + configName).toString()
);
}
}
private void handleUpdateGroup(Packet packet) {}
private void handleDeleteGroup(Packet packet) {}
private void handleAddAction(Packet packet) {}
private void handleChangeAction(Packet packet) {
// TODO: Handle renaming action and updating comment.
}
private void handleDeleteAction(Packet packet) {}
private void handleAddVariable(Packet packet) {}
private void handleUpdateVariable(Packet packet) {}
private void handleChangeVariable(Packet packet) {}
private void handleDeleteVariable(Packet packet) {}
/**************************************
PACKET HELPER FUNCTIONS
**************************************/
static public Packet packetHandShake(String clientUUID) {
return Packet.create(Packet.PacketType.HANDSHAKE, clientUUID);
}
static public Packet packetHeartBeat() {
return Packet.create(Packet.PacketType.HEARTBEAT, Packet.PROTOCOL_HEARTBEAT);
}
static private Packet packetError(String errorTitle, String errorMessage) {
return Packet.create(Packet.PacketType.ERROR, errorTitle + Packet.PROTOCOL_SEPERATOR + errorMessage);
}
static public Packet packetUploadConfig(String configName, String json) {
return Packet.create(Packet.PacketType.UPLOAD_CONFIG, configName + Packet.PROTOCOL_SEPERATOR + json);
}
static public Packet packetDownloadConfig(String configName) {
return Packet.create(Packet.PacketType.DOWNLOAD_CONFIG, configName);
}
static public Packet packetListConfigs() {
String configsList = "";
final ArrayList<String> configs = Backend.instance().configsList();
int i = 0;
for (final String configName : configs) {
final String path = Backend.instance().configPath(configName);
Config config = Backend.instance().gsonForConfig().fromJson(Backend.instance().readFromFile(path), Config.class);
configsList += configName + "," + config.getConfiguration().revision;
if (i != configs.size() - 1) {
configsList += Packet.PROTOCOL_SEPERATOR;
}
i++;
}
return Packet.create(Packet.PacketType.LIST_CONFIGS, configsList);
}
static public Packet packetSelectConfig(String configName) {
return Packet.create(Packet.PacketType.SELECT_CONFIG, configName);
}
static public Packet packetAddConfig(String configName) {
return Packet.create(Packet.PacketType.ADD_CONFIG, configName);
}
static public Packet packetUpdateConfig(String oldConfigName, String newConfigName) {
return Packet.create(Packet.PacketType.UPDATE_CONFIG, oldConfigName + Packet.PROTOCOL_SEPERATOR + newConfigName);
}
static public Packet packetDeleteConfig(String configName) {
return Packet.create(Packet.PacketType.DELETE_CONFIG, configName);
}
static public Packet packetAddGroup(String configName, String groupName) {
return Packet.create(Packet.PacketType.ADD_GROUP, configName + Packet.PROTOCOL_SEPERATOR + groupName);
}
static public Packet packetUpdateGroup(String configName, String oldGroupName, String newGroupName) {
return Packet.create(Packet.PacketType.UPDATE_GROUP, configName + Packet.PROTOCOL_SEPERATOR +
oldGroupName + Packet.PROTOCOL_SEPERATOR + newGroupName);
}
static public Packet packetDeleteGroup(String configName, String groupName) {
return Packet.create(Packet.PacketType.DELETE_GROUP, configName + Packet.PROTOCOL_SEPERATOR + groupName);
}
// TODO
static public Packet packetAddAction(String configName, String groupName, String actionName) {
return Packet.create(Packet.PacketType.ADD_ACTION, configName + Packet.PROTOCOL_SEPERATOR + groupName);
}
// TODO
static public Packet packetUpdateAction(String configName, String oldGroupName, String newGroupName) {
return Packet.create(Packet.PacketType.UPDATE_ACTION, configName + Packet.PROTOCOL_SEPERATOR +
oldGroupName + Packet.PROTOCOL_SEPERATOR + newGroupName);
}
// TODO
static public Packet packetDeleteAction(String configName, String groupName) {
return Packet.create(Packet.PacketType.DELETE_ACTION, configName + Packet.PROTOCOL_SEPERATOR + groupName);
}
// TODO
static public Packet packetAddVariable(String configName, String groupName) {
return Packet.create(Packet.PacketType.ADD_VARIABLE, configName + Packet.PROTOCOL_SEPERATOR + groupName);
}
// TODO
static public Packet packetUpdateVariable(String configName, String oldGroupName, String newGroupName) {
return Packet.create(Packet.PacketType.UPDATE_VARIABLE, configName + Packet.PROTOCOL_SEPERATOR +
oldGroupName + Packet.PROTOCOL_SEPERATOR + newGroupName);
}
static public Packet packetDeleteVariable(String configName, String groupName, String actionName, String variableName) {
return Packet.create(Packet.PacketType.DELETE_VARIABLE, configName + Packet.PROTOCOL_SEPERATOR + groupName +
Packet.PROTOCOL_SEPERATOR + actionName +
Packet.PROTOCOL_SEPERATOR + variableName);
}
}

View File

@@ -0,0 +1,195 @@
package org.timecrafters.TimeCraftersConfigurationTool.tacnet;
import android.util.Log;
import org.timecrafters.TimeCraftersConfigurationTool.backend.TACNET;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
public class Server {
private ServerSocket server;
private int port;
private Client activeClient;
private long lastSyncTime = 0;
private long syncInterval = TACNET.SYNC_INTERVAL;
private String TAG = "TACNET|Server";
private int packetsSent, packetsReceived, clientLastPacketsSent, clientLastPacketsReceived = 0;
private long dataSent, dataReceived, clientLastDataSent, clientLastDataReceived = 0;
private Runnable handleClientRunner;
private PacketHandler packetHandler;
private long lastHeartBeatSent = 0;
private long heartBeatInterval = TACNET.HEARTBEAT_INTERVAL;
public Server(int port) throws IOException {
this.server = new ServerSocket();
this.port = port;
this.packetHandler = new PacketHandler(false);
this.handleClientRunner = new Runnable() {
@Override
public void run() {
handleClient();
}
};
}
public void start() throws IOException {
new Thread(new Runnable() {
@Override
public void run() {
int connectionAttempts = 0;
while(!server.isBound() && connectionAttempts < 10) {
try {
server.bind(new InetSocketAddress(port));
Log.i(TAG, "Server bound and ready!");
} catch (IOException e) {
connectionAttempts++;
Log.e(TAG, "Server failed to bind: " + e.getMessage());
}
}
while (!server.isClosed()) {
try {
runServer();
} catch (IOException e) {
Log.e(TAG, "Error running server: " + e.getMessage());
}
}
}
}).start();
}
private void runServer() throws IOException {
while (!isClosed()) {
final Client client = new Client();
client.setSyncInterval(syncInterval);
client.setSocket(this.server.accept());
if (activeClient != null && !activeClient.isClosed()) {
Log.i(TAG, "Too many clients, already have one connected!");
client.close("Too many clients!");
} else {
this.activeClient = client;
activeClient.puts(PacketHandler.packetHandShake( activeClient.uuid() ).toString());
activeClient.puts(PacketHandler.packetListConfigs().toString());
Log.i(TAG, "Client connected!");
new Thread(new Runnable() {
@Override
public void run() {
while(activeClient != null && !activeClient.isClosed()) {
if (System.currentTimeMillis() > lastSyncTime + syncInterval) {
lastSyncTime = System.currentTimeMillis();
activeClient.sync(handleClientRunner);
updateNetStats();
}
try {
Thread.sleep(syncInterval);
} catch (InterruptedException e) {
// Failed to sleep, i guess.
}
}
updateNetStats();
activeClient = null;
clientLastPacketsSent = 0;
clientLastPacketsReceived = 0;
clientLastDataSent = 0;
clientLastDataReceived = 0;
// AppSync.getMainActivity().clientDisconnected();
}
}).start();
}
}
}
private void handleClient() {
if (activeClient != null && !activeClient.isClosed()) {
String message = activeClient.gets();
if (message != null) {
packetHandler.handle(message);
}
if (System.currentTimeMillis() > lastHeartBeatSent + heartBeatInterval) {
lastHeartBeatSent = System.currentTimeMillis();
activeClient.puts(PacketHandler.packetHeartBeat().toString());
}
}
}
public void stop() throws IOException {
if (this.activeClient != null) {
this.activeClient.close();
this.activeClient = null;
}
this.server.close();
}
public boolean hasActiveClient() {
return activeClient != null;
}
public Client getActiveClient() {
return activeClient;
}
public int getPacketsSent() {
return packetsSent;
}
public int getPacketsReceived() {
return packetsReceived;
}
public long getDataSent() {
return dataSent;
}
public long getDataReceived() {
return dataReceived;
}
private void updateNetStats() {
if (activeClient != null) {
// NOTE: In and Out are reversed for Server stats
packetsSent += activeClient.getPacketsReceived() - clientLastPacketsReceived;
packetsReceived += activeClient.getPacketsSent() - clientLastPacketsSent;
dataSent += activeClient.getDataReceived() - clientLastDataReceived;
dataReceived += activeClient.getDataSent() - clientLastDataSent;
clientLastPacketsSent = activeClient.getPacketsSent();
clientLastPacketsReceived = activeClient.getPacketsReceived();
clientLastDataSent = activeClient.getDataSent();
clientLastDataReceived = activeClient.getDataReceived();
}
}
public boolean isBound() {
return this.server.isBound();
}
public boolean isClosed() {
return this.server.isClosed();
}
}

View File

@@ -0,0 +1,17 @@
package org.timecrafters.TimeCraftersConfigurationTool;
import org.junit.Test;
import static org.junit.Assert.*;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() {
assertEquals(4, 2 + 2);
}
}

View File

@@ -1,2 +1,3 @@
include ':TimeCraftersConfigurationTool'
include ':FtcRobotController'
include ':TeamCode'