diff --git a/src/main/java/competition/auto_programs/TestVisionAuto.java b/src/main/java/competition/auto_programs/TestVisionAuto.java index 5eadf2b0..27a11fca 100644 --- a/src/main/java/competition/auto_programs/TestVisionAuto.java +++ b/src/main/java/competition/auto_programs/TestVisionAuto.java @@ -5,6 +5,7 @@ import competition.subsystems.pose.PoseSubsystem; import edu.wpi.first.wpilibj2.command.InstantCommand; import edu.wpi.first.wpilibj2.command.SequentialCommandGroup; +import org.kobe.xbot.Client; import javax.inject.Inject; @@ -14,7 +15,7 @@ public class TestVisionAuto extends SequentialCommandGroup { public TestVisionAuto( DriveSubsystem drive, PoseSubsystem pose, - DriveToGivenNoteWithVisionCommand driveToGivenNoteWithVisionCommand + DriveToWaypointsWithVisionCommand driveToWaypointsWithVisionCommand ) { @@ -28,6 +29,6 @@ public TestVisionAuto( }) ); - this.addCommands(driveToGivenNoteWithVisionCommand); + this.addCommands(driveToWaypointsWithVisionCommand); } } diff --git a/src/main/java/competition/commandgroups/DriveToWaypointsWithVisionCommand.java b/src/main/java/competition/commandgroups/DriveToWaypointsWithVisionCommand.java new file mode 100644 index 00000000..8d84a699 --- /dev/null +++ b/src/main/java/competition/commandgroups/DriveToWaypointsWithVisionCommand.java @@ -0,0 +1,110 @@ +package competition.commandgroups; + +import competition.subsystems.collector.CollectorSubsystem; +import competition.subsystems.drive.DriveSubsystem; +import competition.subsystems.drive.commands.DriveToGivenNoteCommand; +import competition.subsystems.oracle.DynamicOracle; +import competition.subsystems.pose.PoseSubsystem; +import competition.subsystems.vision.NoteAcquisitionMode; +import competition.subsystems.vision.VisionSubsystem; +import edu.wpi.first.math.geometry.Pose2d; +import edu.wpi.first.math.geometry.Translation2d; +import xbot.common.properties.PropertyFactory; +import xbot.common.subsystems.drive.control_logic.HeadingModule; +import xbot.common.trajectory.XbotSwervePoint; + +import javax.inject.Inject; +import java.util.ArrayList; + +public class DriveToWaypointsWithVisionCommand extends SwerveSimpleTrajectoryCommand { + + DynamicOracle oracle; + DriveSubsystem drive; + public Translation2d[] waypoints = null; + double maximumSpeedOverride = 0; + DynamicOracle oracle; + PoseSubsystem pose; + DriveSubsystem drive; + VisionSubsystem vision; + CollectorSubsystem collector; + boolean hasDoneVisionCheckYet = false; + XTablesClient xclient; + + private NoteAcquisitionMode noteAcquisitionMode = NoteAcquisitionMode.BlindApproach; + + @Inject + DriveToWaypointsWithVisionCommand(PoseSubsystem pose, DriveSubsystem drive, DynamicOracle oracle, + PropertyFactory pf, HeadingModule.HeadingModuleFactory headingModuleFactory, + VisionSubsystem vision, CollectorSubsystem collector) { + super(drive, oracle, pose, pf, headingModuleFactory); + this.oracle = oracle; + this.pose = pose; + this.drive = drive; + this.vision = vision; + this.collector = collector; + } + + @Override + public void initialize() { + // The init here takes care of going to the initially given "static" note position. + super.initialize(); + noteAcquisitionMode = NoteAcquisitionMode.BlindApproach; + hasDoneVisionCheckYet = false; + xclient = new XTablesClient(); + } + + //allows for driving not in a straight line + public void prepareToDriveAtGivenNoteWithWaypoints(Translation2d... waypoints){ + if (waypoints == null){ + return; + } + ArrayList swervePoints = new ArrayList<>(); + for (Translation2d waypoint : waypoints){ + swervePoints.add(XbotSwervePoint.createPotentiallyFilppedXbotSwervePoint(waypoint,Rotation2d.fromDegrees(180),5)); + } + // when driving to a note, the robot must face backwards, as the robot's intake is on the back + this.logic.setKeyPoints(swervePoints); + this.logic.setAimAtGoalDuringFinalLeg(true); + this.logic.setDriveBackwards(true); + this.logic.setEnableConstantVelocity(true); + + double suggestedSpeed = drive.getSuggestedAutonomousMaximumSpeed(); + if (maximumSpeedOverride > suggestedSpeed) { + log.info("Using maximum speed override"); + suggestedSpeed = maximumSpeedOverride; + } else { + log.info("Not using max speed override"); + } + + this.logic.setConstantVelocity(suggestedSpeed); + + reset(); + } + + //allows for driving not in a straight line + public void retrieveWaypointsFromVision() { + ArrayList coordinates = xclient.getArray("target_waypoints", Coordinate); + ArrayList waypoints = new ArrayList(); + for (Coordinate coordinate : coordinates) { + waypoints.add(new Translation2d(coordinate.x, coordinate.y)); + } + + this.prepareToDriveAtGivenNoteWithWaypoints(waypoints); + } + + @Override + public void execute() { + this.retrieveWaypointsFromVision(); + super.execute(); + } + + @Override + public boolean isFinished() { + return super.isFinished(); + } + + private class Coordinate { + double x; + double y; + } +} diff --git a/src/main/java/org/kobe/xbot/Client/ImageStreamClient.java b/src/main/java/org/kobe/xbot/Client/ImageStreamClient.java new file mode 100644 index 00000000..227569db --- /dev/null +++ b/src/main/java/org/kobe/xbot/Client/ImageStreamClient.java @@ -0,0 +1,71 @@ +//package org.kobe.xbot.Client; +// +//import org.bytedeco.javacpp.BytePointer; +//import org.bytedeco.opencv.global.opencv_imgcodecs; +//import org.bytedeco.opencv.opencv_core.Mat; +//import org.kobe.xbot.Utilities.Logger.XTablesLogger; +// +//import java.io.BufferedInputStream; +//import java.io.IOException; +//import java.io.InputStream; +//import java.net.HttpURLConnection; +//import java.net.URL; +//import java.util.concurrent.ExecutorService; +//import java.util.concurrent.Executors; +//import java.util.concurrent.atomic.AtomicBoolean; +//import java.util.function.Consumer; +// +//public class ImageStreamClient { +// private static final XTablesLogger logger = XTablesLogger.getLogger(); +// private final String serverUrl; +// private final Consumer consumer; +// private final AtomicBoolean running = new AtomicBoolean(false); +// +// public ImageStreamClient(String serverUrl, Consumer onFrame) { +// this.serverUrl = serverUrl; +// this.consumer = onFrame; +// } +// +// public void start(ExecutorService service) { +// running.set(true); +// service.execute(() -> { +// try { +// while (running.get() && !Thread.currentThread().isInterrupted()) { +// HttpURLConnection connection = (HttpURLConnection) new URL(serverUrl).openConnection(); +// connection.setRequestMethod("GET"); +// +// try (InputStream inputStream = new BufferedInputStream(connection.getInputStream())) { +// byte[] byteArray = inputStream.readAllBytes(); +// if (byteArray.length == 0) { +// logger.warning("Received empty byte array."); +// continue; +// } +// +// Mat frame = byteArrayToMat(byteArray); +// if (frame.empty()) { +// logger.severe("Failed to decode frame."); +// continue; +// } +// consumer.accept(frame); +// } catch (IOException e) { +// logger.severe("Error reading stream: " + e.getMessage()); +// Thread.sleep(1000); +// } finally { +// connection.disconnect(); +// } +// } +// } catch (IOException | InterruptedException e) { +// logger.severe("Error connecting to server: " + e.getMessage()); +// } +// }); +// } +// +// public void stop() { +// running.set(false); +// } +// +// private Mat byteArrayToMat(byte[] byteArray) { +// Mat mat = new Mat(new BytePointer(byteArray)); +// return opencv_imgcodecs.imdecode(mat, opencv_imgcodecs.IMREAD_COLOR); +// } +//} diff --git a/src/main/java/org/kobe/xbot/Client/ImageStreamServer.java b/src/main/java/org/kobe/xbot/Client/ImageStreamServer.java new file mode 100644 index 00000000..536e8000 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Client/ImageStreamServer.java @@ -0,0 +1,67 @@ +//package org.kobe.xbot.Client; +// +//import com.sun.net.httpserver.HttpExchange; +//import com.sun.net.httpserver.HttpHandler; +//import com.sun.net.httpserver.HttpServer; +//import org.kobe.xbot.Utilities.Logger.XTablesLogger; +// +//import java.io.IOException; +//import java.io.OutputStream; +//import java.net.InetSocketAddress; +//import java.util.concurrent.Executors; +// +//public class ImageStreamServer { +// private static final XTablesLogger logger = XTablesLogger.getLogger(); +// private static HttpServer server; +// private static boolean isServerRunning = false; +// private byte[] currentFrame; +// private final String endpoint; +// +// public ImageStreamServer(String name) { +// this.endpoint = "/" + name; +// } +// +// public ImageStreamServer start() throws IOException { +// if (server == null) { +// server = HttpServer.create(new InetSocketAddress(4888), 0); +// server.setExecutor(Executors.newCachedThreadPool()); +// } +// +// server.createContext(endpoint, new StreamHandler()); +// if (!isServerRunning) { +// server.start(); +// isServerRunning = true; +// logger.info(("Server started at: http://localhost:" + server.getAddress().getPort()) + endpoint); +// } else { +// logger.info(("Server added endpoint: http://localhost:" + server.getAddress().getPort()) + endpoint); +// } +// return this; +// } +// +// public void stop() { +// if (server != null && isServerRunning) { +// server.stop(0); +// isServerRunning = false; +// logger.info("Camera streaming server stopped."); +// } +// } +// +// public void updateFrame(byte[] frame) { +// this.currentFrame = frame; +// } +// +// private class StreamHandler implements HttpHandler { +// @Override +// public void handle(HttpExchange exchange) throws IOException { +// if (currentFrame != null) { +// exchange.getResponseHeaders().set("Content-Type", "image/jpeg"); +// exchange.sendResponseHeaders(200, currentFrame.length); +// OutputStream os = exchange.getResponseBody(); +// os.write(currentFrame); +// os.close(); +// } else { +// exchange.sendResponseHeaders(503, -1); // Service unavailable if no frame available +// } +// } +// } +//} diff --git a/src/main/java/org/kobe/xbot/Client/Main.java b/src/main/java/org/kobe/xbot/Client/Main.java new file mode 100644 index 00000000..af2466b3 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Client/Main.java @@ -0,0 +1,119 @@ +package org.kobe.xbot.Client; +// EXAMPLE SETUP + +import org.kobe.xbot.Server.XTablesData; +import org.kobe.xbot.Utilities.ResponseStatus; +import org.kobe.xbot.Utilities.ScriptResponse; + +import java.util.List; +import java.util.function.Consumer; + +public class Main { + private static final String SERVER_ADDRESS = "localhost"; // Server address + private static final int SERVER_PORT = 1735; // Server port + + public static void main(String[] args) { + // Initialize a new client with address and port + XTablesClient client = new XTablesClient(SERVER_ADDRESS, SERVER_PORT, 3, true); + // Thread blocks until connection is successful + + // Get raw JSON from server + String response = client.getRawJSON().complete(); + System.out.println(response); + + // Variable for connection status, updates on every message + System.out.println("Connected? " + client.getSocketClient().isConnected); + // Get latency from server + XTablesClient.LatencyInfo info = client.ping_latency().complete(); + System.out.println("Network Latency: " + info.getNetworkLatencyMS() + "ms"); + System.out.println("Round Trip Latency: " + info.getRoundTripLatencyMS() + "ms"); + System.out.println("CPU Usage: " + info.getSystemStatistics().getProcessCpuLoadPercentage()); + + // -------- PUT VALUES -------- + // "OK" - Value updated + // "FAIL" - Failed to update + + // Put a string value into server + ResponseStatus status = client.putString("SmartDashboard", "Some Value1" + Math.random()).complete(); + System.out.println(status); + + // Put a string value into a sub table + status = client.putString("SmartDashboard.sometable", "Some Value").complete(); + System.out.println(status); + + // Put a string value into server asynchronously + client.putString("SmartDashboard", "Some Value").queue(); + + // Put a string value into server asynchronously with response + client.putString("SmartDashboard", "Some Value").queue(System.out::println); + + // Put a string value into server asynchronously with response & fail + client.putString("SmartDashboard", "Some Value").queue(System.out::println, System.err::println); + + // Put an object value into server thread block + status = client.putObject("SmartDashboard", new String("OK")).complete(); + System.out.println(status); + + // Put an integer value on sub table asynchronously + client.putInteger("SmartDashboard.somevalue.awdwadawd", 488).queue(); + + // Put an integer array value on sub table asynchronously + client.putArray("SmartDashboard.somearray", List.of("Test", "TEAM XBOT")).queue(); + + // -------- GET VALUES -------- + // value - Value retrieved + // null - No value + + // Get all tables on server + List tables = client.getTables("").complete(); + System.out.println(tables); + + // Get sub tables of table on server + List sub_tables = client.getTables("SmartDashboard").complete(); + System.out.println(sub_tables); + + // Get string from sub-table + String value = client.getString("SmartDashboard.somevalue").complete(); + System.out.println(value); + + // Get integer from sub-table + Integer integer = client.getInteger("SmartDashboard.somevalue").complete(); + System.out.println(integer); + + // Subscribe to an update event on a key + status = client.subscribeUpdateEvent("SmartDashboard", Integer.class, new_value -> + System.out.println("New Value: " + new_value) + ) + .complete(); + System.out.println("Subscribe status: " + status); + + // Define a consumer for update events + Consumer> updateConsumer = update -> { + System.out.println("Update received: " + update); + }; + // Subscribe to updates for a specific key + status = client.subscribeUpdateEvent("SmartDashboard", String.class, updateConsumer).complete(); + System.out.println("Subscribe status: " + status); + // Unsubscribe after a certain condition is met + status = client.unsubscribeUpdateEvent("SmartDashboard", String.class, updateConsumer).complete(); + System.out.println("Unsubscribe status: " + status); + + // Check weather or not the cache is set up and ready to be used. + // Any updates/retrievals from cache does not make any request to server. + // Cache updates on any update server side. + System.out.println("Is cache ready? " + client.isCacheReady()); + if (client.isCacheReady()) { + XTablesData cache = client.getCache(); + + // Example: Get data from cache + String valueFromCache = cache.get("SmartDashboard.sometable"); + + System.out.println("Value from cache: " + valueFromCache); + } + + + ScriptResponse scriptResponse = client.runScript("test", null).complete(); + System.out.println("Status: " + scriptResponse.getStatus()); + System.out.println("Script Response: " + scriptResponse.getResponse()); + } +} \ No newline at end of file diff --git a/src/main/java/org/kobe/xbot/Client/OverloadTest.java b/src/main/java/org/kobe/xbot/Client/OverloadTest.java new file mode 100644 index 00000000..bcfd0bc9 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Client/OverloadTest.java @@ -0,0 +1,23 @@ +package org.kobe.xbot.Client; + + + + +import java.io.IOException; + +public class OverloadTest { + private static final String SERVER_ADDRESS = "localhost"; // Server address + private static final int SERVER_PORT = 1735; // Server port + + public static void main(String[] args) throws IOException, InterruptedException { + // Initialize a new client with address and port + XTablesClient client = new XTablesClient(); + + while(true) { + client.executePutDouble("SmartDashboard.test", Math.random()); + } + + } + + +} diff --git a/src/main/java/org/kobe/xbot/Client/RequestAction.java b/src/main/java/org/kobe/xbot/Client/RequestAction.java new file mode 100644 index 00000000..80528cef --- /dev/null +++ b/src/main/java/org/kobe/xbot/Client/RequestAction.java @@ -0,0 +1,277 @@ +package org.kobe.xbot.Client; + +import com.google.gson.Gson; +import org.kobe.xbot.Utilities.Logger.XTablesLogger; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; + +/** + * Handles asynchronous and synchronous request actions to a SocketClient. + * This class encapsulates the logic for sending data over a network, handling responses, + * and managing errors during communication. It supports both queued (asynchronous) + * and complete (synchronous) operations. + *

+ * + * @author Kobe + */ +public class RequestAction { + private static long defaultCompleteTimeout = 5000; + private static final XTablesLogger logger = XTablesLogger.getLogger(); + private final SocketClient client; + private final String value; + private final Type type; + + /** + * Sets the default timeout for synchronous complete operations. + * + * @param defaultCompleteTimeout The timeout value in milliseconds. + */ + public static void setDefaultCompleteTimeout(long defaultCompleteTimeout) { + RequestAction.defaultCompleteTimeout = defaultCompleteTimeout; + } + + /** + * Constructs a RequestAction with a specified type. + * + * @param client The client used to send requests. + * @param value The data to be sent. + * @param type The expected return type of the response. + */ + public RequestAction(SocketClient client, String value, Type type) { + this.client = client; + this.value = value; + this.type = type; + } + + /** + * Constructs a RequestAction without a specified type. + * + * @param client The client used to send requests. + * @param value The data to be sent. + */ + public RequestAction(SocketClient client, String value) { + this.client = client; + this.value = value; + this.type = null; + } + + /** + * Queues a request to be sent asynchronously with success and failure handlers. + * + * @param onSuccess Consumer to handle the successful response. + * @param onFailure Consumer to handle failures during the request execution. + */ + public void queue(Consumer onSuccess, Consumer onFailure) { + long startTime = System.nanoTime(); + if (doNotRun()) return; + beforeRun(); + try { + CompletableFuture future = client.sendAsync(value, defaultCompleteTimeout); + future.thenAccept(result -> { + T parsed = parseResponse(startTime, result); + result = formatResult(result); + T res = null; + if (parsed == null) { + if (type == null) { + res = (T) result; + } else res = new Gson().fromJson(result, type); + } + boolean response = onResponse(parsed == null ? res : parsed); + if (response) onSuccess.accept(parsed == null ? res : parsed); + }) + .exceptionally(ex -> { + onFailure.accept(ex); + return null; + }); + } catch (IOException e) { + logger.severe(e.getMessage()); + } + } + + /** + * Queues a request to be sent asynchronously with a success handler. + * + * @param onSuccess Consumer to handle the successful response. + */ + public void queue(Consumer onSuccess) { + long startTime = System.nanoTime(); + if (doNotRun()) return; + beforeRun(); + try { + CompletableFuture future = client.sendAsync(value, defaultCompleteTimeout); + future.thenAccept(result -> { + T parsed = parseResponse(startTime, result); + result = formatResult(result); + T res = null; + if (parsed == null) { + if (type == null) { + res = (T) result; + } else res = new Gson().fromJson(result, type); + } + boolean response = onResponse(parsed == null ? res : parsed); + if (response) onSuccess.accept(parsed == null ? res : parsed); + }); + } catch (IOException e) { + logger.severe(e.getMessage()); + } + } + + /** + * Queues a request to be sent asynchronously without any additional handlers. + */ + public void queue() { + long startTime = System.nanoTime(); + if (doNotRun()) return; + beforeRun(); + try { + CompletableFuture future = client.sendAsync(value, defaultCompleteTimeout); + future.thenAccept(result -> { + T parsed = parseResponse(startTime, result); + result = formatResult(result); + T res = null; + if (parsed == null) { + if (type == null) { + res = (T) result; + } else res = new Gson().fromJson(result, type); + } + onResponse(parsed == null ? res : parsed); + }); + } catch (IOException e) { + logger.severe(e.getMessage()); + } + } + + /** + * Sends a request and waits for the completion synchronously. + * + * @param timeoutMS The timeout value in milliseconds for the synchronous operation. + * @return The response received, parsed by {@code parseResponse}. + */ + public T complete(long timeoutMS) { + long startTime = System.nanoTime(); + if (doNotRun()) return returnValueIfNotRan(); + beforeRun(); + try { + String result = client.sendComplete(value, timeoutMS); + T parsed = parseResponse(startTime, result); + result = formatResult(result); + T res = null; + if (parsed == null) { + if (type == null) { + res = (T) result; + } else { + res = new Gson().fromJson(result, type); + } + } + boolean response = onResponse(parsed == null ? res : parsed); + if (!response) return returnValueIfNotRan(); + return parsed == null ? res : parsed; + } catch (ExecutionException | TimeoutException | IOException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + + /** + * Sends a request to the server and does not wait for a response. + * Instructs the server to perform the action without responding. + * This method defaults to sending the request synchronously. + */ + public void execute() { + try { + client.sendExecute(value); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + + /** + * Sends a request and waits for the completion synchronously using the default timeout. + * + * @return The response received, parsed by {@code parseResponse}. + */ + public T complete() { + return complete(defaultCompleteTimeout); + } + + // ----------------------------- OVERRIDE METHODS ----------------------------- + + /** + * Called to determine if the request should not be run. + * Meant to be overridden in subclasses to provide specific conditions. + *

+ * Order of execution: 1 (First method called in the sequence) + * + * @return true if the request should not be sent, false otherwise. + */ + public boolean doNotRun() { + return false; + } + + /** + * Called before sending the request. + * Meant to be overridden in subclasses to perform any necessary setup or validation before running the request. + *

+ * Order of execution: 2 (Called before the request is sent) + */ + public void beforeRun() { + } + + /** + * Parses the response received from the server. + * Meant to be overridden in subclasses to parse the response based on specific needs. + *

+ * Order of execution: 3 (Called when a response is received) + * + * @param startTime The start time of the request, used for calculating latency. + * @param result The raw result from the server. + * @return The parsed response as type T. + */ + public T parseResponse(long startTime, String result) { + return null; + } + + /** + * Formats the raw result string before parsing it. + * Meant to be overridden in subclasses to provide specific formatting logic. + *

+ * Order of execution: 4 (Called after receiving the raw response and before parsing) + * + * @param result The raw result string from the server. + * @return The formatted result string. + */ + public String formatResult(String result) { + return result; + } + + /** + * Called when a response is received. + * Meant to be overridden in subclasses to handle specific actions on response. + *

+ * Order of execution: 5 (Called after parsing and formatting the response) + * + * @param result The result of the response. + * @return true if the response was handled successfully, false otherwise. + */ + public boolean onResponse(T result) { + return true; + } + + /** + * Returns a value when {@code doNotRun} returns true and the action is not performed. + * Meant to be overridden in subclasses to provide a default value. + *

+ * Order of execution: 6 (Called if `doNotRun` returns true) + * + * @return The default value to return if the request is not run. + */ + public T returnValueIfNotRan() { + return null; + } +} diff --git a/src/main/java/org/kobe/xbot/Client/SocketClient.java b/src/main/java/org/kobe/xbot/Client/SocketClient.java new file mode 100644 index 00000000..70558a64 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Client/SocketClient.java @@ -0,0 +1,436 @@ +package org.kobe.xbot.Client; + +import com.sun.jdi.connect.spi.ClosedConnectionException; +import org.kobe.xbot.Utilities.Logger.XTablesLogger; +import org.kobe.xbot.Utilities.*; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.Socket; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.logging.Logger; + +public class SocketClient { + private final XTablesLogger logger = XTablesLogger.getLogger(); + private ExecutorService executor; + private ThreadPoolExecutor socketExecutor; + private String SERVER_ADDRESS; + private int SERVER_PORT; + private long RECONNECT_DELAY_MS; + private boolean CLEAR_UPDATE_MESSAGES = true; + private final List MESSAGES = new ArrayList<>() { + private final Logger logger = Logger.getLogger(ArrayList.class.getName()); + + @Override + public boolean add(RequestInfo requestInfo) { + boolean hasLogged = false; + boolean added = super.add(requestInfo); + while (added && size() > 100) { + if (!hasLogged) { + logger.info("Dumping all old cached messages..."); + hasLogged = true; + } + super.remove(0); + } + return added; + } + }; + private final int MAX_THREADS; + public Boolean isConnected = false; + private PrintWriter out = null; + private BufferedReader in = null; + private Socket socket; + private Consumer> updateConsumer; + private Consumer deleteConsumer; + private final XTablesClient xTablesClient; + + public SocketClient(String SERVER_ADDRESS, int SERVER_PORT, long RECONNECT_DELAY_MS, int MAX_THREADS_ARG, XTablesClient xTablesClient) { + this.socket = null; + this.MAX_THREADS = Math.max(MAX_THREADS_ARG, 1); + this.SERVER_ADDRESS = SERVER_ADDRESS; + this.SERVER_PORT = SERVER_PORT; + this.RECONNECT_DELAY_MS = RECONNECT_DELAY_MS; + this.executor = getWorkerExecutor(MAX_THREADS); + this.xTablesClient = xTablesClient; + } + + public String getSERVER_ADDRESS() { + return SERVER_ADDRESS; + } + + public ExecutorService getExecutor() { + return executor; + } + + public int getSERVER_PORT() { + return SERVER_PORT; + } + + public SocketClient setSERVER_ADDRESS(String SERVER_ADDRESS) { + this.SERVER_ADDRESS = SERVER_ADDRESS; + return this; + } + + public List getMESSAGES() { + return MESSAGES; + } + + public SocketClient setSERVER_PORT(int SERVER_PORT) { + this.SERVER_PORT = SERVER_PORT; + return this; + } + + public long getRECONNECT_DELAY_MS() { + return RECONNECT_DELAY_MS; + } + + public SocketClient setRECONNECT_DELAY_MS(long RECONNECT_DELAY_MS) { + this.RECONNECT_DELAY_MS = RECONNECT_DELAY_MS; + return this; + } + + public List getMessages() { + return MESSAGES; + } + + public void setUpdateConsumer(Consumer> updateConsumer) { + this.updateConsumer = updateConsumer; + } + + public SocketClient setDeleteConsumer(Consumer deleteConsumer) { + this.deleteConsumer = deleteConsumer; + return this; + } + + public boolean isCLEAR_UPDATE_MESSAGES() { + return CLEAR_UPDATE_MESSAGES; + } + + public SocketClient setCLEAR_UPDATE_MESSAGES(boolean CLEAR_UPDATE_MESSAGES) { + this.CLEAR_UPDATE_MESSAGES = CLEAR_UPDATE_MESSAGES; + return this; + } + + public void connect() { + while (true) { + try { + logger.info(String.format("Connecting to server: %1$s:%2$s", SERVER_ADDRESS, SERVER_PORT)); + socket = new Socket(SERVER_ADDRESS, SERVER_PORT); + if (!socket.isConnected()) throw new IOException(); + out = new PrintWriter(socket.getOutputStream(), true); + in = new BufferedReader(new InputStreamReader(socket.getInputStream())); + logger.info(String.format("Connected to server: %1$s:%2$s", SERVER_ADDRESS, SERVER_PORT)); + if (executor == null || executor.isShutdown()) { + this.executor = getWorkerExecutor(MAX_THREADS); + } + if (socketExecutor != null) { + socketExecutor.shutdownNow(); + } + this.socketExecutor = getSocketExecutor(); + socketExecutor.execute(new ClientMessageListener(socket)); + List> requestActions = xTablesClient.resubscribeToAllUpdateEvents(); + if (!requestActions.isEmpty()) { + logger.info("Resubscribing to all previously submitted update events."); + xTablesClient.queueAll(requestActions); + logger.info("Queued " + requestActions.size() + " subscriptions successfully!"); + } + if (xTablesClient.resubscribeToDeleteEvents()) { + logger.info("Subscribing to previously submitted delete event."); + new RequestAction<>(this, new ResponseInfo(null, MethodType.SUBSCRIBE_DELETE).parsed(), ResponseStatus.class).queue(); + logger.info("Queued delete event subscription successfully!"); + } + isConnected = true; + break; + } catch (IOException e) { + logger.warning("Failed to connect to server. Retrying..."); + try { + // Wait before attempting reconnection + TimeUnit.MILLISECONDS.sleep(RECONNECT_DELAY_MS); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + } + } + + private ThreadPoolExecutor getSocketExecutor() { + return new ThreadPoolExecutor( + 0, + 3, + 60L, + TimeUnit.SECONDS, + new ArrayBlockingQueue<>(10), + new ThreadFactory() { + private final AtomicInteger counter = new AtomicInteger(1); + + @Override + public Thread newThread(Runnable r) { + String messageName = "SocketClient-MessageHandler-" + counter.getAndIncrement(); + Thread thread = new Thread(r); + thread.setDaemon(false); + thread.setName(messageName); + logger.info("Starting SocketClient main thread: " + thread.getName()); + return thread; + } + } + ); + } + + private ExecutorService getWorkerExecutor(int initialMaxThreads) { + ThreadPoolExecutor executor = new ThreadPoolExecutor( + 0, + initialMaxThreads, + 1, TimeUnit.MINUTES, + new ArrayBlockingQueue<>(1000) + ); + executor.setThreadFactory(new ThreadFactory() { + private final AtomicInteger counter = new AtomicInteger(1); + + @Override + public Thread newThread(Runnable r) { + String workerName = "SocketClient-WorkerThread-" + counter.getAndIncrement(); + Thread thread = new Thread(r); + thread.setDaemon(true); + thread.setName(workerName); + logger.info("Starting worker thread: " + workerName); + return thread; + } + }); + executor.setRejectedExecutionHandler((r, executor1) -> { + logger.warning(String.format("Too many tasks in thread queue (%1$s). Clearing queue now...", executor1.getQueue().size())); + executor1.getQueue().clear(); + if (!executor1.isShutdown()) { + executor1.execute(r); + } + }); + + return executor; + } + + + public class ClientMessageListener implements Runnable { + private final Socket socket; + + public ClientMessageListener(Socket socket) { + this.socket = socket; + } + + @Override + public void run() { + try { + String message; + while ((message = in.readLine()) != null && !socket.isClosed() && socket.isConnected()) { + isConnected = true; + RequestInfo requestInfo = new RequestInfo(message); + if (requestInfo.getTokens().length >= 3 && requestInfo.getMethod().equals(MethodType.UPDATE_EVENT)) { + String key = requestInfo.getTokens()[1]; + String value = String.join(" ", Arrays.copyOfRange(requestInfo.getTokens(), 2, requestInfo.getTokens().length)); + if (updateConsumer != null) { + KeyValuePair keyValuePair = new KeyValuePair<>(key, value); + executor.execute(() -> updateConsumer.accept(keyValuePair)); // maybe implement a cachedThread instead of using the same executor as socket client + if (CLEAR_UPDATE_MESSAGES) MESSAGES.remove(requestInfo); + } + } else if (requestInfo.getTokens().length >= 2 && requestInfo.getMethod().equals(MethodType.DELETE_EVENT)) { + String key = requestInfo.getTokens()[1]; + if (Utilities.validateKey(key, true)) { + if (deleteConsumer != null) { + executor.execute(() -> deleteConsumer.accept(key)); + } + } + } else { + MESSAGES.add(requestInfo); + } + } + isConnected = false; + if (!socket.isClosed()) { + logger.warning("Disconnected from the server. Reconnecting..."); + try { + // Wait before attempting reconnection + TimeUnit.MILLISECONDS.sleep(RECONNECT_DELAY_MS); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + reconnect(); + } + } catch (IOException e) { + isConnected = false; + if (!socket.isClosed()) { + System.err.println("Error reading message from server: " + e.getMessage()); + logger.warning("Disconnected from the server. Reconnecting..."); + try { + // Wait before attempting reconnection + TimeUnit.MILLISECONDS.sleep(RECONNECT_DELAY_MS); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + reconnect(); + } + } + + } + } + + public RequestInfo waitForMessage(String ID, long timeout, TimeUnit unit) { + long startTime = System.currentTimeMillis(); + long timeoutMillis = unit.toMillis(timeout); + + while (System.currentTimeMillis() - startTime < timeoutMillis) { + try { + for (RequestInfo message : new ArrayList<>(MESSAGES)) { + if (message != null && message.getID().equals(ID)) { + MESSAGES.remove(message); + return message; + } + } + } catch (Exception ignored) {} + } + return null; + } + + + public RequestInfo sendMessageAndWaitForReply(ResponseInfo responseInfo, long timeout, TimeUnit unit) throws InterruptedException { + sendMessage(responseInfo); + return waitForMessage(responseInfo.getID(), timeout, unit); + } + + public void sendMessage(ResponseInfo responseInfo) { + out.println(responseInfo.parsed()); + out.flush(); + } + + public void sendMessageRaw(String raw) { + out.println(raw); + out.flush(); + } + + public void stopAll() { + long startTime = System.nanoTime(); + logger.severe("Shutting down all threads and processes."); + try { + if (socketExecutor != null) { + socketExecutor.shutdownNow(); + } + if (executor != null) { + executor.shutdownNow(); + } + } catch (Exception e) { + logger.severe("Failed to shutdown main threads: " + e.getMessage()); + } + isConnected = false; + try { + if (socket != null) { + socket.close(); + } + if (out != null) { + out.close(); + } + if (in != null) { + in.close(); + } + } catch (IOException e) { + logger.severe("Failed to close socket or streams: " + e.getMessage()); + } + double elapsedTimeMS = (System.nanoTime() - startTime) / 1e6; + logger.severe("SocketClient is now closed. (" + elapsedTimeMS + "ms)"); + } + + + public void reconnect() { + long startTime = System.nanoTime(); + logger.severe("Shutting down all worker threads."); + try { + if (executor != null) { + executor.shutdownNow(); + } + } catch (Exception e) { + logger.severe("Failed to shutdown worker threads: " + e.getMessage()); + } + isConnected = false; + try { + if (socket != null) { + socket.close(); + } + if (out != null) { + out.close(); + } + if (in != null) { + in.close(); + } + } catch (IOException e) { + logger.severe("Failed to close socket or streams: " + e.getMessage()); + } + double elapsedTimeMS = (System.nanoTime() - startTime) / 1e6; + logger.severe("SocketClient is now closed. (" + elapsedTimeMS + "ms)"); + this.connect(); + } + + + public Socket getSocket() { + return socket; + } + + + public CompletableFuture sendAsync(String message, long timeoutMS) throws IOException { + if (executor == null || executor.isShutdown()) + throw new IOException("The worker thread executor is shutdown and no new requests can be made."); + CompletableFuture future = new CompletableFuture<>(); + executor.execute(() -> { + try { + RequestInfo requestInfo = sendMessageAndWaitForReply(ResponseInfo.from(message), timeoutMS, TimeUnit.MILLISECONDS); + if (requestInfo == null) throw new ClosedConnectionException(); + String[] tokens = requestInfo.getTokens(); + future.complete(String.join(" ", Arrays.copyOfRange(tokens, 1, tokens.length))); + } catch (InterruptedException | ClosedConnectionException e) { + future.completeExceptionally(e); + } + }); + return future; + } + + public void sendExecute(String message) throws IOException { + if (executor == null || executor.isShutdown()) + throw new IOException("The worker thread executor is shutdown and no new requests can be made."); + + sendMessage(ResponseInfo.from(message).setID("IGNORED")); + + } + + public String sendComplete(String message, long msTimeout) throws ExecutionException, InterruptedException, TimeoutException, IOException { + return sendAsync(message, msTimeout).get(msTimeout, TimeUnit.MILLISECONDS); + } + + public static class KeyValuePair { + private String key; + private T value; + + public KeyValuePair(String key, T value) { + this.key = key; + this.value = value; + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public T getValue() { + return value; + } + + public void setValue(T value) { + this.value = value; + } + } + +} + diff --git a/src/main/java/org/kobe/xbot/Client/XTablesClient.java b/src/main/java/org/kobe/xbot/Client/XTablesClient.java new file mode 100644 index 00000000..92a0ba80 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Client/XTablesClient.java @@ -0,0 +1,995 @@ +package org.kobe.xbot.Client; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import org.kobe.xbot.Server.XTablesData; +import org.kobe.xbot.Utilities.*; +import org.kobe.xbot.Utilities.Entities.UpdateConsumer; +import org.kobe.xbot.Utilities.Exceptions.BackupException; +import org.kobe.xbot.Utilities.Exceptions.ServerFlaggedValueException; +import org.kobe.xbot.Utilities.Logger.XTablesLogger; + +import javax.jmdns.JmDNS; +import javax.jmdns.ServiceEvent; +import javax.jmdns.ServiceInfo; +import javax.jmdns.ServiceListener; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.net.InetAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.function.Consumer; + +/** + * Handles client-side interactions with the XTables server. + * This class provides various constructors to connect to the XTables server using different configurations: + * direct connection with specified address and port, or service discovery via mDNS with optional settings. + *

+ * The XTablesClient class supports operations like caching, subscribing to updates and delete events, + * running scripts, managing key-value pairs, and handling video streams. + * It uses Gson for JSON parsing and integrates with a custom SocketClient for network communication. + *

+ * + * @author Kobe + */ + + +public class XTablesClient { + + /** + * Connects to the XTables instance using the specified server address and port with direct connection settings. + * + * @param SERVER_ADDRESS the address of the server to connect to. + * @param SERVER_PORT the port of the server to connect to. + * @param MAX_THREADS the maximum number of threads to use for client operations. + * @param useCache flag indicating whether to use caching. + * @throws IllegalArgumentException if the server port is 5353, which is reserved for mDNS services. + */ + public XTablesClient(String SERVER_ADDRESS, int SERVER_PORT, int MAX_THREADS, boolean useCache) { + if (SERVER_PORT == 5353) + throw new IllegalArgumentException("The port 5353 is reserved for mDNS services."); + initializeClient(SERVER_ADDRESS, SERVER_PORT, MAX_THREADS, useCache); + } + + /** + * Connects to the first instance of XTables found with default settings (mDNS). + * This constructor uses mDNS (Multicast DNS) to discover the first available XTables instance on the network. + * The default settings include using a maximum of five threads for client operations and disabling caching. + * It is designed for quick and simple client initialization without needing to specify server details manually. + */ + public XTablesClient() { + this(null, 5, false); + } + + + /** + * Connects to the first instance of XTables found with the specified settings (mDNS). + * This constructor uses mDNS (Multicast DNS) to discover the first available XTables instance on the network. + * It allows for customized client configuration by specifying the maximum number of threads for client operations + * and whether to enable caching. This is useful for scenarios where you want to use custom settings but do not + * want to manually specify the server details. + * + * @param MAX_THREADS the maximum number of threads to use for client operations, ensuring efficient handling of multiple requests. + * @param useCache flag indicating whether to use client-side caching for faster data retrieval and reduced server load. + */ + public XTablesClient(int MAX_THREADS, boolean useCache) { + this(null, MAX_THREADS, useCache); + } + + + /** + * Connects to the XTables instance with the specified mDNS service name and settings. + * This constructor uses mDNS (Multicast DNS) to discover the XTables instance on the network + * that matches the provided service name. It allows for customized client configuration + * by specifying the maximum number of threads for client operations and whether to enable caching. + * + * @param name the mDNS service name of the XTables instance to connect to. If null, connect to the first found instance. + * @param MAX_THREADS the maximum number of threads to use for client operations, ensuring efficient handling of multiple requests. + * @param useCache flag indicating whether to use client-side caching for faster data retrieval and reduced server load. + */ + public XTablesClient(String name, int MAX_THREADS, boolean useCache) { + try { + InetAddress addr = null; + while (addr == null) { + addr = Utilities.getLocalInetAddress(); + if(addr == null) { + logger.severe("No non-loopback IPv4 address found. Trying again in 1 second..."); + Thread.sleep(1000); + } + } + try (JmDNS jmdns = JmDNS.create(addr)) { + CountDownLatch serviceLatch = new CountDownLatch(1); + final boolean[] serviceFound = {false}; + final String[] serviceAddressIP = new String[1]; + final Integer[] socketServiceServerPort = new Integer[1]; + + jmdns.addServiceListener("_xtables._tcp.local.", new ServiceListener() { + @Override + public void serviceAdded(ServiceEvent event) { + logger.info("Service found: " + event.getName()); + jmdns.requestServiceInfo(event.getType(), event.getName(), true); + } + + @Override + public void serviceRemoved(ServiceEvent event) { + logger.info("Service removed: " + event.getName()); + } + + @Override + public void serviceResolved(ServiceEvent event) { + synchronized (serviceFound) { + if (!serviceFound[0]) { + ServiceInfo serviceInfo = event.getInfo(); + String serviceAddress = serviceInfo.getInet4Addresses()[0].getHostAddress(); + int socketServerPort = -1; + String portStr = serviceInfo.getPropertyString("port"); + try { + socketServerPort = Integer.parseInt(portStr); + } catch (NumberFormatException e) { + logger.warning("Invalid port format from mDNS attribute. Waiting for next resolve..."); + } + + String localAddress = null; + if (name != null && name.equalsIgnoreCase("localhost")) { + try { + localAddress = Utilities.getLocalIPAddress(); + serviceAddress = localAddress; + } catch (Exception e) { + logger.severe("Could not find localhost address: " + e.getMessage()); + } + } + if (socketServerPort != -1 && serviceAddress != null && !serviceAddress.trim().isEmpty() && (localAddress != null || serviceInfo.getName().equals(name) || serviceAddress.equals(name) || name == null)) { + serviceFound[0] = true; + logger.info("Service resolved: " + serviceInfo.getQualifiedName()); + logger.info("Address: " + serviceAddress + " Port: " + socketServerPort); + serviceAddressIP[0] = serviceAddress; + socketServiceServerPort[0] = socketServerPort; + serviceLatch.countDown(); + } + } + } + } + }); + if (name == null) logger.info("Listening for first instance of XTABLES service on port 5353..."); + else logger.info("Listening for '" + name + "' XTABLES services on port 5353..."); + while(serviceLatch.getCount() > 0 && !serviceFound[0] && !Thread.currentThread().isInterrupted()) { + try { + jmdns.requestServiceInfo("_xtables._tcp.local.", "XTablesService", true, 1000); + } catch (Exception ignored) {} + } + serviceLatch.await(); + logger.info("Service latch released, proceeding to close mDNS services..."); + jmdns.close(); + logger.info("mDNS service successfully closed. Service discovery resolver shut down."); + + if (serviceAddressIP[0] == null || socketServiceServerPort[0] == null) { + throw new RuntimeException("The service address or port could not be found."); + } else { + initializeClient(serviceAddressIP[0], socketServiceServerPort[0], MAX_THREADS, useCache); + } + } + } catch (IOException | InterruptedException e) { + logger.severe("Service discovery error: " + e.getMessage()); + throw new RuntimeException(e); + } + } + + + // --------------------------------------------------------------- + // ---------------- Methods and Fields --------------------------- + // --------------------------------------------------------------- + + private final XTablesLogger logger = XTablesLogger.getLogger(); + private SocketClient client; + private final Gson gson = new Gson(); + private XTablesData cache; + private long cacheFetchCooldown = 10000; + private boolean isCacheReady = false; + private final CountDownLatch latch = new CountDownLatch(1); + private Thread cacheThread; + public final HashMap>> update_consumers = new HashMap<>(); + public final List> delete_consumers = new ArrayList<>(); + + private void initializeClient(String SERVER_ADDRESS, int SERVER_PORT, int MAX_THREADS, boolean useCache) { + this.client = new SocketClient(SERVER_ADDRESS, SERVER_PORT, 1000, MAX_THREADS, this); + Thread thread = new Thread(() -> { + client.connect(); + client.setUpdateConsumer(this::on_update); + client.setDeleteConsumer(this::on_delete); + latch.countDown(); + }); + thread.setDaemon(true); + thread.start(); + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + if (useCache) { + cacheThread = new Thread(this::enableCache); + cacheThread.setDaemon(true); + cacheThread.start(); + } + } + + public void stopAll() { + client.stopAll(); + if (cacheThread != null) cacheThread.interrupt(); + } + + public long getCacheFetchCooldown() { + return cacheFetchCooldown; + } + + public XTablesClient setCacheFetchCooldown(long cacheFetchCooldown) { + this.cacheFetchCooldown = cacheFetchCooldown; + return this; + } + + private void enableCache() { + try { + initializeCache(); + if (subscribeToCacheUpdates()) { + logger.info("Cache is now setup and ready to use."); + periodicallyUpdateCache(); + } else { + logger.severe("Failed to subscribe to ANY update, NON OK status returned from server."); + } + } catch (Exception e) { + logger.severe("Failed to initialize cache or subscribe to updates. Error:\n" + e.getMessage()); + } + } + + private void initializeCache() { + logger.info("Initializing cache..."); + String rawJSON = getRawJSON().complete(); + cache = new XTablesData(); + cache.updateFromRawJSON(rawJSON); + logger.info("Cache initialized and populated."); + } + + private boolean subscribeToCacheUpdates() { + ResponseStatus responseStatus = subscribeUpdateEvent((updateEvent) -> { + cache.put(updateEvent.getKey(), updateEvent.getValue()); + }).complete(); + if (responseStatus.equals(ResponseStatus.OK)) logger.info("Cache is now subscribed for updates."); + isCacheReady = responseStatus.equals(ResponseStatus.OK); + return isCacheReady; + } + + private void periodicallyUpdateCache() { + while (!Thread.currentThread().isInterrupted()) { + try { + String newRawJSON = getRawJSON().complete(); + cache.updateFromRawJSON(newRawJSON); + logger.info("Cache has been auto re-populated."); + Thread.sleep(cacheFetchCooldown); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.warning("Cache update thread was interrupted."); + break; + } + } + } + + + public boolean isCacheReady() { + return isCacheReady; + } + + public XTablesData getCache() { + return cache; + } + + public void updateServerAddress(String SERVER_ADDRESS, int SERVER_PORT) { + client.setSERVER_ADDRESS(SERVER_ADDRESS).setSERVER_PORT(SERVER_PORT).reconnect(); + } + + + public RequestAction subscribeUpdateEvent(String key, Class type, Consumer> consumer) { + Utilities.validateKey(key, true); + return subscribeUpdateEventNoCheck(key, type, consumer); + } + + private RequestAction subscribeUpdateEventNoCheck(String key, Class type, Consumer> consumer) { + return new RequestAction<>(client, new ResponseInfo(null, MethodType.SUBSCRIBE_UPDATE, key).parsed(), ResponseStatus.class) { + @Override + public boolean onResponse(ResponseStatus result) { + if (result.equals(ResponseStatus.OK)) { + List> consumers = update_consumers.computeIfAbsent(key, k -> new ArrayList<>()); + consumers.add(new UpdateConsumer<>(type, consumer)); + } + return true; + } + }; + } + + public List> resubscribeToAllUpdateEvents() { + List> all = new ArrayList<>(); + for (String key : update_consumers.keySet()) { + all.add(new RequestAction<>(client, new ResponseInfo(null, MethodType.SUBSCRIBE_UPDATE, key).parsed(), ResponseStatus.class)); + } + return all; + } + + public boolean resubscribeToDeleteEvents() { + return !delete_consumers.isEmpty(); + } + + public List completeAll(List> requestActions) { + List responses = new ArrayList<>(); + for (RequestAction requestAction : requestActions) { + T response = requestAction.complete(); + responses.add(response); + } + return responses; + } + + public void queueAll(List> requestActions) { + for (RequestAction requestAction : requestActions) { + requestAction.queue(); + } + } + + public RequestAction subscribeUpdateEvent(Consumer> consumer) { + String key = " "; + return subscribeUpdateEventNoCheck(key, null, consumer); + } + + public RequestAction unsubscribeUpdateEvent(String key, Class type, Consumer> consumer) { + Utilities.validateKey(key, true); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.UNSUBSCRIBE_UPDATE, key).parsed(), ResponseStatus.class) { + @Override + public boolean onResponse(ResponseStatus result) { + if (result.equals(ResponseStatus.OK)) { + List> consumers = update_consumers.computeIfAbsent(key, k -> new ArrayList<>()); + consumers.removeIf(updateConsumer -> updateConsumer.getType().equals(type) && updateConsumer.getConsumer().equals(consumer)); + if (consumers.isEmpty()) { + update_consumers.remove(key); + } + } + return true; + } + + @Override + public ResponseStatus returnValueIfNotRan() { + return ResponseStatus.OK; + } + + @Override + public boolean doNotRun() { + List> consumers = update_consumers.computeIfAbsent(key, k -> new ArrayList<>()); + consumers.removeIf(updateConsumer -> updateConsumer.getType().equals(type) && updateConsumer.getConsumer().equals(consumer)); + return !consumers.isEmpty(); + } + }; + } + + public RequestAction unsubscribeUpdateEvent(Consumer> consumer) { + String key = " "; + return new RequestAction<>(client, new ResponseInfo(null, MethodType.UNSUBSCRIBE_UPDATE, key).parsed(), ResponseStatus.class) { + @Override + public boolean onResponse(ResponseStatus result) { + if (result.equals(ResponseStatus.OK)) { + List> consumers = update_consumers.computeIfAbsent(key, k -> new ArrayList<>()); + consumers.removeIf(updateConsumer -> updateConsumer.getType().equals(String.class) && updateConsumer.getConsumer().equals(consumer)); + if (consumers.isEmpty()) { + update_consumers.remove(key); + } + } + return true; + } + + @Override + public ResponseStatus returnValueIfNotRan() { + return ResponseStatus.OK; + } + + @Override + public boolean doNotRun() { + List> consumers = update_consumers.computeIfAbsent(key, k -> new ArrayList<>()); + consumers.removeIf(updateConsumer -> updateConsumer.getType().equals(String.class) && updateConsumer.getConsumer().equals(consumer)); + return !consumers.isEmpty(); + } + }; + } + + public RequestAction subscribeDeleteEvent(Consumer consumer) { + return new RequestAction<>(client, new ResponseInfo(null, MethodType.SUBSCRIBE_DELETE).parsed(), ResponseStatus.class) { + @Override + public boolean onResponse(ResponseStatus result) { + if (result.equals(ResponseStatus.OK)) { + delete_consumers.add(consumer); + } + return true; + } + + }; + } + + public RequestAction unsubscribeDeleteEvent(Consumer consumer) { + return new RequestAction<>(client, new ResponseInfo(null, MethodType.UNSUBSCRIBE_DELETE).parsed(), ResponseStatus.class) { + @Override + public boolean onResponse(ResponseStatus result) { + if (result.equals(ResponseStatus.OK)) { + delete_consumers.remove(consumer); + } + return true; + } + + @Override + public ResponseStatus returnValueIfNotRan() { + return ResponseStatus.OK; + } + + @Override + public boolean doNotRun() { + delete_consumers.remove(consumer); + return !delete_consumers.isEmpty(); + } + }; + } + + + + + private void on_update(SocketClient.KeyValuePair keyValuePair) { + processUpdate(keyValuePair, keyValuePair.getKey()); + if (update_consumers.containsKey(" ")) { + processUpdate(keyValuePair, " "); + } + } + + private void on_delete(String key) { + synchronized (delete_consumers) { + for (Consumer consumer : delete_consumers) { + try { + consumer.accept(key); + } catch (Exception e) { + logger.severe("There was a exception while running delete subscriber callback: " + e.getMessage()); + } + } + } + } + + private void processUpdate(SocketClient.KeyValuePair keyValuePair, String key) { + List> consumers = update_consumers.computeIfAbsent(key, k -> new ArrayList<>()); + for (UpdateConsumer updateConsumer : consumers) { + UpdateConsumer typedUpdateConsumer = (UpdateConsumer) updateConsumer; + Consumer> consumer = typedUpdateConsumer.getConsumer(); + Class type = typedUpdateConsumer.getType(); + if (type != null) { + try { + T parsed = gson.fromJson(keyValuePair.getValue(), type); + consumer.accept(new SocketClient.KeyValuePair<>(keyValuePair.getKey(), parsed)); + } catch (JsonSyntaxException ignored) { + } catch (Exception e) { + logger.severe("There was a exception while running update subscriber callback: " + e.getMessage()); + + } + } else { + UpdateConsumer typedUpdateConsumer2 = (UpdateConsumer) updateConsumer; + Consumer> consumer2 = typedUpdateConsumer2.getConsumer(); + try { + consumer2.accept(keyValuePair); + } catch (Exception e) { + logger.severe("There was a exception while running subscriber callback: " + e.getMessage()); + + } + } + } + } + + + public RequestAction putRawUnsafe(String key, String value) { + logger.warning("This method is not recommend to be used. Please use XTablesClient#putRaw instead for a more safe put."); + Utilities.validateKey(key, true); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.PUT, key + " " + value).parsed(), ResponseStatus.class) { + /** + * Called before sending the request. Meant to be overridden in subclasses to perform any necessary setup or validation before running the request. + */ + @Override + public void beforeRun() { + if (!Utilities.isValidValue(value)) { + logger.warning("Invalid JSON value for key '" + key + "': " + value); + logger.warning("The key '" + key + "' may be flagged by the server."); + } + } + }; + } + + public RequestAction putRaw(String key, String value) { + Utilities.validateKey(key, true); + if (!Utilities.isValidValue(value)) throw new JsonSyntaxException("The value is not a valid JSON."); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.PUT, key + " " + value).parsed(), ResponseStatus.class) { + /** + * Returns a value when {@code doNotRun} returns true and the action is not performed. Meant to be overridden in subclasses to provide a default value. + * + * @return The default value to return if the request is not run. + */ + @Override + public ResponseStatus returnValueIfNotRan() { + return ResponseStatus.FAIL; + } + + /** + * Determines if the request should not run. Meant to be overridden in subclasses to provide specific conditions. + * + * @return true if the request should not be sent, false otherwise. + */ + @Override + public boolean doNotRun() { + return !Utilities.isValidValue(value); + } + }; + } + + public RequestAction getByteFrame(String key) { + Utilities.validateKey(key, true); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.GET, key).parsed(), ByteFrame.class) { + @Override + public String formatResult(String result) { + String[] parts = result.split(" "); + return String.join(" ", Arrays.copyOfRange(parts, 1, parts.length)); + } + + @Override + public ByteFrame parseResponse(long startTime, String result) { + checkFlaggedValue(result); + return null; + } + }; + } + + public RequestAction saveBackup(String directory, String inputFilename) { + Utilities.validateName(inputFilename, true); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.GET_RAW_JSON).parsed(), null) { + + /** + * Parses the response received from the server. + * Meant to be overridden in subclasses to parse the response based on specific needs. + *

+ * Order of execution: 3 (Called when a response is received) + * + * @param startTime The start time of the request, used for calculating latency. + * @param result The raw result from the server. + * @return The parsed response as type T. + */ + @Override + public File parseResponse(long startTime, String result) { + String response = DataCompression.decompressAndConvertBase64(result); + Path path = Paths.get(directory); + if (!Files.exists(path)) { + try { + Files.createDirectories(path); + } catch (IOException e) { + logger.severe("There was a exception while creating the directory: " + e.getMessage()); + } + } + String filename = inputFilename.contains(".") + ? inputFilename.substring(0, inputFilename.lastIndexOf('.')) + ".json" + : inputFilename + ".json"; + String filePath = directory + "/" + filename; + + try (FileWriter file = new FileWriter(filePath)) { + file.write(response); + logger.info("Successfully saved JSON content to " + filePath); + return new File(filePath); + } catch (IOException e) { + throw new BackupException(e); + } + + } + + + }; + } + + public RequestAction putString(String key, String value) { + Utilities.validateKey(key, true); + String parsedValue = gson.toJson(value); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.PUT, key + " " + parsedValue).parsed(), ResponseStatus.class); + } + + public void executePutString(String key, String value) { + Utilities.validateKey(key, true); + client.sendMessageRaw("IGNORED:PUT " + key + " \"" + value + "\""); + } + + public void executePutInteger(String key, int value) { + Utilities.validateKey(key, true); + client.sendMessageRaw("IGNORED:PUT " + key + " " + value); + } + + public void executePutDouble(String key, double value) { + Utilities.validateKey(key, true); + client.sendMessageRaw("IGNORED:PUT " + key + " " + value); + } + + public void executePutLong(String key, long value) { + Utilities.validateKey(key, true); + client.sendMessageRaw("IGNORED:PUT " + key + " " + value); + } + + public void executePutBoolean(String key, boolean value) { + Utilities.validateKey(key, true); + client.sendMessageRaw("IGNORED:PUT " + key + " " + value); + } + + public void executePutArray(String key, List value) { + Utilities.validateKey(key, true); + String parsedValue = gson.toJson(value); + client.sendMessageRaw("IGNORED:PUT " + key + " " + parsedValue); + } + + public void executePutObject(String key, Object value) { + Utilities.validateKey(key, true); + String parsedValue = gson.toJson(value); + client.sendMessageRaw("IGNORED:PUT " + key + " " + parsedValue); + } + +// public RequestAction registerImageStreamServer(String name) { +// Utilities.validateName(name, true); +// return new RequestAction<>(client, new ResponseInfo(null, MethodType.REGISTER_VIDEO_STREAM, name).parsed(), VideoStreamResponse.class) { +// /** +// * Called when a response is received. +// * Meant to be overridden in subclasses to handle specific actions on response. +// *

+// * Order of execution: 5 (Called after parsing and formatting the response) +// * +// * @param result The result of the response. +// * @return true if the response was handled successfully, false otherwise. +// */ +// @Override +// public boolean onResponse(VideoStreamResponse result) { +// if (result.getStatus().equals(ImageStreamStatus.OKAY)) { +// ImageStreamServer streamServer = new ImageStreamServer(name); +// try { +// streamServer.start(); +// result.setStreamServer(streamServer); +// } catch (IOException e) { +// logger.severe("There was an exception while starting video stream: " + e.getMessage()); +// result.setStatus(ImageStreamStatus.FAIL_START_SERVER); +// } +// } +// return true; +// } +// +// /** +// * Parses the response received from the server. +// * Meant to be overridden in subclasses to parse the response based on specific needs. +// *

+// * Order of execution: 3 (Called when a response is received) +// * +// * @param startTime The start time of the request, used for calculating latency. +// * @param result The raw result from the server. +// * @return The parsed response as type T. +// */ +// @Override +// public VideoStreamResponse parseResponse(long startTime, String result) { +// return new VideoStreamResponse(ImageStreamStatus.valueOf(result)); +// } +// }; +// } + +// public RequestAction registerImageStreamClient(String name, Consumer consumer) { +// Utilities.validateName(name, true); +// return new RequestAction<>(client, new ResponseInfo(null, MethodType.GET_VIDEO_STREAM, name).parsed(), VideoStreamResponse.class) { +// /** +// * Called when a response is received. +// * Meant to be overridden in subclasses to handle specific actions on response. +// *

+// * Order of execution: 5 (Called after parsing and formatting the response) +// * +// * @param result The result of the response. +// * @return true if the response was handled successfully, false otherwise. +// */ +// @Override +// public boolean onResponse(VideoStreamResponse result) { +// if (result.getStatus().equals(ImageStreamStatus.OKAY)) { +// ImageStreamClient streamClient = new ImageStreamClient(result.getAddress(), consumer); +// streamClient.start(client.getExecutor()); +// result.setStreamClient(streamClient); +// } +// return true; +// } +// +// /** +// * Parses the response received from the server. +// * Meant to be overridden in subclasses to parse the response based on specific needs. +// *

+// * Order of execution: 3 (Called when a response is received) +// * +// * @param startTime The start time of the request, used for calculating latency. +// * @param result The raw result from the server. +// * @return The parsed response as type T. +// */ +// @Override +// public VideoStreamResponse parseResponse(long startTime, String result) { +// if (Utilities.contains(ImageStreamStatus.class, result)) +// return new VideoStreamResponse(ImageStreamStatus.valueOf(result)); +// return new VideoStreamResponse(ImageStreamStatus.OKAY).setAddress(gson.fromJson(result, String.class)); +// } +// }; +// } + + public RequestAction renameKey(String key, String newName) { + Utilities.validateKey(key, true); + Utilities.validateName(newName, true); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.PUT, key + " " + newName).parsed(), ResponseStatus.class); + } + + public RequestAction putBoolean(String key, Boolean value) { + Utilities.validateKey(key, true); + String parsedValue = gson.toJson(value); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.PUT, key + " " + parsedValue).parsed(), ResponseStatus.class); + } + + public RequestAction putArray(String key, List value) { + Utilities.validateKey(key, true); + String parsedValue = gson.toJson(value); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.PUT, key + " " + parsedValue).parsed(), ResponseStatus.class); + } + + public RequestAction putByteFrame(String key, byte[] value) { + Utilities.validateKey(key, true); + String parsedValue = gson.toJson(new ByteFrame(value)); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.PUT, key + " " + parsedValue).parsed(), ResponseStatus.class); + } + + public RequestAction putInteger(String key, Integer value) { + Utilities.validateKey(key, true); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.PUT, key + " " + value).parsed(), ResponseStatus.class); + } + + public RequestAction putObject(String key, Object value) { + Utilities.validateKey(key, true); + String parsedValue = gson.toJson(value); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.PUT, key + " " + parsedValue).parsed(), ResponseStatus.class); + } + + public RequestAction delete(String key) { + Utilities.validateKey(key, true); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.DELETE, key).parsed(), ResponseStatus.class); + } + + public RequestAction deleteAll() { + return delete(""); + } + + public RequestAction getRaw(String key) { + return getString(key); + } + + public RequestAction getRawJSON() { + return new RequestAction<>(client, new ResponseInfo(null, MethodType.GET_RAW_JSON).parsed(), null) { + /** + * Parses the response received from the server. Meant to be overridden in subclasses to parse the response based on specific needs. + * + * @param startTime The start time of the request, used for calculating latency. + * @param result The raw result from the server. + * @return The parsed response as type T. + */ + @Override + public String parseResponse(long startTime, String result) { + return DataCompression.decompressAndConvertBase64(result); + } + }; + } + + public RequestAction getString(String key) { + Utilities.validateKey(key, true); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.GET, key).parsed(), String.class) { + @Override + public String formatResult(String result) { + String[] parts = result.split(" "); + return String.join(" ", Arrays.copyOfRange(parts, 1, parts.length)); + } + }; + } + + public RequestAction getImageStreamAddress(String name) { + Utilities.validateName(name, true); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.GET_VIDEO_STREAM, name).parsed(), String.class); + } + + public RequestAction getBoolean(String key) { + Utilities.validateKey(key, true); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.GET, key).parsed(), Boolean.class) { + /** + * Parses the response received from the server. Meant to be overridden in subclasses to parse the response based on specific needs. + * + * @param startTime The start time of the request, used for calculating latency. + * @param result The raw result from the server. + * @return The parsed response as type T. + */ + @Override + public Boolean parseResponse(long startTime, String result) { + checkFlaggedValue(result); + return null; + } + + @Override + public String formatResult(String result) { + String[] parts = result.split(" "); + return String.join(" ", Arrays.copyOfRange(parts, 1, parts.length)); + } + }; + } + + public RequestAction runScript(String name, String customData) { + Utilities.validateName(name, true); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.RUN_SCRIPT, customData == null || customData.trim().isEmpty() ? name : name + " " + customData).parsed(), ScriptResponse.class) { + /** + * Parses the response received from the server. Meant to be overridden in subclasses to parse the response based on specific needs. + * + * @param startTime The start time of the request, used for calculating latency. + * @param result The raw result from the server. + * @return The parsed response as type T. + */ + @Override + public ScriptResponse parseResponse(long startTime, String result) { + String[] parts = result.split(" "); + ResponseStatus status = ResponseStatus.valueOf(parts[0]); + String response = String.join(" ", Arrays.copyOfRange(parts, 1, parts.length)); + if (response == null || response.trim().isEmpty()) response = null; + if (status.equals(ResponseStatus.OK)) { + return new ScriptResponse(response, status); + } else { + return new ScriptResponse(response, ResponseStatus.FAIL); + } + } + + @Override + public String formatResult(String result) { + String[] parts = result.split(" "); + return String.join(" ", Arrays.copyOfRange(parts, 1, parts.length)); + } + }; + } + + public RequestAction getObject(String key, Class type) { + Utilities.validateKey(key, true); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.GET, key).parsed(), type) { + @Override + public String formatResult(String result) { + String[] parts = result.split(" "); + return String.join(" ", Arrays.copyOfRange(parts, 1, parts.length)); + } + + @Override + public T parseResponse(long startTime, String result) { + checkFlaggedValue(result); + return null; + } + }; + } + + public RequestAction getInteger(String key) { + Utilities.validateKey(key, true); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.GET, key).parsed(), Integer.class) { + @Override + public String formatResult(String result) { + String[] parts = result.split(" "); + return String.join(" ", Arrays.copyOfRange(parts, 1, parts.length)); + } + + @Override + public Integer parseResponse(long startTime, String result) { + + checkFlaggedValue(result); + return null; + } + }; + } + + public RequestAction> getArray(String key, Class type) { + Utilities.validateKey(key, true); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.GET, key).parsed(), type) { + @Override + public String formatResult(String result) { + String[] parts = result.split(" "); + return String.join(" ", Arrays.copyOfRange(parts, 1, parts.length)); + } + + @Override + public ArrayList parseResponse(long startTime, String result) { + checkFlaggedValue(result); + return null; + } + }; + } + + private static void checkFlaggedValue(String result) { + if (result.split(" ")[0].equals("FLAGGED")) { + throw new ServerFlaggedValueException("This key is flagged as invalid JSON therefore cannot be parsed. Use XTablesClient#getRaw instead."); + } + } + + public RequestAction> getTables(String key) { + Utilities.validateKey(key, true); + return new RequestAction<>(client, new ResponseInfo(null, MethodType.GET_TABLES, key).parsed(), ArrayList.class); + } + + public RequestAction> getTables() { + return getTables(""); + } + + public RequestAction rebootServer() { + return new RequestAction<>(client, new ResponseInfo(null, MethodType.REBOOT_SERVER).parsed(), ResponseStatus.class); + } + + public RequestAction sendCustomMessage(MethodType method, String message, Class type) { + return new RequestAction<>(client, new ResponseInfo(null, method, message).parsed(), type); + } + + public SocketClient getSocketClient() { + return client; + } + + public RequestAction ping_latency() { + return new RequestAction<>(client, new ResponseInfo(null, MethodType.PING).parsed()) { + @Override + public LatencyInfo parseResponse(long startTime, String result) { + RequestInfo info = new RequestInfo(result); + if (info.getTokens().length == 2 && info.getTokens()[0].equals("OK")) { + SystemStatistics stats = gson.fromJson(info.getTokens()[1], SystemStatistics.class); + long serverTime = stats.getNanoTime(); + long currentTime = System.nanoTime(); + long networkLatency = Math.abs(currentTime - serverTime); + long roundTripLatency = Math.abs(currentTime - startTime); + return new LatencyInfo(networkLatency / 1e6, roundTripLatency / 1e6, stats); + } else { + return null; + } + + } + }; + + } + + + public static class LatencyInfo { + private double networkLatencyMS; + private double roundTripLatencyMS; + private SystemStatistics systemStatistics; + + public LatencyInfo(double networkLatencyMS, double roundTripLatencyMS, SystemStatistics systemStatistics) { + this.networkLatencyMS = networkLatencyMS; + this.roundTripLatencyMS = roundTripLatencyMS; + this.systemStatistics = systemStatistics; + } + + public double getNetworkLatencyMS() { + return networkLatencyMS; + } + + public void setNetworkLatencyMS(double networkLatencyMS) { + this.networkLatencyMS = networkLatencyMS; + } + + public double getRoundTripLatencyMS() { + return roundTripLatencyMS; + } + + public void setRoundTripLatencyMS(double roundTripLatencyMS) { + this.roundTripLatencyMS = roundTripLatencyMS; + } + + public SystemStatistics getSystemStatistics() { + return systemStatistics; + } + + public void setSystemStatistics(SystemStatistics systemStatistics) { + this.systemStatistics = systemStatistics; + } + } + +} diff --git a/src/main/java/org/kobe/xbot/Server/ConfigLoader.java b/src/main/java/org/kobe/xbot/Server/ConfigLoader.java new file mode 100644 index 00000000..c6b47bd5 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Server/ConfigLoader.java @@ -0,0 +1,76 @@ +package org.kobe.xbot.Server; + +import org.kobe.xbot.Utilities.Logger.XTablesLogger; + +import java.io.*; +import java.util.Properties; + +public class ConfigLoader { + private Properties properties = new Properties(); + private String fileName = "xtables.properties"; + private final XTablesLogger logger = XTablesLogger.getLogger(); // Your custom logger + + public ConfigLoader() { + loadProperties(); + } + private void loadProperties() { + File configFile = new File(fileName); + + if (configFile.exists()) { + logger.info("Configuration file found: " + configFile.getAbsolutePath()); + // Load existing properties file from the filesystem + try (FileInputStream input = new FileInputStream(configFile)) { + properties.load(input); + logger.info("Properties loaded successfully from " + configFile.getAbsolutePath()); + } catch (IOException e) { + logger.severe("Error loading properties file: " + e.getMessage()); + } + } else { + logger.warning("Configuration file not found: " + configFile.getAbsolutePath()); + logger.info("Attempting to load configuration from classpath."); + + // Attempt to load from classpath + try (InputStream input = getClass().getClassLoader().getResourceAsStream(fileName)) { + if (input != null) { + properties.load(input); + logger.info("Properties loaded successfully from classpath."); + } else { + logger.severe("Failed to load properties file from classpath. Input stream is null."); + logger.info("Creating default configuration file."); + // Create a new properties file with default settings + setDefaultProperties(); + saveProperties(configFile); + } + } catch (IOException e) { + logger.severe("Error loading properties file from classpath: " + e.getMessage()); + } + } + } + + + private void setDefaultProperties() { + properties.setProperty("servers.password", "8080"); + properties.setProperty("servers.user", "jdbc:mysql://localhost:3306/mydb"); + properties.setProperty("server.port", ""); + logger.info("Default properties set."); + } + + private void saveProperties(File configFile) { + try (FileOutputStream output = new FileOutputStream(configFile)) { + properties.store(output, "Default XTABLES Configuration"); + logger.info("Default configuration file created at " + configFile.getAbsolutePath()); + } catch (IOException e) { + logger.severe("Error saving properties file: " + e.getMessage()); + } + } + + public String getProperty(String key) { + return properties.getProperty(key); + } + + public static void main(String[] args) { + ConfigLoader config = new ConfigLoader(); + String port = config.getProperty("server.port"); + System.out.println("Server Port: " + port); + } +} diff --git a/src/main/java/org/kobe/xbot/Server/Main.java b/src/main/java/org/kobe/xbot/Server/Main.java new file mode 100644 index 00000000..b5bde2e2 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Server/Main.java @@ -0,0 +1,25 @@ +package org.kobe.xbot.Server; + +import org.kobe.xbot.Utilities.Logger.XTablesLogger; + +public class Main { + private static final XTablesLogger logger = XTablesLogger.getLogger(); + + public static void main(String[] args) { + if (args.length > 0) { + try { + int port = Integer.parseInt(args[0]); + if (port < 0 || port > 65535) { + logger.severe("Error: The specified port '" + args[0] + "' is outside the specified range of valid port values."); + } else { + XTables.startInstance("XTablesService", port); + } + } catch (NumberFormatException e) { + logger.severe("Error: The specified port '" + args[0] + "' is not a valid integer."); + } + } else { + logger.info("No port number provided. Default port 1735 is being used."); + XTables.startInstance("XTablesService", 1735); + } + } +} diff --git a/src/main/java/org/kobe/xbot/Server/XTables.java b/src/main/java/org/kobe/xbot/Server/XTables.java new file mode 100644 index 00000000..4f555167 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Server/XTables.java @@ -0,0 +1,755 @@ +/** + * XTables class manages a server that allows clients to interact with a key-value data structure. + * It provides methods to start the server, handle client connections, and process client requests. + * The class also supports server reboot functionality and notification of clients upon data updates. + * + *

+ * + * @author Kobe + */ + +package org.kobe.xbot.Server; + +import com.google.gson.Gson; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.handler.ContextHandler; +import org.eclipse.jetty.server.handler.HandlerList; +import org.eclipse.jetty.server.handler.ResourceHandler; +import org.eclipse.jetty.servlet.FilterHolder; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.servlets.CrossOriginFilter; +import org.kobe.xbot.Utilities.*; +import org.kobe.xbot.Utilities.Entities.ScriptParameters; +import org.kobe.xbot.Utilities.Exceptions.ScriptAlreadyExistsException; +import org.kobe.xbot.Utilities.Logger.XTablesLogger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.jmdns.JmDNS; +import javax.jmdns.ServiceInfo; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URL; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.stream.Collectors; + +public class XTables { + private static final Logger log = LoggerFactory.getLogger(XTables.class); + private final String SERVICE_NAME; + private static final int SERVICE_PORT = 5353; + private static final AtomicReference instance = new AtomicReference<>(); + private static final Gson gson = new Gson(); + private static final XTablesLogger logger = XTablesLogger.getLogger(); + private JmDNS jmdns; + private ServiceInfo serviceInfo; + private final Set clients = new HashSet<>(); + private final XTablesData table = new XTablesData(); + private ServerSocket serverSocket; + private final int port; + private final ExecutorService clientThreadPool; + private final HashMap> scripts = new HashMap<>(); + private static Thread mainThread; + private static final CountDownLatch latch = new CountDownLatch(1); + private ExecutorService mdnsExecutorService; + private Server userInterfaceServer; + private static final AtomicReference status = new AtomicReference<>(XTableStatus.OFFLINE); + + private XTables(String SERVICE_NAME, int PORT) { + this.port = PORT; + this.SERVICE_NAME = SERVICE_NAME; + instance.set(this); + this.clientThreadPool = Executors.newCachedThreadPool(); + startServer(); + } + + public static XTables startInstance(String SERVICE_NAME, int PORT) { + if (instance.get() == null) { + if (PORT == 5353) + throw new IllegalArgumentException("The port 5353 is reserved for mDNS services."); + if (SERVICE_NAME.equalsIgnoreCase("localhost")) + throw new IllegalArgumentException("The mDNS service name cannot be localhost!"); + status.set(XTableStatus.STARTING); + Thread main = new Thread(() -> new XTables(SERVICE_NAME, PORT)); + main.setName("XTABLES-SERVER"); + main.setDaemon(false); + main.start(); + try { + latch.await(); + } catch (InterruptedException e) { + logger.severe("Main thread interrupted: " + e.getMessage()); + Thread.currentThread().interrupt(); + } + mainThread = main; + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + logger.info("Shutdown hook triggered. Stopping server..."); + instance.get().stopServer(true); + logger.info("Server stopped gracefully."); + })); + + } + return instance.get(); + } + + public static void stopInstance() { + XTables xTables = instance.get(); + xTables.stopServer(false); + mainThread.interrupt(); + instance.set(null); + } + + public static XTables getInstance() { + return instance.get(); + } + + public static XTableStatus getStatus() { + return status.get(); + } + + public void addScript(String name, Function script) { + if (scripts.containsKey(name)) + throw new ScriptAlreadyExistsException("There is already a script with the name: '" + name + "'"); + scripts.put(name, script); + logger.info("Added script '" + name + "'"); + } + + public void removeScript(String name) { + scripts.remove(name); + } + + private void startServer() { + try { + status.set(XTableStatus.STARTING); + if (mdnsExecutorService != null) { + mdnsExecutorService.shutdownNow(); + } + this.mdnsExecutorService = Executors.newCachedThreadPool(); + mdnsExecutorService.execute(() -> initializeMDNSWithRetries(15)); + + try { + serverSocket = new ServerSocket(port); + } catch (IOException e) { + if (e.getMessage().contains("Address already in use")) { + logger.fatal("Port " + port + " is already in use."); + logger.fatal("Exiting server now..."); + System.exit(1); + } else { + throw e; + } + } + logger.info("Server started. Listening on " + serverSocket.getLocalSocketAddress() + "..."); + if (userInterfaceServer == null || !userInterfaceServer.isRunning()) { + try { + userInterfaceServer = new Server(4880); + // Static resource handler + ResourceHandler resourceHandler = new ResourceHandler(); + resourceHandler.setDirectoriesListed(true); + URL resourceURL = XTables.class.getResource("/static"); + assert resourceURL != null; + String resourceBase = resourceURL.toExternalForm(); + resourceHandler.setResourceBase(resourceBase); + + ContextHandler staticContext = new ContextHandler("/"); + staticContext.setHandler(resourceHandler); + + ServletContextHandler servletContextHandler = new ServletContextHandler(ServletContextHandler.SESSIONS); + servletContextHandler.setContextPath("/"); + + // Add a servlet to handle GET requests + servletContextHandler.addServlet(new ServletHolder(new HttpServlet() { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.setContentType("application/json"); + resp.setCharacterEncoding("UTF-8"); + SystemStatistics systemStatistics = new SystemStatistics(clients.size()); + systemStatistics.setStatus(XTables.getStatus()); + synchronized (clients) { + systemStatistics.setClientDataList(clients.stream().map(m -> new ClientData(m.clientSocket.getInetAddress().getHostAddress(), m.totalMessages, m.identifier)).collect(Collectors.toList())); + int i = 0; + for (ClientHandler client : clients) { + i += client.totalMessages; + } + systemStatistics.setTotalMessages(i); + } + resp.getWriter().println(gson.toJson(systemStatistics)); + } + }), "/api/get"); + servletContextHandler.addServlet(new ServletHolder(new HttpServlet() { + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.setContentType("application/json"); + resp.setCharacterEncoding("UTF-8"); + boolean response = rebootServer(); + if (response) { + resp.setStatus(HttpServletResponse.SC_OK); + resp.getWriter().println("{ \"status\": \"success\", \"message\": \"Server has been rebooted!\"}"); + } else { + resp.setStatus(HttpServletResponse.SC_CONFLICT); + resp.getWriter().println("{ \"status\": \"failed\", \"message\": \"Server cannot reboot while: " + getStatus() + "\"}"); + } + } + }), "/api/reboot"); + servletContextHandler.addServlet(new ServletHolder(new HttpServlet() { + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + String uuidParam = req.getParameter("uuid"); + + resp.setContentType("application/json"); + resp.setCharacterEncoding("UTF-8"); + if (uuidParam == null || uuidParam.isEmpty()) { + resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); + resp.getWriter().println("{ \"status\": \"failed\", \"message\": \"No UUID found in parameter!\"}"); + return; + } + UUID uuid; + + try { + uuid = UUID.fromString(uuidParam); + } catch (IllegalArgumentException e) { + resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); + resp.getWriter().println("{ \"status\": \"failed\", \"message\": \"Invalid UUID format!\"}"); + return; + } + + synchronized (clients) { + Optional clientHandler = clients.stream().filter(f -> f.identifier.equals(uuid.toString())).findFirst(); + if (clientHandler.isPresent()) { + clientHandler.get().disconnect(); + resp.setStatus(HttpServletResponse.SC_OK); + resp.getWriter().println("{ \"status\": \"success\", \"message\": \"The client has been disconnected!\"}"); + } else { + resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); + resp.getWriter().println("{ \"status\": \"failed\", \"message\": \"This client does not exist!\"}"); + } + } + + + } + }), "/api/disconnect"); + + FilterHolder cors = servletContextHandler.addFilter(CrossOriginFilter.class, "/*", EnumSet.of(DispatcherType.REQUEST)); + cors.setInitParameter(CrossOriginFilter.ALLOWED_ORIGINS_PARAM, "*"); + cors.setInitParameter(CrossOriginFilter.ACCESS_CONTROL_ALLOW_ORIGIN_HEADER, "*"); + cors.setInitParameter(CrossOriginFilter.ALLOWED_METHODS_PARAM, "GET,POST,HEAD"); + cors.setInitParameter(CrossOriginFilter.ALLOWED_HEADERS_PARAM, "X-Requested-With,Content-Type,Accept,Origin"); + + // Combine the handlers + HandlerList handlers = new HandlerList(); + handlers.addHandler(staticContext); + handlers.addHandler(servletContextHandler); + + userInterfaceServer.setHandler(handlers); + userInterfaceServer.start(); + logger.info("The local XTABLES user interface started at port http://localhost:4880!"); + } catch (Exception e) { + logger.warning("The local XTABLES user interface failed to start!"); + } + } + latch.countDown(); + status.set(XTableStatus.ONLINE); + while (!serverSocket.isClosed() && !Thread.currentThread().isInterrupted()) { + Socket clientSocket = serverSocket.accept(); + logger.info(String.format("Client connected: %1$s:%2$s", clientSocket.getInetAddress(), clientSocket.getPort())); + ClientHandler clientHandler = new ClientHandler(clientSocket); + clients.add(clientHandler); + clientThreadPool.execute(clientHandler); + status.set(XTableStatus.ONLINE); + } + } catch (IOException e) { + logger.severe("Socket error occurred: " + e.getMessage()); + status.set(XTableStatus.OFFLINE); + if (serverSocket != null && !serverSocket.isClosed()) { + try { + serverSocket.close(); + } catch (IOException ex) { + logger.severe("Error closing server socket: " + ex.getMessage()); + } + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public void stopServer(boolean fullShutdown) { + try { + logger.info("Closing connections to all clients..."); + status.set(XTableStatus.OFFLINE); + for (ClientHandler client : new ArrayList<>(clients)) { + if (client != null) client.clientSocket.close(); + } + + clients.clear(); + logger.info("Closing socket server..."); + if (serverSocket != null && !serverSocket.isClosed()) { + serverSocket.close(); + } + table.delete(""); + mdnsExecutorService.shutdownNow(); + if (jmdns != null && serviceInfo != null) { + logger.info("Unregistering mDNS service: " + serviceInfo.getQualifiedName() + " on port " + SERVICE_PORT + "..."); + jmdns.unregisterService(serviceInfo); + jmdns.close(); + logger.info("mDNS service unregistered and mDNS closed"); + } + if (fullShutdown) { + mainThread.interrupt(); + if (userInterfaceServer != null) userInterfaceServer.stop(); + clientThreadPool.shutdownNow(); + } + } catch (IOException e) { + logger.severe("Error occurred during server stop: " + e.getMessage()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void initializeMDNSWithRetries(int maxRetries) { + int attempt = 0; + long delay = 1000; // 1 second mDNS setup retry + + while (attempt < maxRetries && !Thread.currentThread().isInterrupted()) { + try { + attempt++; + InetAddress addr = Utilities.getLocalInetAddress(); + if (addr == null) { + throw new RuntimeException("No local IP address found!"); + } + logger.info("Initializing mDNS with address: " + addr.getHostAddress()); + jmdns = JmDNS.create(addr); + // Create the service with additional attributes + Map props = new HashMap<>(); + props.put("port", String.valueOf(port)); + serviceInfo = ServiceInfo.create("_xtables._tcp.local.", SERVICE_NAME, SERVICE_PORT, 0, 0, props); + jmdns.registerService(serviceInfo); + logger.info("mDNS service registered: " + serviceInfo.getQualifiedName() + " on port " + SERVICE_PORT); + return; + } catch (Exception e) { + logger.severe("Error initializing mDNS (attempt " + attempt + "): " + e.getMessage()); + if (attempt >= maxRetries) { + logger.severe("Max retries reached. Giving up on mDNS initialization."); + stopInstance(); + System.exit(0); + return; + } + + try { + logger.info("Retying mDNS initialization in " + delay + " ms."); + TimeUnit.MILLISECONDS.sleep(delay); + } catch (InterruptedException ie) { + logger.severe("Retry sleep interrupted: " + ie.getMessage()); + Thread.currentThread().interrupt(); + } + } + } + } + + public boolean rebootServer() { + if (!getStatus().equals(XTableStatus.ONLINE)) { + logger.warning("Cannot reboot server when status is: " + getStatus()); + return false; + } + try { + status.set(XTableStatus.REBOOTING); + stopServer(false); + logger.info("Starting socket server in 1 second..."); + status.set(XTableStatus.STARTING); + TimeUnit.SECONDS.sleep(1); + logger.info("Starting server..."); + status.set(XTableStatus.STARTING); + Thread thread = new Thread(this::startServer); + thread.setDaemon(false); + thread.start(); + return true; + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + private void notifyUpdateChangeClients(String key, String value) { + synchronized (clients) { + Iterator iterator = clients.iterator(); + if (iterator.hasNext()) { + do { + ClientHandler client = iterator.next(); + try { + Set updateEvents = client.getUpdateEvents(); + if (updateEvents.contains("") || updateEvents.contains(key)) { + client.sendUpdate(key, value); + } + } catch (Exception | Error e) { + logger.warning("Failed to push updates to client: " + e.getMessage()); + } + } while (iterator.hasNext()); + } + } + } + + + private void notifyDeleteChangeClients(String key) { + synchronized (clients) { + for (ClientHandler client : clients) { + if (client.getDeleteEvent()) { + client.sendDelete(key); + } + } + } + } + + // Thread to handle each client connection + private class ClientHandler extends Thread { + private final Socket clientSocket; + private final PrintWriter out; + private final Set updateEvents = new HashSet<>(); + private final AtomicBoolean deleteEvent = new AtomicBoolean(false); + private int totalMessages = 0; + private final String identifier; + private List streams; + + public ClientHandler(Socket socket) throws IOException { + this.clientSocket = socket; + this.identifier = UUID.randomUUID().toString(); + this.out = new PrintWriter(clientSocket.getOutputStream(), true); + super.setDaemon(true); + } + + public Set getUpdateEvents() { + return updateEvents; + } + + public Boolean getDeleteEvent() { + return deleteEvent.get(); + } + + @Override + public void run() { + ScheduledExecutorService messages_log_executor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread thread = new Thread(r); + thread.setDaemon(true); + return thread; + }); + messages_log_executor.scheduleAtFixedRate(() -> { + if (totalMessages != 0) { + logger.info("Received " + String.format("%,d", totalMessages) + " messages from IP " + clientSocket.getInetAddress() + ":" + clientSocket.getPort() + " in the last minute."); + totalMessages = 0; + } + }, 60, 60, TimeUnit.SECONDS); + + // try with resources for no memory leak + + try (PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true); + BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()))) { + + String inputLine; + while ((inputLine = in.readLine()) != null && !this.isInterrupted()) { + totalMessages++; + String raw = inputLine.replace("\n", "").trim(); + String[] tokens = tokenize(raw, ' '); + String[] requestTokens = tokenize(tokens[0], ':'); + String id = requestTokens[0]; + MethodType methodType; + try { + methodType = MethodType.valueOf(requestTokens[1]); + } catch (Exception e) { + methodType = MethodType.UNKNOWN; + } + boolean shouldReply = !id.equals("IGNORED"); + if (tokens.length == 2 && methodType.equals(MethodType.REGISTER_VIDEO_STREAM)) { + String name = tokens[1].trim(); + if (Utilities.validateName(name, false)) { + if (clients.stream().anyMatch(clientHandler -> clientHandler.streams != null && clientHandler.streams.contains(name))) { + if (shouldReply) { + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.REGISTER_VIDEO_STREAM, ImageStreamStatus.FAIL_NAME_ALREADY_EXISTS.name()); + out.println(responseInfo.parsed()); + out.flush(); + } + } else { + if (streams == null) { + streams = Collections.synchronizedList(new ArrayList<>()); + } + streams.add(name); + if (shouldReply) { + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.REGISTER_VIDEO_STREAM, ImageStreamStatus.OKAY.name()); + out.println(responseInfo.parsed()); + out.flush(); + } + } + } else if (shouldReply) { + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.REGISTER_VIDEO_STREAM, ImageStreamStatus.FAIL_INVALID_NAME.name()); + out.println(responseInfo.parsed()); + out.flush(); + } + } else if (shouldReply && tokens.length == 2 && methodType.equals(MethodType.GET_VIDEO_STREAM)) { + String name = tokens[1]; + if (Utilities.validateName(name, false)) { + Optional optional = clients.stream().filter(clientHandler -> clientHandler.streams != null && clientHandler.streams.contains(name)).findFirst(); + ResponseInfo responseInfo; + responseInfo = optional.map(clientHandler -> { + String clientAddress = clientHandler.clientSocket.getInetAddress().getHostAddress(); + return new ResponseInfo(id, MethodType.GET_VIDEO_STREAM, gson.toJson(String.format("http://%1$s:4888/%2$s", clientAddress.equals("127.0.0.1") || clientAddress.equals("::1") ? Utilities.getLocalIPAddress() : clientAddress.replaceFirst("/", ""), name))); + }) + .orElseGet(() -> new ResponseInfo(id, MethodType.GET_VIDEO_STREAM, ImageStreamStatus.FAIL_INVALID_NAME.name())); + out.println(responseInfo.parsed()); + out.flush(); + } else { + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.GET_VIDEO_STREAM, ImageStreamStatus.FAIL_INVALID_NAME.name()); + out.println(responseInfo.parsed()); + out.flush(); + } + } else if (shouldReply && tokens.length == 2 && methodType.equals(MethodType.GET)) { + String key = tokens[1]; + String result = gson.toJson(table.get(key)); + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.GET, String.format("%1$s " + result, table.isFlaggedKey(key) ? "FLAGGED" : "GOOD")); + out.println(responseInfo.parsed()); + out.flush(); + } else if (shouldReply && tokens.length == 2 && methodType.equals(MethodType.GET_TABLES)) { + String key = tokens[1]; + String result = gson.toJson(table.getTables(key)); + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.GET_TABLES, result); + out.println(responseInfo.parsed()); + out.flush(); + } else if (tokens.length >= 3 && methodType.equals(MethodType.PUT)) { + String key = tokens[1]; + String value = String.join(" ", Arrays.copyOfRange(tokens, 2, tokens.length)); + boolean response = table.put(key, value); + if (shouldReply) { + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.PUT, response ? "OK" : "FAIL"); + out.println(responseInfo.parsed()); + out.flush(); + } + if (response) { + notifyUpdateChangeClients(key, value); + } + } else if (tokens.length >= 2 && methodType.equals(MethodType.RUN_SCRIPT)) { + String name = tokens[1]; + String customData = String.join(" ", Arrays.copyOfRange(tokens, 2, tokens.length)); + if (scripts.containsKey(name)) { + clientThreadPool.execute(() -> { + long startTime = System.nanoTime(); + String returnValue; + ResponseStatus status = ResponseStatus.OK; + try { + returnValue = scripts.get(name).apply(new ScriptParameters(table, customData == null || customData.trim().isEmpty() ? null : customData.trim())); + long endTime = System.nanoTime(); + double durationMillis = (endTime - startTime) / 1e6; + logger.info("The script '" + name + "' ran successfully."); + logger.info("Start time: " + startTime + " ns"); + logger.info("End time: " + endTime + " ns"); + logger.info("Duration: " + durationMillis + " ms"); + } catch (Exception e) { + long endTime = System.nanoTime(); + double durationMillis = (endTime - startTime) / 1e6; + returnValue = e.getMessage(); + status = ResponseStatus.FAIL; + logger.severe("The script '" + name + "' encountered an error."); + logger.severe("Error message: " + e.getMessage()); + logger.severe("Start time: " + startTime + " ns"); + logger.severe("End time: " + endTime + " ns"); + logger.severe("Duration: " + durationMillis + " ms"); + } + String response = returnValue == null || returnValue.trim().isEmpty() ? status.name() : status.name() + " " + returnValue.trim(); + if (shouldReply) { + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.RUN_SCRIPT, response); + out.println(responseInfo.parsed()); + out.flush(); + } + }); + } else if (shouldReply) { + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.PUT, "FAIL SCRIPT_NOT_FOUND"); + out.println(responseInfo.parsed()); + out.flush(); + } + } else if (tokens.length == 3 && methodType.equals(MethodType.UPDATE_KEY)) { + String key = tokens[1]; + String value = tokens[2]; + if (!Utilities.validateName(value, false)) { + if (shouldReply) { + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.UPDATE_KEY, "FAIL"); + out.println(responseInfo.parsed()); + out.flush(); + } + } else { + boolean response = table.renameKey(key, value); + if (shouldReply) { + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.UPDATE_KEY, response ? "OK" : "FAIL"); + out.println(responseInfo.parsed()); + out.flush(); + } + } + } else if (tokens.length == 2 && methodType.equals(MethodType.DELETE)) { + String key = tokens[1]; + boolean response = table.delete(key); + if (shouldReply) { + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.DELETE, response ? "OK" : "FAIL"); + out.println(responseInfo.parsed()); + out.flush(); + } + if (response) { + notifyDeleteChangeClients(key); + } + } else if (tokens.length == 1 && methodType.equals(MethodType.DELETE)) { + boolean response = table.delete(""); + if (shouldReply) { + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.DELETE, response ? "OK" : "FAIL"); + out.println(responseInfo.parsed()); + out.flush(); + } + } else if (tokens.length == 1 && methodType.equals(MethodType.SUBSCRIBE_DELETE)) { + deleteEvent.set(true); + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.SUBSCRIBE_DELETE, "OK"); + out.println(responseInfo.parsed()); + out.flush(); + } else if (tokens.length == 1 && methodType.equals(MethodType.UNSUBSCRIBE_DELETE)) { + deleteEvent.set(false); + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.UNSUBSCRIBE_DELETE, "OK"); + out.println(responseInfo.parsed()); + out.flush(); + } else if (tokens.length == 2 && methodType.equals(MethodType.SUBSCRIBE_UPDATE)) { + String key = tokens[1]; + updateEvents.add(key); + if (shouldReply) { + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.SUBSCRIBE_UPDATE, "OK"); + out.println(responseInfo.parsed()); + out.flush(); + } + } else if (tokens.length == 1 && methodType.equals(MethodType.SUBSCRIBE_UPDATE)) { + updateEvents.add(""); + if (shouldReply) { + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.SUBSCRIBE_UPDATE, "OK"); + out.println(responseInfo.parsed()); + out.flush(); + } + } else if (tokens.length == 2 && methodType.equals(MethodType.UNSUBSCRIBE_UPDATE)) { + String key = tokens[1]; + boolean success = updateEvents.remove(key); + if (shouldReply) { + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.UNSUBSCRIBE_UPDATE, success ? "OK" : "FAIL"); + out.println(responseInfo.parsed()); + out.flush(); + } + } else if (tokens.length == 1 && methodType.equals(MethodType.UNSUBSCRIBE_UPDATE)) { + boolean success = updateEvents.remove(""); + if (shouldReply) { + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.UNSUBSCRIBE_UPDATE, success ? "OK" : "FAIL"); + out.println(responseInfo.parsed()); + out.flush(); + } + } else if (shouldReply && tokens.length == 1 && methodType.equals(MethodType.PING)) { + SystemStatistics systemStatistics = new SystemStatistics(clients.size()); + int i = 0; + for (ClientHandler client : clients) { + i += client.totalMessages; + } + systemStatistics.setTotalMessages(i); + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.PING, ResponseStatus.OK.name() + " " + gson.toJson(systemStatistics).replaceAll(" ", "")); + out.println(responseInfo.parsed()); + out.flush(); + } else if (shouldReply && tokens.length == 1 && methodType.equals(MethodType.GET_TABLES)) { + String result = gson.toJson(table.getTables("")); + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.GET_TABLES, result); + out.println(responseInfo.parsed()); + out.flush(); + } else if (shouldReply && tokens.length == 1 && methodType.equals(MethodType.GET_RAW_JSON)) { + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.GET_RAW_JSON, DataCompression.compressAndConvertBase64(table.toJSON())); + out.println(responseInfo.parsed()); + out.flush(); + } else if (tokens.length == 1 && methodType.equals(MethodType.REBOOT_SERVER)) { + if (shouldReply) { + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.REBOOT_SERVER, ResponseStatus.OK.name()); + out.println(responseInfo.parsed()); + out.flush(); + } + rebootServer(); + } else if (shouldReply) { + // Invalid command + logger.warning("Unknown command received from " + clientSocket.getInetAddress().getHostAddress() + ": " + inputLine); + + ResponseInfo responseInfo = new ResponseInfo(id, MethodType.UNKNOWN, ResponseStatus.FAIL.name()); + out.println(responseInfo.parsed()); + out.flush(); + + } + } + } catch (IOException e) { + String message = e.getMessage(); + if (message.contains("Connection reset")) { + logger.info(String.format("Client disconnected: %1$s:%2$s", clientSocket.getInetAddress(), clientSocket.getPort())); + } else { + logger.severe("Error occurred: " + e.getMessage()); + } + } finally { + logger.info(String.format("Starting cleanup for client: %1$s:%2$s", + clientSocket.getInetAddress(), + clientSocket.getPort())); + try { + clientSocket.close(); + } catch (IOException e) { + logger.severe("Error closing client socket: " + e.getMessage()); + } + clients.remove(this); + messages_log_executor.shutdownNow(); + logger.info(String.format("Finishing cleanup for client: %1$s:%2$s.", + clientSocket.getInetAddress(), + clientSocket.getPort())); + this.interrupt(); + + } + } + + public void disconnect() throws IOException { + clientSocket.close(); + clients.remove(this); + this.interrupt(); + } + + public void sendUpdate(String key, String value) { + out.println("null:UPDATE_EVENT " + (key + " " + value).replaceAll("\n", "")); + out.flush(); + } + + public void sendDelete(String key) { + out.println("null:DELETE_EVENT " + (key).replaceAll("\n", "")); + out.flush(); + } + } + + private String[] tokenize(String input, char delimiter) { + int count = 1; + int length = input.length(); + for (int i = 0; i < length; i++) { + if (input.charAt(i) == delimiter) { + count++; + } + } + + // Allocate array for results + String[] result = new String[count]; + int index = 0; + int tokenStart = 0; + + // Second pass: Extract tokens + for (int i = 0; i < length; i++) { + if (input.charAt(i) == delimiter) { + result[index++] = input.substring(tokenStart, i); + tokenStart = i + 1; + } + } + + // Add last token + result[index] = input.substring(tokenStart); + + return result; + } +} diff --git a/src/main/java/org/kobe/xbot/Server/XTablesData.java b/src/main/java/org/kobe/xbot/Server/XTablesData.java new file mode 100644 index 00000000..e89757da --- /dev/null +++ b/src/main/java/org/kobe/xbot/Server/XTablesData.java @@ -0,0 +1,212 @@ +package org.kobe.xbot.Server; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import org.kobe.xbot.Utilities.Logger.XTablesLogger; +import org.kobe.xbot.Utilities.Utilities; + +import java.util.*; + +public class XTablesData { + private static final XTablesLogger logger = XTablesLogger.getLogger(); + private static final Gson gson = new GsonBuilder().create(); + private static final Set flaggedKeys = Collections.synchronizedSet(new HashSet<>()); + private Map data; + private String value; + + public XTablesData() { + // Initialize the data map lazily + } + + // Method to put a value into the nested structure + public boolean put(String key, String value) { + Utilities.validateKey(key, true); +// if(!Utilities.isValidValue((String) value) && !flaggedKeys.contains(key)) { +// flaggedKeys.add(key); +// logger.warning("Invalid JSON value for key '" + key + "': " + value); +// logger.warning("The key '" + key + "' is now a flagged value."); +// } else if (Utilities.isValidValue((String) value) && flaggedKeys.contains(key)) { +// flaggedKeys.remove(key); +// logger.warning("The key '" + key + "' is no longer a flagged value."); +// } + XTablesData current = this; + int start = 0; + int length = key.length(); + + for (int i = 0; i < length; i++) { + if (key.charAt(i) == '.') { + if (i > start) { + String k = key.substring(start, i); // Extract the part of the key + + if (current.data == null) { + current.data = new HashMap<>(); + } + current.data.putIfAbsent(k, new XTablesData()); + current = current.data.get(k); + } + start = i + 1; + } + } + +// Handle the last part of the key + if (start < length) { + String k = key.substring(start); + + if (current.data == null) { + current.data = new HashMap<>(); + } + current.data.putIfAbsent(k, new XTablesData()); + current = current.data.get(k); + } + + current.value = value; + return true; // Operation successful + + } + + public int size() { + int count = (value != null) ? 1 : 0; + if (data != null) { + for (XTablesData child : data.values()) { + count += child.size(); + } + } + return count; + } + + public boolean isFlaggedKey(String key) { + return false; +// Utilities.validateKey(key, true); +// return flaggedKeys.contains(key); + } + + // Method to get a value from the nested structure + public String get(String key) { + XTablesData current = getLevelxTablesData(key); + return (current != null) ? current.value : null; + } + + public String get(String key, String defaultValue) { + Utilities.validateKey(key, true); + String result = get(key); + return (result != null) ? result : defaultValue; + } + + private XTablesData getLevelxTablesData(String key) { + Utilities.validateKey(key, true); + String[] keys = key.split("\\."); // Split the key by '.' + XTablesData current = this; + + // Traverse through the nested structure + for (String k : keys) { + if (current.data == null || !current.data.containsKey(k)) { + return null; // Key not found + } + current = current.data.get(k); + } + return current; + } + + public boolean renameKey(String oldKey, String newKeyName) { + Utilities.validateKey(oldKey, true); + Utilities.validateName(newKeyName, true); + if (oldKey == null || newKeyName == null || oldKey.isEmpty() || newKeyName.isEmpty()) { + return false; // Invalid parameters + } + + String[] oldKeys = oldKey.split("\\."); + if (oldKeys.length == 0) { + return false; // No key to rename + } + + // Split the old key and construct the new key + String parentKey = String.join(".", Arrays.copyOf(oldKeys, oldKeys.length - 1)); + String newKey = parentKey.isEmpty() ? newKeyName : parentKey + "." + newKeyName; + + XTablesData parentNode = getLevelxTablesData(parentKey); + if (parentNode == null || !parentNode.data.containsKey(oldKeys[oldKeys.length - 1])) { + return false; // Old key does not exist + } + + // Get the old node + XTablesData oldNode = parentNode.data.get(oldKeys[oldKeys.length - 1]); + if (oldNode == null) { + return false; + } + + // Check if new key already exists + if (parentNode.data.containsKey(newKeyName)) { + return false; // New key already exists + } + + // Rename by moving the node + parentNode.data.put(newKeyName, oldNode); + parentNode.data.remove(oldKeys[oldKeys.length - 1]); + + // Update flagged keys +// if (flaggedKeys.contains(oldKey)) { +// flaggedKeys.remove(oldKey); +// flaggedKeys.add(newKey); +// } + + return true; // Successfully renamed + } + + // Method to get all tables at a given level + public Set getTables(String key) { + Utilities.validateKey(key, true); + if (key.isEmpty()) { + return (data != null) ? data.keySet() : null; + } + XTablesData current = getLevelxTablesData(key); + return (current != null && current.data != null) ? current.data.keySet() : null; + } + + // Method to delete a value at a given level + public boolean delete(String key) { + Utilities.validateKey(key, true); + if (key.isEmpty()) { + this.data = null; // Remove everything if the key is empty +// flaggedKeys.clear(); // Clear flagged keys if everything is removed + return true; + } + String[] keys = key.split("\\."); // Split the key by '.' + XTablesData current = this; + + // Traverse through the nested structure until reaching the level to delete + for (int i = 0; i < keys.length - 1; i++) { + String k = keys[i]; + if (current.data == null || !current.data.containsKey(k)) { + return false; // Key not found + } + current = current.data.get(k); + } + + boolean result = current.data != null && current.data.remove(keys[keys.length - 1]) != null; + +// if (result) { +// flaggedKeys.remove(key); +// } + + return result; + } + + public String toJSON() { + return gson.toJson(this.data); + } + + public void updateFromRawJSON(String json) { + // Replace the current map directly with the parsed data + // This avoids unnecessary clearing and re-creating of the map + Map newData = gson.fromJson(json, new TypeToken>() { + }.getType()); + + // Check if the new data is null, which might be the case if json is empty or invalid + if (newData == null) { + newData = new HashMap<>(); // Ensure the data field is never null + } + + this.data = newData; // Directly assign the new data + } +} diff --git a/src/main/java/org/kobe/xbot/Utilities/ByteFrame.java b/src/main/java/org/kobe/xbot/Utilities/ByteFrame.java new file mode 100644 index 00000000..c21246a0 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/ByteFrame.java @@ -0,0 +1,23 @@ +package org.kobe.xbot.Utilities; + +public class ByteFrame { + private long timestamp = System.nanoTime(); + private final byte[] frame; + + public ByteFrame(final byte[] frame) { + this.frame = frame; + } + + public byte[] getFrame() { + return frame; + } + + public ByteFrame setTimestamp(long timestamp) { + this.timestamp = timestamp; + return this; + } + + public long getTimestamp() { + return timestamp; + } +} diff --git a/src/main/java/org/kobe/xbot/Utilities/ClientData.java b/src/main/java/org/kobe/xbot/Utilities/ClientData.java new file mode 100644 index 00000000..52e71731 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/ClientData.java @@ -0,0 +1,25 @@ +package org.kobe.xbot.Utilities; + +public class ClientData { + private final String clientIP; + private final int messages; + private final String identifier; + + public ClientData(String clientIP, int messages, String identifier) { + this.clientIP = clientIP; + this.messages = messages; + this.identifier = identifier; + } + + public String getIdentifier() { + return identifier; + } + + public int getMessages() { + return messages; + } + + public String getClientIP() { + return clientIP; + } +} diff --git a/src/main/java/org/kobe/xbot/Utilities/DataCompression.java b/src/main/java/org/kobe/xbot/Utilities/DataCompression.java new file mode 100644 index 00000000..c81e6a6a --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/DataCompression.java @@ -0,0 +1,130 @@ +/* + * DataCompression class provides methods for data compression using Gzip and Base64 encoding for network transmission. + * + * Author: Kobe + * + */ + +package org.kobe.xbot.Utilities; + +import org.kobe.xbot.Utilities.Logger.XTablesLogger; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.Inflater; +import java.util.zip.InflaterInputStream; + +public class DataCompression { + private static boolean log = false; + private static final XTablesLogger logger = XTablesLogger.getLogger(); + private static int compressionLevel = Deflater.DEFLATED; + private static double speedAverageMS = 1; + + /** + * Compresses the raw string data and converts it to Base64 format. + * + * @param raw The raw string data to be compressed. + * @return The compressed data in Base64 format. + */ + public static String compressAndConvertBase64(String raw) { + byte[] compressedData = compress(raw.getBytes()); + return Base64.getEncoder().encodeToString(compressedData); + } + + /** + * Decompresses the Base64-encoded data and converts it back to its original string format. + * + * @param base64 The Base64-encoded compressed data. + * @return The decompressed original string data. + */ + public static String decompressAndConvertBase64(String base64) { + byte[] decompressedDataString = Base64.getDecoder().decode(base64); + byte[] decompressedData = decompress(decompressedDataString); + assert decompressedData != null; + return new String(decompressedData); + } + + /** + * Gets the average compression speed threshold in milliseconds. + * + * @return The average compression speed threshold. + */ + public static double getSpeedAverageMS() { + return speedAverageMS; + } + + /** + * Sets the average compression speed threshold in milliseconds. + * + * @param speedAverageMS The new average compression speed threshold. + */ + public static void setSpeedAverageMS(double speedAverageMS) { + DataCompression.speedAverageMS = speedAverageMS; + } + + public static void disableLog() { + DataCompression.log = false; + } + + public static void enableLog() { + DataCompression.log = true; + } + + private static byte[] compress(byte[] data) { + try { + long startTime = System.nanoTime(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + Deflater deflater = new Deflater(compressionLevel); + DeflaterOutputStream deflaterOutputStream = new DeflaterOutputStream(outputStream, deflater); + deflaterOutputStream.write(data); + deflaterOutputStream.close(); + adjustCompressionLevel(System.nanoTime() - startTime); + return outputStream.toByteArray(); + } catch (IOException e) { + if (log) logger.severe(e.getMessage()); + return null; + } + } + + private static byte[] decompress(byte[] compressedData) { + try { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + Inflater inflater = new Inflater(); + InflaterInputStream inflaterInputStream = new InflaterInputStream(new ByteArrayInputStream(compressedData), inflater); + byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = inflaterInputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + inflaterInputStream.close(); + return outputStream.toByteArray(); + } catch (IOException e) { + if (log) logger.severe(e.getMessage()); + return null; + } + } + + private static void adjustCompressionLevel(long elapsed) { + double ms = elapsed / 1e6; + + if (ms < DataCompression.speedAverageMS) { + // If compression takes less time than the average, increase compression level + if (DataCompression.compressionLevel < Deflater.BEST_COMPRESSION) { + DataCompression.compressionLevel++; + if (log) + logger.info("Compression time (" + ms + " ms) is faster than average. Increasing compression level to: " + DataCompression.compressionLevel); + } + } else { + // If compression takes more time than the average, decrease compression level + if (DataCompression.compressionLevel > Deflater.NO_COMPRESSION) { + DataCompression.compressionLevel--; + if (log) + logger.info("Compression time (" + ms + " ms) is slower than average. Reducing compression level to: " + DataCompression.compressionLevel); + } + } + } +} diff --git a/src/main/java/org/kobe/xbot/Utilities/Entities/ScriptParameters.java b/src/main/java/org/kobe/xbot/Utilities/Entities/ScriptParameters.java new file mode 100644 index 00000000..9899ac45 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/Entities/ScriptParameters.java @@ -0,0 +1,38 @@ +package org.kobe.xbot.Utilities.Entities; + +import org.kobe.xbot.Server.XTablesData; + +public class ScriptParameters { + private XTablesData data; + private String customData; + + public ScriptParameters(XTablesData data, String customData) { + this.data = data; + this.customData = customData; + } + + public XTablesData getData() { + return data; + } + + public void setData(XTablesData data) { + this.data = data; + } + + public String getCustomData() { + return customData; + } + + public void setCustomData(String customData) { + this.customData = customData; + } + + @Override + public String toString() { + return "ScriptParameters{" + + "data=" + data + + ", customData='" + customData + '\'' + + '}'; + } +} + diff --git a/src/main/java/org/kobe/xbot/Utilities/Entities/UpdateConsumer.java b/src/main/java/org/kobe/xbot/Utilities/Entities/UpdateConsumer.java new file mode 100644 index 00000000..c28b0ba0 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/Entities/UpdateConsumer.java @@ -0,0 +1,39 @@ +package org.kobe.xbot.Utilities.Entities; + +import org.kobe.xbot.Client.SocketClient; + +import java.util.function.Consumer; + +public class UpdateConsumer { + private Class type; + private Consumer> consumer; + + public UpdateConsumer(Class type, Consumer> consumer) { + this.type = type; + this.consumer = consumer; + } + + public Class getType() { + return type; + } + + public void setType(Class type) { + this.type = type; + } + + public Consumer> getConsumer() { + return consumer; + } + + public void setConsumer(Consumer> consumer) { + this.consumer = consumer; + } + + @Override + public String toString() { + return "UpdateConsumer{" + + "type=" + type + + ", consumer=" + consumer + + '}'; + } +} diff --git a/src/main/java/org/kobe/xbot/Utilities/Exceptions/BackupException.java b/src/main/java/org/kobe/xbot/Utilities/Exceptions/BackupException.java new file mode 100644 index 00000000..b03b99e5 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/Exceptions/BackupException.java @@ -0,0 +1,48 @@ +package org.kobe.xbot.Utilities.Exceptions; + +/** + * Exception thrown when a backup operation fails. + * This exception is used to indicate that an attempt to create or manage a backup + * has encountered an error. + *

+ * Author: Kobe + */ +public class BackupException extends RuntimeException { + + /** + * Constructs a new exception with {@code null} as its detail message. + * The cause is not initialized and may be subsequently initialized by a call to {@link #initCause}. + */ + public BackupException() { + super(); + } + + /** + * Constructs a new exception with the specified detail message. + * The cause is not initialized and may be subsequently initialized by a call to {@link #initCause}. + * + * @param message The detail message. The detail message is saved for later retrieval by the {@link #getMessage()} method. + */ + public BackupException(String message) { + super(message); + } + + /** + * Constructs a new exception with the specified detail message and cause. + * + * @param message The detail message. The detail message is saved for later retrieval by the {@link #getMessage()} method. + * @param cause The cause (which is saved for later retrieval by the {@link #getCause()} method). A null value is permitted, and indicates that the cause is nonexistent or unknown. + */ + public BackupException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new exception with the specified cause and a detail message of {@code (cause == null ? null : cause.toString())} (which typically contains the class and detail message of {@code cause}). + * + * @param cause The cause (which is saved for later retrieval by the {@link #getCause()} method). A null value is permitted, and indicates that the cause is nonexistent or unknown. + */ + public BackupException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/org/kobe/xbot/Utilities/Exceptions/ScriptAlreadyExistsException.java b/src/main/java/org/kobe/xbot/Utilities/Exceptions/ScriptAlreadyExistsException.java new file mode 100644 index 00000000..c62bc8c2 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/Exceptions/ScriptAlreadyExistsException.java @@ -0,0 +1,48 @@ +package org.kobe.xbot.Utilities.Exceptions; + +/** + * Exception thrown when a script already exists in the system. + * This exception is used to indicate that an attempt to create or register a script + * has failed because a script with the same identifier or name already exists. + *

+ * Author: Kobe + */ +public class ScriptAlreadyExistsException extends RuntimeException { + + /** + * Constructs a new exception with {@code null} as its detail message. + * The cause is not initialized and may be subsequently initialized by a call to {@link #initCause}. + */ + public ScriptAlreadyExistsException() { + super(); + } + + /** + * Constructs a new exception with the specified detail message. + * The cause is not initialized and may be subsequently initialized by a call to {@link #initCause}. + * + * @param message The detail message. The detail message is saved for later retrieval by the {@link #getMessage()} method. + */ + public ScriptAlreadyExistsException(String message) { + super(message); + } + + /** + * Constructs a new exception with the specified detail message and cause. + * + * @param message The detail message. The detail message is saved for later retrieval by the {@link #getMessage()} method. + * @param cause The cause (which is saved for later retrieval by the {@link #getCause()} method). A null value is permitted, and indicates that the cause is nonexistent or unknown. + */ + public ScriptAlreadyExistsException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new exception with the specified cause and a detail message of {@code (cause == null ? null : cause.toString())} (which typically contains the class and detail message of {@code cause}). + * + * @param cause The cause (which is saved for later retrieval by the {@link #getCause()} method). A null value is permitted, and indicates that the cause is nonexistent or unknown. + */ + public ScriptAlreadyExistsException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/org/kobe/xbot/Utilities/Exceptions/ServerFlaggedValueException.java b/src/main/java/org/kobe/xbot/Utilities/Exceptions/ServerFlaggedValueException.java new file mode 100644 index 00000000..8e879b47 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/Exceptions/ServerFlaggedValueException.java @@ -0,0 +1,48 @@ +package org.kobe.xbot.Utilities.Exceptions; + +/** + * Exception thrown when a value is flagged by the network tables server. + * This exception is used to indicate that an operation has failed because the value + * in question has been flagged by the server, possibly for violating some rules or constraints. + *

+ * Author: Kobe + */ +public class ServerFlaggedValueException extends RuntimeException { + + /** + * Constructs a new exception with {@code null} as its detail message. + * The cause is not initialized and may be subsequently initialized by a call to {@link #initCause}. + */ + public ServerFlaggedValueException() { + super(); + } + + /** + * Constructs a new exception with the specified detail message. + * The cause is not initialized and may be subsequently initialized by a call to {@link #initCause}. + * + * @param message The detail message. The detail message is saved for later retrieval by the {@link #getMessage()} method. + */ + public ServerFlaggedValueException(String message) { + super(message); + } + + /** + * Constructs a new exception with the specified detail message and cause. + * + * @param message The detail message. The detail message is saved for later retrieval by the {@link #getMessage()} method. + * @param cause The cause (which is saved for later retrieval by the {@link #getCause()} method). A null value is permitted, and indicates that the cause is nonexistent or unknown. + */ + public ServerFlaggedValueException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new exception with the specified cause and a detail message of {@code (cause == null ? null : cause.toString())} (which typically contains the class and detail message of {@code cause}). + * + * @param cause The cause (which is saved for later retrieval by the {@link #getCause()} method). A null value is permitted, and indicates that the cause is nonexistent or unknown. + */ + public ServerFlaggedValueException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/org/kobe/xbot/Utilities/ImageStreamStatus.java b/src/main/java/org/kobe/xbot/Utilities/ImageStreamStatus.java new file mode 100644 index 00000000..f05bc7c8 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/ImageStreamStatus.java @@ -0,0 +1,8 @@ +package org.kobe.xbot.Utilities; + +public enum ImageStreamStatus { + FAIL_NAME_ALREADY_EXISTS, + FAIL_INVALID_NAME, + FAIL_START_SERVER, + OKAY +} diff --git a/src/main/java/org/kobe/xbot/Utilities/Json.java b/src/main/java/org/kobe/xbot/Utilities/Json.java new file mode 100644 index 00000000..a8a4268c --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/Json.java @@ -0,0 +1,2752 @@ +package org.kobe.xbot.Utilities; + +import java.io.IOException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.net.URI; +import java.net.URL; +import java.text.CharacterIterator; +import java.text.StringCharacterIterator; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +//import java.util.function.Function; +import java.util.regex.Pattern; + + +public class Json implements java.io.Serializable, Iterable +{ + private static final long serialVersionUID = 1L; + + /** + *

+ * This interface defines how Json instances are constructed. There is a + * default implementation for each kind of Json value, but you can provide + * your own implementation. For example, you might want a different representation of + * an object than a regular HashMap. Or you might want string comparison to be + * case insensitive. + *

+ * + *

+ * In addition, the {@link #make(Object)} method allows you plug-in your own mapping + * of arbitrary Java objects to Json instances. You might want to implement + * a Java Beans to JSON mapping or any other JSON serialization that makes sense in your + * project. + *

+ * + *

+ * To avoid implementing all methods in that interface, you can extend the {@link DefaultFactory} + * default implementation and simply overwrite the ones you're interested in. + *

+ * + *

+ * The factory implementation used by the Json classes is specified simply by calling + * the {@link #setGlobalFactory(Factory)} method. The factory is a static, global variable by default. + * If you need different factories in different areas of a single application, you may attach them + * to different threads of execution using the {@link #attachFactory(Factory)}. Recall a separate + * copy of static variables is made per ClassLoader, so for example in a web application context, that + * global factory can be different for each web application (as Java web servers usually use a separate + * class loader per application). Thread-local factories are really a provision for special cases. + *

+ * + * @author Borislav Iordanov + * + */ + public static interface Factory + { + /** + * Construct and return an object representing JSON null. Implementations are + * free to cache a return the same instance. The resulting value must return + * true from isNull() and null from + * getValue(). + * + * @return The representation of a JSON null value. + */ + Json nil(); + + /** + * Construct and return a JSON boolean. The resulting value must return + * true from isBoolean() and the passed + * in parameter from getValue(). + * @param value The boolean value. + * @return A JSON with isBoolean() == true. Implementations + * are free to cache and return the same instance for true and false. + */ + Json bool(boolean value); + + /** + * Construct and return a JSON string. The resulting value must return + * true from isString() and the passed + * in parameter from getValue(). + * @param value The string to wrap as a JSON value. + * @return A JSON element with the given string as a value. + */ + Json string(String value); + + /** + * Construct and return a JSON number. The resulting value must return + * true from isNumber() and the passed + * in parameter from getValue(). + * + * @param value The numeric value. + * @return Json instance representing that value. + */ + Json number(Number value); + + /** + * Construct and return a JSON object. The resulting value must return + * true from isObject() and an implementation + * of java.util.Map from getValue(). + * + * @return An empty JSON object. + */ + Json object(); + + /** + * Construct and return a JSON object. The resulting value must return + * true from isArray() and an implementation + * of java.util.List from getValue(). + * + * @return An empty JSON array. + */ + Json array(); + + /** + * Construct and return a JSON object. The resulting value can be of any + * JSON type. The method is responsible for examining the type of its + * argument and performing an appropriate mapping to a Json + * instance. + * + * @param anything An arbitray Java object from which to construct a Json + * element. + * @return The newly constructed Json instance. + */ + Json make(Object anything); + } + + public static interface Function { + + /** + * Applies this function to the given argument. + * + * @param t the function argument + * @return the function result + */ + R apply(T t); + } + + /** + *

+ * Represents JSON schema - a specific data format that a JSON entity must + * follow. The idea of a JSON schema is very similar to XML. Its main purpose + * is validating input. + *

+ * + *

+ * More information about the various JSON schema specifications can be + * found at http://json-schema.org. JSON Schema is an IETF draft (v4 currently) and + * our implementation follows this set of specifications. A JSON schema is specified + * as a JSON object that contains keywords defined by the specification. Here are + * a few introductory materials: + *

    + *
  • http://jsonary.com/documentation/json-schema/ - + * a very well-written tutorial covering the whole standard
  • + *
  • http://spacetelescope.github.io/understanding-json-schema/ - + * online book, tutorial (Python/Ruby based)
  • + *
+ *

+ * @author Borislav Iordanov + * + */ + public static interface Schema + { + /** + *

+ * Validate a JSON document according to this schema. The validations attempts to + * proceed even in the face of errors. The return value is always a Json.object + * containing the boolean property ok. When ok is true, + * the return object contains nothing else. When it is false, the return object + * contains a property errors which is an array of error messages for all + * detected schema violations. + *

+ * + * @param document The input document. + * @return {"ok":true} or {"ok":false, errors:["msg1", "msg2", ...]} + */ + Json validate(Json document); + + /** + *

Return the JSON representation of the schema.

+ */ + Json toJson(); + + /** + *

Possible options are: ignoreDefaults:true|false. + *

+ * @return A newly created Json conforming to this schema. + */ + //Json generate(Json options); + } + + @Override + public Iterator iterator() + { + return new Iterator() + { + @Override + public boolean hasNext() { return false; } + @Override + public Json next() { return null; } + @Override + public void remove() { } + }; + } + + static String fetchContent(URL url) + { + java.io.Reader reader = null; + try + { + reader = new java.io.InputStreamReader((java.io.InputStream)url.getContent()); + StringBuilder content = new StringBuilder(); + char [] buf = new char[1024]; + for (int n = reader.read(buf); n > -1; n = reader.read(buf)) + content.append(buf, 0, n); + return content.toString(); + } + catch (Exception ex) + { + throw new RuntimeException(ex); + } + finally + { + if (reader != null) try { reader.close(); } catch (Throwable t) { } + } + } + + static Json resolvePointer(String pointerRepresentation, Json top) + { + String [] parts = pointerRepresentation.split("/"); + Json result = top; + for (String p : parts) + { + // TODO: unescaping and decoding + if (p.length() == 0) + continue; + p = p.replace("~1", "/").replace("~0", "~"); + if (result.isArray()) + result = result.at(Integer.parseInt(p)); + else if (result.isObject()) + result = result.at(p); + else + throw new RuntimeException("Can't resolve pointer " + pointerRepresentation + + " on document " + top.toString(200)); + } + return result; + } + + static URI makeAbsolute(URI base, String ref) throws Exception + { + URI refuri; + if (base != null && base.getAuthority() != null && !new URI(ref).isAbsolute()) + { + StringBuilder sb = new StringBuilder(); + if (base.getScheme() != null) + sb.append(base.getScheme()).append("://"); + sb.append(base.getAuthority()); + if (!ref.startsWith("/")) + { + if (ref.startsWith("#")) + sb.append(base.getPath()); + else + { + int slashIdx = base.getPath().lastIndexOf('/'); + sb.append(slashIdx == -1 ? base.getPath() : base.getPath().substring(0, slashIdx)).append("/"); + } + } + refuri = new URI(sb.append(ref).toString()); + } + else if (base != null) + refuri = base.resolve(ref); + else + refuri = new URI(ref); + return refuri; + } + + static Json resolveRef(URI base, + Json refdoc, + URI refuri, + Map resolved, + Map expanded, + Function uriResolver) throws Exception + { + if (refuri.isAbsolute() && + (base == null || !base.isAbsolute() || + !base.getScheme().equals(refuri.getScheme()) || + !Objects.equals(base.getHost(), refuri.getHost()) || + base.getPort() != refuri.getPort() || + !base.getPath().equals(refuri.getPath()))) + { + URI docuri = null; + refuri = refuri.normalize(); + if (refuri.getHost() == null) + docuri = new URI(refuri.getScheme() + ":" + refuri.getPath()); + else + docuri = new URI(refuri.getScheme() + "://" + refuri.getHost() + + ((refuri.getPort() > -1) ? ":" + refuri.getPort() : "") + + refuri.getPath()); + refdoc = uriResolver.apply(docuri); + refdoc = expandReferences(refdoc, refdoc, docuri, resolved, expanded, uriResolver); + } + if (refuri.getFragment() == null) + return refdoc; + else + return resolvePointer(refuri.getFragment(), refdoc); + } + + + static Json expandReferences(Json json, + Json topdoc, + URI base, + Map resolved, + Map expanded, + Function uriResolver) throws Exception + { + if (expanded.containsKey(json)) return json; + if (json.isObject()) + { + if (json.has("id") && json.at("id").isString()) // change scope of nest references + { + base = base.resolve(json.at("id").asString()); + } + + if (json.has("$ref")) + { + URI refuri = makeAbsolute(base, json.at("$ref").asString()); // base.resolve(json.at("$ref").asString()); + Json ref = resolved.get(refuri.toString()); + if (ref == null) + { + ref = Json.object(); + resolved.put(refuri.toString(), ref); + ref.with(resolveRef(base, topdoc, refuri, resolved, expanded, uriResolver)); + } + json = ref; + } + else + { + for (Map.Entry e : json.asJsonMap().entrySet()) + json.set(e.getKey(), expandReferences(e.getValue(), topdoc, base, resolved, expanded, uriResolver)); + } + } + else if (json.isArray()) + { + for (int i = 0; i < json.asJsonList().size(); i++) + json.set(i, + expandReferences(json.at(i), topdoc, base, resolved, expanded, uriResolver)); + } + expanded.put(json, json); + return json; + } + + static class DefaultSchema implements Schema + { + static interface Instruction extends Function{} + + static Json maybeError(Json errors, Json E) + { return E == null ? errors : (errors == null ? Json.array() : errors).with(E, new Json[0]); } + + // Anything is valid schema + static Instruction any = new Instruction() { public Json apply(Json param) { return null; } }; + + // Type validation + class IsObject implements Instruction { public Json apply(Json param) + { return param.isObject() ? null : Json.make(param.toString(maxchars)); } } + class IsArray implements Instruction { public Json apply(Json param) + { return param.isArray() ? null : Json.make(param.toString(maxchars)); } } + class IsString implements Instruction { public Json apply(Json param) + { return param.isString() ? null : Json.make(param.toString(maxchars)); } } + class IsBoolean implements Instruction { public Json apply(Json param) + { return param.isBoolean() ? null : Json.make(param.toString(maxchars)); } } + class IsNull implements Instruction { public Json apply(Json param) + { return param.isNull() ? null : Json.make(param.toString(maxchars)); } } + class IsNumber implements Instruction { public Json apply(Json param) + { return param.isNumber() ? null : Json.make(param.toString(maxchars)); } } + class IsInteger implements Instruction { public Json apply(Json param) + { return param.isNumber() && ((Number)param.getValue()) instanceof Integer ? null : Json.make(param.toString(maxchars)); } } + + class CheckString implements Instruction + { + int min = 0, max = Integer.MAX_VALUE; + Pattern pattern; + + public Json apply(Json param) + { + Json errors = null; + if (!param.isString()) return errors; + String s = param.asString(); + final int size = s.codePointCount(0, s.length()); + if (size < min || size > max) + errors = maybeError(errors,Json.make("String " + param.toString(maxchars) + + " has length outside of the permitted range [" + min + "," + max + "].")); + if (pattern != null && !pattern.matcher(s).matches()) + errors = maybeError(errors,Json.make("String " + param.toString(maxchars) + + " does not match regex " + pattern.toString())); + return errors; + } + } + + class CheckNumber implements Instruction + { + double min = Double.NaN, max = Double.NaN, multipleOf = Double.NaN; + boolean exclusiveMin = false, exclusiveMax = false; + public Json apply(Json param) + { + Json errors = null; + if (!param.isNumber()) return errors; + double value = param.asDouble(); + if (!Double.isNaN(min) && (value < min || exclusiveMin && value == min)) + errors = maybeError(errors,Json.make("Number " + param + " is below allowed minimum " + min)); + if (!Double.isNaN(max) && (value > max || exclusiveMax && value == max)) + errors = maybeError(errors,Json.make("Number " + param + " is above allowed maximum " + max)); + if (!Double.isNaN(multipleOf) && (value / multipleOf) % 1 != 0) + errors = maybeError(errors,Json.make("Number " + param + " is not a multiple of " + multipleOf)); + return errors; + } + } + + class CheckArray implements Instruction + { + int min = 0, max = Integer.MAX_VALUE; + Boolean uniqueitems = null; + Instruction additionalSchema = any; + Instruction schema; + ArrayList schemas; + + public Json apply(Json param) + { + Json errors = null; + if (!param.isArray()) return errors; + if (schema == null && schemas == null && additionalSchema == null) // no schema specified + return errors; + int size = param.asJsonList().size(); + for (int i = 0; i < size; i++) + { + Instruction S = schema != null ? schema + : (schemas != null && i < schemas.size()) ? schemas.get(i) : additionalSchema; + if (S == null) + errors = maybeError(errors,Json.make("Additional items are not permitted: " + + param.at(i) + " in " + param.toString(maxchars))); + else + errors = maybeError(errors, S.apply(param.at(i))); + if (uniqueitems != null && uniqueitems && param.asJsonList().lastIndexOf(param.at(i)) > i) + errors = maybeError(errors,Json.make("Element " + param.at(i) + " is duplicate in array.")); + if (errors != null && !errors.asJsonList().isEmpty()) + break; + } + if (size < min || size > max) + errors = maybeError(errors,Json.make("Array " + param.toString(maxchars) + + " has number of elements outside of the permitted range [" + min + "," + max + "].")); + return errors; + } + } + + class CheckPropertyPresent implements Instruction + { + String propname; + public CheckPropertyPresent(String propname) { this.propname = propname; } + public Json apply(Json param) + { + if (!param.isObject()) return null; + if (param.has(propname)) return null; + else return Json.array().add(Json.make("Required property " + propname + + " missing from object " + param.toString(maxchars))); + } + } + + class CheckObject implements Instruction + { + int min = 0, max = Integer.MAX_VALUE; + Instruction additionalSchema = any; + ArrayList props = new ArrayList(); + ArrayList patternProps = new ArrayList(); + + // Object validation + class CheckProperty implements Instruction + { + String name; + Instruction schema; + public CheckProperty(String name, Instruction schema) + { this.name = name; this.schema = schema; } + public Json apply(Json param) + { + Json value = param.at(name); + if (value == null) + return null; + else + return schema.apply(param.at(name)); + } + } + + class CheckPatternProperty // implements Instruction + { + Pattern pattern; + Instruction schema; + public CheckPatternProperty(String pattern, Instruction schema) + { this.pattern = Pattern.compile(pattern); this.schema = schema; } + public Json apply(Json param, Set found) + { + Json errors = null; + for (Map.Entry e : param.asJsonMap().entrySet()) + if (pattern.matcher(e.getKey()).find()) { + found.add(e.getKey()); + errors = maybeError(errors, schema.apply(e.getValue())); + } + return errors; + } + } + + public Json apply(Json param) + { + Json errors = null; + if (!param.isObject()) return errors; + HashSet checked = new HashSet(); + for (CheckProperty I : props) { + if (param.has(I.name)) checked.add(I.name); + errors = maybeError(errors, I.apply(param)); + } + for (CheckPatternProperty I : patternProps) { + + errors = maybeError(errors, I.apply(param, checked)); + } + if (additionalSchema != any) for (Map.Entry e : param.asJsonMap().entrySet()) + if (!checked.contains(e.getKey())) + errors = maybeError(errors, additionalSchema == null ? + Json.make("Extra property '" + e.getKey() + + "', schema doesn't allow any properties not explicitly defined:" + + param.toString(maxchars)) + : additionalSchema.apply(e.getValue())); + if (param.asJsonMap().size() < min) + errors = maybeError(errors, Json.make("Object " + param.toString(maxchars) + + " has fewer than the permitted " + min + " number of properties.")); + if (param.asJsonMap().size() > max) + errors = maybeError(errors, Json.make("Object " + param.toString(maxchars) + + " has more than the permitted " + min + " number of properties.")); + return errors; + } + } + + class Sequence implements Instruction + { + ArrayList seq = new ArrayList(); + public Json apply(Json param) + { + Json errors = null; + for (Instruction I : seq) + errors = maybeError(errors, I.apply(param)); + return errors; + } + public Sequence add(Instruction I) { seq.add(I); return this; } + } + + class CheckType implements Instruction + { + Json types; + public CheckType(Json types) { this.types = types; } + public Json apply(Json param) + { + String ptype = param.isString() ? "string" : + param.isObject() ? "object" : + param.isArray() ? "array" : + param.isNumber() ? "number" : + param.isNull() ? "null" : "boolean"; + for (Json type : types.asJsonList()) + if (type.asString().equals(ptype)) + return null; + else if (type.asString().equals("integer") && + param.isNumber() && + param.asDouble() % 1 == 0) + return null; + return Json.array().add(Json.make("Type mistmatch for " + param.toString(maxchars) + + ", allowed types: " + types)); + } + } + + class CheckEnum implements Instruction + { + Json theenum; + public CheckEnum(Json theenum) { this.theenum = theenum; } + public Json apply(Json param) + { + for (Json option : theenum.asJsonList()) + if (param.equals(option)) + return null; + return Json.array().add("Element " + param.toString(maxchars) + + " doesn't match any of enumerated possibilities " + theenum); + } + } + + class CheckAny implements Instruction + { + ArrayList alternates = new ArrayList(); + Json schema; + public Json apply(Json param) + { + for (Instruction I : alternates) + if (I.apply(param) == null) + return null; + return Json.array().add("Element " + param.toString(maxchars) + + " must conform to at least one of available sub-schemas " + + schema.toString(maxchars)); + } + } + + class CheckOne implements Instruction + { + ArrayList alternates = new ArrayList(); + Json schema; + public Json apply(Json param) + { + int matches = 0; + Json errors = Json.array(); + for (Instruction I : alternates) + { + Json result = I.apply(param); + if (result == null) + matches++; + else + errors.add(result); + } + if (matches != 1) + { + return Json.array().add("Element " + param.toString(maxchars) + + " must conform to exactly one of available sub-schemas, but not more " + + schema.toString(maxchars)).add(errors); + } + else + return null; + } + } + + class CheckNot implements Instruction + { + Instruction I; + Json schema; + public CheckNot(Instruction I, Json schema) { this.I = I; this.schema = schema; } + public Json apply(Json param) + { + if (I.apply(param) != null) + return null; + else + return Json.array().add("Element " + param.toString(maxchars) + + " must NOT conform to the schema " + schema.toString(maxchars)); + } + } + + class CheckSchemaDependency implements Instruction + { + Instruction schema; + String property; + public CheckSchemaDependency(String property, Instruction schema) { this.property = property; this.schema = schema; } + public Json apply(Json param) + { + if (!param.isObject()) return null; + else if (!param.has(property)) return null; + else return (schema.apply(param)); + } + } + + class CheckPropertyDependency implements Instruction + { + Json required; + String property; + public CheckPropertyDependency(String property, Json required) { this.property = property; this.required = required; } + public Json apply(Json param) + { + if (!param.isObject()) return null; + if (!param.has(property)) return null; + else + { + Json errors = null; + for (Json p : required.asJsonList()) + if (!param.has(p.asString())) + errors = maybeError(errors, Json.make("Conditionally required property " + p + + " missing from object " + param.toString(maxchars))); + return errors; + } + } + } + + Instruction compile(Json S, Map compiled) + { + Instruction result = compiled.get(S); + if (result != null) + return result; + Sequence seq = new Sequence(); + compiled.put(S, seq); + if (S.has("type") && !S.is("type", "any")) + seq.add(new CheckType(S.at("type").isString() ? + Json.array().add(S.at("type")) : S.at("type"))); + if (S.has("enum")) + seq.add(new CheckEnum(S.at("enum"))); + if (S.has("allOf")) + { + Sequence sub = new Sequence(); + for (Json x : S.at("allOf").asJsonList()) + sub.add(compile(x, compiled)); + seq.add(sub); + } + if (S.has("anyOf")) + { + CheckAny any = new CheckAny(); + any.schema = S.at("anyOf"); + for (Json x : any.schema.asJsonList()) + any.alternates.add(compile(x, compiled)); + seq.add(any); + } + if (S.has("oneOf")) + { + CheckOne any = new CheckOne(); + any.schema = S.at("oneOf"); + for (Json x : any.schema.asJsonList()) + any.alternates.add(compile(x, compiled)); + seq.add(any); + } + if (S.has("not")) + seq.add(new CheckNot(compile(S.at("not"), compiled), S.at("not"))); + + if (S.has("required") && S.at("required").isArray()) + { + for (Json p : S.at("required").asJsonList()) + seq.add(new CheckPropertyPresent(p.asString())); + } + CheckObject objectCheck = new CheckObject(); + if (S.has("properties")) + for (Map.Entry p : S.at("properties").asJsonMap().entrySet()) + objectCheck.props.add(objectCheck.new CheckProperty( + p.getKey(), compile(p.getValue(), compiled))); + if (S.has("patternProperties")) + for (Map.Entry p : S.at("patternProperties").asJsonMap().entrySet()) + objectCheck.patternProps.add(objectCheck.new CheckPatternProperty(p.getKey(), + compile(p.getValue(), compiled))); + if (S.has("additionalProperties")) + { + if (S.at("additionalProperties").isObject()) + objectCheck.additionalSchema = compile(S.at("additionalProperties"), compiled); + else if (!S.at("additionalProperties").asBoolean()) + objectCheck.additionalSchema = null; // means no additional properties allowed + } + if (S.has("minProperties")) + objectCheck.min = S.at("minProperties").asInteger(); + if (S.has("maxProperties")) + objectCheck.max = S.at("maxProperties").asInteger(); + + if (!objectCheck.props.isEmpty() || !objectCheck.patternProps.isEmpty() || + objectCheck.additionalSchema != any || + objectCheck.min > 0 || objectCheck.max < Integer.MAX_VALUE) + seq.add(objectCheck); + + CheckArray arrayCheck = new CheckArray(); + if (S.has("items")) + if (S.at("items").isObject()) + arrayCheck.schema = compile(S.at("items"), compiled); + else + { + arrayCheck.schemas = new ArrayList(); + for (Json s : S.at("items").asJsonList()) + arrayCheck.schemas.add(compile(s, compiled)); + } + if (S.has("additionalItems")) + if (S.at("additionalItems").isObject()) + arrayCheck.additionalSchema = compile(S.at("additionalItems"), compiled); + else if (!S.at("additionalItems").asBoolean()) + arrayCheck.additionalSchema = null; + if (S.has("uniqueItems")) + arrayCheck.uniqueitems = S.at("uniqueItems").asBoolean(); + if (S.has("minItems")) + arrayCheck.min = S.at("minItems").asInteger(); + if (S.has("maxItems")) + arrayCheck.max = S.at("maxItems").asInteger(); + if (arrayCheck.schema != null || arrayCheck.schemas != null || + arrayCheck.additionalSchema != any || + arrayCheck.uniqueitems != null || + arrayCheck.max < Integer.MAX_VALUE || arrayCheck.min > 0) + seq.add(arrayCheck); + + CheckNumber numberCheck = new CheckNumber(); + if (S.has("minimum")) + numberCheck.min = S.at("minimum").asDouble(); + if (S.has("maximum")) + numberCheck.max = S.at("maximum").asDouble(); + if (S.has("multipleOf")) + numberCheck.multipleOf = S.at("multipleOf").asDouble(); + if (S.has("exclusiveMinimum")) + numberCheck.exclusiveMin = S.at("exclusiveMinimum").asBoolean(); + if (S.has("exclusiveMaximum")) + numberCheck.exclusiveMax = S.at("exclusiveMaximum").asBoolean(); + if (!Double.isNaN(numberCheck.min) || !Double.isNaN(numberCheck.max) || !Double.isNaN(numberCheck.multipleOf)) + seq.add(numberCheck); + + CheckString stringCheck = new CheckString(); + if (S.has("minLength")) + stringCheck.min = S.at("minLength").asInteger(); + if (S.has("maxLength")) + stringCheck.max = S.at("maxLength").asInteger(); + if (S.has("pattern")) + stringCheck.pattern = Pattern.compile(S.at("pattern").asString()); + if (stringCheck.min > 0 || stringCheck.max < Integer.MAX_VALUE || stringCheck.pattern != null) + seq.add(stringCheck); + + if (S.has("dependencies")) + for (Map.Entry e : S.at("dependencies").asJsonMap().entrySet()) + if (e.getValue().isObject()) + seq.add(new CheckSchemaDependency(e.getKey(), compile(e.getValue(), compiled))); + else if (e.getValue().isArray()) + seq.add(new CheckPropertyDependency(e.getKey(), e.getValue())); + else + seq.add(new CheckPropertyDependency(e.getKey(), Json.array(e.getValue()))); + result = seq.seq.size() == 1 ? seq.seq.get(0) : seq; + compiled.put(S, result); + return result; + } + + int maxchars = 50; + URI uri; + Json theschema; + Instruction start; + + DefaultSchema(URI uri, Json theschema, Function relativeReferenceResolver) + { + try + { + this.uri = uri == null ? new URI("") : uri; + if (relativeReferenceResolver == null) + relativeReferenceResolver = new Function() { public Json apply(URI docuri) { + try { return Json.read(fetchContent(docuri.toURL())); } + catch(Exception ex) { throw new RuntimeException(ex); } + }}; + this.theschema = theschema.dup(); + this.theschema = expandReferences(this.theschema, + this.theschema, + this.uri, + new HashMap(), + new IdentityHashMap(), + relativeReferenceResolver); + } + catch (Exception ex) { throw new RuntimeException(ex); } + this.start = compile(this.theschema, new IdentityHashMap()); + } + + public Json validate(Json document) + { + Json result = Json.object("ok", true); + Json errors = start.apply(document); + return errors == null ? result : result.set("errors", errors).set("ok", false); + } + + public Json toJson() + { + return theschema; + } + + public Json generate(Json options) + { + // TODO... + return Json.nil(); + } + } + + public static Schema schema(Json S) + { + return new DefaultSchema(null, S, null); + } + + public static Schema schema(URI uri) + { + return schema(uri, null); + } + + public static Schema schema(URI uri, Function relativeReferenceResolver) + { + try { return new DefaultSchema(uri, Json.read(Json.fetchContent(uri.toURL())), relativeReferenceResolver); } + catch (Exception ex) { throw new RuntimeException(ex); } + } + + public static Schema schema(Json S, URI uri) + { + return new DefaultSchema(uri, S, null); + } + + public static class DefaultFactory implements Factory + { + public Json nil() { return Json.topnull; } + public Json bool(boolean x) { return new BooleanJson(x ? Boolean.TRUE : Boolean.FALSE, null); } + public Json string(String x) { return new StringJson(x, null); } + public Json number(Number x) { return new NumberJson(x, null); } + public Json array() { return new ArrayJson(); } + public Json object() { return new ObjectJson(); } + public Json make(Object anything) + { + if (anything == null) + return topnull; + else if (anything instanceof Json) + return (Json)anything; + else if (anything instanceof String) + return factory().string((String)anything); + else if (anything instanceof Collection) + { + Json L = array(); + for (Object x : (Collection)anything) + L.add(factory().make(x)); + return L; + } + else if (anything instanceof Map) + { + Json O = object(); + for (Map.Entry x : ((Map)anything).entrySet()) + O.set(x.getKey().toString(), factory().make(x.getValue())); + return O; + } + else if (anything instanceof Boolean) + return factory().bool((Boolean)anything); + else if (anything instanceof Number) + return factory().number((Number)anything); + else if (anything.getClass().isArray()) + { + Class comp = anything.getClass().getComponentType(); + if (!comp.isPrimitive()) + return Json.array((Object[])anything); + Json A = array(); + if (boolean.class == comp) + for (boolean b : (boolean[])anything) A.add(b); + else if (byte.class == comp) + for (byte b : (byte[])anything) A.add(b); + else if (char.class == comp) + for (char b : (char[])anything) A.add(b); + else if (short.class == comp) + for (short b : (short[])anything) A.add(b); + else if (int.class == comp) + for (int b : (int[])anything) A.add(b); + else if (long.class == comp) + for (long b : (long[])anything) A.add(b); + else if (float.class == comp) + for (float b : (float[])anything) A.add(b); + else if (double.class == comp) + for (double b : (double[])anything) A.add(b); + return A; + } + else + throw new IllegalArgumentException("Don't know how to convert to Json : " + anything); + } + } + + public static final Factory defaultFactory = new DefaultFactory(); + + private static Factory globalFactory = defaultFactory; + + // TODO: maybe use initialValue thread-local method to attach global factory by default here... + private static ThreadLocal threadFactory = new ThreadLocal(); + + /** + *

Return the {@link Factory} currently in effect. This is the factory that the {@link #make(Object)} method + * will dispatch on upon determining the type of its argument. If you already know the type + * of element to construct, you can avoid the type introspection implicit to the make method + * and call the factory directly. This will result in an optimization.

+ * + * @return the factory + */ + public static Factory factory() + { + Factory f = threadFactory.get(); + return f != null ? f : globalFactory; + } + + /** + *

+ * Specify a global Json {@link Factory} to be used by all threads that don't have a + * specific thread-local factory attached to them. + *

+ * + * @param factory The new global factory + */ + public static void setGlobalFactory(Factory factory) { globalFactory = factory; } + + /** + *

+ * Attach a thread-local Json {@link Factory} to be used specifically by this thread. Thread-local + * Json factories are the only means to have different {@link Factory} implementations used simultaneously + * in the same application (well, more accurately, the same ClassLoader). + *

+ * + * @param factory the new thread local factory + */ + public static void attachFactory(Factory factory) { threadFactory.set(factory); } + + /** + *

+ * Clear the thread-local factory previously attached to this thread via the + * {@link #attachFactory(Factory)} method. The global factory takes effect after + * a call to this method. + *

+ */ + public static void detachFactory() { threadFactory.remove(); } + + /** + *

+ * Parse a JSON entity from its string representation. + *

+ * + * @param jsonAsString A valid JSON representation as per the json.org + * grammar. Cannot be null. + * @return The JSON entity parsed: an object, array, string, number or boolean, or null. Note that + * this method will never return the actual Java null. + */ + public static Json read(String jsonAsString) { return (Json)new Reader().read(jsonAsString); } + + /** + *

+ * Parse a JSON entity from a URL. + *

+ * + * @param location A valid URL where to load a JSON document from. Cannot be null. + * @return The JSON entity parsed: an object, array, string, number or boolean, or null. Note that + * this method will never return the actual Java null. + */ + public static Json read(URL location) { return (Json)new Reader().read(fetchContent(location)); } + + /** + *

+ * Parse a JSON entity from a {@link CharacterIterator}. + *

+ * @param it A character iterator. + * @return the parsed JSON element + * @see #read(String) + */ + public static Json read(CharacterIterator it) { return (Json)new Reader().read(it); } + /** + * @return the null Json instance. + */ + public static Json nil() { return factory().nil(); } + /** + * @return a newly constructed, empty JSON object. + */ + public static Json object() { return factory().object(); } + /** + *

Return a new JSON object initialized from the passed list of + * name/value pairs. The number of arguments must + * be even. Each argument at an even position is taken to be a name + * for the following value. The name arguments are normally of type + * Java String, but they can be of any other type having an appropriate + * toString method. Each value is first converted + * to a Json instance using the {@link #make(Object)} method. + *

+ * @param args A sequence of name value pairs. + * @return the new JSON object. + */ + public static Json object(Object...args) + { + Json j = object(); + if (args.length % 2 != 0) + throw new IllegalArgumentException("An even number of arguments is expected."); + for (int i = 0; i < args.length; i++) + j.set(args[i].toString(), factory().make(args[++i])); + return j; + } + + /** + * @return a new constructed, empty JSON array. + */ + public static Json array() { return factory().array(); } + + /** + *

Return a new JSON array filled up with the list of arguments.

+ * + * @param args The initial content of the array. + * @return the new JSON array + */ + public static Json array(Object...args) + { + Json A = array(); + for (Object x : args) + A.add(factory().make(x)); + return A; + } + + + public static class help + { + /** + *

+ * Perform JSON escaping so that ", <, >, etc. characters are properly encoded in the + * JSON string representation before returning to the client code. This is useful when + * serializing property names or string values. + *

+ */ + public static String escape(String string) { return escaper.escapeJsonString(string); } + + /** + *

+ * Given a JSON Pointer, as per RFC 6901, return the nested JSON value within + * the element parameter. + *

+ */ + public static Json resolvePointer(String pointer, Json element) { return Json.resolvePointer(pointer, element); } + } + + static class JsonSingleValueIterator implements Iterator { + private boolean retrieved = false; + @Override + public boolean hasNext() { + return !retrieved; + } + + @Override + public Json next() { + retrieved = true; + return null; + } + + @Override + public void remove() { + } + } + + + /** + *

+ * Convert an arbitrary Java instance to a {@link Json} instance. + *

+ * + *

+ * Maps, Collections and arrays are recursively copied where each of + * their elements concerted into Json instances as well. The keys + * of a {@link Map} parameter are normally strings, but anything with a meaningful + * toString implementation will work as well. + *

+ * + * @param anything Any Java object that the current JSON factory in effect is capable of handling. + * @return The Json. This method will never return null. It will + * throw an {@link IllegalArgumentException} if it doesn't know how to convert the argument + * to a Json instance. + * @throws IllegalArgumentException when the concrete type of the parameter is + * unknown. + */ + public static Json make(Object anything) + { + return factory().make(anything); + } + + // end of static utility method section + + Json enclosing = null; + + protected Json() { } + protected Json(Json enclosing) { this.enclosing = enclosing; } + + /** + *

Return a string representation of this that does + * not exceed a certain maximum length. This is useful in constructing + * error messages or any other place where only a "preview" of the + * JSON element should be displayed. Some JSON structures can get + * very large and this method will help avoid string serializing + * the whole of them.

+ * @param maxCharacters The maximum number of characters for + * the string representation. + * @return The string representation of this object. + */ + public String toString(int maxCharacters) { return toString(); } + + /** + *

Explicitly set the parent of this element. The parent is presumably an array + * or an object. Normally, there's no need to call this method as the parent is + * automatically set by the framework. You may need to call it however, if you implement + * your own {@link Factory} with your own implementations of the Json types. + *

+ * + * @param enclosing The parent element. + */ + public void attachTo(Json enclosing) { this.enclosing = enclosing; } + + /** + * @return the Json entity, if any, enclosing this + * Json. The returned value can be null or + * a Json object or list, but not one of the primitive types. + * @deprecated This method is both problematic and rarely if every used and + * it will be removed in 2.0. + */ + public final Json up() { return enclosing; } + + /** + * @return a clone (a duplicate) of this Json entity. Note that cloning + * is deep if array and objects. Primitives are also cloned, even though their values are immutable + * because the new enclosing entity (the result of the {@link #up()} method) may be different. + * since they are immutable. + */ + public Json dup() { return this; } + + /** + *

Return the Json element at the specified index of this + * Json array. This method applies only to Json arrays. + *

+ * + * @param index The index of the desired element. + * @return The JSON element at the specified index in this array. + */ + public Json at(int index) { throw new UnsupportedOperationException(); } + + + public Json at(String property) { throw new UnsupportedOperationException(); } + + /** + *

+ * Return the specified property of a Json object if it exists. + * If it doesn't, then create a new property with value the def + * parameter and return that parameter. + *

+ * + * @param property The property to return. + * @param def The default value to set and return in case the property doesn't exist. + */ + public final Json at(String property, Json def) + { + Json x = at(property); + if (x == null) + { +// set(property, def); + return def; + } + else + return x; + } + + /** + *

+ * Return the specified property of a Json object if it exists. + * If it doesn't, then create a new property with value the def + * parameter and return that parameter. + *

+ * + * @param property The property to return. + * @param def The default value to set and return in case the property doesn't exist. + */ + public final Json at(String property, Object def) + { + return at(property, make(def)); + } + + /** + *

+ * Return true if this Json object has the specified property + * and false otherwise. + *

+ * + * @param property The name of the property. + */ + public boolean has(String property) { throw new UnsupportedOperationException(); } + + /** + *

+ * Return true if and only if this Json object has a property with + * the specified value. In particular, if the object has no such property false is returned. + *

+ * + * @param property The property name. + * @param value The value to compare with. Comparison is done via the equals method. + * If the value is not an instance of Json, it is first converted to + * such an instance. + * @return + */ + public boolean is(String property, Object value) { throw new UnsupportedOperationException(); } + + /** + *

+ * Return true if and only if this Json array has an element with + * the specified value at the specified index. In particular, if the array has no element at + * this index, false is returned. + *

+ * + * @param index The 0-based index of the element in a JSON array. + * @param value The value to compare with. Comparison is done via the equals method. + * If the value is not an instance of Json, it is first converted to + * such an instance. + * @return + */ + public boolean is(int index, Object value) { throw new UnsupportedOperationException(); } + + /** + *

+ * Add the specified Json element to this array. + *

+ * + * @return this + */ + public Json add(Json el) { throw new UnsupportedOperationException(); } + + /** + *

+ * Add an arbitrary Java object to this Json array. The object + * is first converted to a Json instance by calling the static + * {@link #make} method. + *

+ * + * @param anything Any Java object that can be converted to a Json instance. + * @return this + */ + public final Json add(Object anything) { return add(make(anything)); } + + /** + *

+ * Remove the specified property from a Json object and return + * that property. + *

+ * + * @param property The property to be removed. + * @return The property value or null if the object didn't have such + * a property to begin with. + */ + public Json atDel(String property) { throw new UnsupportedOperationException(); } + + /** + *

+ * Remove the element at the specified index from a Json array and return + * that element. + *

+ * + * @param index The index of the element to delete. + * @return The element value. + */ + public Json atDel(int index) { throw new UnsupportedOperationException(); } + + /** + *

+ * Delete the specified property from a Json object. + *

+ * + * @param property The property to be removed. + * @return this + */ + public Json delAt(String property) { throw new UnsupportedOperationException(); } + + /** + *

+ * Remove the element at the specified index from a Json array. + *

+ * + * @param index The index of the element to delete. + * @return this + */ + public Json delAt(int index) { throw new UnsupportedOperationException(); } + + /** + *

+ * Remove the specified element from a Json array. + *

+ * + * @param el The element to delete. + * @return this + */ + public Json remove(Json el) { throw new UnsupportedOperationException(); } + + /** + *

+ * Remove the specified Java object (converted to a Json instance) + * from a Json array. This is equivalent to + * remove({@link #make(Object)}). + *

+ * + * @param anything The object to delete. + * @return this + */ + public final Json remove(Object anything) { return remove(make(anything)); } + + /** + *

+ * Set a Json objects's property. + *

+ * + * @param property The property name. + * @param value The value of the property. + * @return this + */ + public Json set(String property, Json value) { throw new UnsupportedOperationException(); } + + /** + *

+ * Set a Json objects's property. + *

+ * + * @param property The property name. + * @param value The value of the property, converted to a Json representation + * with {@link #make}. + * @return this + */ + public final Json set(String property, Object value) { return set(property, make(value)); } + + /** + *

+ * Change the value of a JSON array element. This must be an array. + *

+ * @param index 0-based index of the element in the array. + * @param value the new value of the element + * @return this + */ + public Json set(int index, Object value) { throw new UnsupportedOperationException(); } + + /** + *

+ * Combine this object or array with the passed in object or array. The types of + * this and the object argument must match. If both are + * Json objects, all properties of the parameter are added to this. + * If both are arrays, all elements of the parameter are appended to this + *

+ * @param object The object or array whose properties or elements must be added to this + * Json object or array. + * @param options A sequence of options that governs the merging process. + * @return this + */ + public Json with(Json object, Json[]options) { throw new UnsupportedOperationException(); } + + /** + * Same as {}@link #with(Json,Json...options)} with each option + * argument converted to Json first. + */ + public Json with(Json object, Object...options) + { + Json [] jopts = new Json[options.length]; + for (int i = 0; i < jopts.length; i++) + jopts[i] = make(options[i]); + return with(object, jopts); + } + + /** + * @return the underlying value of this Json entity. The actual value will + * be a Java Boolean, String, Number, Map, List or null. For complex entities (objects + * or arrays), the method will perform a deep copy and extra underlying values recursively + * for all nested elements. + */ + public Object getValue() { throw new UnsupportedOperationException(); } + + /** + * @return the boolean value of a boolean Json instance. Call + * {@link #isBoolean()} first if you're not sure this instance is indeed a + * boolean. + */ + public boolean asBoolean() { throw new UnsupportedOperationException(); } + + /** + * @return the string value of a string Json instance. Call + * {@link #isString()} first if you're not sure this instance is indeed a + * string. + */ + public String asString() { throw new UnsupportedOperationException(); } + + /** + * @return the integer value of a number Json instance. Call + * {@link #isNumber()} first if you're not sure this instance is indeed a + * number. + */ + public int asInteger() { throw new UnsupportedOperationException(); } + + /** + * @return the float value of a float Json instance. Call + * {@link #isNumber()} first if you're not sure this instance is indeed a + * number. + */ + public float asFloat() { throw new UnsupportedOperationException(); } + + /** + * @return the double value of a number Json instance. Call + * {@link #isNumber()} first if you're not sure this instance is indeed a + * number. + */ + public double asDouble() { throw new UnsupportedOperationException(); } + + /** + * @return the long value of a number Json instance. Call + * {@link #isNumber()} first if you're not sure this instance is indeed a + * number. + */ + public long asLong() { throw new UnsupportedOperationException(); } + + /** + * @return the short value of a number Json instance. Call + * {@link #isNumber()} first if you're not sure this instance is indeed a + * number. + */ + public short asShort() { throw new UnsupportedOperationException(); } + + /** + * @return the byte value of a number Json instance. Call + * {@link #isNumber()} first if you're not sure this instance is indeed a + * number. + */ + public byte asByte() { throw new UnsupportedOperationException(); } + + /** + * @return the first character of a string Json instance. Call + * {@link #isString()} first if you're not sure this instance is indeed a + * string. + */ + public char asChar() { throw new UnsupportedOperationException(); } + + /** + * @return a map of the properties of an object Json instance. The map + * is a clone of the object and can be modified safely without affecting it. Call + * {@link #isObject()} first if you're not sure this instance is indeed a + * Json object. + */ + public Map asMap() { throw new UnsupportedOperationException(); } + + /** + * @return the underlying map of properties of a Json object. The returned + * map is the actual object representation so any modifications to it are modifications + * of the Json object itself. Call + * {@link #isObject()} first if you're not sure this instance is indeed a + * Json object. + */ + public Map asJsonMap() { throw new UnsupportedOperationException(); } + + /** + * @return a list of the elements of a Json array. The list is a clone + * of the array and can be modified safely without affecting it. Call + * {@link #isArray()} first if you're not sure this instance is indeed a + * Json array. + */ + public List asList() { throw new UnsupportedOperationException(); } + + /** + * @return the underlying {@link List} representation of a Json array. + * The returned list is the actual array representation so any modifications to it + * are modifications of the Json array itself. Call + * {@link #isArray()} first if you're not sure this instance is indeed a + * Json array. + */ + public List asJsonList() { throw new UnsupportedOperationException(); } + + /** + * @return true if this is a Json null entity + * and false otherwise. + */ + public boolean isNull() { return false; } + /** + * @return true if this is a Json string entity + * and false otherwise. + */ + public boolean isString() { return false; } + /** + * @return true if this is a Json number entity + * and false otherwise. + */ + public boolean isNumber() { return false; } + /** + * @return true if this is a Json boolean entity + * and false otherwise. + */ + public boolean isBoolean() { return false; } + /** + * @return true if this is a Json array (i.e. list) entity + * and false otherwise. + */ + public boolean isArray() { return false; } + /** + * @return true if this is a Json object entity + * and false otherwise. + */ + public boolean isObject(){ return false; } + /** + * @return true if this is a Json primitive entity + * (one of string, number or boolean) and false otherwise. + * + */ + public boolean isPrimitive() { return isString() || isNumber() || isBoolean(); } + + /** + *

+ * Json-pad this object as an argument to a callback function. + *

+ * + * @param callback The name of the callback function. Can be null or empty, + * in which case no padding is done. + * @return The jsonpadded, stringified version of this object if the callback + * is not null or empty, or just the stringified version of the object. + */ + public String pad(String callback) + { + return (callback != null && callback.length() > 0) + ? callback + "(" + toString() + ");" + : toString(); + } + + //------------------------------------------------------------------------- + // END OF PUBLIC INTERFACE + //------------------------------------------------------------------------- + + /** + * Return an object representing the complete configuration + * of a merge. The properties of the object represent paths + * of the JSON structure being merged and the values represent + * the set of options that apply to each path. + * @param options the configuration options + * @return the configuration object + */ + protected Json collectWithOptions(Json...options) + { + Json result = object(); + for (Json opt : options) + { + if (opt.isString()) + { + if (!result.has("")) + result.set("", object()); + result.at("").set(opt.asString(), true); + } + else + { + if (!opt.has("for")) + opt.set("for", array("")); + Json forPaths = opt.at("for"); + if (!forPaths.isArray()) + forPaths = array(forPaths); + for (Json path : forPaths.asJsonList()) + { + if (!result.has(path.asString())) + result.set(path.asString(), object()); + Json at_path = result.at(path.asString()); + at_path.set("merge", opt.is("merge", true)); + at_path.set("dup", opt.is("dup", true)); + at_path.set("sort", opt.is("sort", true)); + at_path.set("compareBy", opt.at("compareBy", nil())); + } + } + } + return result; + } + + static class NullJson extends Json + { + private static final long serialVersionUID = 1L; + + NullJson() {} + NullJson(Json e) {super(e);} + + public Object getValue() { return null; } + public Json dup() { return new NullJson(); } + public boolean isNull() { return true; } + public String toString() { return "null"; } + public List asList() { return (List)Collections.singletonList(null); } + + public int hashCode() { return 0; } + public boolean equals(Object x) + { + return x instanceof NullJson; + } + + @Override + public Iterator iterator() { + return new JsonSingleValueIterator() { + @Override + public Json next() { + super.next(); + return NullJson.this; + } + }; + } + + } + + static NullJson topnull = new NullJson(); + + /** + *

+ * Set the parent (i.e. enclosing element) of Json element. + *

+ * + * @param el + * @param parent + */ + static void setParent(Json el, Json parent) + { + if (el.enclosing == null) + el.enclosing = parent; + else if (el.enclosing instanceof ParentArrayJson) + ((ParentArrayJson)el.enclosing).L.add(parent); + else + { + ParentArrayJson A = new ParentArrayJson(); + A.L.add(el.enclosing); + A.L.add(parent); + el.enclosing = A; + } + } + + /** + *

+ * Remove/unset the parent (i.e. enclosing element) of Json element. + *

+ * + * @param el + * @param parent + */ + static void removeParent(Json el, Json parent) + { + if (el.enclosing == parent) + el.enclosing = null; + else if (el.enclosing.isArray()) + { + ArrayJson A = (ArrayJson)el.enclosing; + int idx = 0; + while (A.L.get(idx) != parent && idx < A.L.size()) idx++; + if (idx < A.L.size()) + A.L.remove(idx); + } + } + + static class BooleanJson extends Json + { + private static final long serialVersionUID = 1L; + + boolean val; + BooleanJson() {} + BooleanJson(Json e) {super(e);} + BooleanJson(Boolean val, Json e) { super(e); this.val = val; } + + public Object getValue() { return val; } + public Json dup() { return new BooleanJson(val, null); } + public boolean asBoolean() { return val; } + public boolean isBoolean() { return true; } + public String toString() { return val ? "true" : "false"; } + + @SuppressWarnings("unchecked") + public List asList() { return (List)(List)Collections.singletonList(val); } + public int hashCode() { return val ? 1 : 0; } + public boolean equals(Object x) + { + return x instanceof BooleanJson && ((BooleanJson)x).val == val; + } + @Override + public Iterator iterator() { + return new JsonSingleValueIterator() { + @Override + public Json next() { + super.next(); + return BooleanJson.this; + } + }; + } + + } + + static class StringJson extends Json + { + private static final long serialVersionUID = 1L; + + String val; + + StringJson() {} + StringJson(Json e) {super(e);} + StringJson(String val, Json e) { super(e); this.val = val; } + + public Json dup() { return new StringJson(val, null); } + public boolean isString() { return true; } + public Object getValue() { return val; } + public String asString() { return val; } + public int asInteger() { return Integer.parseInt(val); } + public float asFloat() { return Float.parseFloat(val); } + public double asDouble() { return Double.parseDouble(val); } + public long asLong() { return Long.parseLong(val); } + public short asShort() { return Short.parseShort(val); } + public byte asByte() { return Byte.parseByte(val); } + public char asChar() { return val.charAt(0); } + @SuppressWarnings("unchecked") + public List asList() { return (List)(List)Collections.singletonList(val); } + + public String toString() + { + return '"' + escaper.escapeJsonString(val) + '"'; + } + public String toString(int maxCharacters) + { + if (val.length() <= maxCharacters) + return toString(); + else + return '"' + escaper.escapeJsonString(val.subSequence(0, maxCharacters)) + "...\""; + } + + public int hashCode() { return val.hashCode(); } + public boolean equals(Object x) + { + return x instanceof StringJson && ((StringJson)x).val.equals(val); + } + + @Override + public Iterator iterator() { + return new JsonSingleValueIterator() { + @Override + public Json next() { + super.next(); + return StringJson.this; + } + }; + } + + } + + static class NumberJson extends Json + { + private static final long serialVersionUID = 1L; + + Number val; + + NumberJson() {} + NumberJson(Json e) {super(e);} + NumberJson(Number val, Json e) { super(e); this.val = val; } + + public Json dup() { return new NumberJson(val, null); } + public boolean isNumber() { return true; } + public Object getValue() { return val; } + public String asString() { return val.toString(); } + public int asInteger() { return val.intValue(); } + public float asFloat() { return val.floatValue(); } + public double asDouble() { return val.doubleValue(); } + public long asLong() { return val.longValue(); } + public short asShort() { return val.shortValue(); } + public byte asByte() { return val.byteValue(); } + + @SuppressWarnings("unchecked") + public List asList() { return (List)(List)Collections.singletonList(val); } + + public String toString() { return val.toString(); } + public int hashCode() { return val.hashCode(); } + public boolean equals(Object x) + { + return x instanceof NumberJson && val.doubleValue() == ((NumberJson)x).val.doubleValue(); + } + + @Override + public Iterator iterator() { + return new JsonSingleValueIterator() { + @Override + public Json next() { + super.next(); + return NumberJson.this; + } + }; + } + + } + + static class ArrayJson extends Json + { + private static final long serialVersionUID = 1L; + + List L = new ArrayList(); + + ArrayJson() { } + ArrayJson(Json e) { super(e); } + + @Override + public Iterator iterator() { + return L.iterator(); + } + + public Json dup() + { + ArrayJson j = new ArrayJson(); + for (Json e : L) + { + Json v = e.dup(); + v.enclosing = j; + j.L.add(v); + } + return j; + } + + public Json set(int index, Object value) + { + Json jvalue = make(value); + L.set(index, jvalue); + setParent(jvalue, this); + return this; + } + + public List asJsonList() { return L; } + public List asList() + { + ArrayList A = new ArrayList(); + for (Json x: L) + A.add(x.getValue()); + return A; + } + public boolean is(int index, Object value) + { + if (index < 0 || index >= L.size()) + return false; + else + return L.get(index).equals(make(value)); + } + public Object getValue() { return asList(); } + public boolean isArray() { return true; } + public Json at(int index) { return L.get(index); } + public Json add(Json el) + { + L.add(el); + setParent(el, this); + return this; + } + public Json remove(Json el) { L.remove(el); el.enclosing = null; return this; } + + boolean isEqualJson(Json left, Json right) + { + if (left == null) + return right == null; + else + return left.equals(right); + } + + boolean isEqualJson(Json left, Json right, Json fields) + { + if (fields.isNull()) + return left.equals(right); + else if (fields.isString()) + return isEqualJson(resolvePointer(fields.asString(), left), + resolvePointer(fields.asString(), right)); + else if (fields.isArray()) + { + for (Json field : fields.asJsonList()) + if (!isEqualJson(resolvePointer(field.asString(), left), + resolvePointer(field.asString(), right))) + return false; + return true; + } + else + throw new IllegalArgumentException("Compare by options should be either a property name or an array of property names: " + fields); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + int compareJson(Json left, Json right, Json fields) + { + if (fields.isNull()) + return ((Comparable)left.getValue()).compareTo(right.getValue()); + else if (fields.isString()) + { + Json leftProperty = resolvePointer(fields.asString(), left); + Json rightProperty = resolvePointer(fields.asString(), right); + return ((Comparable)leftProperty).compareTo(rightProperty); + } + else if (fields.isArray()) + { + for (Json field : fields.asJsonList()) + { + Json leftProperty = resolvePointer(field.asString(), left); + Json rightProperty = resolvePointer(field.asString(), right); + int result = ((Comparable) leftProperty).compareTo(rightProperty); + if (result != 0) + return result; + } + return 0; + } + else + throw new IllegalArgumentException("Compare by options should be either a property name or an array of property names: " + fields); + } + + Json withOptions(Json array, Json allOptions, String path) + { + Json opts = allOptions.at(path, object()); + boolean dup = opts.is("dup", true); + Json compareBy = opts.at("compareBy", nil()); + if (opts.is("sort", true)) + { + int thisIndex = 0, thatIndex = 0; + while (thatIndex < array.asJsonList().size()) + { + Json thatElement = array.at(thatIndex); + if (thisIndex == L.size()) + { + L.add(dup ? thatElement.dup() : thatElement); + thisIndex++; + thatIndex++; + continue; + } + int compared = compareJson(at(thisIndex), thatElement, compareBy); + if (compared < 0) // this < that + thisIndex++; + else if (compared > 0) // this > that + { + L.add(thisIndex, dup ? thatElement.dup() : thatElement); + thatIndex++; + } else { // equal, ignore + thatIndex++; + } + } + } + else + { + for (Json thatElement : array.asJsonList()) + { + boolean present = false; + for (Json thisElement : L) + if (isEqualJson(thisElement, thatElement, compareBy)) + { + present = true; + break; + } + if (!present) + L.add(dup ? thatElement.dup() : thatElement); + } + } + return this; + } + + public Json with(Json object, Json...options) + { + if (object == null) return this; + if (!object.isArray()) + add(object); + else if (options.length > 0) + { + Json O = collectWithOptions(options); + return withOptions(object, O, ""); + } + else + // what about "enclosing" here? we don't have a provision where a Json + // element belongs to more than one enclosing elements... + L.addAll(((ArrayJson)object).L); + return this; + } + + public Json atDel(int index) + { + Json el = L.remove(index); + if (el != null) + el.enclosing = null; + return el; + } + + public Json delAt(int index) + { + Json el = L.remove(index); + if (el != null) + el.enclosing = null; + return this; + } + + public String toString() + { + return toString(Integer.MAX_VALUE); + } + + public String toString(int maxCharacters) + { + return toStringImpl(maxCharacters, new IdentityHashMap()); + } + + String toStringImpl(int maxCharacters, Map done) + { + StringBuilder sb = new StringBuilder("["); + for (Iterator i = L.iterator(); i.hasNext(); ) + { + Json value = i.next(); + String s = value.isObject() ? ((ObjectJson)value).toStringImpl(maxCharacters, done) + : value.isArray() ? ((ArrayJson)value).toStringImpl(maxCharacters, done) + : value.toString(maxCharacters); + if (sb.length() + s.length() > maxCharacters) + s = s.substring(0, Math.max(0, maxCharacters - sb.length())); + else + sb.append(s); + if (i.hasNext()) + sb.append(","); + if (sb.length() >= maxCharacters) + { + sb.append("..."); + break; + } + } + sb.append("]"); + return sb.toString(); + } + + public int hashCode() { return L.hashCode(); } + public boolean equals(Object x) + { + return x instanceof ArrayJson && ((ArrayJson)x).L.equals(L); + } + } + + static class ParentArrayJson extends ArrayJson + { + + /** + * + */ + private static final long serialVersionUID = 1L; + + } + + static class ObjectJson extends Json + { + private static final long serialVersionUID = 1L; + + Map object = new HashMap(); + + @Override + public Iterator iterator() { + return object.values().iterator(); + } + + ObjectJson() { } + ObjectJson(Json e) { super(e); } + + public Json dup() + { + ObjectJson j = new ObjectJson(); + for (Map.Entry e : object.entrySet()) + { + Json v = e.getValue().dup(); + v.enclosing = j; + j.object.put(e.getKey(), v); + } + return j; + } + + public boolean has(String property) + { + return object.containsKey(property); + } + + public boolean is(String property, Object value) + { + Json p = object.get(property); + if (p == null) + return false; + else + return p.equals(make(value)); + } + + public Json at(String property) + { + return object.get(property); + } + + protected Json withOptions(Json other, Json allOptions, String path) + { + if (!allOptions.has(path)) + allOptions.set(path, object()); + Json options = allOptions.at(path, object()); + boolean duplicate = options.is("dup", true); + if (options.is("merge", true)) + { + for (Map.Entry e : other.asJsonMap().entrySet()) + { + Json local = object.get(e.getKey()); + if (local instanceof ObjectJson) + ((ObjectJson)local).withOptions(e.getValue(), allOptions, path + "/" + e.getKey()); + else if (local instanceof ArrayJson) + ((ArrayJson)local).withOptions(e.getValue(), allOptions, path + "/" + e.getKey()); + else + set(e.getKey(), duplicate ? e.getValue().dup() : e.getValue()); + } + } + else if (duplicate) + for (Map.Entry e : other.asJsonMap().entrySet()) + set(e.getKey(), e.getValue().dup()); + else + for (Map.Entry e : other.asJsonMap().entrySet()) + set(e.getKey(), e.getValue()); + return this; + } + + public Json with(Json x, Json...options) + { + if (x == null) return this; + if (!x.isObject()) + throw new UnsupportedOperationException(); + if (options.length > 0) + { + Json O = collectWithOptions(options); + return withOptions(x, O, ""); + } + else for (Map.Entry e : x.asJsonMap().entrySet()) + set(e.getKey(), e.getValue()); + return this; + } + + public Json set(String property, Json el) + { + if (property == null) + throw new IllegalArgumentException("Null property names are not allowed, value is " + el); + if (el == null) + el = nil(); + setParent(el, this); + object.put(property, el); + return this; + } + + public Json atDel(String property) + { + Json el = object.remove(property); + removeParent(el, this); + return el; + } + + public Json delAt(String property) + { + Json el = object.remove(property); + removeParent(el, this); + return this; + } + + public Object getValue() { return asMap(); } + public boolean isObject() { return true; } + public Map asMap() + { + HashMap m = new HashMap(); + for (Map.Entry e : object.entrySet()) + m.put(e.getKey(), e.getValue().getValue()); + return m; + } + @Override + public Map asJsonMap() { return object; } + + public String toString() + { + return toString(Integer.MAX_VALUE); + } + + public String toString(int maxCharacters) + { + return toStringImpl(maxCharacters, new IdentityHashMap()); + } + + String toStringImpl(int maxCharacters, Map done) + { + StringBuilder sb = new StringBuilder("{"); + if (done.containsKey(this)) + return sb.append("...}").toString(); + done.put(this, this); + for (Iterator> i = object.entrySet().iterator(); i.hasNext(); ) + { + Map.Entry x = i.next(); + sb.append('"'); + sb.append(escaper.escapeJsonString(x.getKey())); + sb.append('"'); + sb.append(":"); + String s = x.getValue().isObject() ? ((ObjectJson)x.getValue()).toStringImpl(maxCharacters, done) + : x.getValue().isArray() ? ((ArrayJson)x.getValue()).toStringImpl(maxCharacters, done) + : x.getValue().toString(maxCharacters); + if (sb.length() + s.length() > maxCharacters) + s = s.substring(0, Math.max(0, maxCharacters - sb.length())); + sb.append(s); + if (i.hasNext()) + sb.append(","); + if (sb.length() >= maxCharacters) + { + sb.append("..."); + break; + } + } + sb.append("}"); + return sb.toString(); + } + public int hashCode() { return object.hashCode(); } + public boolean equals(Object x) + { + return x instanceof ObjectJson && ((ObjectJson)x).object.equals(object); + } + } + + // ------------------------------------------------------------------------ + // Extra utilities, taken from around the internet: + // ------------------------------------------------------------------------ + + /* + * Copyright (C) 2008 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + /** + * A utility class that is used to perform JSON escaping so that ", <, >, etc. characters are + * properly encoded in the JSON string representation before returning to the client code. + * + *

This class contains a single method to escape a passed in string value: + *

+     *   String jsonStringValue = "beforeQuote\"afterQuote";
+     *   String escapedValue = Escaper.escapeJsonString(jsonStringValue);
+     * 

+ * + * @author Inderjeet Singh + * @author Joel Leitch + */ + static Escaper escaper = new Escaper(false); + + final static class Escaper { + + private static final char[] HEX_CHARS = { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' + }; + + private static final Set JS_ESCAPE_CHARS; + private static final Set HTML_ESCAPE_CHARS; + + static { + Set mandatoryEscapeSet = new HashSet(); + mandatoryEscapeSet.add('"'); + mandatoryEscapeSet.add('\\'); + JS_ESCAPE_CHARS = Collections.unmodifiableSet(mandatoryEscapeSet); + + Set htmlEscapeSet = new HashSet(); + htmlEscapeSet.add('<'); + htmlEscapeSet.add('>'); + htmlEscapeSet.add('&'); + htmlEscapeSet.add('='); + htmlEscapeSet.add('\''); +// htmlEscapeSet.add('/'); -- Removing slash for now since it causes some incompatibilities + HTML_ESCAPE_CHARS = Collections.unmodifiableSet(htmlEscapeSet); + } + + private final boolean escapeHtmlCharacters; + + Escaper(boolean escapeHtmlCharacters) { + this.escapeHtmlCharacters = escapeHtmlCharacters; + } + + public String escapeJsonString(CharSequence plainText) { + StringBuilder escapedString = new StringBuilder(plainText.length() + 20); + try { + escapeJsonString(plainText, escapedString); + } catch (IOException e) { + throw new RuntimeException(e); + } + return escapedString.toString(); + } + + private void escapeJsonString(CharSequence plainText, StringBuilder out) throws IOException { + int pos = 0; // Index just past the last char in plainText written to out. + int len = plainText.length(); + + for (int charCount, i = 0; i < len; i += charCount) { + int codePoint = Character.codePointAt(plainText, i); + charCount = Character.charCount(codePoint); + + if (!isControlCharacter(codePoint) && !mustEscapeCharInJsString(codePoint)) { + continue; + } + + out.append(plainText, pos, i); + pos = i + charCount; + switch (codePoint) { + case '\b': + out.append("\\b"); + break; + case '\t': + out.append("\\t"); + break; + case '\n': + out.append("\\n"); + break; + case '\f': + out.append("\\f"); + break; + case '\r': + out.append("\\r"); + break; + case '\\': + out.append("\\\\"); + break; + case '/': + out.append("\\/"); + break; + case '"': + out.append("\\\""); + break; + default: + appendHexJavaScriptRepresentation(codePoint, out); + break; + } + } + out.append(plainText, pos, len); + } + + private boolean mustEscapeCharInJsString(int codepoint) { + if (!Character.isSupplementaryCodePoint(codepoint)) { + char c = (char) codepoint; + return JS_ESCAPE_CHARS.contains(c) + || (escapeHtmlCharacters && HTML_ESCAPE_CHARS.contains(c)); + } + return false; + } + + private static boolean isControlCharacter(int codePoint) { + // JSON spec defines these code points as control characters, so they must be escaped + return codePoint < 0x20 + || codePoint == 0x2028 // Line separator + || codePoint == 0x2029 // Paragraph separator + || (codePoint >= 0x7f && codePoint <= 0x9f); + } + + private static void appendHexJavaScriptRepresentation(int codePoint, Appendable out) + throws IOException { + if (Character.isSupplementaryCodePoint(codePoint)) { + // Handle supplementary unicode values which are not representable in + // javascript. We deal with these by escaping them as two 4B sequences + // so that they will round-trip properly when sent from java to javascript + // and back. + char[] surrogates = Character.toChars(codePoint); + appendHexJavaScriptRepresentation(surrogates[0], out); + appendHexJavaScriptRepresentation(surrogates[1], out); + return; + } + out.append("\\u") + .append(HEX_CHARS[(codePoint >>> 12) & 0xf]) + .append(HEX_CHARS[(codePoint >>> 8) & 0xf]) + .append(HEX_CHARS[(codePoint >>> 4) & 0xf]) + .append(HEX_CHARS[codePoint & 0xf]); + } + } + + public static class MalformedJsonException extends RuntimeException + { + private static final long serialVersionUID = 1L; + public MalformedJsonException(String msg) { super(msg); } + } + + private static class Reader + { + private static final Object OBJECT_END = new String("}"); + private static final Object ARRAY_END = new String("]"); + private static final Object OBJECT_START = new String("{"); + private static final Object ARRAY_START = new String("["); + private static final Object COLON = new String(":"); + private static final Object COMMA = new String(","); + private static final HashSet PUNCTUATION = new HashSet( + Arrays.asList(OBJECT_END, OBJECT_START, ARRAY_END, ARRAY_START, COLON, COMMA)); + public static final int FIRST = 0; + public static final int CURRENT = 1; + public static final int NEXT = 2; + + private static Map escapes = new HashMap(); + static + { + escapes.put(new Character('"'), new Character('"')); + escapes.put(new Character('\\'), new Character('\\')); + escapes.put(new Character('/'), new Character('/')); + escapes.put(new Character('b'), new Character('\b')); + escapes.put(new Character('f'), new Character('\f')); + escapes.put(new Character('n'), new Character('\n')); + escapes.put(new Character('r'), new Character('\r')); + escapes.put(new Character('t'), new Character('\t')); + } + + private CharacterIterator it; + private char c; + private Object token; + private StringBuffer buf = new StringBuffer(); + + private char next() + { + if (it.getIndex() == it.getEndIndex()) + throw new MalformedJsonException("Reached end of input at the " + + it.getIndex() + "th character."); + c = it.next(); + return c; + } + + private char previous() + { + c = it.previous(); + return c; + } + + private void skipWhiteSpace() + { + do + { + if (Character.isWhitespace(c)) + ; + else if (c == '/') + { + next(); + if (c == '*') + { + // skip multiline comments + while (c != CharacterIterator.DONE) + if (next() == '*' && next() == '/') + break; + if (c == CharacterIterator.DONE) + throw new MalformedJsonException("Unterminated comment while parsing JSON string."); + } + else if (c == '/') + while (c != '\n' && c != CharacterIterator.DONE) + next(); + else + { + previous(); + break; + } + } + else + break; + } while (next() != CharacterIterator.DONE); + } + + public Object read(CharacterIterator ci, int start) + { + it = ci; + switch (start) + { + case FIRST: + c = it.first(); + break; + case CURRENT: + c = it.current(); + break; + case NEXT: + c = it.next(); + break; + } + return read(); + } + + public Object read(CharacterIterator it) + { + return read(it, NEXT); + } + + public Object read(String string) + { + return read(new StringCharacterIterator(string), FIRST); + } + + private void expected(Object expectedToken, Object actual) + { + if (expectedToken != actual) + throw new MalformedJsonException("Expected " + expectedToken + ", but got " + actual + " instead"); + } + + @SuppressWarnings("unchecked") + private T read() + { + skipWhiteSpace(); + char ch = c; + next(); + switch (ch) + { + case '"': token = readString(); break; + case '[': token = readArray(); break; + case ']': token = ARRAY_END; break; + case ',': token = COMMA; break; + case '{': token = readObject(); break; + case '}': token = OBJECT_END; break; + case ':': token = COLON; break; + case 't': + if (c != 'r' || next() != 'u' || next() != 'e') + throw new MalformedJsonException("Invalid JSON token: expected 'true' keyword."); + next(); + token = factory().bool(Boolean.TRUE); + break; + case'f': + if (c != 'a' || next() != 'l' || next() != 's' || next() != 'e') + throw new MalformedJsonException("Invalid JSON token: expected 'false' keyword."); + next(); + token = factory().bool(Boolean.FALSE); + break; + case 'n': + if (c != 'u' || next() != 'l' || next() != 'l') + throw new MalformedJsonException("Invalid JSON token: expected 'null' keyword."); + next(); + token = nil(); + break; + default: + c = it.previous(); + if (Character.isDigit(c) || c == '-') { + token = readNumber(); + } + else throw new MalformedJsonException("Invalid JSON near position: " + it.getIndex()); + } + return (T)token; + } + + private String readObjectKey() + { + Object key = read(); + if (key == null) + throw new MalformedJsonException("Missing object key (don't forget to put quotes!)."); + else if (key == OBJECT_END) + return null; + else if (PUNCTUATION.contains(key)) + throw new MalformedJsonException("Missing object key, found: " + key); + else + return ((Json)key).asString(); + } + + private Json readObject() + { + Json ret = object(); + String key = readObjectKey(); + while (token != OBJECT_END) + { + expected(COLON, read()); // should be a colon + if (token != OBJECT_END) + { + Json value = read(); + ret.set(key, value); + if (read() == COMMA) { + key = readObjectKey(); + if (key == null || PUNCTUATION.contains(key)) + throw new MalformedJsonException("Expected a property name, but found: " + key); + } + else + expected(OBJECT_END, token); + } + } + return ret; + } + + private Json readArray() + { + Json ret = array(); + Object value = read(); + while (token != ARRAY_END) + { + if (PUNCTUATION.contains(value)) + throw new MalformedJsonException("Expected array element, but found: " + value); + ret.add((Json)value); + if (read() == COMMA) { + value = read(); + if (value == ARRAY_END) + throw new MalformedJsonException("Expected array element, but found end of array after command."); + } + else + expected(ARRAY_END, token); + } + return ret; + } + + private Json readNumber() + { + int length = 0; + boolean isFloatingPoint = false; + buf.setLength(0); + + if (c == '-') + { + add(); + } + length += addDigits(); + if (c == '.') + { + add(); + length += addDigits(); + isFloatingPoint = true; + } + if (c == 'e' || c == 'E') + { + add(); + if (c == '+' || c == '-') + { + add(); + } + addDigits(); + isFloatingPoint = true; + } + + String s = buf.toString(); + Number n = isFloatingPoint + ? (length < 17) ? Double.valueOf(s) : new BigDecimal(s) + : (length < 20) ? Long.valueOf(s) : new BigInteger(s); + return factory().number(n); + } + + private int addDigits() + { + int ret; + for (ret = 0; Character.isDigit(c); ++ret) + { + add(); + } + return ret; + } + + private Json readString() + { + buf.setLength(0); + while (c != '"') + { + if (c == '\\') + { + next(); + if (c == 'u') + { + add(unicode()); + } + else + { + Object value = escapes.get(new Character(c)); + if (value != null) + { + add(((Character) value).charValue()); + } + } + } + else + { + add(); + } + } + next(); + return factory().string(buf.toString()); + } + + private void add(char cc) + { + buf.append(cc); + next(); + } + + private void add() + { + add(c); + } + + private char unicode() + { + int value = 0; + for (int i = 0; i < 4; ++i) + { + switch (next()) + { + case '0': case '1': case '2': case '3': case '4': + case '5': case '6': case '7': case '8': case '9': + value = (value << 4) + c - '0'; + break; + case 'a': case 'b': case 'c': case 'd': case 'e': case 'f': + value = (value << 4) + (c - 'a') + 10; + break; + case 'A': case 'B': case 'C': case 'D': case 'E': case 'F': + value = (value << 4) + (c - 'A') + 10; + break; + } + } + return (char) value; + } + } +} + diff --git a/src/main/java/org/kobe/xbot/Utilities/Logger/XTablesLogger.java b/src/main/java/org/kobe/xbot/Utilities/Logger/XTablesLogger.java new file mode 100644 index 00000000..eb6f47e8 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/Logger/XTablesLogger.java @@ -0,0 +1,105 @@ +package org.kobe.xbot.Utilities.Logger; + +import org.slf4j.LoggerFactory; + +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class XTablesLogger extends Logger { + private static final String loggerName = "XTablesLogger"; + private static final org.slf4j.Logger log = LoggerFactory.getLogger(XTablesLogger.class); + private static Level defaultLevel = Level.ALL; + private static XTablesLogger instance = null; + private static final String RESET = "\u001B[0m"; + private static final String RED = "\u001B[31m"; + private static final String YELLOW = "\u001B[33m"; + private static final String BLUE = "\u001B[34m"; + private static final String PURPLE = "\u001B[35m"; // For fatal level + + // Define a custom FATAL logging level + public static final Level FATAL = new Level("FATAL", Level.SEVERE.intValue() + 1) {}; + + protected XTablesLogger(String name, String resourceBundleName) { + super(name, resourceBundleName); + } + + public static XTablesLogger getLogger() { + if (instance == null) { + XTablesLogger logger = new XTablesLogger(loggerName, null); + logger.setLevel(defaultLevel); + ConsoleHandler consoleHandler = new ConsoleHandler(); + consoleHandler.setFormatter(new XTablesFormatter()); + logger.addHandler(consoleHandler); + instance = logger; + return logger; + } else return instance; + } + + // Method to log fatal messages + public void fatal(String msg) { + log(FATAL, msg); + } + + // Custom formatter for XTablesLogger + private static class XTablesFormatter extends java.util.logging.Formatter { + + @Override + public String format(java.util.logging.LogRecord record) { + StringBuilder builder = new StringBuilder(); + + // Get current time and date + ZonedDateTime dateTime = ZonedDateTime.now(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMM dd, yyyy hh:mm:ss a"); + String formattedDateTime = dateTime.format(formatter); + + // Get class and method names + String classPath = record.getSourceClassName(); + String methodName = record.getSourceMethodName(); + String className = classPath.split("\\.")[classPath.split("\\.").length - 1]; + + builder.append(getColorFromLevel(record.getLevel())) + .append("[") + .append(record.getLevel().getName()) + .append("] ") + .append(formattedDateTime) + .append(" ") + .append(classPath) + .append(" ") + .append(RESET) + .append(methodName) + .append(": \n") + .append(getColorFromLevel(record.getLevel())) + .append("[") + .append(record.getLevel().getName()) + .append("] ") + .append("[") + .append(className) + .append("] ") + .append(RESET) + .append(record.getMessage()) + .append(System.lineSeparator()); + return builder.toString(); + } + } + + private static String getColorFromLevel(Level level) { + if (level == FATAL) { + return PURPLE; + } else if (level == Level.SEVERE) { + return RED; + } else if (level == Level.WARNING) { + return YELLOW; + } else if (level == Level.INFO) { + return BLUE; + } + return ""; + } + + public static void setLoggingLevel(Level level) { + if (instance == null) defaultLevel = level; + else instance.setLevel(level); + } +} diff --git a/src/main/java/org/kobe/xbot/Utilities/MethodType.java b/src/main/java/org/kobe/xbot/Utilities/MethodType.java new file mode 100644 index 00000000..465b3eac --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/MethodType.java @@ -0,0 +1,22 @@ +package org.kobe.xbot.Utilities; + +public enum MethodType { + GET, + PUT, + GET_RAW_JSON, + UPDATE_EVENT, + GET_TABLES, + UPDATE_KEY, + DELETE, + DELETE_EVENT, + SUBSCRIBE_UPDATE, + UNSUBSCRIBE_UPDATE, + REBOOT_SERVER, + PING, + RUN_SCRIPT, + SUBSCRIBE_DELETE, + UNSUBSCRIBE_DELETE, + REGISTER_VIDEO_STREAM, + GET_VIDEO_STREAM, + UNKNOWN +} diff --git a/src/main/java/org/kobe/xbot/Utilities/RequestInfo.java b/src/main/java/org/kobe/xbot/Utilities/RequestInfo.java new file mode 100644 index 00000000..32bfc0e0 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/RequestInfo.java @@ -0,0 +1,66 @@ +package org.kobe.xbot.Utilities; + +public class RequestInfo { + private final String id; + private final MethodType method; + private final String[] tokens; + + public RequestInfo(String raw) { + // Avoid using regex when not needed + String raw1 = raw.replace("\n", ""); + this.tokens = tokenize(raw1, ' '); + + String[] requestTokens = tokenize(this.tokens[0], ':'); + this.id = requestTokens[0]; + + MethodType parsedMethod; + try { + parsedMethod = MethodType.valueOf(requestTokens[1]); + } catch (Exception e) { + parsedMethod = MethodType.UNKNOWN; + } + this.method = parsedMethod; + } + + public String getID() { + return this.id; + } + + public MethodType getMethod() { + return this.method; + } + + public String[] getTokens() { + return this.tokens; + } + + // Custom method to tokenize string without using regex + private String[] tokenize(String input, char delimiter) { + int count = 1; + int length = input.length(); + for (int i = 0; i < length; i++) { + if (input.charAt(i) == delimiter) { + count++; + } + } + + // Allocate array for results + String[] result = new String[count]; + int index = 0; + int tokenStart = 0; + + // Second pass: Extract tokens + for (int i = 0; i < length; i++) { + if (input.charAt(i) == delimiter) { + result[index++] = input.substring(tokenStart, i); + tokenStart = i + 1; + } + } + + // Add last token + result[index] = input.substring(tokenStart); + + return result; + } + +} diff --git a/src/main/java/org/kobe/xbot/Utilities/ResponseInfo.java b/src/main/java/org/kobe/xbot/Utilities/ResponseInfo.java new file mode 100644 index 00000000..fdaa1312 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/ResponseInfo.java @@ -0,0 +1,52 @@ +package org.kobe.xbot.Utilities; + +import java.util.Arrays; +import java.util.UUID; + +public class ResponseInfo { + private String ID; + private final MethodType method; + private final String response; + + public ResponseInfo(String ID, MethodType method, String response) { + this.response = response; + this.ID = ID == null ? UUID.randomUUID().toString() : ID; + this.method = method; + } + + public ResponseInfo(String ID, MethodType method) { + this.response = ""; + this.ID = ID == null ? UUID.randomUUID().toString() : ID; + this.method = method; + } + + public ResponseInfo setID(String ID) { + this.ID = ID; + return this; + } + + public String getID() { + return ID; + } + + public MethodType getMethod() { + return method; + } + + public String getResponse() { + return response; + } + + public String parsed() { + return String.format("%1$s:%2$s %3$s", this.ID, this.method.toString(), this.response.replaceAll("\n", "")); + } + + public static ResponseInfo from(String raw) { + String[] tokens = raw.split(" "); + String[] requestTokens = tokens[0].split(":"); + String ID = requestTokens[0]; + String stringMethod = requestTokens[1]; + MethodType method = MethodType.valueOf(stringMethod); + return new ResponseInfo(ID, method, String.join(" ", Arrays.copyOfRange(tokens, 1, tokens.length))); + } +} diff --git a/src/main/java/org/kobe/xbot/Utilities/ResponseStatus.java b/src/main/java/org/kobe/xbot/Utilities/ResponseStatus.java new file mode 100644 index 00000000..16d2244f --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/ResponseStatus.java @@ -0,0 +1,6 @@ +package org.kobe.xbot.Utilities; + +public enum ResponseStatus { + OK, + FAIL +} diff --git a/src/main/java/org/kobe/xbot/Utilities/ScriptResponse.java b/src/main/java/org/kobe/xbot/Utilities/ScriptResponse.java new file mode 100644 index 00000000..bf8f292d --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/ScriptResponse.java @@ -0,0 +1,19 @@ +package org.kobe.xbot.Utilities; + +public class ScriptResponse { + private final String response; + private final ResponseStatus status; + + public ScriptResponse(String response, ResponseStatus status) { + this.response = response; + this.status = status; + } + + public String getResponse() { + return response; + } + + public ResponseStatus getStatus() { + return status; + } +} diff --git a/src/main/java/org/kobe/xbot/Utilities/SystemStatistics.java b/src/main/java/org/kobe/xbot/Utilities/SystemStatistics.java new file mode 100644 index 00000000..37c6ac17 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/SystemStatistics.java @@ -0,0 +1,166 @@ +package org.kobe.xbot.Utilities; + +import com.sun.management.OperatingSystemMXBean; +import oshi.SystemInfo; +import oshi.hardware.CentralProcessor; +import oshi.hardware.HardwareAbstractionLayer; + +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.management.ThreadMXBean; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +public class SystemStatistics { + private final long freeMemoryMB; + private final long usedMemoryMB; + private final long maxMemoryMB; + private final double processCpuLoadPercentage; + private final int availableProcessors; + private final long totalThreads; + private final long nanoTime; + private final String health; + private final double powerUsageWatts; + private final int totalClients; + private XTableStatus status; + private int totalMessages; + private String ip; + private List clientDataList; + + private static final MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean(); + private static final OperatingSystemMXBean osMXBean = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean(); + private static final ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); + + private static final SystemInfo systemInfo = new SystemInfo(); + private static final HardwareAbstractionLayer hal = systemInfo.getHardware(); + private static final CentralProcessor processor = hal.getProcessor(); + + private static final AtomicReference prevTicks = new AtomicReference<>(); + private static final AtomicLong lastUpdateTime = new AtomicLong(); + private static final AtomicReference lastPowerUsageWatts = new AtomicReference<>(0.0); + + + public SystemStatistics(int totalClients) { + this.nanoTime = System.nanoTime(); + this.totalClients = totalClients; + this.maxMemoryMB = osMXBean.getTotalPhysicalMemorySize() / (1024 * 1024); + this.freeMemoryMB = osMXBean.getFreePhysicalMemorySize() / (1024 * 1024); + this.usedMemoryMB = maxMemoryMB - freeMemoryMB; + this.processCpuLoadPercentage = osMXBean.getSystemCpuLoad() * 100; + this.availableProcessors = osMXBean.getAvailableProcessors(); + this.powerUsageWatts = getEstimatedPowerConsumption(); + this.totalThreads = threadMXBean.getThreadCount(); + this.ip = Utilities.getLocalIPAddress(); + if (usedMemoryMB <= maxMemoryMB * 0.5 && processCpuLoadPercentage < 50 && totalThreads <= availableProcessors * 4) { + this.health = HealthStatus.GOOD.name(); + } else if (usedMemoryMB <= maxMemoryMB * 0.6 && processCpuLoadPercentage < 70 && totalThreads <= availableProcessors * 6) { + this.health = HealthStatus.OKAY.name(); + } else if (usedMemoryMB <= maxMemoryMB * 0.7 && processCpuLoadPercentage < 85 && totalThreads <= availableProcessors * 8) { + this.health = HealthStatus.STRESSED.name(); + } else if (usedMemoryMB <= maxMemoryMB * 0.85 && processCpuLoadPercentage < 95 && totalThreads <= availableProcessors * 10) { + this.health = HealthStatus.OVERLOAD.name(); + } else { + this.health = HealthStatus.CRITICAL.name(); + } + + + } + + public enum HealthStatus { + GOOD, OKAY, STRESSED, OVERLOAD, CRITICAL, UNKNOWN + } + + public String getIp() { + return ip; + } + + public int getTotalMessages() { + return totalMessages; + } + + public SystemStatistics setTotalMessages(int totalMessages) { + this.totalMessages = totalMessages; + return this; + } + + public XTableStatus getStatus() { + return status; + } + + public SystemStatistics setStatus(XTableStatus status) { + this.status = status; + return this; + } + + public List getClientDataList() { + return clientDataList; + } + + public SystemStatistics setClientDataList(List clientDataList) { + this.clientDataList = clientDataList; + return this; + } + + public int getTotalClients() { + return totalClients; + } + + public long getNanoTime() { + return nanoTime; + } + + public long getFreeMemoryMB() { + return freeMemoryMB; + } + + public long getMaxMemoryMB() { + return maxMemoryMB; + } + + public double getProcessCpuLoadPercentage() { + return processCpuLoadPercentage; + } + + public int getAvailableProcessors() { + return availableProcessors; + } + + public long getTotalThreads() { + return totalThreads; + } + + public double getEstimatedPowerConsumption() { + long currentTime = System.currentTimeMillis(); + + // If this is the first call, initialize prevTicks and lastUpdateTime + if (prevTicks.get() == null) { + prevTicks.set(processor.getSystemCpuLoadTicks()); + lastUpdateTime.set(currentTime); + lastPowerUsageWatts.set(0.0); // Initial value since we don't have enough data yet + return lastPowerUsageWatts.get(); + } + + // Check if at least 1 second has passed since the last update + if (currentTime - lastUpdateTime.get() < 1000) { + return lastPowerUsageWatts.get(); // Return the last known value + } + + // Get CPU load between ticks for estimation + long[] currentTicks = processor.getSystemCpuLoadTicks(); + double cpuLoadBetweenTicks = processor.getSystemCpuLoadBetweenTicks(prevTicks.get()); + + // Update prevTicks and lastUpdateTime + prevTicks.set(currentTicks); + lastUpdateTime.set(currentTime); + + // Estimation of power consumption + double maxFreqGHz = processor.getMaxFreq() / 1_000_000_000.0; + int logicalProcessorCount = processor.getLogicalProcessorCount(); + // CPU Load Between Ticks x Max Frequency in GHz x Logical Processor Count x 10 + double estimatedPower = cpuLoadBetweenTicks * maxFreqGHz * logicalProcessorCount * 10; + lastPowerUsageWatts.set(estimatedPower); + + return estimatedPower; + } +} diff --git a/src/main/java/org/kobe/xbot/Utilities/Utilities.java b/src/main/java/org/kobe/xbot/Utilities/Utilities.java new file mode 100644 index 00000000..4b830fd1 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/Utilities.java @@ -0,0 +1,179 @@ +package org.kobe.xbot.Utilities; + + +//import org.bytedeco.javacpp.BytePointer; +//import org.bytedeco.opencv.global.opencv_imgcodecs; +//import org.bytedeco.opencv.opencv_core.Mat; + +import java.net.*; +import java.util.Enumeration; +import java.util.concurrent.ConcurrentHashMap; + +public class Utilities { + +// public static byte[] matToByteArray(Mat mat) { +// BytePointer bytePointer = new BytePointer(); +// opencv_imgcodecs.imencode(".jpg", mat, bytePointer); // Encode the image +// byte[] byteArray = new byte[(int) bytePointer.limit()]; +// bytePointer.get(byteArray); +// return byteArray; +// } + + + public static String getLocalIPAddress() { + try { + InetAddress localHost = Inet4Address.getLocalHost(); + if (localHost.isLoopbackAddress()) { + return findNonLoopbackAddress().getHostAddress(); + } + return localHost.getHostAddress(); + } catch (UnknownHostException | SocketException e) { + e.printStackTrace(); + } + return null; + } + + public static InetAddress getLocalInetAddress() { + try { + InetAddress localHost = Inet4Address.getLocalHost(); + if (localHost.isLoopbackAddress()) { + return findNonLoopbackAddress(); + } + return localHost; + } catch (UnknownHostException | SocketException ignored) { + } + return null; + } + + private static InetAddress findNonLoopbackAddress() throws SocketException { + Enumeration networkInterfaces = NetworkInterface.getNetworkInterfaces(); + while (networkInterfaces.hasMoreElements()) { + NetworkInterface networkInterface = networkInterfaces.nextElement(); + + // Skip loopback and down interfaces + if (networkInterface.isLoopback() || !networkInterface.isUp()) { + continue; + } + + Enumeration inetAddresses = networkInterface.getInetAddresses(); + while (inetAddresses.hasMoreElements()) { + InetAddress inetAddress = inetAddresses.nextElement(); + + // Return the first non-loopback IPv4 address + if (!inetAddress.isLoopbackAddress() && inetAddress.isSiteLocalAddress() && inetAddress.getHostAddress().contains(".")) { + return inetAddress; + } + } + } + throw new SocketException("No non-loopback IPv4 address found"); + } + + public static > boolean contains(Class enumClass, String constant) { + for (E e : enumClass.getEnumConstants()) { + if (e.name().equals(constant)) { + return true; + } + } + return false; + } + + public static boolean isValidValue(String jsonString) { + try { + // Attempt to parse the JSON string + Json.read(jsonString); + return true; // If parsing succeeds, JSON is valid + } catch (Error | Exception e) { + return false; // If parsing fails, JSON is invalid + } + } + + @SafeVarargs + public static ConcurrentHashMap combineConcurrentHashMaps(ConcurrentHashMap... maps) { + ConcurrentHashMap combinedMap = new ConcurrentHashMap<>(); + for (ConcurrentHashMap map : maps) { + combinedMap.putAll(map); + } + return combinedMap; + } + + public static boolean validateKey(String key, boolean throwError) { + // Check if the key is null or empty + if (key == null) { + if (throwError) throw new IllegalArgumentException("Key cannot be null."); + else return false; + } + + // Check if key contains spaces + if (key.contains(" ")) { + if (throwError) throw new IllegalArgumentException("Key cannot contain spaces."); + else return false; + } + + // Check if key starts or ends with '.' + if (key.startsWith(".") || key.endsWith(".")) { + if (throwError) throw new IllegalArgumentException("Key cannot start or end with '.'"); + else return false; + } + + // Check if key contains multiple consecutive '.' + if (key.contains("..")) { + if (throwError) throw new IllegalArgumentException("Key cannot contain multiple consecutive '.'"); + else return false; + } + + // Check if each part of the key separated by '.' is empty + if (!key.isEmpty()) { + int length = key.length(); + int start = 0; + boolean partFound = false; + + for (int i = 0; i < length; i++) { + if (key.charAt(i) == '.') { + if (i == start) { + // Empty part found + if (throwError) throw new IllegalArgumentException("Key contains empty part(s)."); + else return false; + } + // Process the part if needed here + start = i + 1; + partFound = true; + } + } + + // Check the last part + if (start < length) { + partFound = true; + } else { + if (throwError) throw new IllegalArgumentException("Key contains empty part(s)."); + else return false; + } + + return partFound; + } + + return true; + } + + public static boolean validateName(String name, boolean throwError) { + // Check if the name is null or empty + if (name == null) { + if (throwError) throw new IllegalArgumentException("Name cannot be null."); + else return false; + } + + // Check if the name contains spaces + if (name.contains(" ")) { + if (throwError) throw new IllegalArgumentException("Name cannot contain spaces."); + else return false; + } + + // Check if the name contains '.' + if (name.contains(".")) { + if (throwError) throw new IllegalArgumentException("Name cannot contain '.'"); + else return false; + } + + + return true; + } +} diff --git a/src/main/java/org/kobe/xbot/Utilities/VideoStreamResponse.java b/src/main/java/org/kobe/xbot/Utilities/VideoStreamResponse.java new file mode 100644 index 00000000..8155ec4f --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/VideoStreamResponse.java @@ -0,0 +1,59 @@ +//package org.kobe.xbot.Utilities; +// +//import org.kobe.xbot.Client.ImageStreamClient; +//import org.kobe.xbot.Client.ImageStreamServer; +// +//public class VideoStreamResponse { +// private ImageStreamServer streamServer; +// private ImageStreamClient streamClient; +// private ImageStreamStatus status; +// private String address; +// +// public VideoStreamResponse(ImageStreamServer streamServer, ImageStreamStatus status) { +// this.streamServer = streamServer; +// this.status = status; +// } +// +// public VideoStreamResponse(ImageStreamClient streamClient, ImageStreamStatus status) { +// this.streamClient = streamClient; +// this.status = status; +// } +// public VideoStreamResponse(ImageStreamStatus status) { +// this.status = status; +// } +// +// public String getAddress() { +// return address; +// } +// +// public VideoStreamResponse setAddress(String address) { +// this.address = address; +// return this; +// } +// +// public ImageStreamServer getStreamServer() { +// return streamServer; +// } +// +// public ImageStreamClient getStreamClient() { +// return streamClient; +// } +// +// public VideoStreamResponse setStreamClient(ImageStreamClient streamClient) { +// this.streamClient = streamClient; +// return this; +// } +// +// public void setStreamServer(ImageStreamServer streamServer) { +// this.streamServer = streamServer; +// } +// +// public ImageStreamStatus getStatus() { +// return status; +// } +// +// public VideoStreamResponse setStatus(ImageStreamStatus status) { +// this.status = status; +// return this; +// } +//} diff --git a/src/main/java/org/kobe/xbot/Utilities/XTableStatus.java b/src/main/java/org/kobe/xbot/Utilities/XTableStatus.java new file mode 100644 index 00000000..f0b30ee0 --- /dev/null +++ b/src/main/java/org/kobe/xbot/Utilities/XTableStatus.java @@ -0,0 +1,8 @@ +package org.kobe.xbot.Utilities; + +public enum XTableStatus { + ONLINE, + OFFLINE, + REBOOTING, + STARTING +}