From a8693142f4dc93ef6d1203295431b761c26089ad Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Thu, 3 Oct 2024 16:26:54 -0600 Subject: [PATCH 01/36] Implementing camera controls --- .../collections/MutableReference.java | 68 ++ EOCV-Sim/build.gradle | 2 +- .../serivesmejia/eocvsim/config/Config.java | 8 +- .../gui/dialog/source/CreateCameraSource.java | 6 +- .../eocvsim/gui/util/WebcamDriver.kt | 54 +- .../eocvsim/input/source/CameraSource.java | 34 +- .../eocvsim/input/VisionInputSource.kt | 105 +-- .../input/control/CameraSourceControlMap.kt | 19 + .../control/CameraSourceExposureControl.kt | 65 ++ .../external/FrameReceiverOpenCvCamera.java | 393 +++++---- .../external/source/CameraControlMap.java | 9 + .../vision/external/source/VisionSource.java | 84 +- .../camera/controls/ExposureControl.java | 142 +++ .../camera/controls/FocusControl.java | 117 +++ .../hardware/camera/controls/GainControl.java | 68 ++ .../hardware/camera/controls/PtzControl.java | 102 +++ .../camera/controls/WhiteBalanceControl.java | 86 ++ .../ftc/vision/VisionPortalImpl.java | 829 +++++++++--------- .../org/openftc/easyopencv/OpenCvWebcam.java | 168 +++- 19 files changed, 1599 insertions(+), 760 deletions(-) create mode 100644 Common/src/main/java/org/firstinspires/ftc/robotcore/internal/collections/MutableReference.java create mode 100644 EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/input/control/CameraSourceControlMap.kt create mode 100644 EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/input/control/CameraSourceExposureControl.kt create mode 100644 Vision/src/main/java/io/github/deltacv/vision/external/source/CameraControlMap.java create mode 100644 Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/ExposureControl.java create mode 100644 Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/FocusControl.java create mode 100644 Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/GainControl.java create mode 100644 Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/PtzControl.java create mode 100644 Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/WhiteBalanceControl.java diff --git a/Common/src/main/java/org/firstinspires/ftc/robotcore/internal/collections/MutableReference.java b/Common/src/main/java/org/firstinspires/ftc/robotcore/internal/collections/MutableReference.java new file mode 100644 index 00000000..eaf9e077 --- /dev/null +++ b/Common/src/main/java/org/firstinspires/ftc/robotcore/internal/collections/MutableReference.java @@ -0,0 +1,68 @@ +/* +Copyright (c) 2017 Robert Atkinson + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted (subject to the limitations in the disclaimer below) provided that +the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list +of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the name of Robert Atkinson nor the names of his contributors may be used to +endorse or promote products derived from this software without specific prior +written permission. + +NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY THIS +LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ +package org.firstinspires.ftc.robotcore.internal.collections; + +/** + * A very simple utility. While there might be a built-in Android version of this, + * we couldn't find one after a brief search. + */ +@SuppressWarnings("WeakerAccess") +public class MutableReference +{ + protected T value; + + public MutableReference(T value) + { + this.value = value; + } + + public MutableReference() + { + this(null); + } + + public void setValue(T value) + { + this.value = value; + } + + public T getValue() + { + return this.value; + } + + @Override public String toString() + { + return "[{" + getValue() + "}]"; + } +} diff --git a/EOCV-Sim/build.gradle b/EOCV-Sim/build.gradle index aea2c540..580146ba 100644 --- a/EOCV-Sim/build.gradle +++ b/EOCV-Sim/build.gradle @@ -60,7 +60,7 @@ dependencies { implementation "org.apache.logging.log4j:log4j-core:$log4j_version" implementation "org.apache.logging.log4j:log4j-slf4j-impl:$log4j_version" - implementation "com.github.deltacv:steve:1.0.0" + implementation "com.github.deltacv:steve:1.1.1" implementation "com.github.deltacv.AprilTagDesktop:AprilTagDesktop:$apriltag_plugin_version" implementation 'info.picocli:picocli:4.6.1' diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/config/Config.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/config/Config.java index c7576f15..fafa38e7 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/config/Config.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/config/Config.java @@ -50,7 +50,7 @@ public class Config { public volatile Size videoRecordingSize = new Size(640, 480); public volatile PipelineFps videoRecordingFps = PipelineFps.MEDIUM; - public volatile WebcamDriver preferredWebcamDriver = WebcamDriver.OpenIMAJ; + public volatile WebcamDriver preferredWebcamDriver = WebcamDriver.OpenPnp; public volatile String workspacePath = CompiledPipelineManager.Companion.getDEF_WORKSPACE_FOLDER().getAbsolutePath(); public volatile TunableFieldPanelConfig.Config globalTunableFieldsConfig = @@ -65,10 +65,4 @@ public class Config { public volatile List superAccessPluginHashes = new ArrayList<>(); - public Config() { - if(SysUtil.ARCH != SysUtil.SystemArchitecture.X86_64) { - preferredWebcamDriver = WebcamDriver.OpenCV; - } - } - } \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/source/CreateCameraSource.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/source/CreateCameraSource.java index b3ecdf00..493bdefd 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/source/CreateCameraSource.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/source/CreateCameraSource.java @@ -32,6 +32,7 @@ import io.github.deltacv.steve.opencv.OpenCvWebcam; import io.github.deltacv.steve.opencv.OpenCvWebcamBackend; import io.github.deltacv.steve.openimaj.OpenIMAJWebcamBackend; +import io.github.deltacv.steve.openpnp.OpenPnpBackend; import org.opencv.core.Mat; import org.opencv.core.Size; import org.slf4j.Logger; @@ -95,7 +96,10 @@ public CreateCameraSource(JFrame parent, EOCVSim eocvSim) { public void initCreateImageSource() { WebcamDriver preferredDriver = eocvSim.getConfig().preferredWebcamDriver; - if(preferredDriver == WebcamDriver.OpenIMAJ) { + if(preferredDriver == WebcamDriver.OpenPnp) { + Webcam.Companion.setBackend(OpenPnpBackend.INSTANCE); + webcams = Webcam.Companion.getAvailableWebcams(); + } else if(preferredDriver == WebcamDriver.OpenIMAJ) { try { Webcam.Companion.setBackend(OpenIMAJWebcamBackend.INSTANCE); webcams = Webcam.Companion.getAvailableWebcams(); diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/util/WebcamDriver.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/util/WebcamDriver.kt index 1dba5fed..7ea6591a 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/util/WebcamDriver.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/util/WebcamDriver.kt @@ -1,28 +1,28 @@ -/* - * Copyright (c) 2021 Sebastian Erives - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -package com.github.serivesmejia.eocvsim.gui.util - -enum class WebcamDriver { - OpenIMAJ, OpenCV +/* + * Copyright (c) 2021 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package com.github.serivesmejia.eocvsim.gui.util + +enum class WebcamDriver { + OpenPnp, OpenIMAJ, OpenCV } \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/input/source/CameraSource.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/input/source/CameraSource.java index 3998454a..9ddfcbf2 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/input/source/CameraSource.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/input/source/CameraSource.java @@ -24,14 +24,18 @@ package com.github.serivesmejia.eocvsim.input.source; import com.github.serivesmejia.eocvsim.gui.Visualizer; +import com.github.serivesmejia.eocvsim.gui.util.WebcamDriver; import com.github.serivesmejia.eocvsim.input.InputSource; import com.github.serivesmejia.eocvsim.util.StrUtil; import com.google.gson.annotations.Expose; import io.github.deltacv.steve.Webcam; +import io.github.deltacv.steve.WebcamPropertyControl; import io.github.deltacv.steve.WebcamRotation; import io.github.deltacv.steve.opencv.OpenCvWebcam; import io.github.deltacv.steve.opencv.OpenCvWebcamBackend; import io.github.deltacv.steve.openimaj.OpenIMAJWebcamBackend; +import io.github.deltacv.steve.openpnp.OpenPnpBackend; +import io.github.deltacv.steve.openpnp.OpenPnpWebcam; import org.opencv.core.Mat; import org.opencv.core.Size; import org.opencv.imgproc.Imgproc; @@ -100,6 +104,11 @@ public CameraSource(int webcamIndex, Size size) { isLegacyByIndex = true; } + public WebcamPropertyControl getWebcamPropertyControl() { + if(camera == null) return null; + return camera.getPropertyControl(); + } + @Override public void setSize(Size size) { this.size = size; @@ -113,7 +122,11 @@ public boolean init() { if(rotation == null) rotation = WebcamRotation.UPRIGHT; if(webcamName != null) { - Webcam.Companion.setBackend(OpenIMAJWebcamBackend.INSTANCE); + if(eocvSim.getConfig().preferredWebcamDriver == WebcamDriver.OpenPnp) { + Webcam.Companion.setBackend(OpenPnpBackend.INSTANCE); + } else { + Webcam.Companion.setBackend(OpenIMAJWebcamBackend.INSTANCE); + } List webcams = Webcam.Companion.getAvailableWebcams(); @@ -143,10 +156,16 @@ public boolean init() { camera.setResolution(size); camera.setRotation(rotation); - camera.open(); + + try { + camera.open(); + } catch (Exception ex) { + logger.error("Error while opening camera " + webcamIndex, ex); + return false; + } if (!camera.isOpen()) { - logger.error("Unable to open camera " + webcamIndex); + logger.error("Unable to open camera " + webcamIndex + ", isOpen() returned false."); return false; } @@ -220,9 +239,12 @@ public Mat update() { if (size == null) size = lastFrame.size(); - // not needed. cameras return RGB now - // Imgproc.cvtColor(newFrame, lastFrame, Imgproc.COLOR_BGR2RGB); - newFrame.copyTo(lastFrame); + + if(camera instanceof OpenPnpWebcam) + // we will look like the smurfs if we don't do this... + // (OpenPnpWebcam returns BGR instead of RGB, ugh, i should fix that) + Imgproc.cvtColor(newFrame, lastFrame, Imgproc.COLOR_BGR2RGB); + else newFrame.copyTo(lastFrame); newFrame.release(); newFrame.returnMat(); diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/input/VisionInputSource.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/input/VisionInputSource.kt index 2ef966d2..44e50b8c 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/input/VisionInputSource.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/input/VisionInputSource.kt @@ -1,50 +1,57 @@ -package io.github.deltacv.eocvsim.input - -import com.github.serivesmejia.eocvsim.input.InputSource -import com.github.serivesmejia.eocvsim.util.loggerForThis -import io.github.deltacv.vision.external.source.VisionSourceBase -import io.github.deltacv.vision.external.util.ThrowableHandler -import io.github.deltacv.vision.external.util.Timestamped -import org.opencv.core.Mat -import org.opencv.core.Size - -class VisionInputSource( - private val inputSource: InputSource, - throwableHandler: ThrowableHandler? = null -) : VisionSourceBase(throwableHandler) { - - val logger by loggerForThis() - - override fun init(): Int { - return 0 - } - - override fun close(): Boolean { - inputSource.close() - inputSource.reset() - return true - } - - override fun startSource(size: Size?): Boolean { - inputSource.setSize(size) - inputSource.init() - return true - } - - override fun stopSource(): Boolean { - inputSource.close() - return true; - } - - private val emptyMat = Mat() - - override fun pullFrame(): Timestamped { - return try { - val frame = inputSource.update(); - Timestamped(frame, inputSource.captureTimeNanos) - } catch(e: Exception) { - Timestamped(emptyMat, 0) - } - } - +package io.github.deltacv.eocvsim.input + +import com.github.serivesmejia.eocvsim.input.InputSource +import com.github.serivesmejia.eocvsim.input.source.CameraSource +import com.github.serivesmejia.eocvsim.util.loggerForThis +import io.github.deltacv.eocvsim.input.control.CameraSourceControlMap +import io.github.deltacv.vision.external.source.CameraControlMap +import io.github.deltacv.vision.external.source.VisionSourceBase +import io.github.deltacv.vision.external.util.ThrowableHandler +import io.github.deltacv.vision.external.util.Timestamped +import org.opencv.core.Mat +import org.opencv.core.Size + +class VisionInputSource( + private val inputSource: InputSource, + throwableHandler: ThrowableHandler? = null +) : VisionSourceBase(throwableHandler) { + + val logger by loggerForThis() + + override fun init(): Int { + return 0 + } + + override fun getControlMap() = if(inputSource is CameraSource) { + CameraSourceControlMap(inputSource) + } else throw IllegalStateException("Controls are not available for source ${inputSource.name}") + + override fun close(): Boolean { + inputSource.close() + inputSource.reset() + return true + } + + override fun startSource(size: Size?): Boolean { + inputSource.setSize(size) + inputSource.init() + return true + } + + override fun stopSource(): Boolean { + inputSource.close() + return true; + } + + private val emptyMat = Mat() + + override fun pullFrame(): Timestamped { + return try { + val frame = inputSource.update(); + Timestamped(frame, inputSource.captureTimeNanos) + } catch(e: Exception) { + Timestamped(emptyMat, 0) + } + } + } \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/input/control/CameraSourceControlMap.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/input/control/CameraSourceControlMap.kt new file mode 100644 index 00000000..27148794 --- /dev/null +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/input/control/CameraSourceControlMap.kt @@ -0,0 +1,19 @@ +package io.github.deltacv.eocvsim.input.control + +import com.github.serivesmejia.eocvsim.input.source.CameraSource +import io.github.deltacv.vision.external.source.CameraControlMap +import org.firstinspires.ftc.robotcore.external.hardware.camera.controls.CameraControl +import org.firstinspires.ftc.robotcore.external.hardware.camera.controls.ExposureControl + +class CameraSourceControlMap( + val source: CameraSource +) : CameraControlMap { + + @Suppress("UNCHECKED_CAST") + override fun get(classType: Class) = + when(classType) { + ExposureControl::class.java -> CameraSourceExposureControl(source) as T + else -> null + } + +} \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/input/control/CameraSourceExposureControl.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/input/control/CameraSourceExposureControl.kt new file mode 100644 index 00000000..91433cf0 --- /dev/null +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/input/control/CameraSourceExposureControl.kt @@ -0,0 +1,65 @@ +package io.github.deltacv.eocvsim.input.control + +import com.github.serivesmejia.eocvsim.input.source.CameraSource +import io.github.deltacv.steve.WebcamProperty +import org.firstinspires.ftc.robotcore.external.hardware.camera.controls.ExposureControl +import org.firstinspires.ftc.robotcore.internal.collections.MutableReference +import java.util.concurrent.TimeUnit + +class CameraSourceExposureControl( + cameraSource: CameraSource +) : ExposureControl { + + val control = cameraSource.webcamPropertyControl + + override fun getMode() = if(control.getPropertyAuto(WebcamProperty.EXPOSURE)) { + ExposureControl.Mode.Auto + } else ExposureControl.Mode.Manual + + override fun setMode(mode: ExposureControl.Mode): Boolean { + control.setPropertyAuto(WebcamProperty.EXPOSURE, mode == ExposureControl.Mode.Auto) + return control.getPropertyAuto(WebcamProperty.EXPOSURE) + } + + override fun isModeSupported(mode: ExposureControl.Mode) = + mode == ExposureControl.Mode.Auto || mode == ExposureControl.Mode.Manual + + override fun getMinExposure(resultUnit: TimeUnit): Long { + val bounds = control.getPropertyBounds(WebcamProperty.EXPOSURE) + return TimeUnit.SECONDS.convert(bounds.min.toLong(), resultUnit) + } + + override fun getMaxExposure(resultUnit: TimeUnit): Long { + val bounds = control.getPropertyBounds(WebcamProperty.EXPOSURE) + return TimeUnit.SECONDS.convert(bounds.max.toLong(), resultUnit) + } + + override fun getExposure(resultUnit: TimeUnit): Long { + return TimeUnit.SECONDS.convert(control.getProperty(WebcamProperty.EXPOSURE).toLong(), resultUnit) + } + + override fun getCachedExposure( + resultUnit: TimeUnit, + refreshed: MutableReference, + permittedStaleness: Long, + permittedStalenessUnit: TimeUnit + ) = getExposure(resultUnit) + + override fun setExposure(duration: Long, durationUnit: TimeUnit): Boolean { + val seconds = TimeUnit.SECONDS.convert(duration, durationUnit).toInt() + + control.setProperty(WebcamProperty.EXPOSURE, seconds) + return control.getProperty(WebcamProperty.EXPOSURE) == seconds + } + + override fun isExposureSupported() = control.isPropertySupported(WebcamProperty.EXPOSURE) + + override fun getAePriority(): Boolean { + throw UnsupportedOperationException("AE priority is not supported by EOCV-Sim") + } + + override fun setAePriority(priority: Boolean): Boolean { + throw UnsupportedOperationException("AE priority is not supported by EOCV-Sim") + } + +} \ No newline at end of file diff --git a/Vision/src/main/java/io/github/deltacv/vision/external/FrameReceiverOpenCvCamera.java b/Vision/src/main/java/io/github/deltacv/vision/external/FrameReceiverOpenCvCamera.java index 5d37e5f7..91199b07 100644 --- a/Vision/src/main/java/io/github/deltacv/vision/external/FrameReceiverOpenCvCamera.java +++ b/Vision/src/main/java/io/github/deltacv/vision/external/FrameReceiverOpenCvCamera.java @@ -1,174 +1,221 @@ -/* - * Copyright (c) 2023 OpenFTC & EOCV-Sim implementation by Sebastian Erives - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -package io.github.deltacv.vision.external; - -import io.github.deltacv.vision.external.source.VisionSource; -import io.github.deltacv.vision.external.source.FrameReceiver; -import org.opencv.core.Core; -import org.opencv.core.Mat; -import org.opencv.core.Size; -import org.openftc.easyopencv.*; - -public class FrameReceiverOpenCvCamera extends OpenCvCameraBase implements OpenCvWebcam, FrameReceiver { - - private final VisionSource source; - OpenCvViewport handedViewport; - - boolean streaming = false; - - public FrameReceiverOpenCvCamera(VisionSource source, OpenCvViewport handedViewport, boolean viewportEnabled) { - super(handedViewport, viewportEnabled); - - this.source = source; - this.handedViewport = handedViewport; - } - - @Override - public int openCameraDevice() { - prepareForOpenCameraDevice(); - - return source.init(); - } - - @Override - public void openCameraDeviceAsync(AsyncCameraOpenListener cameraOpenListener) { - new Thread(() -> { - int code = openCameraDevice(); - - if(code == 0) { - cameraOpenListener.onOpened(); - } else { - cameraOpenListener.onError(code); - } - }).start(); - } - - @Override - public void closeCameraDevice() { - synchronized (source) { - source.close(); - } - } - - @Override - public void closeCameraDeviceAsync(AsyncCameraCloseListener cameraCloseListener) { - new Thread(() -> { - closeCameraDevice(); - cameraCloseListener.onClose(); - }).start(); - } - - @Override - public void startStreaming(int width, int height) { - startStreaming(width, height, getDefaultRotation()); - } - - @Override - public void startStreaming(int width, int height, OpenCvCameraRotation rotation) { - prepareForStartStreaming(width, height, rotation); - - synchronized (source) { - source.start(new Size(width, height)); - source.attach(this); - } - - streaming = true; - } - - @Override - public void stopStreaming() { - source.stop(); - cleanupForEndStreaming(); - } - - @Override - protected OpenCvCameraRotation getDefaultRotation() { - return OpenCvCameraRotation.UPRIGHT; - } - - @Override - protected int mapRotationEnumToOpenCvRotateCode(OpenCvCameraRotation rotation) - { - /* - * The camera sensor in a webcam is mounted in the logical manner, such - * that the raw image is upright when the webcam is used in its "normal" - * orientation. However, if the user is using it in any other orientation, - * we need to manually rotate the image. - */ - - if(rotation == OpenCvCameraRotation.SENSOR_NATIVE) - { - return -1; - } - else if(rotation == OpenCvCameraRotation.SIDEWAYS_LEFT) - { - return Core.ROTATE_90_COUNTERCLOCKWISE; - } - else if(rotation == OpenCvCameraRotation.SIDEWAYS_RIGHT) - { - return Core.ROTATE_90_CLOCKWISE; - } - else if(rotation == OpenCvCameraRotation.UPSIDE_DOWN) - { - return Core.ROTATE_180; - } - else - { - return -1; - } - } - - @Override - protected boolean cameraOrientationIsTiedToDeviceOrientation() { - return false; - } - - @Override - protected boolean isStreaming() { - return streaming; - } - - @Override - public void onFrameStart() { - if(!isStreaming()) return; - - notifyStartOfFrameProcessing(); - } - - // - // Inheritance from Sourced - // - @Override - public void onNewFrame(Mat frame, long timestamp) { - if(!isStreaming()) return; - if(frame == null || frame.empty()) return; - - handleFrameUserCrashable(frame, timestamp); - } - - @Override - public void stop() { - closeCameraDevice(); - } +/* + * Copyright (c) 2023 OpenFTC & EOCV-Sim implementation by Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package io.github.deltacv.vision.external; + +import io.github.deltacv.vision.external.source.VisionSource; +import io.github.deltacv.vision.external.source.FrameReceiver; +import org.firstinspires.ftc.robotcore.external.hardware.camera.CameraControls; +import org.firstinspires.ftc.robotcore.external.hardware.camera.controls.*; +import org.firstinspires.ftc.robotcore.internal.camera.calibration.CameraCalibrationIdentity; +import org.opencv.core.Core; +import org.opencv.core.Mat; +import org.opencv.core.Size; +import org.openftc.easyopencv.*; + +public class FrameReceiverOpenCvCamera extends OpenCvCameraBase implements OpenCvWebcam, FrameReceiver { + + private final VisionSource source; + OpenCvViewport handedViewport; + + boolean streaming = false; + + public FrameReceiverOpenCvCamera(VisionSource source, OpenCvViewport handedViewport, boolean viewportEnabled) { + super(handedViewport, viewportEnabled); + + this.source = source; + this.handedViewport = handedViewport; + } + + @Override + public int openCameraDevice() { + prepareForOpenCameraDevice(); + + return source.init(); + } + + @Override + public void openCameraDeviceAsync(AsyncCameraOpenListener cameraOpenListener) { + new Thread(() -> { + int code = openCameraDevice(); + + if(code == 0) { + cameraOpenListener.onOpened(); + } else { + cameraOpenListener.onError(code); + } + }).start(); + } + + @Override + public void closeCameraDevice() { + synchronized (source) { + source.close(); + } + } + + @Override + public void closeCameraDeviceAsync(AsyncCameraCloseListener cameraCloseListener) { + new Thread(() -> { + closeCameraDevice(); + cameraCloseListener.onClose(); + }).start(); + } + + @Override + public void startStreaming(int width, int height) { + startStreaming(width, height, getDefaultRotation()); + } + + @Override + public void startStreaming(int width, int height, OpenCvCameraRotation rotation) { + prepareForStartStreaming(width, height, rotation); + + synchronized (source) { + source.start(new Size(width, height)); + source.attach(this); + } + + streaming = true; + } + + @Override + public void stopStreaming() { + source.stop(); + cleanupForEndStreaming(); + } + + + @Override + protected OpenCvCameraRotation getDefaultRotation() { + return OpenCvCameraRotation.UPRIGHT; + } + + @Override + protected int mapRotationEnumToOpenCvRotateCode(OpenCvCameraRotation rotation) + { + /* + * The camera sensor in a webcam is mounted in the logical manner, such + * that the raw image is upright when the webcam is used in its "normal" + * orientation. However, if the user is using it in any other orientation, + * we need to manually rotate the image. + */ + + if(rotation == OpenCvCameraRotation.SENSOR_NATIVE) + { + return -1; + } + else if(rotation == OpenCvCameraRotation.SIDEWAYS_LEFT) + { + return Core.ROTATE_90_COUNTERCLOCKWISE; + } + else if(rotation == OpenCvCameraRotation.SIDEWAYS_RIGHT) + { + return Core.ROTATE_90_CLOCKWISE; + } + else if(rotation == OpenCvCameraRotation.UPSIDE_DOWN) + { + return Core.ROTATE_180; + } + else + { + return -1; + } + } + + @Override + protected boolean cameraOrientationIsTiedToDeviceOrientation() { + return false; + } + + @Override + protected boolean isStreaming() { + return streaming; + } + + @Override + public void onFrameStart() { + if(!isStreaming()) return; + + notifyStartOfFrameProcessing(); + } + + @Override + public void setMillisecondsPermissionTimeout(int ms) { } + + @Override + public void startStreaming(int width, int height, OpenCvCameraRotation rotation, StreamFormat streamFormat) { + startStreaming(width, height, rotation); + } + + @Override + public ExposureControl getExposureControl() { + return getControl(ExposureControl.class); + } + + @Override + public FocusControl getFocusControl() { + return getControl(FocusControl.class); + } + + @Override + public PtzControl getPtzControl() { + return getControl(PtzControl.class); + } + + @Override + public GainControl getGainControl() { + return getControl(GainControl.class); + } + + @Override + public WhiteBalanceControl getWhiteBalanceControl() { + return getControl(WhiteBalanceControl.class); + } + + @Override + public T getControl(Class controlType) { + return source.getControlMap().get(controlType); + } + + @Override + public CameraCalibrationIdentity getCalibrationIdentity() { + return null; // TODO + } + + // + // Inheritance from Sourced + // + @Override + public void onNewFrame(Mat frame, long timestamp) { + if(!isStreaming()) return; + if(frame == null || frame.empty()) return; + + handleFrameUserCrashable(frame, timestamp); + } + + @Override + public void stop() { + closeCameraDevice(); + } } \ No newline at end of file diff --git a/Vision/src/main/java/io/github/deltacv/vision/external/source/CameraControlMap.java b/Vision/src/main/java/io/github/deltacv/vision/external/source/CameraControlMap.java new file mode 100644 index 00000000..b343037b --- /dev/null +++ b/Vision/src/main/java/io/github/deltacv/vision/external/source/CameraControlMap.java @@ -0,0 +1,9 @@ +package io.github.deltacv.vision.external.source; + +import org.firstinspires.ftc.robotcore.external.hardware.camera.controls.CameraControl; + +public interface CameraControlMap { + + T get(Class classType); + +} diff --git a/Vision/src/main/java/io/github/deltacv/vision/external/source/VisionSource.java b/Vision/src/main/java/io/github/deltacv/vision/external/source/VisionSource.java index 5efe9d0e..e7a8afea 100644 --- a/Vision/src/main/java/io/github/deltacv/vision/external/source/VisionSource.java +++ b/Vision/src/main/java/io/github/deltacv/vision/external/source/VisionSource.java @@ -1,41 +1,43 @@ -/* - * Copyright (c) 2023 Sebastian Erives - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -package io.github.deltacv.vision.external.source; - -import org.opencv.core.Size; - -public interface VisionSource { - - int init(); - - boolean start(Size requestedSize); - - boolean attach(FrameReceiver sourced); - boolean remove(FrameReceiver sourced); - - boolean stop(); - - boolean close(); - -} +/* + * Copyright (c) 2023 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package io.github.deltacv.vision.external.source; + +import org.opencv.core.Size; + +public interface VisionSource { + + int init(); + + CameraControlMap getControlMap(); + + boolean start(Size requestedSize); + + boolean attach(FrameReceiver sourced); + boolean remove(FrameReceiver sourced); + + boolean stop(); + + boolean close(); + +} diff --git a/Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/ExposureControl.java b/Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/ExposureControl.java new file mode 100644 index 00000000..da28eb73 --- /dev/null +++ b/Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/ExposureControl.java @@ -0,0 +1,142 @@ +/* +Copyright (c) 2018 Robert Atkinson + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted (subject to the limitations in the disclaimer below) provided that +the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list +of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the name of Robert Atkinson nor the names of his contributors may be used to +endorse or promote products derived from this software without specific prior +written permission. + +NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY THIS +LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ +package org.firstinspires.ftc.robotcore.external.hardware.camera.controls; + +import org.firstinspires.ftc.robotcore.internal.collections.MutableReference; + +import java.util.concurrent.TimeUnit; + +public interface ExposureControl extends CameraControl +{ + //---------------------------------------------------------------------------------------------- + // Focus mode + //---------------------------------------------------------------------------------------------- + + enum Mode + { + Unknown, + Auto, // single trigger auto exposure + ContinuousAuto, // continuous auto exposure + Manual, + ShutterPriority, + AperturePriority; + + public static Mode fromId(int id) + { + if (id >= 0 && id < values().length) + { + return values()[id]; + } + return Unknown; + } + } + + /** + * Get the current exposure mode of the camera + * @return the current exposure mode of the camera + */ + Mode getMode(); + + /** + * Set the exposure mode of the camera + * @param mode the mode to enter + * @return whether the operation was successful + */ + boolean setMode(Mode mode); + + /** + * Check whether the camera supports a given exposure mode + * @param mode the mode in question + * @return whether the mode in question is supported + */ + boolean isModeSupported(Mode mode); + + //---------------------------------------------------------------------------------------------- + // Exposure duration + //---------------------------------------------------------------------------------------------- + + long unknownExposure = 0; + + /** + * Get the shortest exposure time supported by the camera + * @param resultUnit time units of your choosing + * @return the shortest supported exposure time, or 0 if unavailable + */ + long getMinExposure(TimeUnit resultUnit); + + /** + * Get the longest exposure time supported by the camera + * @param resultUnit time units of your choosing + * @return the longest supported exposure time, or 0 if unavailable + */ + long getMaxExposure(TimeUnit resultUnit); + + /** + * Get the camera's current exposure time + * @param resultUnit time units of your choosing + * @return the camera's current exposure time, or 0 if unavailable + */ + long getExposure(TimeUnit resultUnit); + + /** unknownExposure is returned if exposure unavailable */ + @Deprecated + long getCachedExposure(final TimeUnit resultUnit, MutableReference refreshed, final long permittedStaleness, final TimeUnit permittedStalenessUnit); + + /** + * Set the camera's exposure time. Only works if you're in manual mode. + * @param duration exposure time + * @param durationUnit time units of your choice + * @return whether the operation succeeded + */ + boolean setExposure(long duration, TimeUnit durationUnit); + + /** + * Check whether your camera supports control over exposure + * @return whether your camera supports control over exposure + */ + boolean isExposureSupported(); + + /** + * Check whether AE priority is enabled + * @return whether AE priority is enabled + */ + boolean getAePriority(); + + /** + * Chooses whether the camera may vary the frame rate for exposure control reasons. + * A priority value of false means the camera may not vary its frame rate. A value of true means the frame rate is variable + * This setting has no effect outside of the auto and shutter_priority auto-exposure modes. + * @return whether the operation was successful + */ + boolean setAePriority(boolean priority); +} diff --git a/Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/FocusControl.java b/Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/FocusControl.java new file mode 100644 index 00000000..87c56189 --- /dev/null +++ b/Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/FocusControl.java @@ -0,0 +1,117 @@ +/* +Copyright (c) 2018 Robert Atkinson + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted (subject to the limitations in the disclaimer below) provided that +the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list +of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the name of Robert Atkinson nor the names of his contributors may be used to +endorse or promote products derived from this software without specific prior +written permission. + +NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY THIS +LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ +package org.firstinspires.ftc.robotcore.external.hardware.camera.controls; + +public interface FocusControl extends CameraControl +{ + //---------------------------------------------------------------------------------------------- + // Focus mode + //---------------------------------------------------------------------------------------------- + + enum Mode + { + Unknown, + Auto, // single trigger auto focus + ContinuousAuto, // continuous auto focus + Macro, // + Infinity, // + Fixed; // Fixed focus that can't be adjusted + + public static FocusControl.Mode fromId(int index) + { + if (index >= 0 && index < values().length) + { + return values()[index]; + } + return Unknown; + } + } + + /** + * Get the current focus mode of the camera + * @return the current focus mode of the camera + */ + Mode getMode(); + + + /** + * Set the focus mode of the camera + * @param mode the mode to enter + * @return whether the operation was successful + */ + boolean setMode(Mode mode); + + /** + * Check whether the camera supports a given focus mode + * @param mode the mode in question + * @return whether the mode in question is supported + */ + boolean isModeSupported(Mode mode); + + //---------------------------------------------------------------------------------------------- + // Focus length: units are in mm + //---------------------------------------------------------------------------------------------- + + double unknownFocusLength = -1.0; + + /** + * Get the minimum focal distance supported by the camera + * @return the minimum focal distance supported, or 0 if unavailable + */ + double getMinFocusLength(); + + /** + * Get the maximum focal distance supported by the camera + * @return the maximum focal distance supported, or 0 if unavailable + */ + double getMaxFocusLength(); + + /** + * Get the camera's current focus length + * @return the camera's current focus length, or 0 if unavailable + */ + double getFocusLength(); + + /** + * Set the camera's focus length. Only works if you're in fixed mode. + * @param focusLength the desired focus length + * @return whether the operation succeeded + */ + boolean setFocusLength(double focusLength); + + /** + * Check whether your camera supports control over focus + * @return whether your camera supports control over focus + */ + boolean isFocusLengthSupported(); +} diff --git a/Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/GainControl.java b/Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/GainControl.java new file mode 100644 index 00000000..2d0b08d3 --- /dev/null +++ b/Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/GainControl.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2020 Michael Hoogasian + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted (subject to the limitations in the disclaimer below) provided that + * the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this list + * of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, this + * list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * + * Neither the name of Michael Hoogasian nor the names of his contributors may be used to + * endorse or promote products derived from this software without specific prior + * written permission. + * + * NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY THIS + * LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR + * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF + * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package org.firstinspires.ftc.robotcore.external.hardware.camera.controls; + +public interface GainControl extends CameraControl +{ + /*** + * Get the minimum supported gain for this camera + * + * @return the minimum supported gain for this camera + */ + int getMinGain(); + + /*** + * Get the maximum supported gain for this camera + * + * @return the maximum supported gain for this camera + */ + int getMaxGain(); + + /*** + * Get the current gain of this camera + * + * @return the current gain of this camera + */ + int getGain(); + + /*** + * Set the gain for this camera. + * Gain can be adjusted only if ExposureControl Mode is set to MANUAL (typically not the default) + * Further, gain will be actively managed if in AUTO exposure mode. + * + * @param gain the gain to set + * @return whether the operation was successful or not + */ + boolean setGain(int gain); +} diff --git a/Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/PtzControl.java b/Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/PtzControl.java new file mode 100644 index 00000000..d8da2cdc --- /dev/null +++ b/Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/PtzControl.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2020 Michael Hoogasian + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted (subject to the limitations in the disclaimer below) provided that + * the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this list + * of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, this + * list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * + * Neither the name of Michael Hoogasian nor the names of his contributors may be used to + * endorse or promote products derived from this software without specific prior + * written permission. + * + * NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY THIS + * LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR + * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF + * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + + +package org.firstinspires.ftc.robotcore.external.hardware.camera.controls; + +public interface PtzControl extends CameraControl +{ + class PanTiltHolder + { + public int pan; + public int tilt; + } + + /** + * Gets the current virtual pan/tilt of the camera + * + * @return the current virtual pan/tilt of the camera + */ + PanTiltHolder getPanTilt(); + + /** + * Set's the camera's virtual pan/tilt + * + * @param panTiltHolder the virtual pan/tilt the camera should assume + * @return whether the operation was successful + */ + boolean setPanTilt(PanTiltHolder panTiltHolder); + + /** + * Gets the minimum virtual pan/tilt values of the camera + * + * @return the minimum virtual pan/tilt values of the camera + */ + PanTiltHolder getMinPanTilt(); + + /** + * Gets the maximum virtual pan/tilt values of the camera + * + * @return the maximum virtual pan/tilt values of the camera + */ + PanTiltHolder getMaxPanTilt(); + + /** + * Gets the current virtual zoom level of the camera + * + * @return the current virtual zoom level of the camera + */ + int getZoom(); + + /** + * Sets the camera's virtual zoom level + * + * @param zoom the zoom level the camera should assume + * @return whether the operation was successful + */ + boolean setZoom(int zoom); + + /** + * Gets the minimum virtual zoom level of the camera + * + * @return the minimum virtual zoom level of the camera + */ + int getMinZoom(); + + /** + * Gets the maximum virtual zoom level of the camera + * + * @return the maximum virtual zoom level of the camera + */ + int getMaxZoom(); +} \ No newline at end of file diff --git a/Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/WhiteBalanceControl.java b/Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/WhiteBalanceControl.java new file mode 100644 index 00000000..565a7210 --- /dev/null +++ b/Vision/src/main/java/org/firstinspires/ftc/robotcore/external/hardware/camera/controls/WhiteBalanceControl.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2021 Michael Hoogasian + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted (subject to the limitations in the disclaimer below) provided that + * the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this list + * of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, this + * list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * + * Neither the name of Michael Hoogasian nor the names of his contributors may be used to + * endorse or promote products derived from this software without specific prior + * written permission. + * + * NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY THIS + * LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR + * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF + * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package org.firstinspires.ftc.robotcore.external.hardware.camera.controls; + +public interface WhiteBalanceControl extends CameraControl +{ + enum Mode /* If you change this order, remember to update jni_devicehandle.cpp! */ + { + UNKNOWN, + AUTO, + MANUAL + } + + /** + * Get the current white balance mode of the camera + * @return the current white balance mode of the camera + */ + Mode getMode(); + + /** + * Set the white balance mode for the camera + * @param mode the mode to enter + * @return whether the operation was successful + */ + boolean setMode(Mode mode); + + /*** + * Get the minimum white balance temperature for this camera + * + * @return min white balance temp, in degrees Kelvin + */ + int getMinWhiteBalanceTemperature(); + + /*** + * Get the maximum white balance temperature for this camera + * + * @return max white balance temp, in degrees Kelvin + */ + int getMaxWhiteBalanceTemperature(); + + /*** + * Get the current white balance temperature for this camera + * + * @return current white balance temp, in degrees Kelvin + */ + int getWhiteBalanceTemperature(); + + /*** + * Set the white balance temperature for this camera + * + * @param temperature temperature to set, in degrees Kelvin + * @return whether the operation was successful + */ + boolean setWhiteBalanceTemperature(int temperature); +} diff --git a/Vision/src/main/java/org/firstinspires/ftc/vision/VisionPortalImpl.java b/Vision/src/main/java/org/firstinspires/ftc/vision/VisionPortalImpl.java index a0455ccd..ce1275e7 100644 --- a/Vision/src/main/java/org/firstinspires/ftc/vision/VisionPortalImpl.java +++ b/Vision/src/main/java/org/firstinspires/ftc/vision/VisionPortalImpl.java @@ -1,412 +1,419 @@ -/* - * Copyright (c) 2023 FIRST - * - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, - * are permitted (subject to the limitations in the disclaimer below) provided that - * the following conditions are met: - * - * Redistributions of source code must retain the above copyright notice, this list - * of conditions and the following disclaimer. - * - * Redistributions in binary form must reproduce the above copyright notice, this - * list of conditions and the following disclaimer in the documentation and/or - * other materials provided with the distribution. - * - * Neither the name of FIRST nor the names of its contributors may be used to - * endorse or promote products derived from this software without specific prior - * written permission. - * - * NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY THIS - * LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, - * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE - * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER - * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR - * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF - * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package org.firstinspires.ftc.vision; - -import android.graphics.Canvas; -import android.util.Size; - -import com.qualcomm.robotcore.util.RobotLog; - -import io.github.deltacv.vision.internal.source.ftc.SourcedCameraName; -import org.firstinspires.ftc.robotcore.external.hardware.camera.CameraName; -import org.firstinspires.ftc.robotcore.external.hardware.camera.WebcamName; -import org.firstinspires.ftc.robotcore.external.hardware.camera.controls.CameraControl; -import org.firstinspires.ftc.robotcore.internal.camera.calibration.CameraCalibration; -import org.opencv.core.Mat; -import org.openftc.easyopencv.OpenCvCamera; -import org.openftc.easyopencv.OpenCvCameraFactory; -import org.openftc.easyopencv.OpenCvCameraRotation; -import org.openftc.easyopencv.OpenCvWebcam; -import org.openftc.easyopencv.TimestampedOpenCvPipeline; - -public class VisionPortalImpl extends VisionPortal -{ - protected OpenCvCamera camera; - protected volatile CameraState cameraState = CameraState.CAMERA_DEVICE_CLOSED; - protected VisionProcessor[] processors; - protected volatile boolean[] processorsEnabled; - protected volatile CameraCalibration calibration; - protected final boolean autoPauseCameraMonitor; - protected final Object userStateMtx = new Object(); - protected final Size cameraResolution; - protected final StreamFormat webcamStreamFormat; - protected static final OpenCvCameraRotation CAMERA_ROTATION = OpenCvCameraRotation.SENSOR_NATIVE; - protected String captureNextFrame; - protected final Object captureFrameMtx = new Object(); - - public VisionPortalImpl(CameraName camera, int cameraMonitorViewId, boolean autoPauseCameraMonitor, Size cameraResolution, StreamFormat webcamStreamFormat, VisionProcessor[] processors) - { - this.processors = processors; - this.cameraResolution = cameraResolution; - this.webcamStreamFormat = webcamStreamFormat; - processorsEnabled = new boolean[processors.length]; - - for (int i = 0; i < processors.length; i++) - { - processorsEnabled[i] = true; - } - - this.autoPauseCameraMonitor = autoPauseCameraMonitor; - - createCamera(camera, cameraMonitorViewId); - startCamera(); - } - - protected void startCamera() - { - if (camera == null) - { - throw new IllegalStateException("This should never happen"); - } - - if (cameraResolution == null) // was the user a silly silly - { - throw new IllegalArgumentException("parameters.cameraResolution == null"); - } - - camera.setViewportRenderer(OpenCvCamera.ViewportRenderer.NATIVE_VIEW); - - if(!(camera instanceof OpenCvWebcam)) - { - camera.setViewportRenderingPolicy(OpenCvCamera.ViewportRenderingPolicy.OPTIMIZE_VIEW); - } - - cameraState = CameraState.OPENING_CAMERA_DEVICE; - camera.openCameraDeviceAsync(new OpenCvCamera.AsyncCameraOpenListener() - { - @Override - public void onOpened() - { - cameraState = CameraState.CAMERA_DEVICE_READY; - cameraState = CameraState.STARTING_STREAM; - - camera.startStreaming(cameraResolution.getWidth(), cameraResolution.getHeight(), CAMERA_ROTATION); - - camera.setPipeline(new ProcessingPipeline()); - cameraState = CameraState.STREAMING; - } - - @Override - public void onError(int errorCode) - { - cameraState = CameraState.ERROR; - RobotLog.ee("VisionPortalImpl", "Camera opening failed."); - } - }); - } - - protected void createCamera(CameraName cameraName, int cameraMonitorViewId) - { - if (cameraName == null) // was the user a silly silly - { - throw new IllegalArgumentException("parameters.camera == null"); - } - else if (cameraName instanceof SourcedCameraName) // Webcams - { - camera = OpenCvCameraFactory.getInstance().createWebcam((WebcamName) cameraName, cameraMonitorViewId); - } - else // ¯\_(ツ)_/¯ - { - throw new IllegalArgumentException("Unknown camera name"); - } - } - - @Override - public void setProcessorEnabled(VisionProcessor processor, boolean enabled) - { - int numProcessorsEnabled = 0; - boolean ok = false; - - for (int i = 0; i < processors.length; i++) - { - if (processor == processors[i]) - { - processorsEnabled[i] = enabled; - ok = true; - } - - if (processorsEnabled[i]) - { - numProcessorsEnabled++; - } - } - - if (ok) - { - if (autoPauseCameraMonitor) - { - if (numProcessorsEnabled == 0) - { - camera.pauseViewport(); - } - else - { - camera.resumeViewport(); - } - } - } - else - { - throw new IllegalArgumentException("Processor not attached to this helper!"); - } - } - - @Override - public boolean getProcessorEnabled(VisionProcessor processor) - { - for (int i = 0; i < processors.length; i++) - { - if (processor == processors[i]) - { - return processorsEnabled[i]; - } - } - - throw new IllegalArgumentException("Processor not attached to this helper!"); - } - - @Override - public CameraState getCameraState() - { - return cameraState; - } - - @Override - public void setActiveCamera(WebcamName webcamName) - { - throw new UnsupportedOperationException("setActiveCamera is only supported for switchable webcams"); - } - - @Override - public WebcamName getActiveCamera() - { - throw new UnsupportedOperationException("getActiveCamera is only supported for switchable webcams"); - } - - @Override - public T getCameraControl(Class controlType) - { - if (cameraState == CameraState.STREAMING) - { - throw new UnsupportedOperationException("Getting controls is not yet supported in EOCV-Sim"); - } - else - { - throw new IllegalStateException("You cannot use camera controls until the camera is streaming"); - } - } - - class ProcessingPipeline extends TimestampedOpenCvPipeline - { - @Override - public void init(Mat firstFrame) - { - for (VisionProcessor processor : processors) - { - processor.init(firstFrame.width(), firstFrame.height(), calibration); - } - } - - @Override - public Mat processFrame(Mat input, long captureTimeNanos) - { - synchronized (captureFrameMtx) - { - if (captureNextFrame != null) - { - // saveMatToDiskFullPath(input, "/sdcard/VisionPortal-" + captureNextFrame + ".png"); - } - - captureNextFrame = null; - } - - Object[] processorDrawCtxes = new Object[processors.length]; // cannot re-use frome to frame - - for (int i = 0; i < processors.length; i++) - { - if (processorsEnabled[i]) - { - processorDrawCtxes[i] = processors[i].processFrame(input, captureTimeNanos); - } - } - - requestViewportDrawHook(processorDrawCtxes); - - return input; - } - - @Override - public void onDrawFrame(Canvas canvas, int onscreenWidth, int onscreenHeight, float scaleBmpPxToCanvasPx, float scaleCanvasDensity, Object userContext) - { - Object[] ctx = (Object[]) userContext; - - for (int i = 0; i < processors.length; i++) - { - if (processorsEnabled[i]) - { - processors[i].onDrawFrame(canvas, onscreenWidth, onscreenHeight, scaleBmpPxToCanvasPx, scaleCanvasDensity, ctx[i]); - } - } - } - } - - @Override - public void saveNextFrameRaw(String filepath) - { - synchronized (captureFrameMtx) - { - captureNextFrame = filepath; - } - } - - @Override - public void stopStreaming() - { - synchronized (userStateMtx) - { - if (cameraState == CameraState.STREAMING || cameraState == CameraState.STARTING_STREAM) - { - cameraState = CameraState.STOPPING_STREAM; - new Thread(() -> - { - synchronized (userStateMtx) - { - camera.stopStreaming(); - cameraState = CameraState.CAMERA_DEVICE_READY; - } - }).start(); - } - else if (cameraState == CameraState.STOPPING_STREAM - || cameraState == CameraState.CAMERA_DEVICE_READY - || cameraState == CameraState.CLOSING_CAMERA_DEVICE) - { - // be idempotent - } - else - { - throw new RuntimeException("Illegal CameraState when calling stopStreaming()"); - } - } - } - - @Override - public void resumeStreaming() - { - synchronized (userStateMtx) - { - if (cameraState == CameraState.CAMERA_DEVICE_READY || cameraState == CameraState.STOPPING_STREAM) - { - cameraState = CameraState.STARTING_STREAM; - new Thread(() -> - { - synchronized (userStateMtx) - { - if (camera instanceof OpenCvWebcam) - { - ((OpenCvWebcam)camera).startStreaming(cameraResolution.getWidth(), cameraResolution.getHeight(), CAMERA_ROTATION, webcamStreamFormat.eocvStreamFormat); - } - else - { - camera.startStreaming(cameraResolution.getWidth(), cameraResolution.getHeight(), CAMERA_ROTATION); - } - cameraState = CameraState.STREAMING; - } - }).start(); - } - else if (cameraState == CameraState.STREAMING - || cameraState == CameraState.STARTING_STREAM - || cameraState == CameraState.OPENING_CAMERA_DEVICE) // we start streaming automatically after we open - { - // be idempotent - } - else - { - throw new RuntimeException("Illegal CameraState when calling stopStreaming()"); - } - } - } - - @Override - public void stopLiveView() - { - OpenCvCamera cameraSafe = camera; - - if (cameraSafe != null) - { - camera.pauseViewport(); - } - } - - @Override - public void resumeLiveView() - { - OpenCvCamera cameraSafe = camera; - - if (cameraSafe != null) - { - camera.resumeViewport(); - } - } - - @Override - public float getFps() - { - OpenCvCamera cameraSafe = camera; - - if (cameraSafe != null) - { - return cameraSafe.getFps(); - } - else - { - return 0; - } - } - - @Override - public void close() - { - synchronized (userStateMtx) - { - cameraState = CameraState.CLOSING_CAMERA_DEVICE; - - if (camera != null) - { - camera.closeCameraDeviceAsync(() -> cameraState = CameraState.CAMERA_DEVICE_CLOSED); - } - - camera = null; - } - } +/* + * Copyright (c) 2023 FIRST + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted (subject to the limitations in the disclaimer below) provided that + * the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this list + * of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, this + * list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * + * Neither the name of FIRST nor the names of its contributors may be used to + * endorse or promote products derived from this software without specific prior + * written permission. + * + * NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY THIS + * LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR + * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF + * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package org.firstinspires.ftc.vision; + +import android.graphics.Canvas; +import android.util.Size; + +import com.qualcomm.robotcore.util.RobotLog; + +import io.github.deltacv.vision.internal.source.ftc.SourcedCameraName; +import org.firstinspires.ftc.robotcore.external.hardware.camera.CameraName; +import org.firstinspires.ftc.robotcore.external.hardware.camera.WebcamName; +import org.firstinspires.ftc.robotcore.external.hardware.camera.controls.CameraControl; +import org.firstinspires.ftc.robotcore.internal.camera.calibration.CameraCalibration; +import org.opencv.core.Mat; +import org.openftc.easyopencv.OpenCvCamera; +import org.openftc.easyopencv.OpenCvCameraFactory; +import org.openftc.easyopencv.OpenCvCameraRotation; +import org.openftc.easyopencv.OpenCvWebcam; +import org.openftc.easyopencv.TimestampedOpenCvPipeline; + +public class VisionPortalImpl extends VisionPortal +{ + protected OpenCvCamera camera; + protected volatile CameraState cameraState = CameraState.CAMERA_DEVICE_CLOSED; + protected VisionProcessor[] processors; + protected volatile boolean[] processorsEnabled; + protected volatile CameraCalibration calibration; + protected final boolean autoPauseCameraMonitor; + protected final Object userStateMtx = new Object(); + protected final Size cameraResolution; + protected final StreamFormat webcamStreamFormat; + protected static final OpenCvCameraRotation CAMERA_ROTATION = OpenCvCameraRotation.SENSOR_NATIVE; + protected String captureNextFrame; + protected final Object captureFrameMtx = new Object(); + + public VisionPortalImpl(CameraName camera, int cameraMonitorViewId, boolean autoPauseCameraMonitor, Size cameraResolution, StreamFormat webcamStreamFormat, VisionProcessor[] processors) + { + this.processors = processors; + this.cameraResolution = cameraResolution; + this.webcamStreamFormat = webcamStreamFormat; + processorsEnabled = new boolean[processors.length]; + + for (int i = 0; i < processors.length; i++) + { + processorsEnabled[i] = true; + } + + this.autoPauseCameraMonitor = autoPauseCameraMonitor; + + createCamera(camera, cameraMonitorViewId); + startCamera(); + } + + protected void startCamera() + { + if (camera == null) + { + throw new IllegalStateException("This should never happen"); + } + + if (cameraResolution == null) // was the user a silly silly + { + throw new IllegalArgumentException("parameters.cameraResolution == null"); + } + + camera.setViewportRenderer(OpenCvCamera.ViewportRenderer.NATIVE_VIEW); + + if(!(camera instanceof OpenCvWebcam)) + { + camera.setViewportRenderingPolicy(OpenCvCamera.ViewportRenderingPolicy.OPTIMIZE_VIEW); + } + + cameraState = CameraState.OPENING_CAMERA_DEVICE; + camera.openCameraDeviceAsync(new OpenCvCamera.AsyncCameraOpenListener() + { + @Override + public void onOpened() + { + cameraState = CameraState.CAMERA_DEVICE_READY; + cameraState = CameraState.STARTING_STREAM; + + camera.startStreaming(cameraResolution.getWidth(), cameraResolution.getHeight(), CAMERA_ROTATION); + + camera.setPipeline(new ProcessingPipeline()); + cameraState = CameraState.STREAMING; + } + + @Override + public void onError(int errorCode) + { + cameraState = CameraState.ERROR; + RobotLog.ee("VisionPortalImpl", "Camera opening failed."); + } + }); + } + + protected void createCamera(CameraName cameraName, int cameraMonitorViewId) + { + if (cameraName == null) // was the user a silly silly + { + throw new IllegalArgumentException("parameters.camera == null"); + } + else if (cameraName instanceof SourcedCameraName) // Webcams + { + camera = OpenCvCameraFactory.getInstance().createWebcam((WebcamName) cameraName, cameraMonitorViewId); + } + else // ¯\_(ツ)_/¯ + { + throw new IllegalArgumentException("Unknown camera name"); + } + } + + @Override + public void setProcessorEnabled(VisionProcessor processor, boolean enabled) + { + int numProcessorsEnabled = 0; + boolean ok = false; + + for (int i = 0; i < processors.length; i++) + { + if (processor == processors[i]) + { + processorsEnabled[i] = enabled; + ok = true; + } + + if (processorsEnabled[i]) + { + numProcessorsEnabled++; + } + } + + if (ok) + { + if (autoPauseCameraMonitor) + { + if (numProcessorsEnabled == 0) + { + camera.pauseViewport(); + } + else + { + camera.resumeViewport(); + } + } + } + else + { + throw new IllegalArgumentException("Processor not attached to this helper!"); + } + } + + @Override + public boolean getProcessorEnabled(VisionProcessor processor) + { + for (int i = 0; i < processors.length; i++) + { + if (processor == processors[i]) + { + return processorsEnabled[i]; + } + } + + throw new IllegalArgumentException("Processor not attached to this helper!"); + } + + @Override + public CameraState getCameraState() + { + return cameraState; + } + + @Override + public void setActiveCamera(WebcamName webcamName) + { + throw new UnsupportedOperationException("setActiveCamera is only supported for switchable webcams"); + } + + @Override + public WebcamName getActiveCamera() + { + throw new UnsupportedOperationException("getActiveCamera is only supported for switchable webcams"); + } + + @Override + public T getCameraControl(Class controlType) + { + if (cameraState == CameraState.STREAMING) + { + if (camera instanceof OpenCvWebcam) + { + return ((OpenCvWebcam) camera).getControl(controlType); + } + else + { + throw new UnsupportedOperationException("Getting controls is only supported for webcams"); + } + } + else + { + throw new IllegalStateException("You cannot use camera controls until the camera is streaming"); + } + } + + class ProcessingPipeline extends TimestampedOpenCvPipeline + { + @Override + public void init(Mat firstFrame) + { + for (VisionProcessor processor : processors) + { + processor.init(firstFrame.width(), firstFrame.height(), calibration); + } + } + + @Override + public Mat processFrame(Mat input, long captureTimeNanos) + { + synchronized (captureFrameMtx) + { + if (captureNextFrame != null) + { + // saveMatToDiskFullPath(input, "/sdcard/VisionPortal-" + captureNextFrame + ".png"); + } + + captureNextFrame = null; + } + + Object[] processorDrawCtxes = new Object[processors.length]; // cannot re-use frome to frame + + for (int i = 0; i < processors.length; i++) + { + if (processorsEnabled[i]) + { + processorDrawCtxes[i] = processors[i].processFrame(input, captureTimeNanos); + } + } + + requestViewportDrawHook(processorDrawCtxes); + + return input; + } + + @Override + public void onDrawFrame(Canvas canvas, int onscreenWidth, int onscreenHeight, float scaleBmpPxToCanvasPx, float scaleCanvasDensity, Object userContext) + { + Object[] ctx = (Object[]) userContext; + + for (int i = 0; i < processors.length; i++) + { + if (processorsEnabled[i]) + { + processors[i].onDrawFrame(canvas, onscreenWidth, onscreenHeight, scaleBmpPxToCanvasPx, scaleCanvasDensity, ctx[i]); + } + } + } + } + + @Override + public void saveNextFrameRaw(String filepath) + { + synchronized (captureFrameMtx) + { + captureNextFrame = filepath; + } + } + + @Override + public void stopStreaming() + { + synchronized (userStateMtx) + { + if (cameraState == CameraState.STREAMING || cameraState == CameraState.STARTING_STREAM) + { + cameraState = CameraState.STOPPING_STREAM; + new Thread(() -> + { + synchronized (userStateMtx) + { + camera.stopStreaming(); + cameraState = CameraState.CAMERA_DEVICE_READY; + } + }).start(); + } + else if (cameraState == CameraState.STOPPING_STREAM + || cameraState == CameraState.CAMERA_DEVICE_READY + || cameraState == CameraState.CLOSING_CAMERA_DEVICE) + { + // be idempotent + } + else + { + throw new RuntimeException("Illegal CameraState when calling stopStreaming()"); + } + } + } + + @Override + public void resumeStreaming() + { + synchronized (userStateMtx) + { + if (cameraState == CameraState.CAMERA_DEVICE_READY || cameraState == CameraState.STOPPING_STREAM) + { + cameraState = CameraState.STARTING_STREAM; + new Thread(() -> + { + synchronized (userStateMtx) + { + if (camera instanceof OpenCvWebcam) + { + ((OpenCvWebcam)camera).startStreaming(cameraResolution.getWidth(), cameraResolution.getHeight(), CAMERA_ROTATION, webcamStreamFormat.eocvStreamFormat); + } + else + { + camera.startStreaming(cameraResolution.getWidth(), cameraResolution.getHeight(), CAMERA_ROTATION); + } + cameraState = CameraState.STREAMING; + } + }).start(); + } + else if (cameraState == CameraState.STREAMING + || cameraState == CameraState.STARTING_STREAM + || cameraState == CameraState.OPENING_CAMERA_DEVICE) // we start streaming automatically after we open + { + // be idempotent + } + else + { + throw new RuntimeException("Illegal CameraState when calling stopStreaming()"); + } + } + } + + @Override + public void stopLiveView() + { + OpenCvCamera cameraSafe = camera; + + if (cameraSafe != null) + { + camera.pauseViewport(); + } + } + + @Override + public void resumeLiveView() + { + OpenCvCamera cameraSafe = camera; + + if (cameraSafe != null) + { + camera.resumeViewport(); + } + } + + @Override + public float getFps() + { + OpenCvCamera cameraSafe = camera; + + if (cameraSafe != null) + { + return cameraSafe.getFps(); + } + else + { + return 0; + } + } + + @Override + public void close() + { + synchronized (userStateMtx) + { + cameraState = CameraState.CLOSING_CAMERA_DEVICE; + + if (camera != null) + { + camera.closeCameraDeviceAsync(() -> cameraState = CameraState.CAMERA_DEVICE_CLOSED); + } + + camera = null; + } + } } \ No newline at end of file diff --git a/Vision/src/main/java/org/openftc/easyopencv/OpenCvWebcam.java b/Vision/src/main/java/org/openftc/easyopencv/OpenCvWebcam.java index 924116cc..d4d73b01 100644 --- a/Vision/src/main/java/org/openftc/easyopencv/OpenCvWebcam.java +++ b/Vision/src/main/java/org/openftc/easyopencv/OpenCvWebcam.java @@ -1,44 +1,124 @@ -/* - * Copyright (c) 2020 OpenFTC Team - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package org.openftc.easyopencv; - -public interface OpenCvWebcam extends OpenCvCamera { - - default void startStreaming(int width, int height, OpenCvCameraRotation cameraRotation, StreamFormat eocvStreamFormat) { - startStreaming(width, height, cameraRotation); - } - - enum StreamFormat - { - // The only format that was supported historically; it is uncompressed but - // chroma subsampled and uses lots of bandwidth - this limits frame rate - // at higher resolutions and also limits the ability to use two cameras - // on the same bus to lower resolutions - YUY2, - - // Compressed motion JPEG stream format; allows for higher resolutions at - // full frame rate, and better ability to use two cameras on the same bus. - // Requires extra CPU time to run decompression routine. - MJPEG; - } - -} +/* + * Copyright (c) 2020 OpenFTC Team + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.openftc.easyopencv; + +import org.firstinspires.ftc.robotcore.external.hardware.camera.controls.CameraControl; +import org.firstinspires.ftc.robotcore.external.hardware.camera.controls.ExposureControl; +import org.firstinspires.ftc.robotcore.external.hardware.camera.controls.FocusControl; +import org.firstinspires.ftc.robotcore.external.hardware.camera.controls.GainControl; +import org.firstinspires.ftc.robotcore.external.hardware.camera.controls.PtzControl; +import org.firstinspires.ftc.robotcore.external.hardware.camera.controls.WhiteBalanceControl; +import org.firstinspires.ftc.robotcore.internal.camera.calibration.CameraCalibrationIdentity; + +public interface OpenCvWebcam extends OpenCvCamera +{ + /** + * Set how long to wait for permission to open the + * camera before giving up + * @param ms milliseconds to wait for + */ + void setMillisecondsPermissionTimeout(int ms); + + enum StreamFormat + { + // The only format that was supported historically; it is uncompressed but + // chroma subsampled and uses lots of bandwidth - this limits frame rate + // at higher resolutions and also limits the ability to use two cameras + // on the same bus to lower resolutions + YUY2, + + // Compressed motion JPEG stream format; allows for higher resolutions at + // full frame rate, and better ability to use two cameras on the same bus. + // Requires extra CPU time to run decompression routine. + MJPEG; + } + + /** + * Same as {@link #startStreaming(int, int, OpenCvCameraRotation)} except for + * @param streamFormat indicates the desired stream format. + */ + void startStreaming(int width, int height, OpenCvCameraRotation rotation, StreamFormat streamFormat); + + /*** + * Gets the {@link ExposureControl} for this webcam. + * Please see that interface's javadoc for how to use + * it. It is an interface provided directly by the SDK + * UVC driver, not EasyOpenCV. + * + * @return the ExposureControl for this webcam + */ + ExposureControl getExposureControl(); + + /*** + * Gets the {@link FocusControl} for this webcam. + * Please see that interface's javadoc for how to use + * it. It is an interface provided directly by the SDK + * UVC driver, not EasyOpenCV. + * + * @return the FocusControl for this webcam + */ + FocusControl getFocusControl(); + + /*** + * Gets the {@link PtzControl} for this webcam. + * Please see that interface's javadoc for how to use + * it. It is an interface provided directly by the SDK + * UVC driver, not EasyOpenCV. + * + * @return the PtzControl for this webcam + */ + PtzControl getPtzControl(); + + /*** + * Gets the {@link GainControl} for this webcam. + * Please see that interface's javadoc for how to use + * it. It is an interface provided directly by the SDK + * UVC driver, not EasyOpenCV. + * + * @return the GainControl for this webcam + */ + GainControl getGainControl(); + + /*** + * Gets the {@link WhiteBalanceControl} for this webcam. + * Please see that interface's javadoc for how to use + * it. It is an interface provided directly by the SDK + * UVC driver, not EasyOpenCV. + * + * @return the WhiteBalanceControl for this webcam + */ + WhiteBalanceControl getWhiteBalanceControl(); + + /** + * Gets a control for this webcam + * @param controlType the class of the control to get + * @return the requested control + */ + T getControl(Class controlType); + + /** + * Gets the {@link CameraCalibrationIdentity} for this webcam. + * Note: the camera must be opened before calling this! + * @return the CameraCalibrationIdentity for this webcam + */ + CameraCalibrationIdentity getCalibrationIdentity(); +} \ No newline at end of file From cfd73c142d5b5e51993df6a986082ca6437057d5 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Sun, 6 Oct 2024 12:49:11 -0600 Subject: [PATCH 02/36] Fix variable tuner to actually use reflectTarget --- .../gui/dialog/source/CreateCameraSource.java | 6 +- .../serivesmejia/eocvsim/gui/theme/Theme.java | 3 + .../eocvsim/input/InputSourceManager.java | 3 +- .../eocvsim/tuner/TunerManager.java | 12 +- .../eocvsim/tuner/field/BooleanField.java | 2 +- .../eocvsim/tuner/field/EnumField.kt | 2 +- .../eocvsim/tuner/field/NumericField.java | 2 +- .../eocvsim/tuner/field/StringField.java | 2 +- .../eocvsim/tuner/field/cv/PointField.java | 2 +- .../eocvsim/tuner/field/cv/RectField.kt | 2 +- .../eocvsim/tuner/field/cv/ScalarField.java | 2 +- .../tuner/field/numeric/DoubleField.java | 2 +- .../tuner/field/numeric/FloatField.java | 2 +- .../tuner/field/numeric/IntegerField.java | 2 +- .../tuner/field/numeric/LongField.java | 2 +- .../eventloop/opmode/OpModePipelineHandler.kt | 237 +++++++++--------- .../eocvsim/input/VisionInputSourceHander.kt | 141 ++++++----- .../samples/ConceptAprilTagEpico.java | 196 +++++++++++++++ 18 files changed, 417 insertions(+), 203 deletions(-) create mode 100644 TeamCode/src/main/java/org/firstinspires/ftc/robotcontroller/external/samples/ConceptAprilTagEpico.java diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/source/CreateCameraSource.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/source/CreateCameraSource.java index 493bdefd..016330b8 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/source/CreateCameraSource.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/source/CreateCameraSource.java @@ -98,7 +98,11 @@ public void initCreateImageSource() { if(preferredDriver == WebcamDriver.OpenPnp) { Webcam.Companion.setBackend(OpenPnpBackend.INSTANCE); - webcams = Webcam.Companion.getAvailableWebcams(); + try { + webcams = Webcam.Companion.getAvailableWebcams(); + } catch (Throwable e) { + logger.error("OpenPnp failed to discover cameras with error", e); + } } else if(preferredDriver == WebcamDriver.OpenIMAJ) { try { Webcam.Companion.setBackend(OpenIMAJWebcamBackend.INSTANCE); diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/theme/Theme.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/theme/Theme.java index 98fb99c0..1b061492 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/theme/Theme.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/theme/Theme.java @@ -33,6 +33,9 @@ public enum Theme { Default(() -> { UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName()); }), + System(() -> { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + }), Light(FlatLightLaf::setup), Dark(FlatDarkLaf::setup), Darcula(FlatDarculaLaf::setup), diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/input/InputSourceManager.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/input/InputSourceManager.java index 27af6819..97c08910 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/input/InputSourceManager.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/input/InputSourceManager.java @@ -134,6 +134,8 @@ public void addInputSource(String name, InputSource inputSource, boolean dispatc if (sources.containsKey(name)) return; + inputSource.eocvSim = eocvSim; + if(eocvSim.visualizer.sourceSelectorPanel != null) { eocvSim.visualizer.sourceSelectorPanel.setAllowSourceSwitching(false); } @@ -214,7 +216,6 @@ public boolean setInputSource(String sourceName) { if (src != null) { src.reset(); - src.eocvSim = eocvSim; } //check if source type is a camera, and if so, create a please wait dialog diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/TunerManager.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/TunerManager.java index 8995031a..09367341 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/TunerManager.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/TunerManager.java @@ -75,7 +75,7 @@ public void init() { if(tunableFieldAcceptors == null) { tunableFieldAcceptors = new HashMap<>(); // oh god... - eocvSim.getClasspathScan().getScanResult().getTunableFieldAcceptorClasses().forEach(tunableFieldAcceptors::put); + tunableFieldAcceptors.putAll(eocvSim.getClasspathScan().getScanResult().getTunableFieldAcceptorClasses()); } // for some reason, acceptorManager becomes null after a certain time passes @@ -89,8 +89,8 @@ public void init() { firstInit = false; } - if (eocvSim.pipelineManager.getCurrentPipeline() != null) { - addFieldsFrom(eocvSim.pipelineManager.getCurrentPipeline()); + if (eocvSim.pipelineManager.getReflectTarget() != null) { + addFieldsFrom(eocvSim.pipelineManager.getReflectTarget()); eocvSim.visualizer.updateTunerFields(createTunableFieldPanels()); for(TunableField field : fields.toArray(new TunableField[0])) { @@ -151,7 +151,7 @@ public Class getTunableFieldOf(VirtualField field) { return tunableFieldClass; } - public void addFieldsFrom(OpenCvPipeline pipeline) { + public void addFieldsFrom(Object pipeline) { if (pipeline == null) return; VirtualReflectContext reflectContext = reflect.contextOf(pipeline); @@ -165,11 +165,11 @@ public void addFieldsFrom(OpenCvPipeline pipeline) { // we can't handle this type if(tunableFieldClass == null) continue; - //yay we have a registered TunableField which handles this + //yay we have a registered TunableField which handles this. //now, lets do some more reflection to instantiate this TunableField //and add it to the list... try { - Constructor constructor = tunableFieldClass.getConstructor(OpenCvPipeline.class, VirtualField.class, EOCVSim.class); + Constructor constructor = tunableFieldClass.getConstructor(Object.class, VirtualField.class, EOCVSim.class); this.fields.add(constructor.newInstance(pipeline, field, eocvSim)); } catch(InvocationTargetException e) { if(e.getCause() instanceof CancelTunableFieldAddingException) { diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/BooleanField.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/BooleanField.java index e1bd7809..3480c0a8 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/BooleanField.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/BooleanField.java @@ -37,7 +37,7 @@ public class BooleanField extends TunableField { boolean lastVal; volatile boolean hasChanged = false; - public BooleanField(OpenCvPipeline instance, VirtualField reflectionField, EOCVSim eocvSim) throws IllegalAccessException { + public BooleanField(Object instance, VirtualField reflectionField, EOCVSim eocvSim) throws IllegalAccessException { super(instance, reflectionField, eocvSim, AllowMode.TEXT); setGuiFieldAmount(0); diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/EnumField.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/EnumField.kt index c25a5a2c..220b2628 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/EnumField.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/EnumField.kt @@ -8,7 +8,7 @@ import io.github.deltacv.eocvsim.virtualreflect.VirtualField import org.openftc.easyopencv.OpenCvPipeline @RegisterTunableField -class EnumField(private val instance: OpenCvPipeline, +class EnumField(instance: Any, reflectionField: VirtualField, eocvSim: EOCVSim) : TunableField>(instance, reflectionField, eocvSim, AllowMode.TEXT) { diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/NumericField.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/NumericField.java index 26f7405b..f28f02f3 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/NumericField.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/NumericField.java @@ -38,7 +38,7 @@ abstract public class NumericField extends TunableField { protected volatile boolean hasChanged = false; - public NumericField(OpenCvPipeline instance, VirtualField reflectionField, EOCVSim eocvSim, AllowMode allowMode) throws IllegalAccessException { + public NumericField(Object instance, VirtualField reflectionField, EOCVSim eocvSim, AllowMode allowMode) throws IllegalAccessException { super(instance, reflectionField, eocvSim, allowMode); } diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/StringField.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/StringField.java index ee78c291..80e60552 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/StringField.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/StringField.java @@ -41,7 +41,7 @@ public class StringField extends TunableField { volatile boolean hasChanged = false; - public StringField(OpenCvPipeline instance, VirtualField reflectionField, EOCVSim eocvSim) throws IllegalAccessException { + public StringField(Object instance, VirtualField reflectionField, EOCVSim eocvSim) throws IllegalAccessException { super(instance, reflectionField, eocvSim, AllowMode.TEXT); if(initialFieldValue != null) { diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/cv/PointField.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/cv/PointField.java index 41c399a2..4de7b0c0 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/cv/PointField.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/cv/PointField.java @@ -42,7 +42,7 @@ public class PointField extends TunableField { volatile boolean hasChanged = false; - public PointField(OpenCvPipeline instance, VirtualField reflectionField, EOCVSim eocvSim) throws IllegalAccessException { + public PointField(Object instance, VirtualField reflectionField, EOCVSim eocvSim) throws IllegalAccessException { super(instance, reflectionField, eocvSim, AllowMode.ONLY_NUMBERS_DECIMAL); if(initialFieldValue != null) { diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/cv/RectField.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/cv/RectField.kt index 7bd0b937..989b88ce 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/cv/RectField.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/cv/RectField.kt @@ -32,7 +32,7 @@ import org.openftc.easyopencv.OpenCvPipeline import java.lang.reflect.Field @RegisterTunableField -class RectField(instance: OpenCvPipeline, reflectionField: VirtualField, eocvSim: EOCVSim) : +class RectField(instance: Any, reflectionField: VirtualField, eocvSim: EOCVSim) : TunableField(instance, reflectionField, eocvSim, AllowMode.ONLY_NUMBERS_DECIMAL) { private var rect = arrayOf(0.0, 0.0, 0.0, 0.0) diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/cv/ScalarField.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/cv/ScalarField.java index 1c380131..f4d09510 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/cv/ScalarField.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/cv/ScalarField.java @@ -43,7 +43,7 @@ public class ScalarField extends TunableField { volatile boolean hasChanged = false; - public ScalarField(OpenCvPipeline instance, VirtualField reflectionField, EOCVSim eocvSim) throws IllegalAccessException { + public ScalarField(Object instance, VirtualField reflectionField, EOCVSim eocvSim) throws IllegalAccessException { super(instance, reflectionField, eocvSim, AllowMode.ONLY_NUMBERS_DECIMAL); if(initialFieldValue == null) { diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/numeric/DoubleField.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/numeric/DoubleField.java index d7af5eda..06fb9e8e 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/numeric/DoubleField.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/numeric/DoubleField.java @@ -34,7 +34,7 @@ @RegisterTunableField public class DoubleField extends NumericField { - public DoubleField(OpenCvPipeline instance, VirtualField reflectionField, EOCVSim eocvSim) throws IllegalAccessException { + public DoubleField(Object instance, VirtualField reflectionField, EOCVSim eocvSim) throws IllegalAccessException { super(instance, reflectionField, eocvSim, AllowMode.ONLY_NUMBERS_DECIMAL); value = (double) initialFieldValue; } diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/numeric/FloatField.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/numeric/FloatField.java index eb6928b7..a7fe7b12 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/numeric/FloatField.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/numeric/FloatField.java @@ -34,7 +34,7 @@ @RegisterTunableField public class FloatField extends NumericField { - public FloatField(OpenCvPipeline instance, VirtualField reflectionField, EOCVSim eocvSim) throws IllegalAccessException { + public FloatField(Object instance, VirtualField reflectionField, EOCVSim eocvSim) throws IllegalAccessException { super(instance, reflectionField, eocvSim, AllowMode.ONLY_NUMBERS_DECIMAL); value = (float) initialFieldValue; } diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/numeric/IntegerField.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/numeric/IntegerField.java index a4cd5b96..0c1a8bd6 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/numeric/IntegerField.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/numeric/IntegerField.java @@ -34,7 +34,7 @@ @RegisterTunableField public class IntegerField extends NumericField { - public IntegerField(OpenCvPipeline instance, VirtualField reflectionField, EOCVSim eocvSim) throws IllegalAccessException { + public IntegerField(Object instance, VirtualField reflectionField, EOCVSim eocvSim) throws IllegalAccessException { super(instance, reflectionField, eocvSim, AllowMode.ONLY_NUMBERS); value = (int) initialFieldValue; } diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/numeric/LongField.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/numeric/LongField.java index 839a7b42..c0c18a25 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/numeric/LongField.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/numeric/LongField.java @@ -34,7 +34,7 @@ @RegisterTunableField public class LongField extends NumericField { - public LongField(OpenCvPipeline instance, VirtualField reflectionField, EOCVSim eocvSim) throws IllegalAccessException { + public LongField(Object instance, VirtualField reflectionField, EOCVSim eocvSim) throws IllegalAccessException { super(instance, reflectionField, eocvSim, AllowMode.ONLY_NUMBERS); value = (long) initialFieldValue; } diff --git a/EOCV-Sim/src/main/java/com/qualcomm/robotcore/eventloop/opmode/OpModePipelineHandler.kt b/EOCV-Sim/src/main/java/com/qualcomm/robotcore/eventloop/opmode/OpModePipelineHandler.kt index dda9ea55..0e16ae09 100644 --- a/EOCV-Sim/src/main/java/com/qualcomm/robotcore/eventloop/opmode/OpModePipelineHandler.kt +++ b/EOCV-Sim/src/main/java/com/qualcomm/robotcore/eventloop/opmode/OpModePipelineHandler.kt @@ -1,118 +1,121 @@ -package com.qualcomm.robotcore.eventloop.opmode - -import com.github.serivesmejia.eocvsim.EOCVSim -import com.github.serivesmejia.eocvsim.input.InputSource -import com.github.serivesmejia.eocvsim.input.InputSourceManager -import com.github.serivesmejia.eocvsim.pipeline.handler.SpecificPipelineHandler -import com.github.serivesmejia.eocvsim.util.event.EventHandler -import com.qualcomm.robotcore.hardware.HardwareMap -import io.github.deltacv.eocvsim.input.VisionInputSourceHander -import io.github.deltacv.vision.external.source.ThreadSourceHander -import org.firstinspires.ftc.robotcore.external.Telemetry -import org.openftc.easyopencv.OpenCvPipeline -import org.openftc.easyopencv.OpenCvViewport - -/** - * Enum class to classify the type of an OpMode - */ -enum class OpModeType { AUTONOMOUS, TELEOP } - -/** - * Handler for OpModes in the pipeline manager - * It helps to manage the lifecycle of OpModes - * and to set the input source and viewport - * within it in order to transform a pipeline - * lifecycle into an OpMode lifecycle - * @param inputSourceManager the input source manager to set the input source - * @param viewport the viewport to set in the OpMode - * @see SpecificPipelineHandler - */ -class OpModePipelineHandler(val inputSourceManager: InputSourceManager, private val viewport: OpenCvViewport) : SpecificPipelineHandler( - { it is OpMode } -) { - - private val onStop = EventHandler("OpModePipelineHandler-onStop") - - /** - * Set up for the OpMode lifecycle - * It sets the input source and registers the VisionInputSourceHander - * to let the OpMode request and find input sources from the simulator - */ - override fun preInit() { - if(pipeline == null) return - - inputSourceManager.setInputSource(null) - ThreadSourceHander.register(VisionInputSourceHander(pipeline?.notifier ?: return, viewport)) - - pipeline?.telemetry = telemetry - pipeline?.hardwareMap = HardwareMap() } - - override fun init() { } - - override fun processFrame(currentInputSource: InputSource?) { - } - - /** - * Stops the OpMode and the pipeline if an exception is thrown - * It also calls the onStop event to notify any listeners - * that the OpMode has stopped - */ - override fun onException() { - if(pipeline != null) { - pipeline!!.forceStop() - onStop.run() - } - } - - /** - * Stops the OpMode and the pipeline when the pipeline is changed - * It also calls the onStop event to notify any listeners - * that the OpMode has stopped - */ - override fun onChange(beforePipeline: OpenCvPipeline?, newPipeline: OpenCvPipeline, telemetry: Telemetry) { - if(beforePipeline is OpMode) { - beforePipeline.forceStop() - onStop.run() - } - - super.onChange(beforePipeline, newPipeline, telemetry) - } - -} - -/** - * Extension function to get the OpModeType of a class from its annotations - * @return the OpModeType of the class - */ -val Class<*>.opModeType get() = when { - this.autonomousAnnotation != null -> OpModeType.AUTONOMOUS - this.teleopAnnotation != null -> OpModeType.TELEOP - else -> null -} - -/** - * Extension function to get the OpModeType of an OpMode from its annotations - * @return the OpModeType of the OpMode - */ -val OpMode.opModeType get() = this.javaClass.opModeType - -/** - * Extension function to get the Autonomous annotation of a class - * @return the Autonomous annotation of the class - */ -val Class<*>.autonomousAnnotation get() = this.getAnnotation(Autonomous::class.java) - -/** - * Extension function to get the TeleOp annotation of a class - * @return the TeleOp annotation of the class - */ -val Class<*>.teleopAnnotation get() = this.getAnnotation(TeleOp::class.java) - -/** - * Extension function to get the Autonomous annotation of an OpMode - */ -val OpMode.autonomousAnnotation get() = this.javaClass.autonomousAnnotation -/** - * Extension function to get the TeleOp annotation of an OpMode - */ +package com.qualcomm.robotcore.eventloop.opmode + +import com.github.serivesmejia.eocvsim.EOCVSim +import com.github.serivesmejia.eocvsim.input.InputSource +import com.github.serivesmejia.eocvsim.input.InputSourceManager +import com.github.serivesmejia.eocvsim.pipeline.handler.SpecificPipelineHandler +import com.github.serivesmejia.eocvsim.util.event.EventHandler +import com.qualcomm.robotcore.hardware.HardwareMap +import io.github.deltacv.eocvsim.input.VisionInputSourceHander +import io.github.deltacv.vision.external.source.ThreadSourceHander +import org.firstinspires.ftc.robotcore.external.Telemetry +import org.openftc.easyopencv.OpenCvPipeline +import org.openftc.easyopencv.OpenCvViewport + +/** + * Enum class to classify the type of an OpMode + */ +enum class OpModeType { AUTONOMOUS, TELEOP } + +/** + * Handler for OpModes in the pipeline manager + * It helps to manage the lifecycle of OpModes + * and to set the input source and viewport + * within it in order to transform a pipeline + * lifecycle into an OpMode lifecycle + * @param inputSourceManager the input source manager to set the input source + * @param viewport the viewport to set in the OpMode + * @see SpecificPipelineHandler + */ +class OpModePipelineHandler( + val inputSourceManager: InputSourceManager, + private val viewport: OpenCvViewport +) : SpecificPipelineHandler( + { it is OpMode } +) { + + private val onStop = EventHandler("OpModePipelineHandler-onStop") + + /** + * Set up for the OpMode lifecycle + * It sets the input source and registers the VisionInputSourceHander + * to let the OpMode request and find input sources from the simulator + */ + override fun preInit() { + if(pipeline == null) return + + inputSourceManager.setInputSource(null) + ThreadSourceHander.register(VisionInputSourceHander(pipeline?.notifier ?: return, viewport, inputSourceManager)) + + pipeline?.telemetry = telemetry + pipeline?.hardwareMap = HardwareMap() } + + override fun init() { } + + override fun processFrame(currentInputSource: InputSource?) { + } + + /** + * Stops the OpMode and the pipeline if an exception is thrown + * It also calls the onStop event to notify any listeners + * that the OpMode has stopped + */ + override fun onException() { + if(pipeline != null) { + pipeline!!.forceStop() + onStop.run() + } + } + + /** + * Stops the OpMode and the pipeline when the pipeline is changed + * It also calls the onStop event to notify any listeners + * that the OpMode has stopped + */ + override fun onChange(beforePipeline: OpenCvPipeline?, newPipeline: OpenCvPipeline, telemetry: Telemetry) { + if(beforePipeline is OpMode) { + beforePipeline.forceStop() + onStop.run() + } + + super.onChange(beforePipeline, newPipeline, telemetry) + } + +} + +/** + * Extension function to get the OpModeType of a class from its annotations + * @return the OpModeType of the class + */ +val Class<*>.opModeType get() = when { + this.autonomousAnnotation != null -> OpModeType.AUTONOMOUS + this.teleopAnnotation != null -> OpModeType.TELEOP + else -> null +} + +/** + * Extension function to get the OpModeType of an OpMode from its annotations + * @return the OpModeType of the OpMode + */ +val OpMode.opModeType get() = this.javaClass.opModeType + +/** + * Extension function to get the Autonomous annotation of a class + * @return the Autonomous annotation of the class + */ +val Class<*>.autonomousAnnotation get() = this.getAnnotation(Autonomous::class.java) + +/** + * Extension function to get the TeleOp annotation of a class + * @return the TeleOp annotation of the class + */ +val Class<*>.teleopAnnotation get() = this.getAnnotation(TeleOp::class.java) + +/** + * Extension function to get the Autonomous annotation of an OpMode + */ +val OpMode.autonomousAnnotation get() = this.javaClass.autonomousAnnotation +/** + * Extension function to get the TeleOp annotation of an OpMode + */ val OpMode.teleopAnnotation get() = this.javaClass.teleopAnnotation \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/input/VisionInputSourceHander.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/input/VisionInputSourceHander.kt index d57af4e0..f2682f9c 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/input/VisionInputSourceHander.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/input/VisionInputSourceHander.kt @@ -1,68 +1,75 @@ -package io.github.deltacv.eocvsim.input - -import com.github.serivesmejia.eocvsim.input.source.CameraSource -import com.github.serivesmejia.eocvsim.input.source.ImageSource -import com.github.serivesmejia.eocvsim.input.source.VideoSource -import io.github.deltacv.vision.external.source.ViewportAndSourceHander -import io.github.deltacv.vision.external.source.VisionSource -import io.github.deltacv.vision.internal.opmode.OpModeNotifier -import io.github.deltacv.vision.internal.opmode.OpModeState -import io.github.deltacv.vision.internal.opmode.RedirectToOpModeThrowableHandler -import org.opencv.core.Mat -import org.opencv.core.Size -import org.opencv.videoio.VideoCapture -import org.openftc.easyopencv.OpenCvViewport -import java.io.File -import java.io.IOException -import javax.imageio.ImageIO - -class VisionInputSourceHander(val notifier: OpModeNotifier, val viewport: OpenCvViewport) : ViewportAndSourceHander { - - private fun isImage(path: String) = try { - ImageIO.read(File(path)) != null - } catch(ex: IOException) { false } - - private fun isVideo(path: String): Boolean { - val capture = VideoCapture(path) - val mat = Mat() - - capture.read(mat) - - val isVideo = !mat.empty() - - capture.release() - - return isVideo - } - - override fun hand(name: String): VisionSource { - val source = VisionInputSource(if(File(name).exists()) { - if(isImage(name)) { - ImageSource(name) - } else if(isVideo(name)) { - VideoSource(name, null) - } else throw IllegalArgumentException("File is neither an image nor a video") - } else { - val index = name.toIntOrNull() - ?: if(name == "default" || name == "Webcam 1") 0 - else throw IllegalArgumentException("Unknown source $name") - - CameraSource(index, Size(640.0, 480.0)) - }, RedirectToOpModeThrowableHandler(notifier)) - - notifier.onStateChange { - when(notifier.state) { - OpModeState.STOPPED -> { - source.stop() - it.removeThis() - } - else -> {} - } - } - - return source - } - - override fun viewport() = viewport - +package io.github.deltacv.eocvsim.input + +import com.github.serivesmejia.eocvsim.input.InputSourceManager +import com.github.serivesmejia.eocvsim.input.source.CameraSource +import com.github.serivesmejia.eocvsim.input.source.ImageSource +import com.github.serivesmejia.eocvsim.input.source.VideoSource +import io.github.deltacv.vision.external.source.ViewportAndSourceHander +import io.github.deltacv.vision.external.source.VisionSource +import io.github.deltacv.vision.internal.opmode.OpModeNotifier +import io.github.deltacv.vision.internal.opmode.OpModeState +import io.github.deltacv.vision.internal.opmode.RedirectToOpModeThrowableHandler +import org.opencv.core.Mat +import org.opencv.core.Size +import org.opencv.videoio.VideoCapture +import org.openftc.easyopencv.OpenCvViewport +import java.io.File +import java.io.IOException +import javax.imageio.ImageIO + +class VisionInputSourceHander( + val notifier: OpModeNotifier, + val viewport: OpenCvViewport, + val inputSourceManager: InputSourceManager +) : ViewportAndSourceHander { + + private fun isImage(path: String) = try { + ImageIO.read(File(path)) != null + } catch(ex: IOException) { false } + + private fun isVideo(path: String): Boolean { + val capture = VideoCapture(path) + val mat = Mat() + + capture.read(mat) + + val isVideo = !mat.empty() + + capture.release() + + return isVideo + } + + override fun hand(name: String): VisionSource { + val source = VisionInputSource(if(File(name).exists()) { + if(isImage(name)) { + ImageSource(name) + } else if(isVideo(name)) { + VideoSource(name, null) + } else throw IllegalArgumentException("File is neither an image nor a video") + } else { + val index = name.toIntOrNull() + ?: if(name == "default" || name == "Webcam 1") 0 + else null + + if(index == null) { + inputSourceManager.sources.get(name) ?: throw IllegalArgumentException("Input source $name not found") + } else CameraSource(index, Size(640.0, 480.0)) + }, RedirectToOpModeThrowableHandler(notifier)) + + notifier.onStateChange { + when(notifier.state) { + OpModeState.STOPPED -> { + source.stop() + it.removeThis() + } + else -> {} + } + } + + return source + } + + override fun viewport() = viewport + } \ No newline at end of file diff --git a/TeamCode/src/main/java/org/firstinspires/ftc/robotcontroller/external/samples/ConceptAprilTagEpico.java b/TeamCode/src/main/java/org/firstinspires/ftc/robotcontroller/external/samples/ConceptAprilTagEpico.java new file mode 100644 index 00000000..28964c7f --- /dev/null +++ b/TeamCode/src/main/java/org/firstinspires/ftc/robotcontroller/external/samples/ConceptAprilTagEpico.java @@ -0,0 +1,196 @@ +/* Copyright (c) 2023 FIRST. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted (subject to the limitations in the disclaimer below) provided that + * the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this list + * of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, this + * list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * + * Neither the name of FIRST nor the names of its contributors may be used to endorse or + * promote products derived from this software without specific prior written permission. + * + * NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY THIS + * LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package org.firstinspires.ftc.robotcontroller.external.samples; + +import com.qualcomm.robotcore.eventloop.opmode.Disabled; +import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; +import com.qualcomm.robotcore.eventloop.opmode.TeleOp; +import org.firstinspires.ftc.robotcore.external.hardware.camera.BuiltinCameraDirection; +import org.firstinspires.ftc.robotcore.external.hardware.camera.WebcamName; +import org.firstinspires.ftc.robotcore.external.hardware.camera.controls.ExposureControl; +import org.firstinspires.ftc.vision.VisionPortal; +import org.firstinspires.ftc.vision.apriltag.AprilTagDetection; +import org.firstinspires.ftc.vision.apriltag.AprilTagProcessor; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * This 2023-2024 OpMode illustrates the basics of AprilTag recognition and pose estimation, + * including Java Builder structures for specifying Vision parameters. + * + * Use Android Studio to Copy this Class, and Paste it into your team's code folder with a new name. + * Remove or comment out the @Disabled line to add this OpMode to the Driver Station OpMode list. + */ +@TeleOp(name = "Concept: AprilTag", group = "Concept") +// @Disabled +public class ConceptAprilTagEpico extends LinearOpMode { + + private static final boolean USE_WEBCAM = true; // true for webcam, false for phone camera + + /** + * {@link #aprilTag} is the variable to store our instance of the AprilTag processor. + */ + private AprilTagProcessor aprilTag; + + /** + * {@link #visionPortal} is the variable to store our instance of the vision portal. + */ + private VisionPortal visionPortal; + + @Override + public void runOpMode() { + + initAprilTag(); + + // Wait for the DS start button to be touched. + telemetry.addData("DS preview on/off", "3 dots, Camera Stream"); + telemetry.addData(">", "Touch Play to start OpMode"); + telemetry.update(); + + waitForStart(); + + if (opModeIsActive()) { + visionPortal.getCameraControl(ExposureControl.class).setMode( + ExposureControl.Mode.Manual + ); + + visionPortal.getCameraControl(ExposureControl.class).setExposure( + 1000, TimeUnit.MILLISECONDS + ); + + while (opModeIsActive()) { + telemetry.addData("exposure", visionPortal.getCameraControl(ExposureControl.class).getExposure(TimeUnit.MILLISECONDS)); + telemetry.addData("max exposure", visionPortal.getCameraControl(ExposureControl.class).getMaxExposure(TimeUnit.MILLISECONDS)); + telemetry.addData("min exposure", visionPortal.getCameraControl(ExposureControl.class).getMinExposure(TimeUnit.MILLISECONDS)); + + telemetryAprilTag(); + + // Push telemetry to the Driver Station. + telemetry.update(); + + // Share the CPU. + sleep(20); + } + } + + // Save more CPU resources when camera is no longer needed. + visionPortal.close(); + + } // end method runOpMode() + + /** + * Initialize the AprilTag processor. + */ + private void initAprilTag() { + + // Create the AprilTag processor. + aprilTag = new AprilTagProcessor.Builder() + //.setDrawAxes(false) + //.setDrawCubeProjection(false) + //.setDrawTagOutline(true) + //.setTagFamily(AprilTagProcessor.TagFamily.TAG_36h11) + //.setTagLibrary(AprilTagGameDatabase.getCenterStageTagLibrary()) + //.setOutputUnits(DistanceUnit.INCH, AngleUnit.DEGREES) + + // == CAMERA CALIBRATION == + // If you do not manually specify calibration parameters, the SDK will attempt + // to load a predefined calibration for your camera. + //.setLensIntrinsics(578.272, 578.272, 402.145, 221.506) + + // ... these parameters are fx, fy, cx, cy. + + .build(); + + // Create the vision portal by using a builder. + VisionPortal.Builder builder = new VisionPortal.Builder(); + + // Set the camera (webcam vs. built-in RC phone camera). + if (USE_WEBCAM) { + builder.setCamera(hardwareMap.get(WebcamName.class, "pepe")); + } else { + builder.setCamera(BuiltinCameraDirection.BACK); + } + + // Choose a camera resolution. Not all cameras support all resolutions. + //builder.setCameraResolution(new Size(640, 480)); + + // Enable the RC preview (LiveView). Set "false" to omit camera monitoring. + //builder.enableCameraMonitoring(true); + + // Set the stream format; MJPEG uses less bandwidth than default YUY2. + //builder.setStreamFormat(VisionPortal.StreamFormat.YUY2); + + // Choose whether or not LiveView stops if no processors are enabled. + // If set "true", monitor shows solid orange screen if no processors enabled. + // If set "false", monitor shows camera view without annotations. + //builder.setAutoStopLiveView(false); + + // Set and enable the processor. + builder.addProcessor(aprilTag); + + // Build the Vision Portal, using the above settings. + visionPortal = builder.build(); + + // Disable or re-enable the aprilTag processor at any time. + //visionPortal.setProcessorEnabled(aprilTag, true); + + } // end method initAprilTag() + + + /** + * Function to add telemetry about AprilTag detections. + */ + private void telemetryAprilTag() { + + List currentDetections = aprilTag.getDetections(); + telemetry.addData("# AprilTags Detected", currentDetections.size()); + + // Step through the list of detections and display info for each one. + for (AprilTagDetection detection : currentDetections) { + if (detection.metadata != null) { + telemetry.addLine(String.format("\n==== (ID %d) %s", detection.id, detection.metadata.name)); + telemetry.addLine(String.format("XYZ %6.1f %6.1f %6.1f (inch)", detection.ftcPose.x, detection.ftcPose.y, detection.ftcPose.z)); + telemetry.addLine(String.format("PRY %6.1f %6.1f %6.1f (deg)", detection.ftcPose.pitch, detection.ftcPose.roll, detection.ftcPose.yaw)); + telemetry.addLine(String.format("RBE %6.1f %6.1f %6.1f (inch, deg, deg)", detection.ftcPose.range, detection.ftcPose.bearing, detection.ftcPose.elevation)); + } else { + telemetry.addLine(String.format("\n==== (ID %d) Unknown", detection.id)); + telemetry.addLine(String.format("Center %6.0f %6.0f (pixels)", detection.center.x, detection.center.y)); + } + } // end for() loop + + // Add "key" information to telemetry + telemetry.addLine("\nkey:\nXYZ = X (Right), Y (Forward), Z (Up) dist."); + telemetry.addLine("PRY = Pitch, Roll & Yaw (XYZ Rotation)"); + telemetry.addLine("RBE = Range, Bearing & Elevation"); + + } // end method telemetryAprilTag() + +} // end class \ No newline at end of file From 359fee6f222fd3360b91f6d2fa68af5f29c700a8 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Sun, 6 Oct 2024 12:49:36 -0600 Subject: [PATCH 03/36] Delete testing OpMode --- .../samples/ConceptAprilTagEpico.java | 196 ------------------ 1 file changed, 196 deletions(-) delete mode 100644 TeamCode/src/main/java/org/firstinspires/ftc/robotcontroller/external/samples/ConceptAprilTagEpico.java diff --git a/TeamCode/src/main/java/org/firstinspires/ftc/robotcontroller/external/samples/ConceptAprilTagEpico.java b/TeamCode/src/main/java/org/firstinspires/ftc/robotcontroller/external/samples/ConceptAprilTagEpico.java deleted file mode 100644 index 28964c7f..00000000 --- a/TeamCode/src/main/java/org/firstinspires/ftc/robotcontroller/external/samples/ConceptAprilTagEpico.java +++ /dev/null @@ -1,196 +0,0 @@ -/* Copyright (c) 2023 FIRST. All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, - * are permitted (subject to the limitations in the disclaimer below) provided that - * the following conditions are met: - * - * Redistributions of source code must retain the above copyright notice, this list - * of conditions and the following disclaimer. - * - * Redistributions in binary form must reproduce the above copyright notice, this - * list of conditions and the following disclaimer in the documentation and/or - * other materials provided with the distribution. - * - * Neither the name of FIRST nor the names of its contributors may be used to endorse or - * promote products derived from this software without specific prior written permission. - * - * NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY THIS - * LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, - * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE - * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER - * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, - * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package org.firstinspires.ftc.robotcontroller.external.samples; - -import com.qualcomm.robotcore.eventloop.opmode.Disabled; -import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; -import com.qualcomm.robotcore.eventloop.opmode.TeleOp; -import org.firstinspires.ftc.robotcore.external.hardware.camera.BuiltinCameraDirection; -import org.firstinspires.ftc.robotcore.external.hardware.camera.WebcamName; -import org.firstinspires.ftc.robotcore.external.hardware.camera.controls.ExposureControl; -import org.firstinspires.ftc.vision.VisionPortal; -import org.firstinspires.ftc.vision.apriltag.AprilTagDetection; -import org.firstinspires.ftc.vision.apriltag.AprilTagProcessor; - -import java.util.List; -import java.util.concurrent.TimeUnit; - -/** - * This 2023-2024 OpMode illustrates the basics of AprilTag recognition and pose estimation, - * including Java Builder structures for specifying Vision parameters. - * - * Use Android Studio to Copy this Class, and Paste it into your team's code folder with a new name. - * Remove or comment out the @Disabled line to add this OpMode to the Driver Station OpMode list. - */ -@TeleOp(name = "Concept: AprilTag", group = "Concept") -// @Disabled -public class ConceptAprilTagEpico extends LinearOpMode { - - private static final boolean USE_WEBCAM = true; // true for webcam, false for phone camera - - /** - * {@link #aprilTag} is the variable to store our instance of the AprilTag processor. - */ - private AprilTagProcessor aprilTag; - - /** - * {@link #visionPortal} is the variable to store our instance of the vision portal. - */ - private VisionPortal visionPortal; - - @Override - public void runOpMode() { - - initAprilTag(); - - // Wait for the DS start button to be touched. - telemetry.addData("DS preview on/off", "3 dots, Camera Stream"); - telemetry.addData(">", "Touch Play to start OpMode"); - telemetry.update(); - - waitForStart(); - - if (opModeIsActive()) { - visionPortal.getCameraControl(ExposureControl.class).setMode( - ExposureControl.Mode.Manual - ); - - visionPortal.getCameraControl(ExposureControl.class).setExposure( - 1000, TimeUnit.MILLISECONDS - ); - - while (opModeIsActive()) { - telemetry.addData("exposure", visionPortal.getCameraControl(ExposureControl.class).getExposure(TimeUnit.MILLISECONDS)); - telemetry.addData("max exposure", visionPortal.getCameraControl(ExposureControl.class).getMaxExposure(TimeUnit.MILLISECONDS)); - telemetry.addData("min exposure", visionPortal.getCameraControl(ExposureControl.class).getMinExposure(TimeUnit.MILLISECONDS)); - - telemetryAprilTag(); - - // Push telemetry to the Driver Station. - telemetry.update(); - - // Share the CPU. - sleep(20); - } - } - - // Save more CPU resources when camera is no longer needed. - visionPortal.close(); - - } // end method runOpMode() - - /** - * Initialize the AprilTag processor. - */ - private void initAprilTag() { - - // Create the AprilTag processor. - aprilTag = new AprilTagProcessor.Builder() - //.setDrawAxes(false) - //.setDrawCubeProjection(false) - //.setDrawTagOutline(true) - //.setTagFamily(AprilTagProcessor.TagFamily.TAG_36h11) - //.setTagLibrary(AprilTagGameDatabase.getCenterStageTagLibrary()) - //.setOutputUnits(DistanceUnit.INCH, AngleUnit.DEGREES) - - // == CAMERA CALIBRATION == - // If you do not manually specify calibration parameters, the SDK will attempt - // to load a predefined calibration for your camera. - //.setLensIntrinsics(578.272, 578.272, 402.145, 221.506) - - // ... these parameters are fx, fy, cx, cy. - - .build(); - - // Create the vision portal by using a builder. - VisionPortal.Builder builder = new VisionPortal.Builder(); - - // Set the camera (webcam vs. built-in RC phone camera). - if (USE_WEBCAM) { - builder.setCamera(hardwareMap.get(WebcamName.class, "pepe")); - } else { - builder.setCamera(BuiltinCameraDirection.BACK); - } - - // Choose a camera resolution. Not all cameras support all resolutions. - //builder.setCameraResolution(new Size(640, 480)); - - // Enable the RC preview (LiveView). Set "false" to omit camera monitoring. - //builder.enableCameraMonitoring(true); - - // Set the stream format; MJPEG uses less bandwidth than default YUY2. - //builder.setStreamFormat(VisionPortal.StreamFormat.YUY2); - - // Choose whether or not LiveView stops if no processors are enabled. - // If set "true", monitor shows solid orange screen if no processors enabled. - // If set "false", monitor shows camera view without annotations. - //builder.setAutoStopLiveView(false); - - // Set and enable the processor. - builder.addProcessor(aprilTag); - - // Build the Vision Portal, using the above settings. - visionPortal = builder.build(); - - // Disable or re-enable the aprilTag processor at any time. - //visionPortal.setProcessorEnabled(aprilTag, true); - - } // end method initAprilTag() - - - /** - * Function to add telemetry about AprilTag detections. - */ - private void telemetryAprilTag() { - - List currentDetections = aprilTag.getDetections(); - telemetry.addData("# AprilTags Detected", currentDetections.size()); - - // Step through the list of detections and display info for each one. - for (AprilTagDetection detection : currentDetections) { - if (detection.metadata != null) { - telemetry.addLine(String.format("\n==== (ID %d) %s", detection.id, detection.metadata.name)); - telemetry.addLine(String.format("XYZ %6.1f %6.1f %6.1f (inch)", detection.ftcPose.x, detection.ftcPose.y, detection.ftcPose.z)); - telemetry.addLine(String.format("PRY %6.1f %6.1f %6.1f (deg)", detection.ftcPose.pitch, detection.ftcPose.roll, detection.ftcPose.yaw)); - telemetry.addLine(String.format("RBE %6.1f %6.1f %6.1f (inch, deg, deg)", detection.ftcPose.range, detection.ftcPose.bearing, detection.ftcPose.elevation)); - } else { - telemetry.addLine(String.format("\n==== (ID %d) Unknown", detection.id)); - telemetry.addLine(String.format("Center %6.0f %6.0f (pixels)", detection.center.x, detection.center.y)); - } - } // end for() loop - - // Add "key" information to telemetry - telemetry.addLine("\nkey:\nXYZ = X (Right), Y (Forward), Z (Up) dist."); - telemetry.addLine("PRY = Pitch, Roll & Yaw (XYZ Rotation)"); - telemetry.addLine("RBE = Range, Bearing & Elevation"); - - } // end method telemetryAprilTag() - -} // end class \ No newline at end of file From c53fa668dbda1c62ef936b61667101e2622d0219 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Mon, 7 Oct 2024 01:28:01 -0600 Subject: [PATCH 04/36] Adding JavaPackager for building native executables --- EOCV-Sim/build.gradle | 33 +++++++++++++------ .../github/serivesmejia/eocvsim/EOCVSim.kt | 3 +- .../com/github/serivesmejia/eocvsim/Main.kt | 28 ++++++++-------- .../compiler/PipelineStandardFileManager.kt | 8 ++++- .../serivesmejia/eocvsim/util/SysUtil.java | 14 +++++++- build.common.gradle | 8 ++--- build.gradle | 1 + gradle/wrapper/gradle-wrapper.properties | 2 +- 8 files changed, 66 insertions(+), 31 deletions(-) diff --git a/EOCV-Sim/build.gradle b/EOCV-Sim/build.gradle index 580146ba..bc8a1c7a 100644 --- a/EOCV-Sim/build.gradle +++ b/EOCV-Sim/build.gradle @@ -6,8 +6,7 @@ plugins { id 'org.jetbrains.kotlin.jvm' id 'com.github.johnrengelman.shadow' id 'maven-publish' - - id 'edu.sc.seis.launch4j' version '3.0.6' + id 'io.github.fvarrui.javapackager.plugin' } apply from: '../build.common.gradle' @@ -34,15 +33,29 @@ test { apply from: '../test-logging.gradle' -launch4j { - mainClassName = 'com.github.serivesmejia.eocvsim.Main' - icon = "${projectDir}/src/main/resources/images/icon/ico_eocvsim.ico" - - outfile = "${project.name}-${standardVersion}.exe" - - copyConfigurable = [] // Prevents copying dependencies - jarTask = shadowJar +tasks.register('pack', io.github.fvarrui.javapackager.gradle.PackageTask) { + dependsOn build + // mandatory + mainClass = 'com.github.serivesmejia.eocvsim.Main' + // optional + bundleJre = true + customizedJre = false + generateInstaller = true + platform = "auto" + + winConfig { + icoFile = file('src/main/resources/images/icon/ico_eocvsim.ico') + originalFilename = "${name}-Installer.exe" + + generateMsi = false + disableDirPage = false + disableProgramGroupPage = false + disableFinishedPage = false + } + linuxConfig { + pngFile = file('src/main/resources/images/icon/ico_eocvsim.png') + } } dependencies { diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt index 85ae0517..49298ebe 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt @@ -36,6 +36,7 @@ import com.github.serivesmejia.eocvsim.pipeline.PipelineSource import com.github.serivesmejia.eocvsim.tuner.TunerManager import com.github.serivesmejia.eocvsim.util.ClasspathScan import com.github.serivesmejia.eocvsim.util.FileFilters +import com.github.serivesmejia.eocvsim.util.JavaProcess import com.github.serivesmejia.eocvsim.util.SysUtil import com.github.serivesmejia.eocvsim.util.event.EventHandler import com.github.serivesmejia.eocvsim.util.exception.MaxActiveContextsException @@ -411,7 +412,7 @@ class EOCVSim(val params: Parameters = Parameters()) { if (isRestarting) { Thread.interrupted() //clear interrupted flag - EOCVSim(params).init() + JavaProcess.exec(Main::class.java, null, null) } } diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/Main.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/Main.kt index bc494082..4bb6415c 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/Main.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/Main.kt @@ -1,5 +1,3 @@ -@file:JvmName("Main") - package com.github.serivesmejia.eocvsim import com.github.serivesmejia.eocvsim.pipeline.PipelineSource @@ -11,19 +9,23 @@ import kotlin.system.exitProcess val jvmMainThread: Thread = Thread.currentThread() var currentMainThread: Thread = jvmMainThread -/** - * Main entry point for the EOCV-Sim CLI - * @param args the command line arguments - * @see CommandLine - */ -fun main(args: Array) { - System.setProperty("sun.java2d.d3d", "false") +object Main { + + /** + * Main entry point for the EOCV-Sim CLI + * @param args the command line arguments + * @see CommandLine + */ + @JvmStatic + fun main(args: Array) { + System.setProperty("sun.java2d.d3d", "false") - val result = CommandLine( - EOCVSimCommandInterface() - ).setCaseInsensitiveEnumValuesAllowed(true).execute(*args) + val result = CommandLine( + EOCVSimCommandInterface() + ).setCaseInsensitiveEnumValuesAllowed(true).execute(*args) - exitProcess(result) + exitProcess(result) + } } /** diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/pipeline/compiler/PipelineStandardFileManager.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/pipeline/compiler/PipelineStandardFileManager.kt index 9ad3854f..5aab1bd6 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/pipeline/compiler/PipelineStandardFileManager.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/pipeline/compiler/PipelineStandardFileManager.kt @@ -44,7 +44,13 @@ class PipelineStandardFileManager(delegate: StandardJavaFileManager) : Delegatin val classpathList = arrayListOf() logger.trace("Scanning classpath files...") - + logger.trace("Classpath: {}", System.getProperty("java.class.path")) + + //add all jars in the libs folder...hardcoded for JavaPackager, ugh + classpathList.addAll( + SysUtil.filesUnder(File(System.getProperty("user.dir") + File.separator + "libs"), ".jar") + ) + for(file in SysUtil.getClasspathFiles()) { val files = SysUtil.filesUnder(file, ".jar") files.forEach { diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/SysUtil.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/SysUtil.java index fd2cadee..9022214e 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/SysUtil.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/SysUtil.java @@ -289,7 +289,19 @@ public static List getClasspathFiles() { ArrayList files = new ArrayList<>(); for(String path : classpaths) { - files.add(new File(path)); + File absoluteFile = new File(path); + + if(absoluteFile.exists()) { + files.add(absoluteFile); + } else { + File relativeFile = new File(System.getProperty("user.dir") + File.separator + path); + logger.trace("Checking relative file {}", relativeFile.getAbsolutePath()); + + if(relativeFile.exists()) { + files.add(relativeFile); + logger.trace("Relative file OK"); + } + } } return files; diff --git a/build.common.gradle b/build.common.gradle index 07f92ee5..043571e7 100644 --- a/build.common.gradle +++ b/build.common.gradle @@ -1,5 +1,5 @@ -java { - toolchain { - languageVersion = JavaLanguageVersion.of(10) - } +java { + toolchain { + languageVersion = JavaLanguageVersion.of(11) + } } \ No newline at end of file diff --git a/build.gradle b/build.gradle index 33730078..4aa666ae 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,7 @@ buildscript { dependencies { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'gradle.plugin.com.github.johnrengelman:shadow:7.1.1' + classpath 'io.github.fvarrui:javapackager:1.7.6' } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cf5dd7d2..731f0325 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From e1c283dd4955911b0319e583d681bc0f7ddcbbc9 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Tue, 8 Oct 2024 05:03:07 -0600 Subject: [PATCH 05/36] Update to apriltag 2.1.0 --- .../external/navigation/Quaternion.java | 300 ++++ EOCV-Sim/build.gradle | 5 +- .../DynamicLoadingClassRestrictions.kt | 211 +-- .../resources/templates/default_workspace.zip | Bin 37207 -> 37199 bytes .../resources/templates/gradle_workspace.zip | Bin 99008 -> 99000 bytes TeamCode/build.gradle | 2 +- .../teamcode/AprilTagDetectionPipeline.java | 656 ++++----- Vision/build.gradle | 90 +- .../apriltag/AprilTagProcessorImpl.java | 1222 ++++++++--------- build.gradle | 4 +- 10 files changed, 1398 insertions(+), 1092 deletions(-) create mode 100644 Common/src/main/java/org/firstinspires/ftc/robotcore/external/navigation/Quaternion.java diff --git a/Common/src/main/java/org/firstinspires/ftc/robotcore/external/navigation/Quaternion.java b/Common/src/main/java/org/firstinspires/ftc/robotcore/external/navigation/Quaternion.java new file mode 100644 index 00000000..0dcef9a5 --- /dev/null +++ b/Common/src/main/java/org/firstinspires/ftc/robotcore/external/navigation/Quaternion.java @@ -0,0 +1,300 @@ +/* +Copyright (c) 2016 Robert Atkinson + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted (subject to the limitations in the disclaimer below) provided that +the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list +of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the name of Robert Atkinson nor the names of his contributors may be used to +endorse or promote products derived from this software without specific prior +written permission. + +NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY THIS +LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ +package org.firstinspires.ftc.robotcore.external.navigation; + +import org.firstinspires.ftc.robotcore.external.matrices.MatrixF; +import org.firstinspires.ftc.robotcore.external.matrices.OpenGLMatrix; +import org.firstinspires.ftc.robotcore.external.matrices.VectorF; + +import java.util.Locale; + +/** + * A {@link Quaternion} can indicate an orientation in three-space without the trouble of + * possible gimbal-lock. + * + * @see https://en.wikipedia.org/wiki/Quaternion + * @see https://en.wikipedia.org/wiki/Gimbal_lock + * @see https://www.youtube.com/watch?v=zc8b2Jo7mno + * @see https://www.youtube.com/watch?v=mHVwd8gYLnI + */ +public class Quaternion +{ + //---------------------------------------------------------------------------------------------- + // Constants + //---------------------------------------------------------------------------------------------- + + public static Quaternion identityQuaternion() + { + return new Quaternion(1.0f, 0.0f, 0.0f, 0.0f, 0); + } + + //---------------------------------------------------------------------------------------------- + // State + //---------------------------------------------------------------------------------------------- + + public float w; + public float x; + public float y; + public float z; + + /** + * the time on the System.nanoTime() clock at which the data was acquired. If no + * timestamp is associated with this particular set of data, this value is zero. + */ + public long acquisitionTime; + + //---------------------------------------------------------------------------------------------- + // Construction + //---------------------------------------------------------------------------------------------- + + public Quaternion() + { + this(0, 0, 0, 0, 0); + } + + public Quaternion(float w, float x, float y, float z, long acquisitionTime) + { + this.w = w; + this.x = x; + this.y = y; + this.z = z; + this.acquisitionTime = acquisitionTime; + } + + public static Quaternion fromMatrix(MatrixF m, long acquisitionTime) + { + // https://www.euclideanspace.com/maths/geometry/rotations/conversions/matrixToQuaternion/ + float tr = m.get(0,0) + m.get(1,1) + m.get(2, 2); + + float w, x, y, z; + + if (tr > 0) + { + float s = (float) (Math.sqrt(tr + 1.0) * 2); // S=4*w + w = (float) (0.25 * s); + x = (m.get(2, 1) - m.get(1, 2)) / s; + y = (m.get(0, 2) - m.get(2, 0)) / s; + z = (m.get(1, 0) - m.get(0, 1)) / s; + } + else if ((m.get(0, 0) > m.get(1, 1)) & (m.get(0, 0) > m.get(2, 2))) + { + float s = (float) (Math.sqrt(1.0 + m.get(0, 0) - m.get(1, 1) - m.get(2, 2)) * 2); // S=4*x + w = (m.get(2, 1) - m.get(1, 2)) / s; + x = (float) (0.25 * s); + y = (m.get(0, 1) + m.get(1, 0)) / s; + z = (m.get(0, 2) + m.get(2, 0)) / s; + } + else if (m.get(1, 1) > m.get(2, 2)) + { + float s = (float) (Math.sqrt(1.0 + m.get(1, 1) - m.get(0, 0) - m.get(2, 2)) * 2); // S=4*y + w = (m.get(0, 2) - m.get(2, 0)) / s; + x = (m.get(0, 1) + m.get(1, 0)) / s; + y = (float) (0.25 * s); + z = (m.get(1, 2) + m.get(2, 1)) / s; + } + else + { + float s = (float) (Math.sqrt(1.0 + m.get(2, 2) - m.get(0, 0) - m.get(1, 1)) * 2); // S=4*z + w = (m.get(1, 0) - m.get(0, 1)) / s; + x = (m.get(0, 2) + m.get(2, 0)) / s; + y = (m.get(1, 2) + m.get(2, 1)) / s; + z = (float) (0.25 * s); + } + + return new Quaternion(w, x, y, z, acquisitionTime).normalized(); + } + + //---------------------------------------------------------------------------------------------- + // Operations + //---------------------------------------------------------------------------------------------- + + public float magnitude() + { + return (float)Math.sqrt(w*w + x*x + y*y + z*z); + } + + public Quaternion normalized() + { + float mag = this.magnitude(); + return new Quaternion( + w / mag, + x / mag, + y / mag, + z / mag, + this.acquisitionTime); + } + + public Quaternion conjugate() + { + return new Quaternion(w, -x, -y, -z, this.acquisitionTime); + } + + /** + * @deprecated Use {@link #conjugate()} instead. + */ + @Deprecated + public Quaternion congugate() + { + return conjugate(); + } + + public Quaternion inverse() + { + return normalized().conjugate(); + } + + public Quaternion multiply(Quaternion q, long acquisitionTime) + { + return new Quaternion( + this.w * q.w - this.x * q.x - this.y * q.y - this.z * q.z, + this.w * q.x + this.x * q.w + this.y * q.z - this.z * q.y, + this.w * q.y - this.x * q.z + this.y * q.w + this.z * q.x, + this.w * q.z + this.x * q.y - this.y * q.x + this.z * q.w, + acquisitionTime); + } + + /** + * Apply this rotation to the given vector + * @param vector The vector to rotate (with XYZ order) + * @return The rotated vector + */ + public VectorF applyToVector(VectorF vector) + { + // Adapted from https://www.vcalc.com/wiki/vCalc/V3+-+Vector+Rotation under the + // Creative Common Attribution - ShareAlike License, which all calculators on vCalc are + // licensed under. See Section 3 of the vCalc License and Terms of Use (as of 2022-10-27): + // http://web.archive.org/web/20221027024715/https://www.vcalc.com/static/html/terms.html + + float vx = vector.get(0); + float vy = vector.get(1); + float vz = vector.get(2); + + float qs = this.w; + float q1 = this.x; + float q2 = this.y; + float q3 = this.z; + + //Compute the inverse rotation quaternion "i" + float is = qs; + float i1 = -1*q1; + float i2 = -1*q2; + float i3 = -1*q3; + + float S1V2x = qs*vx; + float S1V2y = qs*vy; + float S1V2z = qs*vz; + + float V1xV21 = q2*vz - q3*vy; + float V1xV22 = -1*(q1*vz - q3*vx); + float V1xV23 = q1*vy - q2*vx; + + float qVs = -1*(q1*vx+q2*vy+q3*vz); + float qV1 = S1V2x + V1xV21; + float qV2 = S1V2y + V1xV22; + float qV3 = S1V2z + V1xV23; + + float TS1V2x = qVs*i1; + float TS1V2y = qVs*i2; + float TS1V2z = qVs*i3; + + float TS2V1x = is*qV1; + float TS2V1y = is*qV2; + float TS2V1z = is*qV3; + + float TV1XV21 = qV2*i3-qV3*i2; + + float qVq1 = TS1V2x + TS2V1x + TV1XV21; + + float TV1XV22 = -1*(qV1*i3-qV3*i1); + float qVq2 = TS1V2y + TS2V1y + TV1XV22; + + float TV1XV23 = qV1*i2-qV2*i1; + float qVq3 = TS1V2z + TS2V1z + TV1XV23; + + return new VectorF(qVq1, qVq2, qVq3); + } + + public MatrixF toMatrix() + { + // https://www.euclideanspace.com/maths/geometry/rotations/conversions/quaternionToMatrix/index.htm + final float xx = x * x; + final float xy = x * y; + final float xz = x * z; + final float xw = x * w; + + final float yy = y * y; + final float yz = y * z; + final float yw = y * w; + + final float zz = z * z; + final float zw = z * w; + + final float m00 = (float) (1.0 - (2.0 * (yy + zz))); + final float m01 = (float) (2.0 * (xy - zw)); + final float m02 = (float) (2.0 * (xz + yw)); + + final float m10 = (float) (2.0 * (xy + zw)); + final float m11 = (float) (1.0 - (2.0 * (xx + zz))); + final float m12 = (float) (2.0 * (yz - xw)); + + final float m20 = (float) (2.0 * (xz - yw)); + final float m21 = (float) (2.0 * (yz + xw)); + final float m22 = (float) (1.0 - (2.0 * (xx + yy))); + + OpenGLMatrix result = new OpenGLMatrix(); + result.put(0, 0, m00); + result.put(0, 1, m01); + result.put(0, 2, m02); + result.put(1, 0, m10); + result.put(1, 1, m11); + result.put(1, 2, m12); + result.put(2, 0, m20); + result.put(2, 1, m21); + result.put(2, 2, m22); + + return result; + } + + public Orientation toOrientation(AxesReference axesReference, AxesOrder axesOrder, AngleUnit angleUnit) + { + Orientation result = Orientation.getOrientation(toMatrix(), axesReference, axesOrder, angleUnit); + result.acquisitionTime = acquisitionTime; + return result; + } + + @Override + public String toString() + { + return String.format(Locale.US, "{w=%.3f, x=%.3f, y=%.3f, z=%.3f}", w, x, y, z); + } +} diff --git a/EOCV-Sim/build.gradle b/EOCV-Sim/build.gradle index bc8a1c7a..b1435e51 100644 --- a/EOCV-Sim/build.gradle +++ b/EOCV-Sim/build.gradle @@ -45,7 +45,6 @@ tasks.register('pack', io.github.fvarrui.javapackager.gradle.PackageTask) { winConfig { icoFile = file('src/main/resources/images/icon/ico_eocvsim.ico') - originalFilename = "${name}-Installer.exe" generateMsi = false disableDirPage = false @@ -74,7 +73,9 @@ dependencies { implementation "org.apache.logging.log4j:log4j-slf4j-impl:$log4j_version" implementation "com.github.deltacv:steve:1.1.1" - implementation "com.github.deltacv.AprilTagDesktop:AprilTagDesktop:$apriltag_plugin_version" + api(apriltag_plugin_dependency) { + exclude group: 'com.github.deltacv.EOCVSim', module: 'Common' + } implementation 'info.picocli:picocli:4.6.1' implementation 'com.google.code.gson:gson:2.8.9' diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/sandbox/restrictions/DynamicLoadingClassRestrictions.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/sandbox/restrictions/DynamicLoadingClassRestrictions.kt index 4547cb54..fb8b3c5e 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/sandbox/restrictions/DynamicLoadingClassRestrictions.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/sandbox/restrictions/DynamicLoadingClassRestrictions.kt @@ -1,106 +1,107 @@ -/* - * Copyright (c) 2024 Sebastian Erives - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -package io.github.deltacv.eocvsim.sandbox.restrictions - -val dynamicLoadingPackageWhitelist = setOf( - "java.lang", - "java.util", - "java.awt", - "javax.swing", - "java.nio", - "java.io.File", - - "kotlin", - - "com.github.serivesmejia.eocvsim", - "io.github.deltacv.eocvsim", - "org.firstinspires.ftc", - "com.qualcomm", - "org.opencv", - "org.openftc", - "android", - - "com.moandjiezana.toml", - "net.lingala.zip4j", - "com.google.gson", - "com.google.jimfs", - "org.slf4j", - "com.apache.logging", - "com.formdev.flatlaf" -) - -val dynamicLoadingPackageBlacklist = setOf( - // System and Runtime Classes - "java.lang.Runtime", - "java.lang.ProcessBuilder", - "java.lang.reflect", - - // File and I/O Operations - "java.io.RandomAccessFile", - "java.nio.file.Paths", - "java.nio.file.Files", - "java.nio.file.FileSystems", - - // Thread and Process Management - "java.lang.Process", - - // EOCV-Sim dangerous utils - "com.github.serivesmejia.eocvsim.util.SysUtil", - "com.github.serivesmejia.eocvsim.util.io", - "com.github.serivesmejia.eocvsim.util.ClasspathScan", - "com.github.serivesmejia.eocvsim.util.JavaProcess", - "com.github.serivesmejia.eocvsim.util.ReflectUtil", - "com.github.serivesmejia.eocvsim.util.FileExtKt", - "com.github.serivesmejia.eocvsim.util.compiler", - "com.github.serivesmejia.eocvsim.config", - - "io.github.deltacv.eocvsim.plugin.sandbox.nio.JimfsWatcher" -) - -val dynamicLoadingMethodBlacklist = setOf( - "java.lang.System#load", - "java.lang.System#loadLibrary", - "java.lang.System#setSecurityManager", - "java.lang.System#getSecurityManager", - "java.lang.System#setProperty", - "java.lang.System#clearProperty", - "java.lang.System#setenv", - - "java.lang.Class#newInstance", - "java.lang.Class#forName", - - "java.io.File#delete", - "java.io.File#createNewFile", - "java.io.File#mkdirs", - "java.io.File#renameTo", - "java.io.File#setExecutable", - "java.io.File#setReadable", - "java.io.File#setWritable", - "java.io.File#setLastModified", - "java.io.File#deleteOnExit", - "java.io.File#setReadOnly", - "java.io.File#setWritable", - "java.io.File#setReadable", - "java.io.File#setExecutable", +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package io.github.deltacv.eocvsim.sandbox.restrictions + +val dynamicLoadingPackageWhitelist = setOf( + "java.lang", + "java.util", + "java.awt", + "javax.swing", + "java.nio", + "java.io.File", + "java.io.PrintStream", + + "kotlin", + + "com.github.serivesmejia.eocvsim", + "io.github.deltacv.eocvsim", + "org.firstinspires.ftc", + "com.qualcomm", + "org.opencv", + "org.openftc", + "android", + + "com.moandjiezana.toml", + "net.lingala.zip4j", + "com.google.gson", + "com.google.jimfs", + "org.slf4j", + "com.apache.logging", + "com.formdev.flatlaf" +) + +val dynamicLoadingPackageBlacklist = setOf( + // System and Runtime Classes + "java.lang.Runtime", + "java.lang.ProcessBuilder", + "java.lang.reflect", + + // File and I/O Operations + "java.io.RandomAccessFile", + "java.nio.file.Paths", + "java.nio.file.Files", + "java.nio.file.FileSystems", + + // Thread and Process Management + "java.lang.Process", + + // EOCV-Sim dangerous utils + "com.github.serivesmejia.eocvsim.util.SysUtil", + "com.github.serivesmejia.eocvsim.util.io", + "com.github.serivesmejia.eocvsim.util.ClasspathScan", + "com.github.serivesmejia.eocvsim.util.JavaProcess", + "com.github.serivesmejia.eocvsim.util.ReflectUtil", + "com.github.serivesmejia.eocvsim.util.FileExtKt", + "com.github.serivesmejia.eocvsim.util.compiler", + "com.github.serivesmejia.eocvsim.config", + + "io.github.deltacv.eocvsim.plugin.sandbox.nio.JimfsWatcher" +) + +val dynamicLoadingMethodBlacklist = setOf( + "java.lang.System#load", + "java.lang.System#loadLibrary", + "java.lang.System#setSecurityManager", + "java.lang.System#getSecurityManager", + "java.lang.System#setProperty", + "java.lang.System#clearProperty", + "java.lang.System#setenv", + + "java.lang.Class#newInstance", + "java.lang.Class#forName", + + "java.io.File#delete", + "java.io.File#createNewFile", + "java.io.File#mkdirs", + "java.io.File#renameTo", + "java.io.File#setExecutable", + "java.io.File#setReadable", + "java.io.File#setWritable", + "java.io.File#setLastModified", + "java.io.File#deleteOnExit", + "java.io.File#setReadOnly", + "java.io.File#setWritable", + "java.io.File#setReadable", + "java.io.File#setExecutable", ) \ No newline at end of file diff --git a/EOCV-Sim/src/main/resources/templates/default_workspace.zip b/EOCV-Sim/src/main/resources/templates/default_workspace.zip index 9d41330a33c9c598bad420dbcb2a416c0cc26c5c..0bbfc537929169d6a6976f7701a91e8a36e3ae95 100644 GIT binary patch delta 3852 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 J^eyxs`!5^7fz$v1 delta 3837 zcmVDjt7TR0#kB zdC=@+dC=@+b$AN^0R-p+000E&0{{T+T5WUNHWL2sUx8+(6FH@6z1T?_H=R=?I_8}$ zX(Ty`o6BS%5|YqTgbt)6t8M=K?E-u!CEH29-KlX!0lUv)7r?#%JbiXVo{^4N?IfQs zLQ->%NweN;kim-imy-^eFuH#P5~Lb3x#U5>g->`u7EH3~j?5+XL*^P}CK)4QMw|te zb7qiG5bE#9ipc;N#5APb=l+~f;(#>71LAN2NP(DzTPhjQyMzXTa5x1>;tFTIWPV6P z6glG_3rHh5LSul94)c)w!~`tb|l@iyE715LXr%4=yL~+artEV=^2K-q~HNOP;jH@cW5D-rJL_!OesK#i-q% z+>*g1Y4>l*zwCb3Al8TB$QqByV1#J)^{{7K&}{cRy_>Gxe@lPf0Be6RAwB!ro&fG- zpkzj9wlzk)YircGf}ia-cF&&N8i;jiPx^>_IT(>P8Ma3gyK~cPkI3+5G#rdA7(^G4 z`*#0w1cIzCw@OI)BhP_yjpOXIus$_k$IOy=cxt#|*)*IKqEr7e_~YiE9Ox(bD|SntJfP zG^Ln|&=Ig)*alpHC&bDWE7*d}?DBw4J?37-jMV}%2zMcqKK0BA^VpJwa`$*vpKkbE z`3Un_j$44AbT+0#Jw9!@W=DTlk|1DbGuf@!-tKD}9_ky+x&l1RLH@yPD7YUsXAdB1 z?I%+GhkJipd%MSA4l1j<`EtIJ!ZB_AT_QpA)0O02Nato7A0~9(Wg%1E`!VD~{uiR6#XRn3*5dDUL(O%_J+mz(-Sxo5L#J~24skpPQlvDv7=_@X@!f#r z!H@nrBJepqCF4mOoCd3 zHXG)7{dsEE)PBnHSllAc?JV!1mZsA2^%UodgmZo)RJ!?mm{8l zT*L>g4Qof+br{Nv91XZ7TztD|1;NkSah@XCi@%gM->;AaMvp-RkW&9zix=J_k#1Hl zO_p>4FAJv^@<8WcMfUN|cOnQ)k&Fg4hPB5eg(u1HqUOljV55Nl$vyyYZ1{_c{@Z_C ze|}h=pg10fFR6B)+1{xT9;3cm-4iipVRsK|)EIXc!;F!?zu!d7IlQ1X8sy~$s zi@Xg!9{pO^EgM7=h$b|fQ3barhsMB8kwDbd{L56F(_EGGwKB_PyFR5 zeaf18wDyx>Cha*NUb!;XezAXf3@Mw(sua)@6Z=L3VlMnaiv!=z?$FGX9r(WasfZP6 z)5RX$FCkxmjbPyEg3c1(sq{k9EqUIBnrunsA522&9POPY)KMmR51z*fp+fR@kljI< z1j|qFzLfx=bpV??D<(poGKZodn=B~QQcpv&W8ma4O#yB30AwMyC|`fF6Qqj`{4~(G zuz@v_nSjqqP6y{dQJlQW7-65~hKQ{MyjEYAQY zx-G0yv}XH`2iMA$@n(&QPn%}lAkPgY+LRmSM313*%*v|3ZMmKGlvN^ss2Z9<&L1c# zCu$&ha{MJl2!_hEm~(#?W`1TYrqD-F@!zpv#AZxFsl!aWKN;EmvE9)f1L-np-hTKE ze)@Bd-GF~KyVl#0WsQsDNP?_MUAKo}daNRe3F`p%e(lM>eZ2}WO{Q~VcVGYLJ~7fo zU~>1kV8(kfVJ-N9r ztdWS;&Mn#QxJ?WDVHT#d&Bf%c>&{d`)y$>8zWLpv7c8{#3_XNOM_M`LZGh z3mxzvJ+Mp=CT)K~Db0&4RSas6lhQ3j$&!?>55NaS!{BoPS;H0lc}2Ci_Pjh%WCi%z zV{WxzN|i@B!^BS~CeyWo3IGuDk;Tl=7TL(jNiv?3lVb1Rc%(o`9sZm<@^`r#d+@nUQ zd8T2v3+{g`l57?Gwa5aq$NYJ?Q2o)Pm=Q*uMSL9;4fN?scr18Y;KnRT-^ImoV54&V zm^sm`z!%1RVJ6Hzj5W7@OZyTtbr*DJcn_(o`YN!~)E5-ePpmq@h(>&Pqm6>Mk>~}u z^M?-ws-db?og(5>m^|gO#IjeJs2TvU@O%{4La=|L4#O&Z%L6rAP@Agb8>r9q6=Ach z%L=mcsL#wI8Z3@EKt)D#gpi;#dPj{6D~$%hda2Jv)wQ4|gmjdZZJokr?;iAuO?*Yt zUaP1!w?ua;|4=_R4v|E*y_c?T#FY2Z)r}KPnnt1Qwg;OHBm0I6UDn`SR45H;3@oUK zn7MyLJ!eG|G-tJpLx`@F%xDTVPYrEnL~Es7Nf;@jzA>sD8x@i>QdGmoPw_&@jIGRO zhC+qiTEPh2yi53@babMQ_|!Hii1}FmRBmfPo5WWOk`0m>@7F;#KN8xYa{5kC#3KN6^f=uhz$FlumAA;K06VAR3wfW$9_88Pw z1#10pltlk4Rt0O{FX^m8()qUr`SS|U=idtYyaM#aw}Muwe*m;xK3aEBDmk!u)xCo{ zj-N@fgz7Pj&*BYzvw$yd>XImZ1tWj1j`WLzEC$sm|~3XWA&-|&qWI^TUvk7{W0H z*T?tjHS6T2G7#Q|saK?MC1-yoF1`NKBM(?rDx=tk{;(Dg&`6fo1M-;aJ2!PrU2z?N zBn3wqZ|64nGQ4M2Z#Yo!$Lc6;Av~8%1-Ommph~&I^zUBy0R5)nFt*!Y*{M?rU2ZZ}a+lF6I-J**-IGSCleW?eSMev@+xs1*T1>fuJb3jH zNzZGN^hVOdTyK2p{^jVuP)h>@ld*{wvw%2}QN7V30YJc8x*;R5_DikU*1jER%YV zUjltvlVFfQlaG!Flg(NYlQfVj0qc`rkb(lle3M|1OOq~<7Ycv~006;=00000>&8Ly diff --git a/EOCV-Sim/src/main/resources/templates/gradle_workspace.zip b/EOCV-Sim/src/main/resources/templates/gradle_workspace.zip index 29a5cc7d28ae2391d8f246b1fb251079f3517655..e69fb559f6a79c8edc27d94a855336d814406ef2 100644 GIT binary patch delta 3877 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 delta 3886 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 diff --git a/TeamCode/build.gradle b/TeamCode/build.gradle index be3aaf0e..112ad2ab 100644 --- a/TeamCode/build.gradle +++ b/TeamCode/build.gradle @@ -8,7 +8,7 @@ apply from: '../build.common.gradle' dependencies { implementation project(':EOCV-Sim') - implementation "com.github.deltacv.AprilTagDesktop:AprilTagDesktop:$apriltag_plugin_version" + implementation "org.jetbrains.kotlin:kotlin-stdlib" } diff --git a/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/AprilTagDetectionPipeline.java b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/AprilTagDetectionPipeline.java index f16770d3..0e34be7c 100644 --- a/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/AprilTagDetectionPipeline.java +++ b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/AprilTagDetectionPipeline.java @@ -1,329 +1,329 @@ -/* - * Copyright (c) 2021 OpenFTC Team - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package org.firstinspires.ftc.teamcode; - -import com.qualcomm.robotcore.eventloop.opmode.Disabled; -import org.firstinspires.ftc.robotcore.external.Telemetry; -import org.firstinspires.ftc.robotcore.external.navigation.*; -import org.opencv.calib3d.Calib3d; -import org.opencv.core.CvType; -import org.opencv.core.Mat; -import org.opencv.core.MatOfDouble; -import org.opencv.core.MatOfPoint2f; -import org.opencv.core.MatOfPoint3f; -import org.opencv.core.Point; -import org.opencv.core.Point3; -import org.opencv.core.Scalar; -import org.opencv.imgproc.Imgproc; -import org.openftc.apriltag.AprilTagDetection; -import org.openftc.apriltag.AprilTagDetectorJNI; -import org.openftc.easyopencv.OpenCvPipeline; - -import java.util.ArrayList; - -@Disabled -public class AprilTagDetectionPipeline extends OpenCvPipeline -{ - // STATIC CONSTANTS - - public static Scalar blue = new Scalar(7,197,235,255); - public static Scalar red = new Scalar(255,0,0,255); - public static Scalar green = new Scalar(0,255,0,255); - public static Scalar white = new Scalar(255,255,255,255); - - static final double FEET_PER_METER = 3.28084; - - // Lens intrinsics - // UNITS ARE PIXELS - // NOTE: this calibration is for the C920 webcam at 800x448. - // You will need to do your own calibration for other configurations! - public static double fx = 578.272; - public static double fy = 578.272; - public static double cx = 402.145; - public static double cy = 221.506; - - // UNITS ARE METERS - public static double TAG_SIZE = 0.166; - - // instance variables - - private long nativeApriltagPtr; - private Mat grey = new Mat(); - private ArrayList detections = new ArrayList<>(); - - private ArrayList detectionsUpdate = new ArrayList<>(); - private final Object detectionsUpdateSync = new Object(); - - Mat cameraMatrix; - - double tagsizeX = TAG_SIZE; - double tagsizeY = TAG_SIZE; - - private float decimation; - private boolean needToSetDecimation; - private final Object decimationSync = new Object(); - - Telemetry telemetry; - - public AprilTagDetectionPipeline(Telemetry telemetry) { - this.telemetry = telemetry; - constructMatrix(); - } - - @Override - public void init(Mat frame) - { - // Allocate a native context object. See the corresponding deletion in the finalizer - nativeApriltagPtr = AprilTagDetectorJNI.createApriltagDetector(AprilTagDetectorJNI.TagFamily.TAG_36h11.string, 3, 3); - } - - @Override - public void finalize() - { - // Delete the native context we created in the init() function - AprilTagDetectorJNI.releaseApriltagDetector(nativeApriltagPtr); - } - - @Override - public Mat processFrame(Mat input) - { - // Convert to greyscale - Imgproc.cvtColor(input, grey, Imgproc.COLOR_RGBA2GRAY); - - synchronized (decimationSync) - { - if(needToSetDecimation) - { - AprilTagDetectorJNI.setApriltagDetectorDecimation(nativeApriltagPtr, decimation); - needToSetDecimation = false; - } - } - - // Run AprilTag - detections = AprilTagDetectorJNI.runAprilTagDetectorSimple(nativeApriltagPtr, grey, TAG_SIZE, fx, fy, cx, cy); - - synchronized (detectionsUpdateSync) - { - detectionsUpdate = detections; - } - - // For fun, use OpenCV to draw 6DOF markers on the image. We actually recompute the pose using - // OpenCV because I haven't yet figured out how to re-use AprilTag's pose in OpenCV. - for(AprilTagDetection detection : detections) - { - Pose pose = poseFromTrapezoid(detection.corners, cameraMatrix, tagsizeX, tagsizeY); - drawAxisMarker(input, tagsizeY/2.0, 6, pose.rvec, pose.tvec, cameraMatrix); - draw3dCubeMarker(input, tagsizeX, tagsizeX, tagsizeY, 5, pose.rvec, pose.tvec, cameraMatrix); - - Orientation rot = Orientation.getOrientation(detection.pose.R, AxesReference.INTRINSIC, AxesOrder.YXZ, AngleUnit.DEGREES); - - telemetry.addLine(String.format("\nDetected tag ID=%d", detection.id)); - telemetry.addLine(String.format("Translation X: %.2f feet", detection.pose.x*FEET_PER_METER)); - telemetry.addLine(String.format("Translation Y: %.2f feet", detection.pose.y*FEET_PER_METER)); - telemetry.addLine(String.format("Translation Z: %.2f feet", detection.pose.z*FEET_PER_METER)); - - telemetry.addLine(String.format("Rotation Yaw: %.2f degrees", rot.firstAngle)); - telemetry.addLine(String.format("Rotation Pitch: %.2f degrees", rot.secondAngle)); - telemetry.addLine(String.format("Rotation Roll: %.2f degrees", rot.thirdAngle)); - } - - telemetry.update(); - - return input; - } - - public void setDecimation(float decimation) - { - synchronized (decimationSync) - { - this.decimation = decimation; - needToSetDecimation = true; - } - } - - public ArrayList getLatestDetections() - { - return detections; - } - - public ArrayList getDetectionsUpdate() - { - synchronized (detectionsUpdateSync) - { - ArrayList ret = detectionsUpdate; - detectionsUpdate = null; - return ret; - } - } - - void constructMatrix() - { - // Construct the camera matrix. - // - // -- -- - // | fx 0 cx | - // | 0 fy cy | - // | 0 0 1 | - // -- -- - // - - cameraMatrix = new Mat(3,3, CvType.CV_32FC1); - - cameraMatrix.put(0,0, fx); - cameraMatrix.put(0,1,0); - cameraMatrix.put(0,2, cx); - - cameraMatrix.put(1,0,0); - cameraMatrix.put(1,1,fy); - cameraMatrix.put(1,2,cy); - - cameraMatrix.put(2, 0, 0); - cameraMatrix.put(2,1,0); - cameraMatrix.put(2,2,1); - } - - /** - * Draw a 3D axis marker on a detection. (Similar to what Vuforia does) - * - * @param buf the RGB buffer on which to draw the marker - * @param length the length of each of the marker 'poles' - * @param rvec the rotation vector of the detection - * @param tvec the translation vector of the detection - * @param cameraMatrix the camera matrix used when finding the detection - */ - void drawAxisMarker(Mat buf, double length, int thickness, Mat rvec, Mat tvec, Mat cameraMatrix) - { - // The points in 3D space we wish to project onto the 2D image plane. - // The origin of the coordinate space is assumed to be in the center of the detection. - MatOfPoint3f axis = new MatOfPoint3f( - new Point3(0,0,0), - new Point3(length,0,0), - new Point3(0,length,0), - new Point3(0,0,-length) - ); - - // Project those points - MatOfPoint2f matProjectedPoints = new MatOfPoint2f(); - Calib3d.projectPoints(axis, rvec, tvec, cameraMatrix, new MatOfDouble(), matProjectedPoints); - Point[] projectedPoints = matProjectedPoints.toArray(); - - // Draw the marker! - Imgproc.line(buf, projectedPoints[0], projectedPoints[1], red, thickness); - Imgproc.line(buf, projectedPoints[0], projectedPoints[2], green, thickness); - Imgproc.line(buf, projectedPoints[0], projectedPoints[3], blue, thickness); - - Imgproc.circle(buf, projectedPoints[0], thickness, white, -1); - } - - void draw3dCubeMarker(Mat buf, double length, double tagWidth, double tagHeight, int thickness, Mat rvec, Mat tvec, Mat cameraMatrix) - { - //axis = np.float32([[0,0,0], [0,3,0], [3,3,0], [3,0,0], - // [0,0,-3],[0,3,-3],[3,3,-3],[3,0,-3] ]) - - // The points in 3D space we wish to project onto the 2D image plane. - // The origin of the coordinate space is assumed to be in the center of the detection. - MatOfPoint3f axis = new MatOfPoint3f( - new Point3(-tagWidth/2, tagHeight/2,0), - new Point3( tagWidth/2, tagHeight/2,0), - new Point3( tagWidth/2,-tagHeight/2,0), - new Point3(-tagWidth/2,-tagHeight/2,0), - new Point3(-tagWidth/2, tagHeight/2,-length), - new Point3( tagWidth/2, tagHeight/2,-length), - new Point3( tagWidth/2,-tagHeight/2,-length), - new Point3(-tagWidth/2,-tagHeight/2,-length)); - - // Project those points - MatOfPoint2f matProjectedPoints = new MatOfPoint2f(); - Calib3d.projectPoints(axis, rvec, tvec, cameraMatrix, new MatOfDouble(), matProjectedPoints); - Point[] projectedPoints = matProjectedPoints.toArray(); - - // Pillars - for(int i = 0; i < 4; i++) - { - Imgproc.line(buf, projectedPoints[i], projectedPoints[i+4], blue, thickness); - } - - // Base lines - //Imgproc.line(buf, projectedPoints[0], projectedPoints[1], blue, thickness); - //Imgproc.line(buf, projectedPoints[1], projectedPoints[2], blue, thickness); - //Imgproc.line(buf, projectedPoints[2], projectedPoints[3], blue, thickness); - //Imgproc.line(buf, projectedPoints[3], projectedPoints[0], blue, thickness); - - // Top lines - Imgproc.line(buf, projectedPoints[4], projectedPoints[5], green, thickness); - Imgproc.line(buf, projectedPoints[5], projectedPoints[6], green, thickness); - Imgproc.line(buf, projectedPoints[6], projectedPoints[7], green, thickness); - Imgproc.line(buf, projectedPoints[4], projectedPoints[7], green, thickness); - } - - /** - * Extracts 6DOF pose from a trapezoid, using a camera intrinsics matrix and the - * original size of the tag. - * - * @param points the points which form the trapezoid - * @param cameraMatrix the camera intrinsics matrix - * @param tagsizeX the original width of the tag - * @param tagsizeY the original height of the tag - * @return the 6DOF pose of the camera relative to the tag - */ - Pose poseFromTrapezoid(Point[] points, Mat cameraMatrix, double tagsizeX , double tagsizeY) - { - // The actual 2d points of the tag detected in the image - MatOfPoint2f points2d = new MatOfPoint2f(points); - - // The 3d points of the tag in an 'ideal projection' - Point3[] arrayPoints3d = new Point3[4]; - arrayPoints3d[0] = new Point3(-tagsizeX/2, tagsizeY/2, 0); - arrayPoints3d[1] = new Point3(tagsizeX/2, tagsizeY/2, 0); - arrayPoints3d[2] = new Point3(tagsizeX/2, -tagsizeY/2, 0); - arrayPoints3d[3] = new Point3(-tagsizeX/2, -tagsizeY/2, 0); - MatOfPoint3f points3d = new MatOfPoint3f(arrayPoints3d); - - // Using this information, actually solve for pose - Pose pose = new Pose(); - Calib3d.solvePnP(points3d, points2d, cameraMatrix, new MatOfDouble(), pose.rvec, pose.tvec, false); - - return pose; - } - - /* - * A simple container to hold both rotation and translation - * vectors, which together form a 6DOF pose. - */ - class Pose - { - Mat rvec; - Mat tvec; - - public Pose() - { - rvec = new Mat(); - tvec = new Mat(); - } - - public Pose(Mat rvec, Mat tvec) - { - this.rvec = rvec; - this.tvec = tvec; - } - } +/* + * Copyright (c) 2021 OpenFTC Team + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.firstinspires.ftc.teamcode; + +import com.qualcomm.robotcore.eventloop.opmode.Disabled; +import org.firstinspires.ftc.robotcore.external.Telemetry; +import org.firstinspires.ftc.robotcore.external.navigation.*; +import org.opencv.calib3d.Calib3d; +import org.opencv.core.CvType; +import org.opencv.core.Mat; +import org.opencv.core.MatOfDouble; +import org.opencv.core.MatOfPoint2f; +import org.opencv.core.MatOfPoint3f; +import org.opencv.core.Point; +import org.opencv.core.Point3; +import org.opencv.core.Scalar; +import org.opencv.imgproc.Imgproc; +import org.openftc.apriltag.AprilTagDetection; +import org.openftc.apriltag.AprilTagDetectorJNI; +import org.openftc.easyopencv.OpenCvPipeline; + +import java.util.ArrayList; + +@Disabled +public class AprilTagDetectionPipeline extends OpenCvPipeline +{ + // STATIC CONSTANTS + + public static Scalar blue = new Scalar(7,197,235,255); + public static Scalar red = new Scalar(255,0,0,255); + public static Scalar green = new Scalar(0,255,0,255); + public static Scalar white = new Scalar(255,255,255,255); + + static final double FEET_PER_METER = 3.28084; + + // Lens intrinsics + // UNITS ARE PIXELS + // NOTE: this calibration is for the C920 webcam at 800x448. + // You will need to do your own calibration for other configurations! + public static double fx = 578.272; + public static double fy = 578.272; + public static double cx = 402.145; + public static double cy = 221.506; + + // UNITS ARE METERS + public static double TAG_SIZE = 0.166; + + // instance variables + + private long nativeApriltagPtr; + private Mat grey = new Mat(); + private ArrayList detections = new ArrayList<>(); + + private ArrayList detectionsUpdate = new ArrayList<>(); + private final Object detectionsUpdateSync = new Object(); + + Mat cameraMatrix; + + double tagsizeX = TAG_SIZE; + double tagsizeY = TAG_SIZE; + + private float decimation; + private boolean needToSetDecimation; + private final Object decimationSync = new Object(); + + Telemetry telemetry; + + public AprilTagDetectionPipeline(Telemetry telemetry) { + this.telemetry = telemetry; + constructMatrix(); + } + + @Override + public void init(Mat frame) + { + // Allocate a native context object. See the corresponding deletion in the finalizer + nativeApriltagPtr = AprilTagDetectorJNI.createApriltagDetector(AprilTagDetectorJNI.TagFamily.TAG_36h11.string, 3, 3); + } + + @Override + public void finalize() + { + // Delete the native context we created in the init() function + AprilTagDetectorJNI.releaseApriltagDetector(nativeApriltagPtr); + } + + @Override + public Mat processFrame(Mat input) + { + // Convert to greyscale + Imgproc.cvtColor(input, grey, Imgproc.COLOR_RGBA2GRAY); + + synchronized (decimationSync) + { + if(needToSetDecimation) + { + AprilTagDetectorJNI.setApriltagDetectorDecimation(nativeApriltagPtr, decimation); + needToSetDecimation = false; + } + } + + // Run AprilTag + detections = AprilTagDetectorJNI.runAprilTagDetectorSimple(nativeApriltagPtr, grey, TAG_SIZE, fx, fy, cx, cy); + + synchronized (detectionsUpdateSync) + { + detectionsUpdate = detections; + } + + // For fun, use OpenCV to draw 6DOF markers on the image. We actually recompute the pose using + // OpenCV because I haven't yet figured out how to re-use AprilTag's pose in OpenCV. + for(AprilTagDetection detection : detections) + { + Pose pose = poseFromTrapezoid(detection.corners, cameraMatrix, tagsizeX, tagsizeY); + drawAxisMarker(input, tagsizeY/2.0, 6, pose.rvec, pose.tvec, cameraMatrix); + draw3dCubeMarker(input, tagsizeX, tagsizeX, tagsizeY, 5, pose.rvec, pose.tvec, cameraMatrix); + + Orientation rot = Orientation.getOrientation(detection.pose.R, AxesReference.INTRINSIC, AxesOrder.YXZ, AngleUnit.DEGREES); + + telemetry.addLine(String.format("\nDetected tag ID=%d", detection.id)); + telemetry.addLine(String.format("Translation X: %.2f feet", detection.pose.x*FEET_PER_METER)); + telemetry.addLine(String.format("Translation Y: %.2f feet", detection.pose.y*FEET_PER_METER)); + telemetry.addLine(String.format("Translation Z: %.2f feet", detection.pose.z*FEET_PER_METER)); + + telemetry.addLine(String.format("Rotation Yaw: %.2f degrees", rot.firstAngle)); + telemetry.addLine(String.format("Rotation Pitch: %.2f degrees", rot.secondAngle)); + telemetry.addLine(String.format("Rotation Roll: %.2f degrees", rot.thirdAngle)); + } + + telemetry.update(); + + return input; + } + + public void setDecimation(float decimation) + { + synchronized (decimationSync) + { + this.decimation = decimation; + needToSetDecimation = true; + } + } + + public ArrayList getLatestDetections() + { + return detections; + } + + public ArrayList getDetectionsUpdate() + { + synchronized (detectionsUpdateSync) + { + ArrayList ret = detectionsUpdate; + detectionsUpdate = null; + return ret; + } + } + + void constructMatrix() + { + // Construct the camera matrix. + // + // -- -- + // | fx 0 cx | + // | 0 fy cy | + // | 0 0 1 | + // -- -- + // + + cameraMatrix = new Mat(3,3, CvType.CV_32FC1); + + cameraMatrix.put(0,0, fx); + cameraMatrix.put(0,1,0); + cameraMatrix.put(0,2, cx); + + cameraMatrix.put(1,0,0); + cameraMatrix.put(1,1,fy); + cameraMatrix.put(1,2,cy); + + cameraMatrix.put(2, 0, 0); + cameraMatrix.put(2,1,0); + cameraMatrix.put(2,2,1); + } + + /** + * Draw a 3D axis marker on a detection. (Similar to what Vuforia does) + * + * @param buf the RGB buffer on which to draw the marker + * @param length the length of each of the marker 'poles' + * @param rvec the rotation vector of the detection + * @param tvec the translation vector of the detection + * @param cameraMatrix the camera matrix used when finding the detection + */ + void drawAxisMarker(Mat buf, double length, int thickness, Mat rvec, Mat tvec, Mat cameraMatrix) + { + // The points in 3D space we wish to project onto the 2D image plane. + // The origin of the coordinate space is assumed to be in the center of the detection. + MatOfPoint3f axis = new MatOfPoint3f( + new Point3(0,0,0), + new Point3(length,0,0), + new Point3(0,length,0), + new Point3(0,0,-length) + ); + + // Project those points + MatOfPoint2f matProjectedPoints = new MatOfPoint2f(); + Calib3d.projectPoints(axis, rvec, tvec, cameraMatrix, new MatOfDouble(), matProjectedPoints); + Point[] projectedPoints = matProjectedPoints.toArray(); + + // Draw the marker! + Imgproc.line(buf, projectedPoints[0], projectedPoints[1], red, thickness); + Imgproc.line(buf, projectedPoints[0], projectedPoints[2], green, thickness); + Imgproc.line(buf, projectedPoints[0], projectedPoints[3], blue, thickness); + + Imgproc.circle(buf, projectedPoints[0], thickness, white, -1); + } + + void draw3dCubeMarker(Mat buf, double length, double tagWidth, double tagHeight, int thickness, Mat rvec, Mat tvec, Mat cameraMatrix) + { + //axis = np.float32([[0,0,0], [0,3,0], [3,3,0], [3,0,0], + // [0,0,-3],[0,3,-3],[3,3,-3],[3,0,-3] ]) + + // The points in 3D space we wish to project onto the 2D image plane. + // The origin of the coordinate space is assumed to be in the center of the detection. + MatOfPoint3f axis = new MatOfPoint3f( + new Point3(-tagWidth/2, tagHeight/2,0), + new Point3( tagWidth/2, tagHeight/2,0), + new Point3( tagWidth/2,-tagHeight/2,0), + new Point3(-tagWidth/2,-tagHeight/2,0), + new Point3(-tagWidth/2, tagHeight/2,-length), + new Point3( tagWidth/2, tagHeight/2,-length), + new Point3( tagWidth/2,-tagHeight/2,-length), + new Point3(-tagWidth/2,-tagHeight/2,-length)); + + // Project those points + MatOfPoint2f matProjectedPoints = new MatOfPoint2f(); + Calib3d.projectPoints(axis, rvec, tvec, cameraMatrix, new MatOfDouble(), matProjectedPoints); + Point[] projectedPoints = matProjectedPoints.toArray(); + + // Pillars + for(int i = 0; i < 4; i++) + { + Imgproc.line(buf, projectedPoints[i], projectedPoints[i+4], blue, thickness); + } + + // Base lines + //Imgproc.line(buf, projectedPoints[0], projectedPoints[1], blue, thickness); + //Imgproc.line(buf, projectedPoints[1], projectedPoints[2], blue, thickness); + //Imgproc.line(buf, projectedPoints[2], projectedPoints[3], blue, thickness); + //Imgproc.line(buf, projectedPoints[3], projectedPoints[0], blue, thickness); + + // Top lines + Imgproc.line(buf, projectedPoints[4], projectedPoints[5], green, thickness); + Imgproc.line(buf, projectedPoints[5], projectedPoints[6], green, thickness); + Imgproc.line(buf, projectedPoints[6], projectedPoints[7], green, thickness); + Imgproc.line(buf, projectedPoints[4], projectedPoints[7], green, thickness); + } + + /** + * Extracts 6DOF pose from a trapezoid, using a camera intrinsics matrix and the + * original size of the tag. + * + * @param points the points which form the trapezoid + * @param cameraMatrix the camera intrinsics matrix + * @param tagsizeX the original width of the tag + * @param tagsizeY the original height of the tag + * @return the 6DOF pose of the camera relative to the tag + */ + Pose poseFromTrapezoid(Point[] points, Mat cameraMatrix, double tagsizeX , double tagsizeY) + { + // The actual 2d points of the tag detected in the image + MatOfPoint2f points2d = new MatOfPoint2f(points); + + // The 3d points of the tag in an 'ideal projection' + Point3[] arrayPoints3d = new Point3[4]; + arrayPoints3d[0] = new Point3(-tagsizeX/2, tagsizeY/2, 0); + arrayPoints3d[1] = new Point3(tagsizeX/2, tagsizeY/2, 0); + arrayPoints3d[2] = new Point3(tagsizeX/2, -tagsizeY/2, 0); + arrayPoints3d[3] = new Point3(-tagsizeX/2, -tagsizeY/2, 0); + MatOfPoint3f points3d = new MatOfPoint3f(arrayPoints3d); + + // Using this information, actually solve for pose + Pose pose = new Pose(); + Calib3d.solvePnP(points3d, points2d, cameraMatrix, new MatOfDouble(), pose.rvec, pose.tvec, false); + + return pose; + } + + /* + * A simple container to hold both rotation and translation + * vectors, which together form a 6DOF pose. + */ + class Pose + { + Mat rvec; + Mat tvec; + + public Pose() + { + rvec = new Mat(); + tvec = new Mat(); + } + + public Pose(Mat rvec, Mat tvec) + { + this.rvec = rvec; + this.tvec = tvec; + } + } } \ No newline at end of file diff --git a/Vision/build.gradle b/Vision/build.gradle index 18a23198..d60a837b 100644 --- a/Vision/build.gradle +++ b/Vision/build.gradle @@ -1,45 +1,47 @@ -plugins { - id 'kotlin' - id 'maven-publish' -} - -apply from: '../build.common.gradle' - -task sourcesJar(type: Jar) { - from sourceSets.main.allJava - archiveClassifier = "sources" -} - -publishing { - publications { - mavenJava(MavenPublication) { - from components.java - artifact sourcesJar - } - } -} - -configurations.all { - resolutionStrategy { - cacheChangingModulesFor 0, 'seconds' - } -} - -dependencies { - implementation project(':Common') - - implementation "com.github.deltacv.AprilTagDesktop:AprilTagDesktop:$apriltag_plugin_version" - - api "org.openpnp:opencv:$opencv_version" - - implementation "org.slf4j:slf4j-api:$slf4j_version" - implementation 'org.jetbrains.kotlin:kotlin-stdlib' - - // Compatibility: Skiko supports many platforms but we will only be adding - // those that are supported by AprilTagDesktop as well - - implementation("org.jetbrains.skiko:skiko-awt-runtime-windows-x64:$skiko_version") - implementation("org.jetbrains.skiko:skiko-awt-runtime-linux-x64:$skiko_version") - implementation("org.jetbrains.skiko:skiko-awt-runtime-macos-x64:$skiko_version") - implementation("org.jetbrains.skiko:skiko-awt-runtime-macos-arm64:$skiko_version") +plugins { + id 'kotlin' + id 'maven-publish' +} + +apply from: '../build.common.gradle' + +task sourcesJar(type: Jar) { + from sourceSets.main.allJava + archiveClassifier = "sources" +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + artifact sourcesJar + } + } +} + +configurations.all { + resolutionStrategy { + cacheChangingModulesFor 0, 'seconds' + } +} + +dependencies { + implementation project(':Common') + + implementation(apriltag_plugin_dependency) { + exclude group: 'com.github.deltacv.EOCVSim', module: 'Common' + } + + api "org.openpnp:opencv:$opencv_version" + + implementation "org.slf4j:slf4j-api:$slf4j_version" + implementation 'org.jetbrains.kotlin:kotlin-stdlib' + + // Compatibility: Skiko supports many platforms but we will only be adding + // those that are supported by AprilTagDesktop as well + + implementation("org.jetbrains.skiko:skiko-awt-runtime-windows-x64:$skiko_version") + implementation("org.jetbrains.skiko:skiko-awt-runtime-linux-x64:$skiko_version") + implementation("org.jetbrains.skiko:skiko-awt-runtime-macos-x64:$skiko_version") + implementation("org.jetbrains.skiko:skiko-awt-runtime-macos-arm64:$skiko_version") } \ No newline at end of file diff --git a/Vision/src/main/java/org/firstinspires/ftc/vision/apriltag/AprilTagProcessorImpl.java b/Vision/src/main/java/org/firstinspires/ftc/vision/apriltag/AprilTagProcessorImpl.java index 023284ed..a9fad3ec 100644 --- a/Vision/src/main/java/org/firstinspires/ftc/vision/apriltag/AprilTagProcessorImpl.java +++ b/Vision/src/main/java/org/firstinspires/ftc/vision/apriltag/AprilTagProcessorImpl.java @@ -1,612 +1,612 @@ -/* - * Copyright (c) 2023 FIRST - * - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, - * are permitted (subject to the limitations in the disclaimer below) provided that - * the following conditions are met: - * - * Redistributions of source code must retain the above copyright notice, this list - * of conditions and the following disclaimer. - * - * Redistributions in binary form must reproduce the above copyright notice, this - * list of conditions and the following disclaimer in the documentation and/or - * other materials provided with the distribution. - * - * Neither the name of FIRST nor the names of its contributors may be used to - * endorse or promote products derived from this software without specific prior - * written permission. - * - * NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY THIS - * LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, - * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE - * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER - * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR - * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF - * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package org.firstinspires.ftc.vision.apriltag; - -import android.graphics.Canvas; - -import com.qualcomm.robotcore.util.MovingStatistics; -import com.qualcomm.robotcore.util.RobotLog; - -import org.firstinspires.ftc.robotcore.external.matrices.GeneralMatrixF; -import org.firstinspires.ftc.robotcore.external.matrices.OpenGLMatrix; -import org.firstinspires.ftc.robotcore.external.matrices.VectorF; -import org.firstinspires.ftc.robotcore.external.navigation.AngleUnit; -import org.firstinspires.ftc.robotcore.external.navigation.Pose3D; -import org.firstinspires.ftc.robotcore.external.navigation.AxesOrder; -import org.firstinspires.ftc.robotcore.external.navigation.AxesReference; -import org.firstinspires.ftc.robotcore.external.navigation.DistanceUnit; -import org.firstinspires.ftc.robotcore.external.navigation.Orientation; -import org.firstinspires.ftc.robotcore.external.navigation.Position; -import org.firstinspires.ftc.robotcore.external.navigation.YawPitchRollAngles; -import org.firstinspires.ftc.robotcore.internal.camera.calibration.CameraCalibration; -import org.opencv.calib3d.Calib3d; -import org.opencv.core.CvType; -import org.opencv.core.Mat; -import org.opencv.core.MatOfDouble; -import org.opencv.core.MatOfPoint2f; -import org.opencv.core.MatOfPoint3f; -import org.opencv.core.Point; -import org.opencv.core.Point3; -import org.opencv.imgproc.Imgproc; -import org.openftc.apriltag.AprilTagDetectorJNI; -import org.openftc.apriltag.ApriltagDetectionJNI; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.ArrayList; - -public class AprilTagProcessorImpl extends AprilTagProcessor -{ - public static final String TAG = "AprilTagProcessorImpl"; - - Logger logger = LoggerFactory.getLogger(TAG); - - private long nativeApriltagPtr; - private Mat grey = new Mat(); - private ArrayList detections = new ArrayList<>(); - - private ArrayList detectionsUpdate = new ArrayList<>(); - private final Object detectionsUpdateSync = new Object(); - private boolean drawAxes; - private boolean drawCube; - private boolean drawOutline; - private boolean drawTagID; - - private Mat cameraMatrix; - - private double fx; - private double fy; - private double cx; - private double cy; - private final boolean suppressCalibrationWarnings; - - private final AprilTagLibrary tagLibrary; - - private float decimation; - private boolean needToSetDecimation; - private final Object decimationSync = new Object(); - - private AprilTagCanvasAnnotator canvasAnnotator; - - private final DistanceUnit outputUnitsLength; - private final AngleUnit outputUnitsAngle; - - private volatile PoseSolver poseSolver = PoseSolver.APRILTAG_BUILTIN; - - private OpenGLMatrix robotInCameraFrame; - - public AprilTagProcessorImpl(OpenGLMatrix robotInCameraFrame, double fx, double fy, double cx, double cy, DistanceUnit outputUnitsLength, AngleUnit outputUnitsAngle, AprilTagLibrary tagLibrary, - boolean drawAxes, boolean drawCube, boolean drawOutline, boolean drawTagID, TagFamily tagFamily, int threads, boolean suppressCalibrationWarnings) - { - this.robotInCameraFrame = robotInCameraFrame; - - this.fx = fx; - this.fy = fy; - this.cx = cx; - this.cy = cy; - - this.tagLibrary = tagLibrary; - this.outputUnitsLength = outputUnitsLength; - this.outputUnitsAngle = outputUnitsAngle; - this.suppressCalibrationWarnings = suppressCalibrationWarnings; - this.drawAxes = drawAxes; - this.drawCube = drawCube; - this.drawOutline = drawOutline; - this.drawTagID = drawTagID; - - // Allocate a native context object. See the corresponding deletion in the finalizer - nativeApriltagPtr = AprilTagDetectorJNI.createApriltagDetector(tagFamily.ATLibTF.string, 3, threads); - } - - @Override - protected void finalize() - { - // Might be null if createApriltagDetector() threw an exception - if(nativeApriltagPtr != 0) - { - // Delete the native context we created in the constructor - AprilTagDetectorJNI.releaseApriltagDetector(nativeApriltagPtr); - nativeApriltagPtr = 0; - } - else - { - System.out.println("AprilTagDetectionPipeline.finalize(): nativeApriltagPtr was NULL"); - } - } - - @Override - public void init(int width, int height, CameraCalibration calibration) - { - // ATTEMPT 1 - If the user provided their own calibration, use that - if (fx != 0 && fy != 0 && cx != 0 && cy != 0) - { - logger.debug(String.format("User provided their own camera calibration fx=%7.3f fy=%7.3f cx=%7.3f cy=%7.3f", - fx, fy, cx, cy)); - } - - // ATTEMPT 2 - If we have valid calibration we can use, use it - else if (calibration != null && !calibration.isDegenerate()) // needed because we may get an all zero calibration to indicate none, instead of null - { - fx = calibration.focalLengthX; - fy = calibration.focalLengthY; - cx = calibration.principalPointX; - cy = calibration.principalPointY; - - // Note that this might have been a scaled calibration - inform the user if so - if (calibration.resolutionScaledFrom != null) - { - String msg = String.format("Camera has not been calibrated for [%dx%d]; applying a scaled calibration from [%dx%d].", width, height, calibration.resolutionScaledFrom.getWidth(), calibration.resolutionScaledFrom.getHeight()); - - if (!suppressCalibrationWarnings) - { - logger.warn(msg); - } - } - // Nope, it was a full up proper calibration - no need to pester the user about anything - else - { - logger.debug(String.format("User did not provide a camera calibration; but we DO have a built in calibration we can use.\n [%dx%d] (NOT scaled) %s\nfx=%7.3f fy=%7.3f cx=%7.3f cy=%7.3f", - calibration.getSize().getWidth(), calibration.getSize().getHeight(), calibration.getIdentity().toString(), fx, fy, cx, cy)); - } - } - - // Okay, we aren't going to have any calibration data we can use, but there are 2 cases to check - else - { - // NO-OP, we cannot implement this for EOCV-Sim in the same way as the FTC SDK - - /* - // If we have a calibration on file, but with a wrong aspect ratio, - // we can't use it, but hey at least we can let the user know about it. - if (calibration instanceof PlaceholderCalibratedAspectRatioMismatch) - { - StringBuilder supportedResBuilder = new StringBuilder(); - - for (CameraCalibration cal : CameraCalibrationHelper.getInstance().getCalibrations(calibration.getIdentity())) - { - supportedResBuilder.append(String.format("[%dx%d],", cal.getSize().getWidth(), cal.getSize().getHeight())); - } - - String msg = String.format("Camera has not been calibrated for [%dx%d]. Pose estimates will likely be inaccurate. However, there are built in calibrations for resolutions: %s", - width, height, supportedResBuilder.toString()); - - if (!suppressCalibrationWarnings) - { - logger.warn(msg); - } - - - // Nah, we got absolutely nothing - else*/ - { - String warning = "User did not provide a camera calibration, nor was a built-in calibration found for this camera. Pose estimates will likely be inaccurate."; - - if (!suppressCalibrationWarnings) - { - logger.warn(warning); - } - } - - // IN EITHER CASE, set it to *something* so we don't crash the native code - fx = 578.272; - fy = 578.272; - cx = width/2; - cy = height/2; - } - - constructMatrix(); - canvasAnnotator = new AprilTagCanvasAnnotator(cameraMatrix); - } - - @Override - public Object processFrame(Mat input, long captureTimeNanos) - { - // Convert to greyscale - Imgproc.cvtColor(input, grey, Imgproc.COLOR_RGBA2GRAY); - - synchronized (decimationSync) - { - if(needToSetDecimation) - { - AprilTagDetectorJNI.setApriltagDetectorDecimation(nativeApriltagPtr, decimation); - needToSetDecimation = false; - } - } - - // Run AprilTag - detections = runAprilTagDetectorForMultipleTagSizes(captureTimeNanos); - - synchronized (detectionsUpdateSync) - { - detectionsUpdate = detections; - } - - // TODO do we need to deep copy this so the user can't mess with it before use in onDrawFrame()? - return detections; - } - - private MovingStatistics solveTime = new MovingStatistics(50); - - // We cannot use runAprilTagDetectorSimple because we cannot assume tags are all the same size - ArrayList runAprilTagDetectorForMultipleTagSizes(long captureTimeNanos) - { - long ptrDetectionArray = AprilTagDetectorJNI.runApriltagDetector(nativeApriltagPtr, grey.dataAddr(), grey.width(), grey.height()); - if (ptrDetectionArray != 0) - { - long[] detectionPointers = ApriltagDetectionJNI.getDetectionPointers(ptrDetectionArray); - ArrayList detections = new ArrayList<>(detectionPointers.length); - - for (long ptrDetection : detectionPointers) - { - AprilTagMetadata metadata = tagLibrary.lookupTag(ApriltagDetectionJNI.getId(ptrDetection)); - - double[][] corners = ApriltagDetectionJNI.getCorners(ptrDetection); - - Point[] cornerPts = new Point[4]; - for (int p = 0; p < 4; p++) - { - cornerPts[p] = new Point(corners[p][0], corners[p][1]); - } - - AprilTagPoseRaw rawPose; - AprilTagPoseFtc ftcPose; - Pose3D robotPose; - - if (metadata != null) - { - PoseSolver solver = poseSolver; // snapshot, can change - - long startSolveTime = System.currentTimeMillis(); - - if (solver == PoseSolver.APRILTAG_BUILTIN) - { - double[] pose = ApriltagDetectionJNI.getPoseEstimate( - ptrDetection, - outputUnitsLength.fromUnit(metadata.distanceUnit, metadata.tagsize), - fx, fy, cx, cy); - - // Build rotation matrix - float[] rotMtxVals = new float[3 * 3]; - for (int i = 0; i < 9; i++) - { - rotMtxVals[i] = (float) pose[3 + i]; - } - - rawPose = new AprilTagPoseRaw( - pose[0], pose[1], pose[2], // x y z - new GeneralMatrixF(3, 3, rotMtxVals)); // R - } - else - { - Pose opencvPose = poseFromTrapezoid( - cornerPts, - cameraMatrix, - outputUnitsLength.fromUnit(metadata.distanceUnit, metadata.tagsize), - solver.code); - - // Build rotation matrix - Mat R = new Mat(3, 3, CvType.CV_32F); - Calib3d.Rodrigues(opencvPose.rvec, R); - float[] tmp2 = new float[9]; - R.get(0,0, tmp2); - - rawPose = new AprilTagPoseRaw( - opencvPose.tvec.get(0,0)[0], // x - opencvPose.tvec.get(1,0)[0], // y - opencvPose.tvec.get(2,0)[0], // z - new GeneralMatrixF(3,3, tmp2)); // R - } - - long endSolveTime = System.currentTimeMillis(); - solveTime.add(endSolveTime-startSolveTime); - } - else - { - // We don't know anything about the tag size so we can't solve the pose - rawPose = null; - } - - if (rawPose != null) - { - Orientation rot = Orientation.getOrientation(rawPose.R, AxesReference.INTRINSIC, AxesOrder.YXZ, outputUnitsAngle); - - ftcPose = new AprilTagPoseFtc( - rawPose.x, // x NB: These are *intentionally* not matched directly; - rawPose.z, // y this is the mapping between the AprilTag coordinate - -rawPose.y, // z system and the FTC coordinate system - -rot.firstAngle, // yaw - rot.secondAngle, // pitch - rot.thirdAngle, // roll - Math.hypot(rawPose.x, rawPose.z), // range - outputUnitsAngle.fromUnit(AngleUnit.RADIANS, Math.atan2(-rawPose.x, rawPose.z)), // bearing - outputUnitsAngle.fromUnit(AngleUnit.RADIANS, Math.atan2(-rawPose.y, rawPose.z))); // elevation - - robotPose = computeRobotPose(rawPose, metadata, captureTimeNanos); - } - else - { - ftcPose = null; - robotPose = null; - } - - double[] center = ApriltagDetectionJNI.getCenterpoint(ptrDetection); - - detections.add(new AprilTagDetection( - ApriltagDetectionJNI.getId(ptrDetection), - ApriltagDetectionJNI.getHamming(ptrDetection), - ApriltagDetectionJNI.getDecisionMargin(ptrDetection), - new Point(center[0], center[1]), cornerPts, metadata, ftcPose, rawPose, robotPose, captureTimeNanos)); - } - - ApriltagDetectionJNI.freeDetectionList(ptrDetectionArray); - return detections; - } - - return new ArrayList<>(); - } - - private Pose3D computeRobotPose(AprilTagPoseRaw rawPose, AprilTagMetadata metadata, long acquisitionTime) - { - // Compute transformation matrix of tag pose in field reference frame - float tagInFieldX = metadata.fieldPosition.get(0); - float tagInFieldY = metadata.fieldPosition.get(1); - float tagInFieldZ = metadata.fieldPosition.get(2); - OpenGLMatrix tagInFieldR = new OpenGLMatrix(metadata.fieldOrientation.toMatrix()); - OpenGLMatrix tagInFieldFrame = OpenGLMatrix.identityMatrix() - .translated(tagInFieldX, tagInFieldY, tagInFieldZ) - .multiplied(tagInFieldR); - - // Compute transformation matrix of camera pose in tag reference frame - float tagInCameraX = (float) DistanceUnit.INCH.fromUnit(outputUnitsLength, rawPose.x); - float tagInCameraY = (float) DistanceUnit.INCH.fromUnit(outputUnitsLength, rawPose.y); - float tagInCameraZ = (float) DistanceUnit.INCH.fromUnit(outputUnitsLength, rawPose.z); - OpenGLMatrix tagInCameraR = new OpenGLMatrix((rawPose.R)); - OpenGLMatrix cameraInTagFrame = OpenGLMatrix.identityMatrix() - .translated(tagInCameraX, tagInCameraY, tagInCameraZ) - .multiplied(tagInCameraR) - .inverted(); - - // Compute transformation matrix of robot pose in field frame - OpenGLMatrix robotInFieldFrame = - tagInFieldFrame - .multiplied(cameraInTagFrame) - .multiplied(robotInCameraFrame); - - // Extract robot location - VectorF robotInFieldTranslation = robotInFieldFrame.getTranslation(); - Position robotPosition = new Position(DistanceUnit.INCH, - robotInFieldTranslation.get(0), - robotInFieldTranslation.get(1), - robotInFieldTranslation.get(2), - acquisitionTime).toUnit(outputUnitsLength); - - // Extract robot orientation - Orientation robotInFieldOrientation = Orientation.getOrientation(robotInFieldFrame, - AxesReference.INTRINSIC, AxesOrder.ZXY, outputUnitsAngle); - YawPitchRollAngles robotOrientation = new YawPitchRollAngles(outputUnitsAngle, - robotInFieldOrientation.firstAngle, - robotInFieldOrientation.secondAngle, - robotInFieldOrientation.thirdAngle, - acquisitionTime); - - return new Pose3D(robotPosition, robotOrientation); - } - - private final Object drawSync = new Object(); - - @Override - public void onDrawFrame(Canvas canvas, int onscreenWidth, int onscreenHeight, float scaleBmpPxToCanvasPx, float scaleCanvasDensity, Object userContext) - { - // Only one draw operation at a time thank you very much. - // (we could be called from two different threads - viewport or camera stream) - synchronized (drawSync) - { - if ((drawAxes || drawCube || drawOutline || drawTagID) && userContext != null) - { - canvasAnnotator.noteDrawParams(scaleBmpPxToCanvasPx, scaleCanvasDensity); - - ArrayList dets = (ArrayList) userContext; - - // For fun, draw 6DOF markers on the image. - for(AprilTagDetection detection : dets) - { - if (drawTagID) - { - canvasAnnotator.drawTagID(detection, canvas); - } - - // Could be null if we couldn't solve the pose earlier due to not knowing tag size - if (detection.rawPose != null) - { - AprilTagMetadata metadata = tagLibrary.lookupTag(detection.id); - double tagSize = outputUnitsLength.fromUnit(metadata.distanceUnit, metadata.tagsize); - - if (drawOutline) - { - canvasAnnotator.drawOutlineMarker(detection, canvas, tagSize); - } - if (drawAxes) - { - canvasAnnotator.drawAxisMarker(detection, canvas, tagSize); - } - if (drawCube) - { - canvasAnnotator.draw3dCubeMarker(detection, canvas, tagSize); - } - } - } - } - } - } - - public void setDecimation(float decimation) - { - synchronized (decimationSync) - { - this.decimation = decimation; - needToSetDecimation = true; - } - } - - @Override - public void setPoseSolver(PoseSolver poseSolver) - { - this.poseSolver = poseSolver; - } - - @Override - public int getPerTagAvgPoseSolveTime() - { - return (int) Math.round(solveTime.getMean()); - } - - public ArrayList getDetections() - { - return detections; - } - - public ArrayList getFreshDetections() - { - synchronized (detectionsUpdateSync) - { - ArrayList ret = detectionsUpdate; - detectionsUpdate = null; - return ret; - } - } - - void constructMatrix() - { - // Construct the camera matrix. - // - // -- -- - // | fx 0 cx | - // | 0 fy cy | - // | 0 0 1 | - // -- -- - // - - cameraMatrix = new Mat(3,3, CvType.CV_32FC1); - - cameraMatrix.put(0,0, fx); - cameraMatrix.put(0,1,0); - cameraMatrix.put(0,2, cx); - - cameraMatrix.put(1,0,0); - cameraMatrix.put(1,1,fy); - cameraMatrix.put(1,2,cy); - - cameraMatrix.put(2, 0, 0); - cameraMatrix.put(2,1,0); - cameraMatrix.put(2,2,1); - } - - /** - * Converts an AprilTag pose to an OpenCV pose - * @param aprilTagPose pose to convert - * @return OpenCV output pose - */ - static Pose aprilTagPoseToOpenCvPose(AprilTagPoseRaw aprilTagPose) - { - Pose pose = new Pose(); - pose.tvec.put(0,0, aprilTagPose.x); - pose.tvec.put(1,0, aprilTagPose.y); - pose.tvec.put(2,0, aprilTagPose.z); - - Mat R = new Mat(3, 3, CvType.CV_32F); - - for (int i = 0; i < 3; i++) - { - for (int j = 0; j < 3; j++) - { - R.put(i,j, aprilTagPose.R.get(i,j)); - } - } - - Calib3d.Rodrigues(R, pose.rvec); - - return pose; - } - - /** - * Extracts 6DOF pose from a trapezoid, using a camera intrinsics matrix and the - * original size of the tag. - * - * @param points the points which form the trapezoid - * @param cameraMatrix the camera intrinsics matrix - * @param tagsize the original length of the tag - * @return the 6DOF pose of the camera relative to the tag - */ - static Pose poseFromTrapezoid(Point[] points, Mat cameraMatrix, double tagsize, int solveMethod) - { - // The actual 2d points of the tag detected in the image - MatOfPoint2f points2d = new MatOfPoint2f(points); - - // The 3d points of the tag in an 'ideal projection' - Point3[] arrayPoints3d = new Point3[4]; - arrayPoints3d[0] = new Point3(-tagsize/2, tagsize/2, 0); - arrayPoints3d[1] = new Point3(tagsize/2, tagsize/2, 0); - arrayPoints3d[2] = new Point3(tagsize/2, -tagsize/2, 0); - arrayPoints3d[3] = new Point3(-tagsize/2, -tagsize/2, 0); - MatOfPoint3f points3d = new MatOfPoint3f(arrayPoints3d); - - // Using this information, actually solve for pose - Pose pose = new Pose(); - Calib3d.solvePnP(points3d, points2d, cameraMatrix, new MatOfDouble(), pose.rvec, pose.tvec, false, solveMethod); - - return pose; - } - - /* - * A simple container to hold both rotation and translation - * vectors, which together form a 6DOF pose. - */ - static class Pose - { - Mat rvec; - Mat tvec; - - public Pose() - { - rvec = new Mat(3, 1, CvType.CV_32F); - tvec = new Mat(3, 1, CvType.CV_32F); - } - - public Pose(Mat rvec, Mat tvec) - { - this.rvec = rvec; - this.tvec = tvec; - } - } +/* + * Copyright (c) 2023 FIRST + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted (subject to the limitations in the disclaimer below) provided that + * the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this list + * of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, this + * list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * + * Neither the name of FIRST nor the names of its contributors may be used to + * endorse or promote products derived from this software without specific prior + * written permission. + * + * NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY THIS + * LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR + * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF + * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package org.firstinspires.ftc.vision.apriltag; + +import android.graphics.Canvas; + +import com.qualcomm.robotcore.util.MovingStatistics; +import com.qualcomm.robotcore.util.RobotLog; + +import org.firstinspires.ftc.robotcore.external.matrices.GeneralMatrixF; +import org.firstinspires.ftc.robotcore.external.matrices.OpenGLMatrix; +import org.firstinspires.ftc.robotcore.external.matrices.VectorF; +import org.firstinspires.ftc.robotcore.external.navigation.AngleUnit; +import org.firstinspires.ftc.robotcore.external.navigation.Pose3D; +import org.firstinspires.ftc.robotcore.external.navigation.AxesOrder; +import org.firstinspires.ftc.robotcore.external.navigation.AxesReference; +import org.firstinspires.ftc.robotcore.external.navigation.DistanceUnit; +import org.firstinspires.ftc.robotcore.external.navigation.Orientation; +import org.firstinspires.ftc.robotcore.external.navigation.Position; +import org.firstinspires.ftc.robotcore.external.navigation.YawPitchRollAngles; +import org.firstinspires.ftc.robotcore.internal.camera.calibration.CameraCalibration; +import org.opencv.calib3d.Calib3d; +import org.opencv.core.CvType; +import org.opencv.core.Mat; +import org.opencv.core.MatOfDouble; +import org.opencv.core.MatOfPoint2f; +import org.opencv.core.MatOfPoint3f; +import org.opencv.core.Point; +import org.opencv.core.Point3; +import org.opencv.imgproc.Imgproc; +import org.openftc.apriltag.AprilTagDetectorJNI; +import org.openftc.apriltag.ApriltagDetectionJNI; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; + +public class AprilTagProcessorImpl extends AprilTagProcessor +{ + public static final String TAG = "AprilTagProcessorImpl"; + + Logger logger = LoggerFactory.getLogger(TAG); + + private long nativeApriltagPtr; + private Mat grey = new Mat(); + private ArrayList detections = new ArrayList<>(); + + private ArrayList detectionsUpdate = new ArrayList<>(); + private final Object detectionsUpdateSync = new Object(); + private boolean drawAxes; + private boolean drawCube; + private boolean drawOutline; + private boolean drawTagID; + + private Mat cameraMatrix; + + private double fx; + private double fy; + private double cx; + private double cy; + private final boolean suppressCalibrationWarnings; + + private final AprilTagLibrary tagLibrary; + + private float decimation; + private boolean needToSetDecimation; + private final Object decimationSync = new Object(); + + private AprilTagCanvasAnnotator canvasAnnotator; + + private final DistanceUnit outputUnitsLength; + private final AngleUnit outputUnitsAngle; + + private volatile PoseSolver poseSolver = PoseSolver.APRILTAG_BUILTIN; + + private OpenGLMatrix robotInCameraFrame; + + public AprilTagProcessorImpl(OpenGLMatrix robotInCameraFrame, double fx, double fy, double cx, double cy, DistanceUnit outputUnitsLength, AngleUnit outputUnitsAngle, AprilTagLibrary tagLibrary, + boolean drawAxes, boolean drawCube, boolean drawOutline, boolean drawTagID, TagFamily tagFamily, int threads, boolean suppressCalibrationWarnings) + { + this.robotInCameraFrame = robotInCameraFrame; + + this.fx = fx; + this.fy = fy; + this.cx = cx; + this.cy = cy; + + this.tagLibrary = tagLibrary; + this.outputUnitsLength = outputUnitsLength; + this.outputUnitsAngle = outputUnitsAngle; + this.suppressCalibrationWarnings = suppressCalibrationWarnings; + this.drawAxes = drawAxes; + this.drawCube = drawCube; + this.drawOutline = drawOutline; + this.drawTagID = drawTagID; + + // Allocate a native context object. See the corresponding deletion in the finalizer + nativeApriltagPtr = AprilTagDetectorJNI.createApriltagDetector(tagFamily.ATLibTF.string, 3, threads); + } + + @Override + protected void finalize() + { + // Might be null if createApriltagDetector() threw an exception + if(nativeApriltagPtr != 0) + { + // Delete the native context we created in the constructor + AprilTagDetectorJNI.releaseApriltagDetector(nativeApriltagPtr); + nativeApriltagPtr = 0; + } + else + { + logger.debug("AprilTagDetectionPipeline.finalize(): nativeApriltagPtr was NULL"); + } + } + + @Override + public void init(int width, int height, CameraCalibration calibration) + { + // ATTEMPT 1 - If the user provided their own calibration, use that + if (fx != 0 && fy != 0 && cx != 0 && cy != 0) + { + logger.debug(String.format("User provided their own camera calibration fx=%7.3f fy=%7.3f cx=%7.3f cy=%7.3f", + fx, fy, cx, cy)); + } + + // ATTEMPT 2 - If we have valid calibration we can use, use it + else if (calibration != null && !calibration.isDegenerate()) // needed because we may get an all zero calibration to indicate none, instead of null + { + fx = calibration.focalLengthX; + fy = calibration.focalLengthY; + cx = calibration.principalPointX; + cy = calibration.principalPointY; + + // Note that this might have been a scaled calibration - inform the user if so + if (calibration.resolutionScaledFrom != null) + { + String msg = String.format("Camera has not been calibrated for [%dx%d]; applying a scaled calibration from [%dx%d].", width, height, calibration.resolutionScaledFrom.getWidth(), calibration.resolutionScaledFrom.getHeight()); + + if (!suppressCalibrationWarnings) + { + logger.warn(msg); + } + } + // Nope, it was a full up proper calibration - no need to pester the user about anything + else + { + logger.debug(String.format("User did not provide a camera calibration; but we DO have a built in calibration we can use.\n [%dx%d] (NOT scaled) %s\nfx=%7.3f fy=%7.3f cx=%7.3f cy=%7.3f", + calibration.getSize().getWidth(), calibration.getSize().getHeight(), calibration.getIdentity().toString(), fx, fy, cx, cy)); + } + } + + // Okay, we aren't going to have any calibration data we can use, but there are 2 cases to check + else + { + // NO-OP, we cannot implement this for EOCV-Sim in the same way as the FTC SDK + + /* + // If we have a calibration on file, but with a wrong aspect ratio, + // we can't use it, but hey at least we can let the user know about it. + if (calibration instanceof PlaceholderCalibratedAspectRatioMismatch) + { + StringBuilder supportedResBuilder = new StringBuilder(); + + for (CameraCalibration cal : CameraCalibrationHelper.getInstance().getCalibrations(calibration.getIdentity())) + { + supportedResBuilder.append(String.format("[%dx%d],", cal.getSize().getWidth(), cal.getSize().getHeight())); + } + + String msg = String.format("Camera has not been calibrated for [%dx%d]. Pose estimates will likely be inaccurate. However, there are built in calibrations for resolutions: %s", + width, height, supportedResBuilder.toString()); + + if (!suppressCalibrationWarnings) + { + logger.warn(msg); + } + + + // Nah, we got absolutely nothing + else*/ + { + String warning = "User did not provide a camera calibration, nor was a built-in calibration found for this camera. Pose estimates will likely be inaccurate."; + + if (!suppressCalibrationWarnings) + { + logger.warn(warning); + } + } + + // IN EITHER CASE, set it to *something* so we don't crash the native code + fx = 578.272; + fy = 578.272; + cx = width/2; + cy = height/2; + } + + constructMatrix(); + canvasAnnotator = new AprilTagCanvasAnnotator(cameraMatrix); + } + + @Override + public Object processFrame(Mat input, long captureTimeNanos) + { + // Convert to greyscale + Imgproc.cvtColor(input, grey, Imgproc.COLOR_RGBA2GRAY); + + synchronized (decimationSync) + { + if(needToSetDecimation) + { + AprilTagDetectorJNI.setApriltagDetectorDecimation(nativeApriltagPtr, decimation); + needToSetDecimation = false; + } + } + + // Run AprilTag + detections = runAprilTagDetectorForMultipleTagSizes(captureTimeNanos); + + synchronized (detectionsUpdateSync) + { + detectionsUpdate = detections; + } + + // TODO do we need to deep copy this so the user can't mess with it before use in onDrawFrame()? + return detections; + } + + private MovingStatistics solveTime = new MovingStatistics(50); + + // We cannot use runAprilTagDetectorSimple because we cannot assume tags are all the same size + ArrayList runAprilTagDetectorForMultipleTagSizes(long captureTimeNanos) + { + long ptrDetectionArray = AprilTagDetectorJNI.runApriltagDetector(nativeApriltagPtr, grey.dataAddr(), grey.width(), grey.height()); + if (ptrDetectionArray != 0) + { + long[] detectionPointers = ApriltagDetectionJNI.getDetectionPointers(ptrDetectionArray); + ArrayList detections = new ArrayList<>(detectionPointers.length); + + for (long ptrDetection : detectionPointers) + { + AprilTagMetadata metadata = tagLibrary.lookupTag(ApriltagDetectionJNI.getId(ptrDetection)); + + double[][] corners = ApriltagDetectionJNI.getCorners(ptrDetection); + + Point[] cornerPts = new Point[4]; + for (int p = 0; p < 4; p++) + { + cornerPts[p] = new Point(corners[p][0], corners[p][1]); + } + + AprilTagPoseRaw rawPose; + AprilTagPoseFtc ftcPose; + Pose3D robotPose; + + if (metadata != null) + { + PoseSolver solver = poseSolver; // snapshot, can change + + long startSolveTime = System.currentTimeMillis(); + + if (solver == PoseSolver.APRILTAG_BUILTIN) + { + double[] pose = ApriltagDetectionJNI.getPoseEstimate( + ptrDetection, + outputUnitsLength.fromUnit(metadata.distanceUnit, metadata.tagsize), + fx, fy, cx, cy); + + // Build rotation matrix + float[] rotMtxVals = new float[3 * 3]; + for (int i = 0; i < 9; i++) + { + rotMtxVals[i] = (float) pose[3 + i]; + } + + rawPose = new AprilTagPoseRaw( + pose[0], pose[1], pose[2], // x y z + new GeneralMatrixF(3, 3, rotMtxVals)); // R + } + else + { + Pose opencvPose = poseFromTrapezoid( + cornerPts, + cameraMatrix, + outputUnitsLength.fromUnit(metadata.distanceUnit, metadata.tagsize), + solver.code); + + // Build rotation matrix + Mat R = new Mat(3, 3, CvType.CV_32F); + Calib3d.Rodrigues(opencvPose.rvec, R); + float[] tmp2 = new float[9]; + R.get(0,0, tmp2); + + rawPose = new AprilTagPoseRaw( + opencvPose.tvec.get(0,0)[0], // x + opencvPose.tvec.get(1,0)[0], // y + opencvPose.tvec.get(2,0)[0], // z + new GeneralMatrixF(3,3, tmp2)); // R + } + + long endSolveTime = System.currentTimeMillis(); + solveTime.add(endSolveTime-startSolveTime); + } + else + { + // We don't know anything about the tag size so we can't solve the pose + rawPose = null; + } + + if (rawPose != null) + { + Orientation rot = Orientation.getOrientation(rawPose.R, AxesReference.INTRINSIC, AxesOrder.YXZ, outputUnitsAngle); + + ftcPose = new AprilTagPoseFtc( + rawPose.x, // x NB: These are *intentionally* not matched directly; + rawPose.z, // y this is the mapping between the AprilTag coordinate + -rawPose.y, // z system and the FTC coordinate system + -rot.firstAngle, // yaw + rot.secondAngle, // pitch + rot.thirdAngle, // roll + Math.hypot(rawPose.x, rawPose.z), // range + outputUnitsAngle.fromUnit(AngleUnit.RADIANS, Math.atan2(-rawPose.x, rawPose.z)), // bearing + outputUnitsAngle.fromUnit(AngleUnit.RADIANS, Math.atan2(-rawPose.y, rawPose.z))); // elevation + + robotPose = computeRobotPose(rawPose, metadata, captureTimeNanos); + } + else + { + ftcPose = null; + robotPose = null; + } + + double[] center = ApriltagDetectionJNI.getCenterpoint(ptrDetection); + + detections.add(new AprilTagDetection( + ApriltagDetectionJNI.getId(ptrDetection), + ApriltagDetectionJNI.getHamming(ptrDetection), + ApriltagDetectionJNI.getDecisionMargin(ptrDetection), + new Point(center[0], center[1]), cornerPts, metadata, ftcPose, rawPose, robotPose, captureTimeNanos)); + } + + ApriltagDetectionJNI.freeDetectionList(ptrDetectionArray); + return detections; + } + + return new ArrayList<>(); + } + + private Pose3D computeRobotPose(AprilTagPoseRaw rawPose, AprilTagMetadata metadata, long acquisitionTime) + { + // Compute transformation matrix of tag pose in field reference frame + float tagInFieldX = metadata.fieldPosition.get(0); + float tagInFieldY = metadata.fieldPosition.get(1); + float tagInFieldZ = metadata.fieldPosition.get(2); + OpenGLMatrix tagInFieldR = new OpenGLMatrix(metadata.fieldOrientation.toMatrix()); + OpenGLMatrix tagInFieldFrame = OpenGLMatrix.identityMatrix() + .translated(tagInFieldX, tagInFieldY, tagInFieldZ) + .multiplied(tagInFieldR); + + // Compute transformation matrix of camera pose in tag reference frame + float tagInCameraX = (float) DistanceUnit.INCH.fromUnit(outputUnitsLength, rawPose.x); + float tagInCameraY = (float) DistanceUnit.INCH.fromUnit(outputUnitsLength, rawPose.y); + float tagInCameraZ = (float) DistanceUnit.INCH.fromUnit(outputUnitsLength, rawPose.z); + OpenGLMatrix tagInCameraR = new OpenGLMatrix((rawPose.R)); + OpenGLMatrix cameraInTagFrame = OpenGLMatrix.identityMatrix() + .translated(tagInCameraX, tagInCameraY, tagInCameraZ) + .multiplied(tagInCameraR) + .inverted(); + + // Compute transformation matrix of robot pose in field frame + OpenGLMatrix robotInFieldFrame = + tagInFieldFrame + .multiplied(cameraInTagFrame) + .multiplied(robotInCameraFrame); + + // Extract robot location + VectorF robotInFieldTranslation = robotInFieldFrame.getTranslation(); + Position robotPosition = new Position(DistanceUnit.INCH, + robotInFieldTranslation.get(0), + robotInFieldTranslation.get(1), + robotInFieldTranslation.get(2), + acquisitionTime).toUnit(outputUnitsLength); + + // Extract robot orientation + Orientation robotInFieldOrientation = Orientation.getOrientation(robotInFieldFrame, + AxesReference.INTRINSIC, AxesOrder.ZXY, outputUnitsAngle); + YawPitchRollAngles robotOrientation = new YawPitchRollAngles(outputUnitsAngle, + robotInFieldOrientation.firstAngle, + robotInFieldOrientation.secondAngle, + robotInFieldOrientation.thirdAngle, + acquisitionTime); + + return new Pose3D(robotPosition, robotOrientation); + } + + private final Object drawSync = new Object(); + + @Override + public void onDrawFrame(Canvas canvas, int onscreenWidth, int onscreenHeight, float scaleBmpPxToCanvasPx, float scaleCanvasDensity, Object userContext) + { + // Only one draw operation at a time thank you very much. + // (we could be called from two different threads - viewport or camera stream) + synchronized (drawSync) + { + if ((drawAxes || drawCube || drawOutline || drawTagID) && userContext != null) + { + canvasAnnotator.noteDrawParams(scaleBmpPxToCanvasPx, scaleCanvasDensity); + + ArrayList dets = (ArrayList) userContext; + + // For fun, draw 6DOF markers on the image. + for(AprilTagDetection detection : dets) + { + if (drawTagID) + { + canvasAnnotator.drawTagID(detection, canvas); + } + + // Could be null if we couldn't solve the pose earlier due to not knowing tag size + if (detection.rawPose != null) + { + AprilTagMetadata metadata = tagLibrary.lookupTag(detection.id); + double tagSize = outputUnitsLength.fromUnit(metadata.distanceUnit, metadata.tagsize); + + if (drawOutline) + { + canvasAnnotator.drawOutlineMarker(detection, canvas, tagSize); + } + if (drawAxes) + { + canvasAnnotator.drawAxisMarker(detection, canvas, tagSize); + } + if (drawCube) + { + canvasAnnotator.draw3dCubeMarker(detection, canvas, tagSize); + } + } + } + } + } + } + + public void setDecimation(float decimation) + { + synchronized (decimationSync) + { + this.decimation = decimation; + needToSetDecimation = true; + } + } + + @Override + public void setPoseSolver(PoseSolver poseSolver) + { + this.poseSolver = poseSolver; + } + + @Override + public int getPerTagAvgPoseSolveTime() + { + return (int) Math.round(solveTime.getMean()); + } + + public ArrayList getDetections() + { + return detections; + } + + public ArrayList getFreshDetections() + { + synchronized (detectionsUpdateSync) + { + ArrayList ret = detectionsUpdate; + detectionsUpdate = null; + return ret; + } + } + + void constructMatrix() + { + // Construct the camera matrix. + // + // -- -- + // | fx 0 cx | + // | 0 fy cy | + // | 0 0 1 | + // -- -- + // + + cameraMatrix = new Mat(3,3, CvType.CV_32FC1); + + cameraMatrix.put(0,0, fx); + cameraMatrix.put(0,1,0); + cameraMatrix.put(0,2, cx); + + cameraMatrix.put(1,0,0); + cameraMatrix.put(1,1,fy); + cameraMatrix.put(1,2,cy); + + cameraMatrix.put(2, 0, 0); + cameraMatrix.put(2,1,0); + cameraMatrix.put(2,2,1); + } + + /** + * Converts an AprilTag pose to an OpenCV pose + * @param aprilTagPose pose to convert + * @return OpenCV output pose + */ + static Pose aprilTagPoseToOpenCvPose(AprilTagPoseRaw aprilTagPose) + { + Pose pose = new Pose(); + pose.tvec.put(0,0, aprilTagPose.x); + pose.tvec.put(1,0, aprilTagPose.y); + pose.tvec.put(2,0, aprilTagPose.z); + + Mat R = new Mat(3, 3, CvType.CV_32F); + + for (int i = 0; i < 3; i++) + { + for (int j = 0; j < 3; j++) + { + R.put(i,j, aprilTagPose.R.get(i,j)); + } + } + + Calib3d.Rodrigues(R, pose.rvec); + + return pose; + } + + /** + * Extracts 6DOF pose from a trapezoid, using a camera intrinsics matrix and the + * original size of the tag. + * + * @param points the points which form the trapezoid + * @param cameraMatrix the camera intrinsics matrix + * @param tagsize the original length of the tag + * @return the 6DOF pose of the camera relative to the tag + */ + static Pose poseFromTrapezoid(Point[] points, Mat cameraMatrix, double tagsize, int solveMethod) + { + // The actual 2d points of the tag detected in the image + MatOfPoint2f points2d = new MatOfPoint2f(points); + + // The 3d points of the tag in an 'ideal projection' + Point3[] arrayPoints3d = new Point3[4]; + arrayPoints3d[0] = new Point3(-tagsize/2, tagsize/2, 0); + arrayPoints3d[1] = new Point3(tagsize/2, tagsize/2, 0); + arrayPoints3d[2] = new Point3(tagsize/2, -tagsize/2, 0); + arrayPoints3d[3] = new Point3(-tagsize/2, -tagsize/2, 0); + MatOfPoint3f points3d = new MatOfPoint3f(arrayPoints3d); + + // Using this information, actually solve for pose + Pose pose = new Pose(); + Calib3d.solvePnP(points3d, points2d, cameraMatrix, new MatOfDouble(), pose.rvec, pose.tvec, false, solveMethod); + + return pose; + } + + /* + * A simple container to hold both rotation and translation + * vectors, which together form a 6DOF pose. + */ + static class Pose + { + Mat rvec; + Mat tvec; + + public Pose() + { + rvec = new Mat(3, 1, CvType.CV_32F); + tvec = new Mat(3, 1, CvType.CV_32F); + } + + public Pose(Mat rvec, Mat tvec) + { + this.rvec = rvec; + this.tvec = tvec; + } + } } \ No newline at end of file diff --git a/build.gradle b/build.gradle index 4aa666ae..9b439732 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,9 @@ buildscript { slf4j_version = "1.7.32" log4j_version = "2.17.1" opencv_version = "4.7.0-0" - apriltag_plugin_version = "2.0.0-C" + apriltag_plugin_version = "2.1.0-B" + apriltag_plugin_dependency = "com.github.deltacv:AprilTagDesktop:$apriltag_plugin_version" + skiko_version = "0.8.15" classgraph_version = "4.8.108" From 199ceccbd483621c069c35e3a010cf8120f1c013 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Wed, 9 Oct 2024 19:30:55 -0600 Subject: [PATCH 06/36] Freeing lock file on restart --- EOCV-Sim/build.gradle | 6 ++++-- .../java/com/github/serivesmejia/eocvsim/EOCVSim.kt | 1 + .../com/github/serivesmejia/eocvsim/config/Config.java | 10 ++++++++++ TeamCode/build.gradle | 1 - .../ftc/teamcode/AprilTagDetectionPipeline.java | 9 +-------- Vision/build.gradle | 2 +- build.gradle | 3 +-- 7 files changed, 18 insertions(+), 14 deletions(-) diff --git a/EOCV-Sim/build.gradle b/EOCV-Sim/build.gradle index b1435e51..1ac0e8b9 100644 --- a/EOCV-Sim/build.gradle +++ b/EOCV-Sim/build.gradle @@ -1,3 +1,5 @@ +import io.github.fvarrui.javapackager.gradle.PackageTask + import java.nio.file.Paths import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -33,7 +35,7 @@ test { apply from: '../test-logging.gradle' -tasks.register('pack', io.github.fvarrui.javapackager.gradle.PackageTask) { +tasks.register('pack', PackageTask) { dependsOn build // mandatory mainClass = 'com.github.serivesmejia.eocvsim.Main' @@ -73,7 +75,7 @@ dependencies { implementation "org.apache.logging.log4j:log4j-slf4j-impl:$log4j_version" implementation "com.github.deltacv:steve:1.1.1" - api(apriltag_plugin_dependency) { + api("com.github.deltacv:AprilTagDesktop:$apriltag_plugin_version") { exclude group: 'com.github.deltacv.EOCVSim', module: 'Common' } diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt index 49298ebe..59544b37 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt @@ -412,6 +412,7 @@ class EOCVSim(val params: Parameters = Parameters()) { if (isRestarting) { Thread.interrupted() //clear interrupted flag + EOCVSimFolder.lock?.lock?.close() JavaProcess.exec(Main::class.java, null, null) } } diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/config/Config.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/config/Config.java index fafa38e7..f50fe1f8 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/config/Config.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/config/Config.java @@ -65,4 +65,14 @@ public class Config { public volatile List superAccessPluginHashes = new ArrayList<>(); + public volatile HashMap flags = new HashMap<>(); + + public boolean hasFlag(String flagName) { + return flags.get(flagName) != null; + } + + public boolean getFlag(String flagName) { + return flags.get(flagName) != null && flags.get(flagName); + } + } \ No newline at end of file diff --git a/TeamCode/build.gradle b/TeamCode/build.gradle index 112ad2ab..a316f0a9 100644 --- a/TeamCode/build.gradle +++ b/TeamCode/build.gradle @@ -8,7 +8,6 @@ apply from: '../build.common.gradle' dependencies { implementation project(':EOCV-Sim') - implementation "org.jetbrains.kotlin:kotlin-stdlib" } diff --git a/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/AprilTagDetectionPipeline.java b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/AprilTagDetectionPipeline.java index 0e34be7c..5e1271fa 100644 --- a/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/AprilTagDetectionPipeline.java +++ b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/AprilTagDetectionPipeline.java @@ -92,17 +92,10 @@ public AprilTagDetectionPipeline(Telemetry telemetry) { @Override public void init(Mat frame) { - // Allocate a native context object. See the corresponding deletion in the finalizer + // Allocate a native context object. nativeApriltagPtr = AprilTagDetectorJNI.createApriltagDetector(AprilTagDetectorJNI.TagFamily.TAG_36h11.string, 3, 3); } - @Override - public void finalize() - { - // Delete the native context we created in the init() function - AprilTagDetectorJNI.releaseApriltagDetector(nativeApriltagPtr); - } - @Override public Mat processFrame(Mat input) { diff --git a/Vision/build.gradle b/Vision/build.gradle index d60a837b..981039b5 100644 --- a/Vision/build.gradle +++ b/Vision/build.gradle @@ -28,7 +28,7 @@ configurations.all { dependencies { implementation project(':Common') - implementation(apriltag_plugin_dependency) { + implementation("com.github.deltacv:AprilTagDesktop:$apriltag_plugin_version") { exclude group: 'com.github.deltacv.EOCVSim', module: 'Common' } diff --git a/build.gradle b/build.gradle index 9b439732..e509ed6b 100644 --- a/build.gradle +++ b/build.gradle @@ -10,11 +10,10 @@ buildscript { log4j_version = "2.17.1" opencv_version = "4.7.0-0" apriltag_plugin_version = "2.1.0-B" - apriltag_plugin_dependency = "com.github.deltacv:AprilTagDesktop:$apriltag_plugin_version" skiko_version = "0.8.15" - classgraph_version = "4.8.108" + classgraph_version = "4.8.112" opencsv_version = "5.5.2" env = findProperty('env') == 'release' ? 'release' : 'dev' From 5ca693d6f71854ec737ff3313fdd015770adc583 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Fri, 18 Oct 2024 22:39:31 -0600 Subject: [PATCH 07/36] Add arm64 for skiko --- .../src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt | 2 +- Vision/build.gradle | 1 + build.gradle | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt index 59544b37..17d99f2d 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt @@ -543,7 +543,7 @@ class EOCVSim(val params: Parameters = Parameters()) { * Checks if the simulator is currently recording * @return true if the simulator is currently recording, false otherwise */ - fun isCurrentlyRecording() = currentRecordingSession?.isRecording ?: false + fun isCurrentlyRecording() = currentRecordingSession?.isRecording == true /** * Updates the visualizer title message diff --git a/Vision/build.gradle b/Vision/build.gradle index 981039b5..d62ff8ce 100644 --- a/Vision/build.gradle +++ b/Vision/build.gradle @@ -42,6 +42,7 @@ dependencies { implementation("org.jetbrains.skiko:skiko-awt-runtime-windows-x64:$skiko_version") implementation("org.jetbrains.skiko:skiko-awt-runtime-linux-x64:$skiko_version") + implementation("org.jetbrains.skiko:skiko-awt-runtime-linux-arm64:$skiko_version") implementation("org.jetbrains.skiko:skiko-awt-runtime-macos-x64:$skiko_version") implementation("org.jetbrains.skiko:skiko-awt-runtime-macos-arm64:$skiko_version") } \ No newline at end of file diff --git a/build.gradle b/build.gradle index e509ed6b..271323d1 100644 --- a/build.gradle +++ b/build.gradle @@ -39,7 +39,7 @@ plugins { allprojects { group 'com.github.deltacv' - version '3.7.1' + version '4.0.0' apply plugin: 'java' From cdecc4c178571e30ee45616f873c247eb1cda68b Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Fri, 18 Oct 2024 23:12:31 -0600 Subject: [PATCH 08/36] Testing apriltags linux arm64 support --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 271323d1..d941433b 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ buildscript { slf4j_version = "1.7.32" log4j_version = "2.17.1" opencv_version = "4.7.0-0" - apriltag_plugin_version = "2.1.0-B" + apriltag_plugin_version = "main-SNAPSHOT" skiko_version = "0.8.15" From 5331522569ade405e99262fce4716c14431964dc Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Fri, 25 Oct 2024 18:22:37 -0600 Subject: [PATCH 09/36] Add plugin repository support to download plugins from a maven repository --- EOCV-Sim/build.gradle | 2 + .../eocvsim/gui/util/ThreadedMatPoster.java | 442 +++++------ .../eocvsim/tuner/field/cv/ScalarField.java | 10 +- .../eocvsim/util/extension/StrExt.kt | 4 +- .../deltacv/eocvsim/plugin/EOCVSimPlugin.kt | 142 ++-- .../plugin/loader/PluginClassLoader.kt | 397 ++++++---- .../eocvsim/plugin/loader/PluginContext.kt | 81 +- .../eocvsim/plugin/loader/PluginLoader.kt | 432 +++++------ .../eocvsim/plugin/loader/PluginManager.kt | 348 ++++----- .../repository/PluginRepositoryManager.kt | 144 ++++ EOCV-Sim/src/main/resources/cache.toml | 1 + EOCV-Sim/src/main/resources/repository.toml | 6 + TeamCode/build.gradle | 2 - .../teamcode/AprilTagDetectionPipeline.java | 9 +- .../openftc/easyopencv/OpenCvCameraBase.java | 718 +++++++++--------- build.gradle | 9 +- 16 files changed, 1504 insertions(+), 1243 deletions(-) create mode 100644 EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt create mode 100644 EOCV-Sim/src/main/resources/cache.toml create mode 100644 EOCV-Sim/src/main/resources/repository.toml diff --git a/EOCV-Sim/build.gradle b/EOCV-Sim/build.gradle index 1ac0e8b9..551458e0 100644 --- a/EOCV-Sim/build.gradle +++ b/EOCV-Sim/build.gradle @@ -96,6 +96,8 @@ dependencies { implementation 'com.moandjiezana.toml:toml4j:0.7.2' implementation 'org.ow2.asm:asm:9.7' + + implementation 'org.jboss.shrinkwrap.resolver:shrinkwrap-resolver-depchain:3.3.2' } task(writeBuildClassJava) { diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/util/ThreadedMatPoster.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/util/ThreadedMatPoster.java index d38c12fe..3c0e7327 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/util/ThreadedMatPoster.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/util/ThreadedMatPoster.java @@ -1,221 +1,223 @@ -/* - * Copyright (c) 2021 Sebastian Erives - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -package com.github.serivesmejia.eocvsim.gui.util; - -import com.github.serivesmejia.eocvsim.util.fps.FpsCounter; -import io.github.deltacv.common.image.MatPoster; -import org.firstinspires.ftc.robotcore.internal.collections.EvictingBlockingQueue; -import org.opencv.core.Mat; -import org.openftc.easyopencv.MatRecycler; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.ArrayList; -import java.util.concurrent.ArrayBlockingQueue; - -public class ThreadedMatPoster implements MatPoster { - private final ArrayList postables = new ArrayList<>(); - - private final EvictingBlockingQueue postQueue; - private final MatRecycler matRecycler; - - private final String name; - - private final Thread posterThread; - - public final FpsCounter fpsCounter = new FpsCounter(); - - private final Object lock = new Object(); - - private volatile boolean paused = false; - - private volatile boolean hasPosterThreadStarted = false; - - Logger logger; - - public static ThreadedMatPoster createWithoutRecycler(String name, int maxQueueItems) { - return new ThreadedMatPoster(name, maxQueueItems, null); - } - - public ThreadedMatPoster(String name, int maxQueueItems) { - this(name, new MatRecycler(maxQueueItems + 2)); - } - - public ThreadedMatPoster(String name, MatRecycler recycler) { - this(name, recycler.getSize(), recycler); - } - - public ThreadedMatPoster(String name, int maxQueueItems, MatRecycler recycler) { - postQueue = new EvictingBlockingQueue<>(new ArrayBlockingQueue<>(maxQueueItems)); - matRecycler = recycler; - posterThread = new Thread(new PosterRunnable(), "MatPoster-" + name + "-Thread"); - - this.name = name; - - logger = LoggerFactory.getLogger("MatPoster-" + name); - - postQueue.setEvictAction(this::evict); //release mat and return it to recycler if it's dropped by the EvictingBlockingQueue - } - - @Override - public void post(Mat m, Object context) { - if (m == null || m.empty()) { - logger.warn("Tried to post empty or null mat, skipped this frame."); - return; - } - - if (matRecycler != null) { - if(matRecycler.getAvailableMatsAmount() < 1) { - //evict one if we don't have any available mats in the recycler - evict(postQueue.poll()); - } - - MatRecycler.RecyclableMat recycledMat = matRecycler.takeMatOrNull(); - m.copyTo(recycledMat); - - postQueue.offer(recycledMat); - } else { - postQueue.offer(m); - } - } - - public void synchronizedPost(Mat m) { - synchronize(() -> post(m)); - } - - public Mat pull() throws InterruptedException { - synchronized(lock) { - return postQueue.take(); - } - } - - public void clearQueue() { - if(postQueue.size() == 0) return; - - synchronized(lock) { - postQueue.clear(); - } - } - - public void synchronize(Runnable runn) { - synchronized(lock) { - runn.run(); - } - } - - public void addPostable(Postable postable) { - //start mat posting thread if it hasn't been started yet - if (!posterThread.isAlive() && !hasPosterThreadStarted) { - posterThread.start(); - } - - postables.add(postable); - } - - public void stop() { - logger.info("Destroying..."); - - posterThread.interrupt(); - - for (Mat m : postQueue) { - if (m != null) { - if(m instanceof MatRecycler.RecyclableMat) { - ((MatRecycler.RecyclableMat)m).returnMat(); - } - } - } - - matRecycler.releaseAll(); - } - - private void evict(Mat m) { - if (m instanceof MatRecycler.RecyclableMat) { - ((MatRecycler.RecyclableMat) m).returnMat(); - } - m.release(); - } - - public void setPaused(boolean paused) { - this.paused = paused; - } - - public boolean getPaused() { - synchronized(lock) { - return paused; - } - } - - public String getName() { - return name; - } - - public interface Postable { - void post(Mat m); - } - - private class PosterRunnable implements Runnable { - - private Mat postableMat = new Mat(); - - @Override - public void run() { - hasPosterThreadStarted = true; - - while (!Thread.interrupted()) { - - while(paused && !Thread.currentThread().isInterrupted()) { - Thread.yield(); - } - - if (postQueue.size() == 0 || postables.size() == 0) continue; //skip if we have no queued frames - - synchronized(lock) { - fpsCounter.update(); - - try { - Mat takenMat = postQueue.take(); - - for (Postable postable : postables) { - takenMat.copyTo(postableMat); - postable.post(postableMat); - } - - takenMat.release(); - - if (takenMat instanceof MatRecycler.RecyclableMat) { - ((MatRecycler.RecyclableMat) takenMat).returnMat(); - } - } catch (InterruptedException e) { - e.printStackTrace(); - break; - } catch (Exception ex) { } - } - - } - - logger.warn("Thread interrupted (" + Integer.toHexString(hashCode()) + ")"); - } - } - +/* + * Copyright (c) 2021 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package com.github.serivesmejia.eocvsim.gui.util; + +import com.github.serivesmejia.eocvsim.util.fps.FpsCounter; +import io.github.deltacv.common.image.MatPoster; +import org.firstinspires.ftc.robotcore.internal.collections.EvictingBlockingQueue; +import org.opencv.core.Mat; +import org.openftc.easyopencv.MatRecycler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.concurrent.ArrayBlockingQueue; + +public class ThreadedMatPoster implements MatPoster { + private final ArrayList postables = new ArrayList<>(); + + private final EvictingBlockingQueue postQueue; + private final MatRecycler matRecycler; + + private final String name; + + private final Thread posterThread; + + public final FpsCounter fpsCounter = new FpsCounter(); + + private final Object lock = new Object(); + + private volatile boolean paused = false; + + private volatile boolean hasPosterThreadStarted = false; + + Logger logger; + + public static ThreadedMatPoster createWithoutRecycler(String name, int maxQueueItems) { + return new ThreadedMatPoster(name, maxQueueItems, null); + } + + public ThreadedMatPoster(String name, int maxQueueItems) { + this(name, new MatRecycler(maxQueueItems + 2)); + } + + public ThreadedMatPoster(String name, MatRecycler recycler) { + this(name, recycler.getSize(), recycler); + } + + public ThreadedMatPoster(String name, int maxQueueItems, MatRecycler recycler) { + postQueue = new EvictingBlockingQueue<>(new ArrayBlockingQueue<>(maxQueueItems)); + matRecycler = recycler; + posterThread = new Thread(new PosterRunnable(), "MatPoster-" + name + "-Thread"); + + this.name = name; + + logger = LoggerFactory.getLogger("MatPoster-" + name); + + postQueue.setEvictAction(this::evict); //release mat and return it to recycler if it's dropped by the EvictingBlockingQueue + } + + @Override + public void post(Mat m, Object context) { + if (m == null || m.empty()) { + logger.warn("Tried to post empty or null mat, skipped this frame."); + return; + } + + if (matRecycler != null) { + if(matRecycler.getAvailableMatsAmount() < 1) { + //evict one if we don't have any available mats in the recycler + evict(postQueue.poll()); + } + + MatRecycler.RecyclableMat recycledMat = matRecycler.takeMatOrNull(); + m.copyTo(recycledMat); + + postQueue.offer(recycledMat); + } else { + postQueue.offer(m); + } + } + + public void synchronizedPost(Mat m) { + synchronize(() -> post(m)); + } + + public Mat pull() throws InterruptedException { + synchronized(lock) { + return postQueue.take(); + } + } + + public void clearQueue() { + if(postQueue.size() == 0) return; + + synchronized(lock) { + postQueue.clear(); + } + } + + public void synchronize(Runnable runn) { + synchronized(lock) { + runn.run(); + } + } + + public void addPostable(Postable postable) { + //start mat posting thread if it hasn't been started yet + if (!posterThread.isAlive() && !hasPosterThreadStarted) { + posterThread.start(); + } + + postables.add(postable); + } + + public void stop() { + logger.info("Destroying..."); + + posterThread.interrupt(); + + for (Mat m : postQueue) { + if (m != null) { + if(m instanceof MatRecycler.RecyclableMat) { + ((MatRecycler.RecyclableMat)m).returnMat(); + } + } + } + + matRecycler.releaseAll(); + } + + private void evict(Mat m) { + if (m instanceof MatRecycler.RecyclableMat) { + ((MatRecycler.RecyclableMat) m).returnMat(); + } + if(m != null) { + m.release(); + } + } + + public void setPaused(boolean paused) { + this.paused = paused; + } + + public boolean getPaused() { + synchronized(lock) { + return paused; + } + } + + public String getName() { + return name; + } + + public interface Postable { + void post(Mat m); + } + + private class PosterRunnable implements Runnable { + + private Mat postableMat = new Mat(); + + @Override + public void run() { + hasPosterThreadStarted = true; + + while (!Thread.interrupted()) { + + while(paused && !Thread.currentThread().isInterrupted()) { + Thread.yield(); + } + + if (postQueue.isEmpty() || postables.isEmpty()) continue; //skip if we have no queued frames + + synchronized(lock) { + fpsCounter.update(); + + try { + Mat takenMat = postQueue.take(); + + for (Postable postable : postables) { + takenMat.copyTo(postableMat); + postable.post(postableMat); + } + + takenMat.release(); + + if (takenMat instanceof MatRecycler.RecyclableMat) { + ((MatRecycler.RecyclableMat) takenMat).returnMat(); + } + } catch (InterruptedException e) { + logger.warn("Thread interrupted ({})", Integer.toHexString(hashCode())); + break; + } catch (Exception ex) { } + } + + } + + logger.warn("Thread interrupted ({})", Integer.toHexString(hashCode())); + } + } + } \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/cv/ScalarField.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/cv/ScalarField.java index f4d09510..3ff0bdd9 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/cv/ScalarField.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/tuner/field/cv/ScalarField.java @@ -89,12 +89,16 @@ public void updateGuiFieldValues() { @Override public void setFieldValue(int index, Object newValue) throws IllegalAccessException { try { + double value; + if(newValue instanceof String) { - scalar.val[index] = Double.parseDouble((String) newValue); + value = Double.parseDouble((String) newValue); } else { - scalar.val[index] = (double)newValue; + value = (double)newValue; } - } catch (Exception ex) { + + scalar.val[index] = value; + } catch (NumberFormatException ex) { throw new IllegalArgumentException("Parameter should be a valid number", ex); } diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/extension/StrExt.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/extension/StrExt.kt index 2fd8253b..9c08fa77 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/extension/StrExt.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/extension/StrExt.kt @@ -10,4 +10,6 @@ fun String.removeFromEnd(rem: String): String { return substring(0, length - rem.length).trim() } return trim() -} \ No newline at end of file +} + +val Any.hexString get() = Integer.toHexString(hashCode())!! \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/EOCVSimPlugin.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/EOCVSimPlugin.kt index 13a9e264..97888157 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/EOCVSimPlugin.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/EOCVSimPlugin.kt @@ -1,68 +1,76 @@ -/* - * Copyright (c) 2024 Sebastian Erives - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -package io.github.deltacv.eocvsim.plugin - -import io.github.deltacv.eocvsim.plugin.loader.PluginContext - -/** - * Represents a plugin for EOCV-Sim - */ -abstract class EOCVSimPlugin { - - /** - * The context of the plugin, containing entry point objects - */ - val context get() = PluginContext.current(this) - - /** - * The EOCV-Sim instance, containing the main functionality of the program - */ - val eocvSim get() = context.eocvSim - - /** - * The virtual filesystem assigned to this plugin. - * With this filesystem, the plugin can access files in a sandboxed environment. - * Without SuperAccess, the plugin can only access files in its own virtual fs. - * @see SandboxFileSystem - */ - val fileSystem get() = context.fileSystem - - var enabled = false - internal set - - /** - * Called when the plugin is loaded by the PluginLoader - */ - abstract fun onLoad() - - /** - * Called when the plugin is enabled by the PluginLoader - */ - abstract fun onEnable() - - /* - * Called when the plugin is disabled by the PluginLoader - */ - abstract fun onDisable() +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package io.github.deltacv.eocvsim.plugin + +import io.github.deltacv.eocvsim.plugin.loader.PluginContext + +/** + * Represents a plugin for EOCV-Sim + */ +abstract class EOCVSimPlugin { + + /** + * The context of the plugin, containing entry point objects + */ + val context get() = PluginContext.current(this) + + /** + * The EOCV-Sim instance, containing the main functionality of the program + */ + val eocvSim get() = context.eocvSim + + /** + * The virtual filesystem assigned to this plugin. + * With this filesystem, the plugin can access files in a sandboxed environment. + * Without SuperAccess, the plugin can only access files in its own virtual fs. + * @see SandboxFileSystem + */ + val fileSystem get() = context.fileSystem + + /** + * The classpath of the plugin, additional to the JVM classpath + * This classpath is used to load classes from the plugin jar file + * and other dependencies that come from other jar files. + * @see io.github.deltacv.eocvsim.plugin.loader.PluginClassLoader + */ + val classpath get() = context.classpath + + var enabled = false + internal set + + /** + * Called when the plugin is loaded by the PluginLoader + */ + abstract fun onLoad() + + /** + * Called when the plugin is enabled by the PluginLoader + */ + abstract fun onEnable() + + /* + * Called when the plugin is disabled by the PluginLoader + */ + abstract fun onDisable() } \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt index af498d85..ec1197dd 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt @@ -1,159 +1,240 @@ -/* - * Copyright (c) 2024 Sebastian Erives - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -package io.github.deltacv.eocvsim.plugin.loader - -import com.github.serivesmejia.eocvsim.util.SysUtil -import com.github.serivesmejia.eocvsim.util.extension.removeFromEnd -import io.github.deltacv.eocvsim.sandbox.restrictions.MethodCallByteCodeChecker -import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingMethodBlacklist -import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingPackageBlacklist -import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingPackageWhitelist -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.IOException -import java.io.InputStream -import java.net.URL -import java.nio.file.FileSystems -import java.util.zip.ZipEntry -import java.util.zip.ZipFile - -/** - * ClassLoader for loading classes from a plugin jar file - * @param pluginJar the jar file of the plugin - * @param pluginContext the plugin context - */ -class PluginClassLoader(private val pluginJar: File, val pluginContextProvider: () -> PluginContext) : ClassLoader() { - - private val zipFile = try { - ZipFile(pluginJar) - } catch(e: Exception) { - throw IOException("Failed to open plugin JAR file", e) - } - - private val loadedClasses = mutableMapOf>() - - init { - FileSystems.getDefault() - } - - private fun loadClass(entry: ZipEntry): Class<*> { - val name = entry.name.removeFromEnd(".class").replace('/', '.') - - zipFile.getInputStream(entry).use { inStream -> - ByteArrayOutputStream().use { outStream -> - SysUtil.copyStream(inStream, outStream) - val bytes = outStream.toByteArray() - - if(!pluginContextProvider().hasSuperAccess) - MethodCallByteCodeChecker(bytes, dynamicLoadingMethodBlacklist) - - val clazz = defineClass(name, bytes, 0, bytes.size) - loadedClasses[name] = clazz - - return clazz - } - } - } - - override fun findClass(name: String) = loadedClasses[name] ?: loadClass(name, false) - - /** - * Load a class from the plugin jar file - * @param name the name of the class to load - * @return the loaded class - * @throws IllegalAccessError if the class is blacklisted - */ - fun loadClassStrict(name: String): Class<*> { - if(!pluginContextProvider().hasSuperAccess) { - for (blacklistedPackage in dynamicLoadingPackageBlacklist) { - if (name.contains(blacklistedPackage)) { - throw IllegalAccessError("Plugins are blacklisted to use $name") - } - } - } - - return loadClass(zipFile.getEntry(name.replace('.', '/') + ".class") ?: throw ClassNotFoundException(name)) - } - - override fun loadClass(name: String, resolve: Boolean): Class<*> { - var clazz = loadedClasses[name] - - if(clazz == null) { - var inWhitelist = false - - for(whiteListedPackage in dynamicLoadingPackageWhitelist) { - if(name.contains(whiteListedPackage)) { - inWhitelist = true - break - } - } - - if(!inWhitelist && !pluginContextProvider().hasSuperAccess) { - throw IllegalAccessError("Plugins are not whitelisted to use $name") - } - - clazz = try { - Class.forName(name) - } catch (e: ClassNotFoundException) { - loadClassStrict(name) - } - - if(resolve) resolveClass(clazz) - } - - return clazz!! - } - - override fun getResourceAsStream(name: String): InputStream? { - val entry = zipFile.getEntry(name) - - if(entry != null) { - try { - return zipFile.getInputStream(entry) - } catch (e: IOException) { } - } - - return super.getResourceAsStream(name) - } - - override fun getResource(name: String): URL? { - // Try to find the resource inside the plugin JAR - val entry = zipFile.getEntry(name) - - if (entry != null) { - try { - // Construct a URL for the resource inside the plugin JAR - return URL("jar:file:${pluginJar.absolutePath}!/$name") - } catch (e: Exception) { - e.printStackTrace() - } - } - - // Fallback to the parent classloader if not found in the plugin JAR - return super.getResource(name) - } - - override fun toString() = "PluginClassLoader@\"${pluginJar.name}\"" - +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package io.github.deltacv.eocvsim.plugin.loader + +import com.github.serivesmejia.eocvsim.util.SysUtil +import com.github.serivesmejia.eocvsim.util.extension.removeFromEnd +import io.github.deltacv.eocvsim.sandbox.restrictions.MethodCallByteCodeChecker +import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingMethodBlacklist +import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingPackageBlacklist +import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingPackageWhitelist +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.net.URL +import java.nio.file.FileSystems +import java.util.zip.ZipEntry +import java.util.zip.ZipFile + +/** + * ClassLoader for loading classes from a plugin jar file + * @param pluginJar the jar file of the plugin + * @param pluginContext the plugin context + */ +class PluginClassLoader(private val pluginJar: File, val classpath: List, val pluginContextProvider: () -> PluginContext) : ClassLoader() { + + private val zipFile = try { + ZipFile(pluginJar) + } catch(e: Exception) { + throw IOException("Failed to open plugin JAR file", e) + } + + private val loadedClasses = mutableMapOf>() + + init { + FileSystems.getDefault() + } + + private fun loadClass(entry: ZipEntry, zipFile: ZipFile = this.zipFile): Class<*> { + val name = entry.name.removeFromEnd(".class").replace('/', '.') + + zipFile.getInputStream(entry).use { inStream -> + ByteArrayOutputStream().use { outStream -> + SysUtil.copyStream(inStream, outStream) + val bytes = outStream.toByteArray() + + if(!pluginContextProvider().hasSuperAccess) + MethodCallByteCodeChecker(bytes, dynamicLoadingMethodBlacklist) + + val clazz = defineClass(name, bytes, 0, bytes.size) + loadedClasses[name] = clazz + + return clazz + } + } + } + + override fun findClass(name: String) = loadedClasses[name] ?: loadClass(name, false) + + /** + * Load a class from the plugin jar file + * @param name the name of the class to load + * @return the loaded class + * @throws IllegalAccessError if the class is blacklisted + */ + fun loadClassStrict(name: String): Class<*> { + if(!pluginContextProvider().hasSuperAccess) { + for (blacklistedPackage in dynamicLoadingPackageBlacklist) { + if (name.contains(blacklistedPackage)) { + throw IllegalAccessError("Plugins are blacklisted to use $name") + } + } + } + + return loadClass(zipFile.getEntry(name.replace('.', '/') + ".class") ?: throw ClassNotFoundException(name)) + } + + override fun loadClass(name: String, resolve: Boolean): Class<*> { + var clazz = loadedClasses[name] + + if(clazz == null) { + var inWhitelist = false + + for(whiteListedPackage in dynamicLoadingPackageWhitelist) { + if(name.contains(whiteListedPackage)) { + inWhitelist = true + break + } + } + + if(!inWhitelist && !pluginContextProvider().hasSuperAccess) { + throw IllegalAccessError("Plugins are not whitelisted to use $name") + } + + clazz = try { + Class.forName(name) + } catch (_: ClassNotFoundException) { + val classpathClass = classFromClasspath(name) + + if(classpathClass != null) { + return classpathClass + } + + loadClassStrict(name) + } + + if(resolve) resolveClass(clazz) + } + + return clazz!! + } + + override fun getResourceAsStream(name: String): InputStream? { + // Try to find the resource inside the classpath + resourceAsStreamFromClasspath(name)?.let { return it } + + val entry = zipFile.getEntry(name) + + if(entry != null) { + try { + return zipFile.getInputStream(entry) + } catch (e: IOException) { } + } + + return super.getResourceAsStream(name) + } + + override fun getResource(name: String): URL? { + resourceFromClasspath(name)?.let { return it } + + // Try to find the resource inside the plugin JAR + val entry = zipFile.getEntry(name) + + if (entry != null) { + try { + // Construct a URL for the resource inside the plugin JAR + return URL("jar:file:${pluginJar.absolutePath}!/$name") + } catch (e: Exception) { + } + } + + // Fallback to the parent classloader if not found in the plugin JAR + return super.getResource(name) + } + + /** + * Get a resource from the classpath specified in the constructor + */ + fun resourceAsStreamFromClasspath(name: String): InputStream? { + for (file in classpath) { + if(file == pluginJar) continue + + val zipFile = ZipFile(file) + + val entry = zipFile.getEntry(name) + + if (entry != null) { + try { + return zipFile.getInputStream(entry) + } catch (e: Exception) { + } + } + } + + return null + } + + /** + * Get a resource from the classpath specified in the constructor + */ + fun resourceFromClasspath(name: String): URL? { + for (file in classpath) { + if(file == pluginJar) continue + + val zipFile = ZipFile(file) + + try { + val entry = zipFile.getEntry(name) + + if (entry != null) { + try { + return URL("jar:file:${file.absolutePath}!/$name") + } catch (e: Exception) { + } + } + } finally { + zipFile.close() + } + } + + return null + } + + /** + * Load a class from the classpath specified in the constructor + */ + fun classFromClasspath(className: String): Class<*>? { + for (file in classpath) { + if(file == pluginJar) continue + + val zipFile = ZipFile(file) + + try { + val entry = zipFile.getEntry(className.replace('.', '/') + ".class") + + if (entry != null) { + return loadClass(entry, zipFile = zipFile) + } + } finally { + zipFile.close() + } + } + + return null + } + + override fun toString() = "PluginClassLoader@\"${pluginJar.name}\"" + } \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginContext.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginContext.kt index 6fe21731..641b7d22 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginContext.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginContext.kt @@ -1,41 +1,42 @@ -/* - * Copyright (c) 2024 Sebastian Erives - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -package io.github.deltacv.eocvsim.plugin.loader - -import com.github.serivesmejia.eocvsim.EOCVSim -import io.github.deltacv.eocvsim.plugin.EOCVSimPlugin -import io.github.deltacv.eocvsim.sandbox.nio.SandboxFileSystem - -class PluginContext( - val eocvSim: EOCVSim, val fileSystem: SandboxFileSystem, val loader: PluginLoader -) { - companion object { - @JvmStatic fun current(plugin: EOCVSimPlugin) = (plugin.javaClass.classLoader as PluginClassLoader).pluginContextProvider() - } - - val plugin get() = loader.plugin - - val hasSuperAccess get() = loader.hasSuperAccess - fun requestSuperAccess(reason: String) = loader.requestSuperAccess(reason) +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package io.github.deltacv.eocvsim.plugin.loader + +import com.github.serivesmejia.eocvsim.EOCVSim +import io.github.deltacv.eocvsim.plugin.EOCVSimPlugin +import io.github.deltacv.eocvsim.sandbox.nio.SandboxFileSystem + +class PluginContext( + val eocvSim: EOCVSim, val fileSystem: SandboxFileSystem, val loader: PluginLoader +) { + companion object { + @JvmStatic fun current(plugin: EOCVSimPlugin) = (plugin.javaClass.classLoader as PluginClassLoader).pluginContextProvider() + } + + val plugin get() = loader.plugin + val classpath get() = loader.classpath + + val hasSuperAccess get() = loader.hasSuperAccess + fun requestSuperAccess(reason: String) = loader.requestSuperAccess(reason) } \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt index c47ebafe..11375fc4 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt @@ -1,215 +1,219 @@ -/* - * Copyright (c) 2024 Sebastian Erives - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -package io.github.deltacv.eocvsim.plugin.loader - -import com.github.serivesmejia.eocvsim.EOCVSim -import com.github.serivesmejia.eocvsim.config.ConfigLoader -import com.github.serivesmejia.eocvsim.util.SysUtil -import com.github.serivesmejia.eocvsim.util.event.EventHandler -import com.github.serivesmejia.eocvsim.util.extension.plus -import com.github.serivesmejia.eocvsim.util.loggerForThis -import com.moandjiezana.toml.Toml -import io.github.deltacv.common.util.ParsedVersion -import io.github.deltacv.eocvsim.plugin.EOCVSimPlugin -import io.github.deltacv.eocvsim.sandbox.nio.SandboxFileSystem -import net.lingala.zip4j.ZipFile -import java.io.File -import java.security.MessageDigest - -/** - * Loads a plugin from a jar file - * @param pluginFile the jar file of the plugin - * @param eocvSim the EOCV-Sim instance - */ -class PluginLoader(val pluginFile: File, val eocvSim: EOCVSim) { - - val logger by loggerForThis() - - var loaded = false - private set - - var enabled = false - private set - - val pluginClassLoader: PluginClassLoader - - lateinit var pluginToml: Toml - private set - - lateinit var pluginName: String - private set - lateinit var pluginVersion: String - private set - - lateinit var pluginAuthor: String - private set - lateinit var pluginAuthorEmail: String - private set - - lateinit var pluginClass: Class<*> - private set - lateinit var plugin: EOCVSimPlugin - private set - - /** - * The file system for the plugin - */ - lateinit var fileSystem: SandboxFileSystem - private set - - val fileSystemZip by lazy { PluginManager.FILESYSTEMS_FOLDER + File.separator + "${hash()}-fs" } - val fileSystemZipPath by lazy { fileSystemZip.toPath() } - - /** - * Whether the plugin has super access (full system access) - */ - val hasSuperAccess get() = eocvSim.config.superAccessPluginHashes.contains(pluginHash) - - init { - pluginClassLoader = PluginClassLoader(pluginFile) { - PluginContext(eocvSim, fileSystem, this) - } - } - - /** - * Load the plugin from the jar file - * @throws InvalidPluginException if the plugin.toml file is not found - * @throws UnsupportedPluginException if the plugin requests an api version higher than the current one - */ - fun load() { - if(loaded) return - - pluginToml = Toml().read(pluginClassLoader.getResourceAsStream("plugin.toml") - ?: throw InvalidPluginException("No plugin.toml in the jar file") - ) - - pluginName = pluginToml.getString("name") ?: throw InvalidPluginException("No name in plugin.toml") - pluginVersion = pluginToml.getString("version") ?: throw InvalidPluginException("No version in plugin.toml") - - pluginAuthor = pluginToml.getString("author") ?: throw InvalidPluginException("No author in plugin.toml") - pluginAuthorEmail = pluginToml.getString("author-email", "") - - logger.info("Loading plugin $pluginName v$pluginVersion by $pluginAuthor") - - setupFs() - - if(pluginToml.contains("api-version")) { - val parsedVersion = ParsedVersion(pluginToml.getString("api-version")) - - if(parsedVersion > EOCVSim.PARSED_VERSION) - throw UnsupportedPluginException("Plugin request api version of v${parsedVersion}, EOCV-Sim is currently running at v${EOCVSim.PARSED_VERSION}") - } - - if(pluginToml.getBoolean("super-access", false)) { - requestSuperAccess(pluginToml.getString("super-access-reason", "")) - } - - pluginClass = pluginClassLoader.loadClassStrict(pluginToml.getString("main")) - plugin = pluginClass.getConstructor().newInstance() as EOCVSimPlugin - - plugin.onLoad() - - loaded = true - } - - private fun setupFs() { - if(!fileSystemZip.exists()) { - val zip = ZipFile(fileSystemZip) // kinda wack but uh, yeah... - zip.addFile(ConfigLoader.CONFIG_SAVEFILE) - zip.removeFile(ConfigLoader.CONFIG_SAVEFILE.name) - zip.close() - } - - fileSystem = SandboxFileSystem(this) - } - - /** - * Enable the plugin - */ - fun enable() { - if(enabled || !loaded) return - - logger.info("Enabling plugin $pluginName v$pluginVersion") - - plugin.enabled = true - plugin.onEnable() - - enabled = true - } - - /** - * Disable the plugin - */ - fun disable() { - if(!enabled || !loaded) return - - logger.info("Disabling plugin $pluginName v$pluginVersion") - - plugin.enabled = false - plugin.onDisable() - - kill() - } - - /** - * Kill the plugin - * This will close the file system and ban the class loader - * @see EventHandler.banClassLoader - */ - fun kill() { - fileSystem.close() - enabled = false - EventHandler.banClassLoader(pluginClassLoader) - } - - /** - * Request super access for the plugin - * @param reason the reason for requesting super access - */ - fun requestSuperAccess(reason: String): Boolean { - if(hasSuperAccess) return true - return eocvSim.pluginManager.requestSuperAccessFor(this, reason) - } - - /** - * Get the hash of the plugin file based off the plugin name and author - * @return the hash - */ - fun hash(): String { - val messageDigest = MessageDigest.getInstance("SHA-256") - messageDigest.update("${pluginName} by ${pluginAuthor}".toByteArray()) - return SysUtil.byteArray2Hex(messageDigest.digest()) - } - - /** - * Get the hash of the plugin file based off the file contents - * @return the hash - */ - val pluginHash by lazy { - val messageDigest = MessageDigest.getInstance("SHA-256") - messageDigest.update(pluginFile.readBytes()) - SysUtil.byteArray2Hex(messageDigest.digest()) - } - +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package io.github.deltacv.eocvsim.plugin.loader + +import com.github.serivesmejia.eocvsim.EOCVSim +import com.github.serivesmejia.eocvsim.config.ConfigLoader +import com.github.serivesmejia.eocvsim.util.SysUtil +import com.github.serivesmejia.eocvsim.util.event.EventHandler +import com.github.serivesmejia.eocvsim.util.extension.plus +import com.github.serivesmejia.eocvsim.util.loggerForThis +import com.moandjiezana.toml.Toml +import io.github.deltacv.common.util.ParsedVersion +import io.github.deltacv.eocvsim.plugin.EOCVSimPlugin +import io.github.deltacv.eocvsim.sandbox.nio.SandboxFileSystem +import net.lingala.zip4j.ZipFile +import java.io.File +import java.security.MessageDigest + +/** + * Loads a plugin from a jar file + * @param pluginFile the jar file of the plugin + * @param eocvSim the EOCV-Sim instance + */ +class PluginLoader( + val pluginFile: File, + val classpath: List, + val eocvSim: EOCVSim +) { + + val logger by loggerForThis() + + var loaded = false + private set + + var enabled = false + private set + + val pluginClassLoader: PluginClassLoader + + lateinit var pluginToml: Toml + private set + + lateinit var pluginName: String + private set + lateinit var pluginVersion: String + private set + + lateinit var pluginAuthor: String + private set + lateinit var pluginAuthorEmail: String + private set + + lateinit var pluginClass: Class<*> + private set + lateinit var plugin: EOCVSimPlugin + private set + + /** + * The file system for the plugin + */ + lateinit var fileSystem: SandboxFileSystem + private set + + val fileSystemZip by lazy { PluginManager.FILESYSTEMS_FOLDER + File.separator + "${hash()}-fs" } + val fileSystemZipPath by lazy { fileSystemZip.toPath() } + + /** + * Whether the plugin has super access (full system access) + */ + val hasSuperAccess get() = eocvSim.config.superAccessPluginHashes.contains(pluginHash) + + init { + pluginClassLoader = PluginClassLoader(pluginFile, classpath) { + PluginContext(eocvSim, fileSystem, this) + } + } + + /** + * Load the plugin from the jar file + * @throws InvalidPluginException if the plugin.toml file is not found + * @throws UnsupportedPluginException if the plugin requests an api version higher than the current one + */ + fun load() { + if(loaded) return + + pluginToml = Toml().read(pluginClassLoader.getResourceAsStream("plugin.toml") + ?: throw InvalidPluginException("No plugin.toml in the jar file") + ) + + pluginName = pluginToml.getString("name") ?: throw InvalidPluginException("No name in plugin.toml") + pluginVersion = pluginToml.getString("version") ?: throw InvalidPluginException("No version in plugin.toml") + + pluginAuthor = pluginToml.getString("author") ?: throw InvalidPluginException("No author in plugin.toml") + pluginAuthorEmail = pluginToml.getString("author-email", "") + + logger.info("Loading plugin $pluginName v$pluginVersion by $pluginAuthor") + + setupFs() + + if(pluginToml.contains("api-version")) { + val parsedVersion = ParsedVersion(pluginToml.getString("api-version")) + + if(parsedVersion > EOCVSim.PARSED_VERSION) + throw UnsupportedPluginException("Plugin request api version of v${parsedVersion}, EOCV-Sim is currently running at v${EOCVSim.PARSED_VERSION}") + } + + if(pluginToml.getBoolean("super-access", false)) { + requestSuperAccess(pluginToml.getString("super-access-reason", "")) + } + + pluginClass = pluginClassLoader.loadClassStrict(pluginToml.getString("main")) + plugin = pluginClass.getConstructor().newInstance() as EOCVSimPlugin + + plugin.onLoad() + + loaded = true + } + + private fun setupFs() { + if(!fileSystemZip.exists()) { + val zip = ZipFile(fileSystemZip) // kinda wack but uh, yeah... + zip.addFile(ConfigLoader.CONFIG_SAVEFILE) + zip.removeFile(ConfigLoader.CONFIG_SAVEFILE.name) + zip.close() + } + + fileSystem = SandboxFileSystem(this) + } + + /** + * Enable the plugin + */ + fun enable() { + if(enabled || !loaded) return + + logger.info("Enabling plugin $pluginName v$pluginVersion") + + plugin.enabled = true + plugin.onEnable() + + enabled = true + } + + /** + * Disable the plugin + */ + fun disable() { + if(!enabled || !loaded) return + + logger.info("Disabling plugin $pluginName v$pluginVersion") + + plugin.enabled = false + plugin.onDisable() + + kill() + } + + /** + * Kill the plugin + * This will close the file system and ban the class loader + * @see EventHandler.banClassLoader + */ + fun kill() { + fileSystem.close() + enabled = false + EventHandler.banClassLoader(pluginClassLoader) + } + + /** + * Request super access for the plugin + * @param reason the reason for requesting super access + */ + fun requestSuperAccess(reason: String): Boolean { + if(hasSuperAccess) return true + return eocvSim.pluginManager.requestSuperAccessFor(this, reason) + } + + /** + * Get the hash of the plugin file based off the plugin name and author + * @return the hash + */ + fun hash(): String { + val messageDigest = MessageDigest.getInstance("SHA-256") + messageDigest.update("${pluginName} by ${pluginAuthor}".toByteArray()) + return SysUtil.byteArray2Hex(messageDigest.digest()) + } + + /** + * Get the hash of the plugin file based off the file contents + * @return the hash + */ + val pluginHash by lazy { + val messageDigest = MessageDigest.getInstance("SHA-256") + messageDigest.update(pluginFile.readBytes()) + SysUtil.byteArray2Hex(messageDigest.digest()) + } + } \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt index ff137677..78aee988 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt @@ -1,170 +1,180 @@ -/* - * Copyright (c) 2024 Sebastian Erives - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -package io.github.deltacv.eocvsim.plugin.loader - -import com.github.serivesmejia.eocvsim.EOCVSim -import com.github.serivesmejia.eocvsim.util.JavaProcess -import com.github.serivesmejia.eocvsim.util.extension.plus -import com.github.serivesmejia.eocvsim.util.io.EOCVSimFolder -import com.github.serivesmejia.eocvsim.util.loggerForThis -import io.github.deltacv.eocvsim.gui.dialog.SuperAccessRequestMain -import java.io.File -import java.util.* - -/** - * Manages the loading, enabling and disabling of plugins - * @param eocvSim the EOCV-Sim instance - */ -class PluginManager(val eocvSim: EOCVSim) { - - companion object { - val PLUGIN_FOLDER = (EOCVSimFolder + File.separator + "plugins").apply { mkdir() } - val FILESYSTEMS_FOLDER = (PLUGIN_FOLDER + File.separator + "filesystems").apply { mkdir() } - - const val GENERIC_SUPERACCESS_WARN = "Plugins run in a restricted environment by default. SuperAccess will grant full system access. Ensure you trust the authors before accepting." - const val GENERIC_LAWYER_YEET = "

By accepting, you acknowledge that deltacv is not responsible for damages caused by third-party plugins." - } - - val logger by loggerForThis() - - private val _pluginFiles = mutableListOf() - - /** - * List of plugin files in the plugins folder - */ - val pluginFiles get() = _pluginFiles.toList() - - private val loaders = mutableMapOf() - - private var isEnabled = false - - /** - * Initializes the plugin manager - * Loads all plugin files in the plugins folder - * Creates a PluginLoader for each plugin file - * and stores them in the loaders map - * @see PluginLoader - */ - fun init() { - val filesInPluginFolder = PLUGIN_FOLDER.listFiles() ?: arrayOf() - - for (file in filesInPluginFolder) { - if (file.extension == "jar") _pluginFiles.add(file) - } - - if(pluginFiles.isEmpty()) { - logger.info("No plugins to load") - return - } - - for (pluginFile in pluginFiles) { - loaders[pluginFile] = PluginLoader(pluginFile, eocvSim) - } - - isEnabled = true - } - - /** - * Loads all plugins - * @see PluginLoader.load - */ - fun loadPlugins() { - for ((file, loader) in loaders) { - try { - loader.load() - } catch (e: Throwable) { - logger.error("Failure loading ${file.name}", e) - loaders.remove(file) - loader.kill() - } - } - } - - /** - * Enables all plugins - * @see PluginLoader.enable - */ - fun enablePlugins() { - for (loader in loaders.values) { - try { - loader.enable() - } catch (e: Throwable) { - logger.error("Failure enabling ${loader.pluginName} v${loader.pluginVersion}", e) - loader.kill() - } - } - } - - /** - * Disables all plugins - * @see PluginLoader.disable - */ - @Synchronized - fun disablePlugins() { - if(!isEnabled) return - - for (loader in loaders.values) { - try { - loader.disable() - } catch (e: Throwable) { - logger.error("Failure disabling ${loader.pluginName} v${loader.pluginVersion}", e) - loader.kill() - } - } - - isEnabled = false - } - - /** - * Requests super access for a plugin loader - * - * @param loader the plugin loader to request super access for - * @param reason the reason for requesting super access - * @return true if super access was granted, false otherwise - */ - fun requestSuperAccessFor(loader: PluginLoader, reason: String): Boolean { - if(loader.hasSuperAccess) return true - - logger.info("Requesting super access for ${loader.pluginName} v${loader.pluginVersion}") - - var warning = "$GENERIC_SUPERACCESS_WARN" - if(reason.trim().isNotBlank()) { - warning += "

$reason" - } - - warning += GENERIC_LAWYER_YEET - - warning += "" - - val name = "${loader.pluginName} by ${loader.pluginAuthor}".replace(" ", "-") - - if(JavaProcess.exec(SuperAccessRequestMain::class.java, null, Arrays.asList(name, warning)) == 171) { - eocvSim.config.superAccessPluginHashes.add(loader.pluginHash) - eocvSim.configManager.saveToFile() - return true - } - - return false - } +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package io.github.deltacv.eocvsim.plugin.loader + +import com.github.serivesmejia.eocvsim.EOCVSim +import com.github.serivesmejia.eocvsim.util.JavaProcess +import com.github.serivesmejia.eocvsim.util.extension.plus +import com.github.serivesmejia.eocvsim.util.io.EOCVSimFolder +import com.github.serivesmejia.eocvsim.util.loggerForThis +import io.github.deltacv.eocvsim.gui.dialog.SuperAccessRequestMain +import io.github.deltacv.eocvsim.plugin.repository.PluginRepositoryManager +import java.io.File +import java.util.* + +/** + * Manages the loading, enabling and disabling of plugins + * @param eocvSim the EOCV-Sim instance + */ +class PluginManager(val eocvSim: EOCVSim) { + + companion object { + val PLUGIN_FOLDER = (EOCVSimFolder + File.separator + "plugins").apply { mkdir() } + val FILESYSTEMS_FOLDER = (PLUGIN_FOLDER + File.separator + "filesystems").apply { mkdir() } + + const val GENERIC_SUPERACCESS_WARN = "Plugins run in a restricted environment by default. SuperAccess will grant full system access. Ensure you trust the authors before accepting." + const val GENERIC_LAWYER_YEET = "

By accepting, you acknowledge that deltacv is not responsible for damages caused by third-party plugins." + } + + val logger by loggerForThis() + + val repositoryManager = PluginRepositoryManager() + + private val _pluginFiles = mutableListOf() + + /** + * List of plugin files in the plugins folder + */ + val pluginFiles get() = _pluginFiles.toList() + + private val loaders = mutableMapOf() + + private var isEnabled = false + + /** + * Initializes the plugin manager + * Loads all plugin files in the plugins folder + * Creates a PluginLoader for each plugin file + * and stores them in the loaders map + * @see PluginLoader + */ + fun init() { + repositoryManager.init() + + val pluginFiles = mutableListOf() + pluginFiles.addAll(repositoryManager.resolveAll()) + + PLUGIN_FOLDER.listFiles()?.let { + pluginFiles.addAll(it.filter { it.extension == "jar" }) + } + + for (file in pluginFiles) { + if (file.extension == "jar") _pluginFiles.add(file) + } + + if(pluginFiles.isEmpty()) { + logger.info("No plugins to load") + return + } + + for (pluginFile in pluginFiles) { + loaders[pluginFile] = PluginLoader(pluginFile, repositoryManager.classpath(), eocvSim) + } + + isEnabled = true + } + + /** + * Loads all plugins + * @see PluginLoader.load + */ + fun loadPlugins() { + for ((file, loader) in loaders) { + try { + loader.load() + } catch (e: Throwable) { + logger.error("Failure loading ${file.name}", e) + loaders.remove(file) + loader.kill() + } + } + } + + /** + * Enables all plugins + * @see PluginLoader.enable + */ + fun enablePlugins() { + for (loader in loaders.values) { + try { + loader.enable() + } catch (e: Throwable) { + logger.error("Failure enabling ${loader.pluginName} v${loader.pluginVersion}", e) + loader.kill() + } + } + } + + /** + * Disables all plugins + * @see PluginLoader.disable + */ + @Synchronized + fun disablePlugins() { + if(!isEnabled) return + + for (loader in loaders.values) { + try { + loader.disable() + } catch (e: Throwable) { + logger.error("Failure disabling ${loader.pluginName} v${loader.pluginVersion}", e) + loader.kill() + } + } + + isEnabled = false + } + + /** + * Requests super access for a plugin loader + * + * @param loader the plugin loader to request super access for + * @param reason the reason for requesting super access + * @return true if super access was granted, false otherwise + */ + fun requestSuperAccessFor(loader: PluginLoader, reason: String): Boolean { + if(loader.hasSuperAccess) return true + + logger.info("Requesting super access for ${loader.pluginName} v${loader.pluginVersion}") + + var warning = "$GENERIC_SUPERACCESS_WARN" + if(reason.trim().isNotBlank()) { + warning += "

$reason" + } + + warning += GENERIC_LAWYER_YEET + + warning += "" + + val name = "${loader.pluginName} by ${loader.pluginAuthor}".replace(" ", "-") + + if(JavaProcess.exec(SuperAccessRequestMain::class.java, null, Arrays.asList(name, warning)) == 171) { + eocvSim.config.superAccessPluginHashes.add(loader.pluginHash) + eocvSim.configManager.saveToFile() + return true + } + + return false + } } \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt new file mode 100644 index 00000000..765b480a --- /dev/null +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt @@ -0,0 +1,144 @@ +package io.github.deltacv.eocvsim.plugin.repository + +import com.github.serivesmejia.eocvsim.util.SysUtil +import com.github.serivesmejia.eocvsim.util.extension.hexString +import com.github.serivesmejia.eocvsim.util.extension.plus +import com.github.serivesmejia.eocvsim.util.loggerForThis +import com.moandjiezana.toml.Toml +import com.moandjiezana.toml.TomlWriter +import io.github.deltacv.eocvsim.plugin.loader.PluginManager +import org.jboss.shrinkwrap.resolver.api.maven.ConfigurableMavenResolverSystem +import org.jboss.shrinkwrap.resolver.api.maven.Maven +import java.io.File + +class PluginRepositoryManager { + + companion object { + val REPOSITORY_FILE = PluginManager.PLUGIN_FOLDER + File.separator + "repository.toml" + val CACHE_FILE = PluginManager.PLUGIN_FOLDER + File.separator + "cache.toml" + + val MAVEN_FOLDER = PluginManager.PLUGIN_FOLDER + File.separator + "maven" + + val REPOSITORY_TOML_RES = PluginRepositoryManager::class.java.getResourceAsStream("/repository.toml") + val CACHE_TOML_RES = PluginRepositoryManager::class.java.getResourceAsStream("/cache.toml") + + const val UNSURE_USER_HELP = "If you're unsure on what to do, delete the repository.toml file and restart the program." + } + + private lateinit var pluginsToml: Toml + private lateinit var pluginsRepositories: Toml + private lateinit var plugins: Toml + + private lateinit var cacheToml: Toml + + private lateinit var resolver: ConfigurableMavenResolverSystem + + val logger by loggerForThis() + + fun init() { + logger.info("Initializing plugin repository manager") + + SysUtil.copyFileIs(CACHE_TOML_RES, CACHE_FILE, false) + cacheToml = Toml().read(CACHE_FILE) + + MAVEN_FOLDER.mkdir() + + SysUtil.copyFileIs(REPOSITORY_TOML_RES, REPOSITORY_FILE, false) + pluginsToml = Toml().read(REPOSITORY_FILE) + + pluginsRepositories = pluginsToml.getTable("repositories") + ?: throw InvalidFileException("No repositories found in repository.toml. $UNSURE_USER_HELP") + plugins = pluginsToml.getTable("plugins") + ?: Toml() + + resolver = Maven.configureResolver() + + for (repo in pluginsRepositories.toMap()) { + if(repo.value !is String) + throw InvalidFileException("Invalid repository URL in repository.toml. $UNSURE_USER_HELP") + + resolver.withRemoteRepo(repo.key, repo.value as String, "default") + + logger.info("Added repository ${repo.key} with URL ${repo.value}") + } + } + + fun resolveAll(): List { + val files = mutableListOf() + + val newCache = mutableMapOf() + + for(cache in cacheToml.toMap()) { + newCache[cache.key] = cache.value as String + } + + for(plugin in plugins.toMap()) { + if(plugin.value !is String) + throw InvalidFileException("Invalid plugin dependency in repository.toml. $UNSURE_USER_HELP") + + logger.info("Resolving plugin dependency ${plugin.key} with ${plugin.value}") + + val pluginDep = plugin.value as String + + var pluginJar: File? = null + + try { + for(cached in cacheToml.toMap()) { + if(cached.key == pluginDep.hexString) { + logger.info("Found cached plugin dependency $pluginDep (${pluginDep.hexString})") + + val cachedFile = File(cached.value as String) + if(cachedFile.exists()) { + files += cachedFile + continue + } + } + } + + resolver.resolve(pluginDep) + .withTransitivity() + .asFile() + .forEach { file -> + if(pluginJar == null) { + // the first file is the plugin jar + pluginJar = file + } + + val destFile = File(MAVEN_FOLDER, file.name) + file.copyTo(destFile, true) + + newCache[pluginDep.hexString] = pluginJar!!.absolutePath + } + + files += pluginJar!! + } catch(ex: Exception) { + logger.warn("Failed to resolve plugin dependency $pluginDep", ex) + } + } + + val cacheBuilder = StringBuilder() + cacheBuilder.append("# Do not edit this file, it is generated by the application.\n") + for(cached in newCache) { + cacheBuilder.append("${cached.key} = \"${cached.value.replace("\\", "/")}\"\n") + } + + SysUtil.saveFileStr(CACHE_FILE, cacheBuilder.toString()) + + return files + } + + fun classpath(): List { + val files = mutableListOf() + + for(jar in MAVEN_FOLDER.listFiles() ?: arrayOf()) { + if(jar.extension == "jar") { + files += jar + } + } + + return files + } +} + + +class InvalidFileException(msg: String) : RuntimeException(msg) \ No newline at end of file diff --git a/EOCV-Sim/src/main/resources/cache.toml b/EOCV-Sim/src/main/resources/cache.toml new file mode 100644 index 00000000..92466bcc --- /dev/null +++ b/EOCV-Sim/src/main/resources/cache.toml @@ -0,0 +1 @@ +# Do not edit this file, it is generated by the application. \ No newline at end of file diff --git a/EOCV-Sim/src/main/resources/repository.toml b/EOCV-Sim/src/main/resources/repository.toml new file mode 100644 index 00000000..4091f87d --- /dev/null +++ b/EOCV-Sim/src/main/resources/repository.toml @@ -0,0 +1,6 @@ +[repositories] +central = "https://repo.maven.apache.org/maven2/" +jitpack = "https://jitpack.io/" + +[plugins] +PaperVision = "com.github.deltacv:PaperVision:1.0.0" \ No newline at end of file diff --git a/TeamCode/build.gradle b/TeamCode/build.gradle index a316f0a9..264ec071 100644 --- a/TeamCode/build.gradle +++ b/TeamCode/build.gradle @@ -1,5 +1,3 @@ -import java.nio.file.Paths - plugins { id 'org.jetbrains.kotlin.jvm' } diff --git a/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/AprilTagDetectionPipeline.java b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/AprilTagDetectionPipeline.java index 5e1271fa..7302ad47 100644 --- a/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/AprilTagDetectionPipeline.java +++ b/TeamCode/src/main/java/org/firstinspires/ftc/teamcode/AprilTagDetectionPipeline.java @@ -25,14 +25,7 @@ import org.firstinspires.ftc.robotcore.external.Telemetry; import org.firstinspires.ftc.robotcore.external.navigation.*; import org.opencv.calib3d.Calib3d; -import org.opencv.core.CvType; -import org.opencv.core.Mat; -import org.opencv.core.MatOfDouble; -import org.opencv.core.MatOfPoint2f; -import org.opencv.core.MatOfPoint3f; -import org.opencv.core.Point; -import org.opencv.core.Point3; -import org.opencv.core.Scalar; +import org.opencv.core.*; import org.opencv.imgproc.Imgproc; import org.openftc.apriltag.AprilTagDetection; import org.openftc.apriltag.AprilTagDetectorJNI; diff --git a/Vision/src/main/java/org/openftc/easyopencv/OpenCvCameraBase.java b/Vision/src/main/java/org/openftc/easyopencv/OpenCvCameraBase.java index 66abc6a0..61fadca5 100644 --- a/Vision/src/main/java/org/openftc/easyopencv/OpenCvCameraBase.java +++ b/Vision/src/main/java/org/openftc/easyopencv/OpenCvCameraBase.java @@ -1,359 +1,359 @@ -/* - * Copyright (c) 2019 OpenFTC Team - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package org.openftc.easyopencv; - -import android.graphics.Bitmap; -import android.graphics.Canvas; -import com.qualcomm.robotcore.util.ElapsedTime; -import com.qualcomm.robotcore.util.MovingStatistics; -import io.github.deltacv.common.pipeline.util.PipelineStatisticsCalculator; -import io.github.deltacv.vision.external.PipelineRenderHook; -import org.opencv.android.Utils; -import org.opencv.core.*; -import org.opencv.imgproc.Imgproc; - -public abstract class OpenCvCameraBase implements OpenCvCamera { - - private OpenCvPipeline pipeline = null; - - private OpenCvViewport viewport; - private OpenCvCameraRotation rotation; - - private final Object pipelineChangeLock = new Object(); - - private Mat rotatedMat = new Mat(); - private Mat matToUseIfPipelineReturnedCropped; - private Mat croppedColorCvtedMat = new Mat(); - - private boolean isStreaming = false; - private boolean viewportEnabled = true; - - private ViewportRenderer desiredViewportRenderer = ViewportRenderer.SOFTWARE; - ViewportRenderingPolicy desiredRenderingPolicy = ViewportRenderingPolicy.MAXIMIZE_EFFICIENCY; - boolean fpsMeterDesired = true; - - private Scalar brown = new Scalar(82, 61, 46, 255); - - private int frameCount = 0; - - private PipelineStatisticsCalculator statistics = new PipelineStatisticsCalculator(); - - private double width; - private double height; - - public OpenCvCameraBase(OpenCvViewport viewport, boolean viewportEnabled) { - this.viewport = viewport; - this.rotation = getDefaultRotation(); - - this.viewportEnabled = viewportEnabled; - } - - @Override - public void showFpsMeterOnViewport(boolean show) { - viewport.setFpsMeterEnabled(show); - } - - @Override - public void pauseViewport() { - viewport.pause(); - } - - @Override - public void resumeViewport() { - viewport.resume(); - } - - @Override - public void setViewportRenderingPolicy(ViewportRenderingPolicy policy) { - viewport.setRenderingPolicy(policy); - } - - @Override - public void setViewportRenderer(ViewportRenderer renderer) { - this.desiredViewportRenderer = renderer; - } - - @Override - public void setPipeline(OpenCvPipeline pipeline) { - this.pipeline = pipeline; - } - - @Override - public int getFrameCount() { - return frameCount; - } - - @Override - public float getFps() { - return statistics.getAvgFps(); - } - - @Override - public int getPipelineTimeMs() { - return statistics.getAvgPipelineTime(); - } - - @Override - public int getOverheadTimeMs() { - return statistics.getAvgOverheadTime(); - } - - @Override - public int getTotalFrameTimeMs() { - return getTotalFrameTimeMs(); - } - - @Override - public int getCurrentPipelineMaxFps() { - return 0; - } - - @Override - public void startRecordingPipeline(PipelineRecordingParameters parameters) { - } - - @Override - public void stopRecordingPipeline() { - } - - protected void notifyStartOfFrameProcessing() { - statistics.newInputFrameStart(); - } - - public synchronized final void prepareForOpenCameraDevice() - { - if (viewportEnabled) - { - setupViewport(); - viewport.setRenderingPolicy(desiredRenderingPolicy); - } - } - - public synchronized final void prepareForStartStreaming(int width, int height, OpenCvCameraRotation rotation) - { - this.rotation = rotation; - this.statistics = new PipelineStatisticsCalculator(); - this.statistics.init(); - - Size sizeAfterRotation = getFrameSizeAfterRotation(width, height, rotation); - - this.width = sizeAfterRotation.width; - this.height = sizeAfterRotation.height; - - if(viewport != null) - { - // viewport.setSize(width, height); - viewport.setOptimizedViewRotation(getOptimizedViewportRotation(rotation)); - viewport.activate(); - } - } - - public synchronized final void cleanupForEndStreaming() { - matToUseIfPipelineReturnedCropped = null; - - if (viewport != null) { - viewport.deactivate(); - } - } - - protected synchronized void handleFrameUserCrashable(Mat frame, long timestamp) { - statistics.newPipelineFrameStart(); - - Mat userProcessedFrame = null; - - int rotateCode = mapRotationEnumToOpenCvRotateCode(rotation); - - if (rotateCode != -1) { - /* - * Rotate onto another Mat rather than doing so in-place. - * - * This does two things: - * 1) It seems that rotating by 90 or 270 in-place - * causes the backing buffer to be re-allocated - * since the width/height becomes swapped. This - * causes a problem for user code which makes a - * submat from the input Mat, because after the - * parent Mat is re-allocated the submat is no - * longer tied to it. Thus, by rotating onto - * another Mat (which is never re-allocated) we - * remove that issue. - * - * 2) Since the backing buffer does need need to be - * re-allocated for each frame, we reduce overhead - * time by about 1ms. - */ - Core.rotate(frame, rotatedMat, rotateCode); - frame = rotatedMat; - } - - final OpenCvPipeline pipelineSafe; - - // Grab a safe reference to what the pipeline currently is, - // since the user is allowed to change it at any time - synchronized (pipelineChangeLock) { - pipelineSafe = pipeline; - } - - if (pipelineSafe != null) { - if (pipelineSafe instanceof TimestampedOpenCvPipeline) { - ((TimestampedOpenCvPipeline) pipelineSafe).setTimestamp(timestamp); - } - - statistics.beforeProcessFrame(); - - userProcessedFrame = pipelineSafe.processFrameInternal(frame); - - statistics.afterProcessFrame(); - } - - // Will point to whatever mat we end up deciding to send to the screen - final Mat matForDisplay; - - if (pipelineSafe == null) { - matForDisplay = frame; - } else if (userProcessedFrame == null) { - throw new OpenCvCameraException("User pipeline returned null"); - } else if (userProcessedFrame.empty()) { - throw new OpenCvCameraException("User pipeline returned empty mat"); - } else if (userProcessedFrame.cols() != frame.cols() || userProcessedFrame.rows() != frame.rows()) { - /* - * The user didn't return the same size image from their pipeline as we gave them, - * ugh. This makes our lives interesting because we can't just send an arbitrary - * frame size to the viewport. It re-uses framebuffers that are of a fixed resolution. - * So, we copy the user's Mat onto a Mat of the correct size, and then send that other - * Mat to the viewport. - */ - - if (userProcessedFrame.cols() > frame.cols() || userProcessedFrame.rows() > frame.rows()) { - /* - * What on earth was this user thinking?! They returned a Mat that's BIGGER in - * a dimension than the one we gave them! - */ - - throw new OpenCvCameraException("User pipeline returned frame of unexpected size"); - } - - //We re-use this buffer, only create if needed - if (matToUseIfPipelineReturnedCropped == null) { - matToUseIfPipelineReturnedCropped = frame.clone(); - } - - //Set to brown to indicate to the user the areas which they cropped off - matToUseIfPipelineReturnedCropped.setTo(brown); - - int usrFrmTyp = userProcessedFrame.type(); - - if (usrFrmTyp == CvType.CV_8UC1) { - /* - * Handle 8UC1 returns (masks and single channels of images); - * - * We have to color convert onto a different mat (rather than - * doing so in place) to avoid breaking any of the user's submats - */ - Imgproc.cvtColor(userProcessedFrame, croppedColorCvtedMat, Imgproc.COLOR_GRAY2RGBA); - userProcessedFrame = croppedColorCvtedMat; //Doesn't affect user's handle, only ours - } else if (usrFrmTyp != CvType.CV_8UC4 && usrFrmTyp != CvType.CV_8UC3) { - /* - * Oof, we don't know how to handle the type they gave us - */ - throw new OpenCvCameraException("User pipeline returned a frame of an illegal type. Valid types are CV_8UC1, CV_8UC3, and CV_8UC4"); - } - - //Copy the user's frame onto a Mat of the correct size - userProcessedFrame.copyTo(matToUseIfPipelineReturnedCropped.submat( - new Rect(0, 0, userProcessedFrame.cols(), userProcessedFrame.rows()))); - - //Send that correct size Mat to the viewport - matForDisplay = matToUseIfPipelineReturnedCropped; - } else { - /* - * Yay, smart user! They gave us the frame size we were expecting! - * Go ahead and send it right on over to the viewport. - */ - matForDisplay = userProcessedFrame; - } - - if (viewport != null) { - viewport.post(matForDisplay, new OpenCvViewport.FrameContext(pipelineSafe, pipelineSafe != null ? pipelineSafe.getUserContextForDrawHook() : null)); - } - - statistics.endFrame(); - - if (viewport != null) { - viewport.notifyStatistics(statistics.getAvgFps(), statistics.getAvgPipelineTime(), statistics.getAvgOverheadTime()); - } - - frameCount++; - } - - - protected OpenCvViewport.OptimizedRotation getOptimizedViewportRotation(OpenCvCameraRotation streamRotation) { - if (!cameraOrientationIsTiedToDeviceOrientation()) { - return OpenCvViewport.OptimizedRotation.NONE; - } - - if (streamRotation == OpenCvCameraRotation.SIDEWAYS_LEFT || streamRotation == OpenCvCameraRotation.SENSOR_NATIVE) { - return OpenCvViewport.OptimizedRotation.ROT_90_COUNTERCLOCWISE; - } else if (streamRotation == OpenCvCameraRotation.SIDEWAYS_RIGHT) { - return OpenCvViewport.OptimizedRotation.ROT_90_CLOCKWISE; - } else if (streamRotation == OpenCvCameraRotation.UPSIDE_DOWN) { - return OpenCvViewport.OptimizedRotation.ROT_180; - } else { - return OpenCvViewport.OptimizedRotation.NONE; - } - } - - protected void setupViewport() { - viewport.setFpsMeterEnabled(fpsMeterDesired); - viewport.setRenderHook(PipelineRenderHook.INSTANCE); - } - - protected abstract OpenCvCameraRotation getDefaultRotation(); - - protected abstract int mapRotationEnumToOpenCvRotateCode(OpenCvCameraRotation rotation); - - protected abstract boolean cameraOrientationIsTiedToDeviceOrientation(); - - protected abstract boolean isStreaming(); - - protected Size getFrameSizeAfterRotation(int width, int height, OpenCvCameraRotation rotation) - { - int screenRenderedWidth, screenRenderedHeight; - int openCvRotateCode = mapRotationEnumToOpenCvRotateCode(rotation); - - if(openCvRotateCode == Core.ROTATE_90_CLOCKWISE || openCvRotateCode == Core.ROTATE_90_COUNTERCLOCKWISE) - { - //noinspection SuspiciousNameCombination - screenRenderedWidth = height; - //noinspection SuspiciousNameCombination - screenRenderedHeight = width; - } - else - { - screenRenderedWidth = width; - screenRenderedHeight = height; - } - - return new Size(screenRenderedWidth, screenRenderedHeight); - } - -} +/* + * Copyright (c) 2019 OpenFTC Team + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.openftc.easyopencv; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import com.qualcomm.robotcore.util.ElapsedTime; +import com.qualcomm.robotcore.util.MovingStatistics; +import io.github.deltacv.common.pipeline.util.PipelineStatisticsCalculator; +import io.github.deltacv.vision.external.PipelineRenderHook; +import org.opencv.android.Utils; +import org.opencv.core.*; +import org.opencv.imgproc.Imgproc; + +public abstract class OpenCvCameraBase implements OpenCvCamera { + + private OpenCvPipeline pipeline = null; + + private OpenCvViewport viewport; + private OpenCvCameraRotation rotation; + + private final Object pipelineChangeLock = new Object(); + + private Mat rotatedMat = new Mat(); + private Mat matToUseIfPipelineReturnedCropped; + private Mat croppedColorCvtedMat = new Mat(); + + private boolean isStreaming = false; + private boolean viewportEnabled = true; + + private ViewportRenderer desiredViewportRenderer = ViewportRenderer.SOFTWARE; + ViewportRenderingPolicy desiredRenderingPolicy = ViewportRenderingPolicy.MAXIMIZE_EFFICIENCY; + boolean fpsMeterDesired = true; + + private Scalar brown = new Scalar(82, 61, 46, 255); + + private int frameCount = 0; + + private PipelineStatisticsCalculator statistics = new PipelineStatisticsCalculator(); + + private double width; + private double height; + + public OpenCvCameraBase(OpenCvViewport viewport, boolean viewportEnabled) { + this.viewport = viewport; + this.rotation = getDefaultRotation(); + + this.viewportEnabled = viewportEnabled; + } + + @Override + public void showFpsMeterOnViewport(boolean show) { + viewport.setFpsMeterEnabled(show); + } + + @Override + public void pauseViewport() { + viewport.pause(); + } + + @Override + public void resumeViewport() { + viewport.resume(); + } + + @Override + public void setViewportRenderingPolicy(ViewportRenderingPolicy policy) { + viewport.setRenderingPolicy(policy); + } + + @Override + public void setViewportRenderer(ViewportRenderer renderer) { + this.desiredViewportRenderer = renderer; + } + + @Override + public void setPipeline(OpenCvPipeline pipeline) { + this.pipeline = pipeline; + } + + @Override + public int getFrameCount() { + return frameCount; + } + + @Override + public float getFps() { + return statistics.getAvgFps(); + } + + @Override + public int getPipelineTimeMs() { + return statistics.getAvgPipelineTime(); + } + + @Override + public int getOverheadTimeMs() { + return statistics.getAvgOverheadTime(); + } + + @Override + public int getTotalFrameTimeMs() { + return getTotalFrameTimeMs(); + } + + @Override + public int getCurrentPipelineMaxFps() { + return 0; + } + + @Override + public void startRecordingPipeline(PipelineRecordingParameters parameters) { + } + + @Override + public void stopRecordingPipeline() { + } + + protected void notifyStartOfFrameProcessing() { + statistics.newInputFrameStart(); + } + + public synchronized final void prepareForOpenCameraDevice() + { + if (viewportEnabled) + { + setupViewport(); + viewport.setRenderingPolicy(desiredRenderingPolicy); + } + } + + public synchronized final void prepareForStartStreaming(int width, int height, OpenCvCameraRotation rotation) + { + this.rotation = rotation; + this.statistics = new PipelineStatisticsCalculator(); + this.statistics.init(); + + Size sizeAfterRotation = getFrameSizeAfterRotation(width, height, rotation); + + this.width = sizeAfterRotation.width; + this.height = sizeAfterRotation.height; + + if(viewport != null) + { + // viewport.setSize(width, height); + viewport.setOptimizedViewRotation(getOptimizedViewportRotation(rotation)); + viewport.activate(); + } + } + + public synchronized final void cleanupForEndStreaming() { + matToUseIfPipelineReturnedCropped = null; + + if (viewport != null) { + viewport.deactivate(); + } + } + + protected synchronized void handleFrameUserCrashable(Mat frame, long timestamp) { + statistics.newPipelineFrameStart(); + + Mat userProcessedFrame = null; + + int rotateCode = mapRotationEnumToOpenCvRotateCode(rotation); + + if (rotateCode != -1) { + /* + * Rotate onto another Mat rather than doing so in-place. + * + * This does two things: + * 1) It seems that rotating by 90 or 270 in-place + * causes the backing buffer to be re-allocated + * since the width/height becomes swapped. This + * causes a problem for user code which makes a + * submat from the input Mat, because after the + * parent Mat is re-allocated the submat is no + * longer tied to it. Thus, by rotating onto + * another Mat (which is never re-allocated) we + * remove that issue. + * + * 2) Since the backing buffer does need need to be + * re-allocated for each frame, we reduce overhead + * time by about 1ms. + */ + Core.rotate(frame, rotatedMat, rotateCode); + frame = rotatedMat; + } + + final OpenCvPipeline pipelineSafe; + + // Grab a safe reference to what the pipeline currently is, + // since the user is allowed to change it at any time + synchronized (pipelineChangeLock) { + pipelineSafe = pipeline; + } + + if (pipelineSafe != null) { + if (pipelineSafe instanceof TimestampedOpenCvPipeline) { + ((TimestampedOpenCvPipeline) pipelineSafe).setTimestamp(timestamp); + } + + statistics.beforeProcessFrame(); + + userProcessedFrame = pipelineSafe.processFrameInternal(frame); + + statistics.afterProcessFrame(); + } + + // Will point to whatever mat we end up deciding to send to the screen + final Mat matForDisplay; + + if (pipelineSafe == null) { + matForDisplay = frame; + } else if (userProcessedFrame == null) { + throw new OpenCvCameraException("User pipeline returned null"); + } else if (userProcessedFrame.empty()) { + throw new OpenCvCameraException("User pipeline returned empty mat"); + } else if (userProcessedFrame.cols() != frame.cols() || userProcessedFrame.rows() != frame.rows()) { + /* + * The user didn't return the same size image from their pipeline as we gave them, + * ugh. This makes our lives interesting because we can't just send an arbitrary + * frame size to the viewport. It re-uses framebuffers that are of a fixed resolution. + * So, we copy the user's Mat onto a Mat of the correct size, and then send that other + * Mat to the viewport. + */ + + if (userProcessedFrame.cols() > frame.cols() || userProcessedFrame.rows() > frame.rows()) { + /* + * What on earth was this user thinking?! They returned a Mat that's BIGGER in + * a dimension than the one we gave them! + */ + + throw new OpenCvCameraException("User pipeline returned frame of unexpected size"); + } + + //We re-use this buffer, only create if needed + if (matToUseIfPipelineReturnedCropped == null) { + matToUseIfPipelineReturnedCropped = frame.clone(); + } + + //Set to brown to indicate to the user the areas which they cropped off + matToUseIfPipelineReturnedCropped.setTo(brown); + + int usrFrmTyp = userProcessedFrame.type(); + + if (usrFrmTyp == CvType.CV_8UC1) { + /* + * Handle 8UC1 returns (masks and single channels of images); + * + * We have to color convert onto a different mat (rather than + * doing so in place) to avoid breaking any of the user's submats + */ + Imgproc.cvtColor(userProcessedFrame, croppedColorCvtedMat, Imgproc.COLOR_GRAY2RGBA); + userProcessedFrame = croppedColorCvtedMat; //Doesn't affect user's handle, only ours + } else if (usrFrmTyp != CvType.CV_8UC4 && usrFrmTyp != CvType.CV_8UC3) { + /* + * Oof, we don't know how to handle the type they gave us + */ + throw new OpenCvCameraException("User pipeline returned a frame of an illegal type. Valid types are CV_8UC1, CV_8UC3, and CV_8UC4"); + } + + //Copy the user's frame onto a Mat of the correct size + userProcessedFrame.copyTo(matToUseIfPipelineReturnedCropped.submat( + new Rect(0, 0, userProcessedFrame.cols(), userProcessedFrame.rows()))); + + //Send that correct size Mat to the viewport + matForDisplay = matToUseIfPipelineReturnedCropped; + } else { + /* + * Yay, smart user! They gave us the frame size we were expecting! + * Go ahead and send it right on over to the viewport. + */ + matForDisplay = userProcessedFrame; + } + + if (viewport != null) { + viewport.post(matForDisplay, new OpenCvViewport.FrameContext(pipelineSafe, pipelineSafe != null ? pipelineSafe.getUserContextForDrawHook() : null)); + } + + statistics.endFrame(); + + if (viewport != null) { + viewport.notifyStatistics(statistics.getAvgFps(), statistics.getAvgPipelineTime(), statistics.getAvgOverheadTime()); + } + + frameCount++; + } + + + protected OpenCvViewport.OptimizedRotation getOptimizedViewportRotation(OpenCvCameraRotation streamRotation) { + if (!cameraOrientationIsTiedToDeviceOrientation()) { + return OpenCvViewport.OptimizedRotation.NONE; + } + + if (streamRotation == OpenCvCameraRotation.SIDEWAYS_LEFT || streamRotation == OpenCvCameraRotation.SENSOR_NATIVE) { + return OpenCvViewport.OptimizedRotation.ROT_90_COUNTERCLOCWISE; + } else if (streamRotation == OpenCvCameraRotation.SIDEWAYS_RIGHT) { + return OpenCvViewport.OptimizedRotation.ROT_90_CLOCKWISE; + } else if (streamRotation == OpenCvCameraRotation.UPSIDE_DOWN) { + return OpenCvViewport.OptimizedRotation.ROT_180; + } else { + return OpenCvViewport.OptimizedRotation.NONE; + } + } + + protected void setupViewport() { + viewport.setFpsMeterEnabled(fpsMeterDesired); + viewport.setRenderHook(PipelineRenderHook.INSTANCE); + } + + protected abstract OpenCvCameraRotation getDefaultRotation(); + + protected abstract int mapRotationEnumToOpenCvRotateCode(OpenCvCameraRotation rotation); + + protected abstract boolean cameraOrientationIsTiedToDeviceOrientation(); + + protected abstract boolean isStreaming(); + + protected Size getFrameSizeAfterRotation(int width, int height, OpenCvCameraRotation rotation) + { + int screenRenderedWidth, screenRenderedHeight; + int openCvRotateCode = mapRotationEnumToOpenCvRotateCode(rotation); + + if(openCvRotateCode == Core.ROTATE_90_CLOCKWISE || openCvRotateCode == Core.ROTATE_90_COUNTERCLOCKWISE) + { + //noinspection SuspiciousNameCombination + screenRenderedWidth = height; + //noinspection SuspiciousNameCombination + screenRenderedHeight = width; + } + else + { + screenRenderedWidth = width; + screenRenderedHeight = height; + } + + return new Size(screenRenderedWidth, screenRenderedHeight); + } + +} diff --git a/build.gradle b/build.gradle index d941433b..23b53910 100644 --- a/build.gradle +++ b/build.gradle @@ -9,14 +9,19 @@ buildscript { slf4j_version = "1.7.32" log4j_version = "2.17.1" opencv_version = "4.7.0-0" - apriltag_plugin_version = "main-SNAPSHOT" + apriltag_plugin_version = "2.1.0-B" skiko_version = "0.8.15" classgraph_version = "4.8.112" opencsv_version = "5.5.2" - env = findProperty('env') == 'release' ? 'release' : 'dev' + Penv = findProperty('env') + if(Penv != null && (Penv != 'dev' && Penv != 'release')) { + throw new GradleException("Invalid env property, must be 'dev' or 'release'") + } + + env = Penv == 'release' ? 'release' : 'dev' println("Current build is: $env") } From cbbb8c4e5acef0f01cd2e2cf477f497d98cd33ca Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Sat, 26 Oct 2024 12:00:11 -0600 Subject: [PATCH 10/36] Add plugin source enum --- .../eocvsim/util/JavaProcess.java | 220 +++++++++--------- .../deltacv/eocvsim/plugin/EOCVSimPlugin.kt | 7 + .../eocvsim/plugin/loader/PluginContext.kt | 1 + .../eocvsim/plugin/loader/PluginLoader.kt | 6 + .../eocvsim/plugin/loader/PluginManager.kt | 8 +- .../repository/PluginRepositoryManager.kt | 36 ++- 6 files changed, 165 insertions(+), 113 deletions(-) diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java index 451ad89c..83f91b7f 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java @@ -1,110 +1,112 @@ -/* - * Copyright (c) 2024 Sebastian Erives - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -package com.github.serivesmejia.eocvsim.util; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.util.LinkedList; -import java.util.List; - -/** - * A utility class for executing a Java process to run a main class within this project. - */ -public final class JavaProcess { - - public interface ProcessIOReceiver { - void receive(InputStream in, InputStream err); - } - - private JavaProcess() {} - - /** - * Executes a Java process with the given class and arguments. - * @param klass the class to execute - * @param ioReceiver the receiver for the process' input and error streams (will use inheritIO if null) - * @param classpath the classpath to use - * (use System.getProperty("java.class.path") for the default classpath) - * @param jvmArgs the JVM arguments to pass to the process - * @param args the arguments to pass to the class - * @return the exit value of the process - * @throws InterruptedException if the process is interrupted - * @throws IOException if an I/O error occurs - */ - public static int execClasspath(Class klass, ProcessIOReceiver ioReceiver, String classpath, List jvmArgs, List args) throws InterruptedException, IOException { - String javaHome = System.getProperty("java.home"); - String javaBin = javaHome + - File.separator + "bin" + - File.separator + "java"; - String className = klass.getName(); - - List command = new LinkedList<>(); - command.add(javaBin); - if (jvmArgs != null) { - command.addAll(jvmArgs); - } - command.add("-cp"); - command.add(classpath); - command.add(className); - if (args != null) { - command.addAll(args); - } - - ProcessBuilder builder = new ProcessBuilder(command); - - if (ioReceiver != null) { - Process process = builder.start(); - ioReceiver.receive(process.getInputStream(), process.getErrorStream()); - killOnExit(process); - - process.waitFor(); - return process.exitValue(); - } else { - builder.inheritIO(); - Process process = builder.start(); - killOnExit(process); - - process.waitFor(); - return process.exitValue(); - } - } - - private static void killOnExit(Process process) { - Runtime.getRuntime().addShutdownHook(new Thread(process::destroy)); - } - - /** - * Executes a Java process with the given class and arguments. - * @param klass the class to execute - * @param jvmArgs the JVM arguments to pass to the process - * @param args the arguments to pass to the class - * @return the exit value of the process - * @throws InterruptedException if the process is interrupted - * @throws IOException if an I/O error occurs - */ - public static int exec(Class klass, List jvmArgs, List args) throws InterruptedException, IOException { - return execClasspath(klass, null, System.getProperty("java.class.path"), jvmArgs, args); - } - +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package com.github.serivesmejia.eocvsim.util; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedList; +import java.util.List; + +/** + * A utility class for executing a Java process to run a main class within this project. + */ +public final class JavaProcess { + + public interface ProcessIOReceiver { + void receive(InputStream in, InputStream err); + } + + private JavaProcess() {} + + /** + * Executes a Java process with the given class and arguments. + * @param klass the class to execute + * @param ioReceiver the receiver for the process' input and error streams (will use inheritIO if null) + * @param classpath the classpath to use + * (use System.getProperty("java.class.path") for the default classpath) + * @param jvmArgs the JVM arguments to pass to the process + * @param args the arguments to pass to the class + * @return the exit value of the process + * @throws InterruptedException if the process is interrupted + * @throws IOException if an I/O error occurs + */ + public static int execClasspath(Class klass, ProcessIOReceiver ioReceiver, String classpath, List jvmArgs, List args) throws InterruptedException, IOException { + String javaHome = System.getProperty("java.home"); + String javaBin = javaHome + + File.separator + "bin" + + File.separator + "java"; + String className = klass.getName(); + + List command = new LinkedList<>(); + command.add(javaBin); + if (jvmArgs != null) { + command.addAll(jvmArgs); + } + command.add("-cp"); + command.add(classpath); + command.add(className); + if (args != null) { + command.addAll(args); + } + + System.out.println("Executing command: " + command); + + ProcessBuilder builder = new ProcessBuilder(command); + + if (ioReceiver != null) { + Process process = builder.start(); + ioReceiver.receive(process.getInputStream(), process.getErrorStream()); + killOnExit(process); + + process.waitFor(); + return process.exitValue(); + } else { + builder.inheritIO(); + Process process = builder.start(); + killOnExit(process); + + process.waitFor(); + return process.exitValue(); + } + } + + private static void killOnExit(Process process) { + Runtime.getRuntime().addShutdownHook(new Thread(process::destroy)); + } + + /** + * Executes a Java process with the given class and arguments. + * @param klass the class to execute + * @param jvmArgs the JVM arguments to pass to the process + * @param args the arguments to pass to the class + * @return the exit value of the process + * @throws InterruptedException if the process is interrupted + * @throws IOException if an I/O error occurs + */ + public static int exec(Class klass, List jvmArgs, List args) throws InterruptedException, IOException { + return execClasspath(klass, null, System.getProperty("java.class.path"), jvmArgs, args); + } + } \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/EOCVSimPlugin.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/EOCVSimPlugin.kt index 97888157..63784a00 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/EOCVSimPlugin.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/EOCVSimPlugin.kt @@ -48,6 +48,13 @@ abstract class EOCVSimPlugin { */ val fileSystem get() = context.fileSystem + /** + * Whether this plugin comes from a maven repository or a file + * This affects the classpath resolution and the way the plugin is loaded. + * @see io.github.deltacv.eocvsim.plugin.loader.PluginSource + */ + val pluginSource get() = context.pluginSource + /** * The classpath of the plugin, additional to the JVM classpath * This classpath is used to load classes from the plugin jar file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginContext.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginContext.kt index 641b7d22..7148f300 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginContext.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginContext.kt @@ -35,6 +35,7 @@ class PluginContext( } val plugin get() = loader.plugin + val pluginSource get() = loader.pluginSource val classpath get() = loader.classpath val hasSuperAccess get() = loader.hasSuperAccess diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt index 11375fc4..72cb4e4a 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt @@ -37,6 +37,11 @@ import net.lingala.zip4j.ZipFile import java.io.File import java.security.MessageDigest +enum class PluginSource { + REPOSITORY, + FILE +} + /** * Loads a plugin from a jar file * @param pluginFile the jar file of the plugin @@ -45,6 +50,7 @@ import java.security.MessageDigest class PluginLoader( val pluginFile: File, val classpath: List, + val pluginSource: PluginSource, val eocvSim: EOCVSim ) { diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt index 78aee988..c483efcc 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt @@ -89,7 +89,13 @@ class PluginManager(val eocvSim: EOCVSim) { } for (pluginFile in pluginFiles) { - loaders[pluginFile] = PluginLoader(pluginFile, repositoryManager.classpath(), eocvSim) + loaders[pluginFile] = PluginLoader( + pluginFile, + repositoryManager.classpath(), + if(pluginFile in repositoryManager.resolvedFiles) + PluginSource.REPOSITORY else PluginSource.FILE, + eocvSim + ) } isEnabled = true diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt index 765b480a..59b79769 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt @@ -1,3 +1,26 @@ +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + package io.github.deltacv.eocvsim.plugin.repository import com.github.serivesmejia.eocvsim.util.SysUtil @@ -5,7 +28,6 @@ import com.github.serivesmejia.eocvsim.util.extension.hexString import com.github.serivesmejia.eocvsim.util.extension.plus import com.github.serivesmejia.eocvsim.util.loggerForThis import com.moandjiezana.toml.Toml -import com.moandjiezana.toml.TomlWriter import io.github.deltacv.eocvsim.plugin.loader.PluginManager import org.jboss.shrinkwrap.resolver.api.maven.ConfigurableMavenResolverSystem import org.jboss.shrinkwrap.resolver.api.maven.Maven @@ -33,6 +55,9 @@ class PluginRepositoryManager { private lateinit var resolver: ConfigurableMavenResolverSystem + private val _resolvedFiles = mutableListOf() + val resolvedFiles get() = _resolvedFiles.toList() + val logger by loggerForThis() fun init() { @@ -52,6 +77,7 @@ class PluginRepositoryManager { ?: Toml() resolver = Maven.configureResolver() + .withClassPathResolution(false) for (repo in pluginsRepositories.toMap()) { if(repo.value !is String) @@ -85,12 +111,14 @@ class PluginRepositoryManager { try { for(cached in cacheToml.toMap()) { if(cached.key == pluginDep.hexString) { - logger.info("Found cached plugin dependency $pluginDep (${pluginDep.hexString})") - val cachedFile = File(cached.value as String) if(cachedFile.exists()) { + logger.info("Found cached plugin dependency $pluginDep (${pluginDep.hexString})") files += cachedFile + _resolvedFiles += cachedFile continue + } else { + newCache.remove(cached.key) } } } @@ -107,6 +135,8 @@ class PluginRepositoryManager { val destFile = File(MAVEN_FOLDER, file.name) file.copyTo(destFile, true) + _resolvedFiles += destFile + newCache[pluginDep.hexString] = pluginJar!!.absolutePath } From 3f2c71bdbf2bc865ec39a9d2ede6023d8122a751 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Sat, 26 Oct 2024 14:06:08 -0600 Subject: [PATCH 11/36] Improve logging of JavaProcess & add exception for duplicate plugins --- .../eocvsim/util/JavaProcess.java | 50 +++++++++++++++++-- .../eocvsim/plugin/loader/PluginLoader.kt | 26 +++++++--- .../eocvsim/plugin/loader/PluginManager.kt | 16 +++++- .../repository/PluginRepositoryManager.kt | 24 +-------- 4 files changed, 81 insertions(+), 35 deletions(-) diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java index 83f91b7f..58418862 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java @@ -23,11 +23,15 @@ package com.github.serivesmejia.eocvsim.util; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.io.File; import java.io.IOException; import java.io.InputStream; import java.util.LinkedList; import java.util.List; +import java.util.Scanner; /** * A utility class for executing a Java process to run a main class within this project. @@ -35,11 +39,43 @@ public final class JavaProcess { public interface ProcessIOReceiver { - void receive(InputStream in, InputStream err); + void receive(InputStream in, InputStream err, int pid); } + public static class SLF4JIOReceiver implements JavaProcess.ProcessIOReceiver { + + private final Logger logger; + + public SLF4JIOReceiver(Logger logger) { + this.logger = logger; + } + + @Override + public void receive(InputStream out, InputStream err, int pid) { + new Thread(() -> { + Scanner sc = new Scanner(out); + while (sc.hasNextLine()) { + logger.info(sc.nextLine()); + } + }, "SLFJ4IOReceiver-out-" + pid).start(); + + new Thread(() -> { + Scanner sc = new Scanner(err); + while (sc.hasNextLine()) { + logger.error(sc.nextLine()); + } + }, "SLF4JIOReceiver-err-" + pid).start(); + } + + } + + private JavaProcess() {} + private static int count; + + private static final Logger logger = LoggerFactory.getLogger(JavaProcess.class); + /** * Executes a Java process with the given class and arguments. * @param klass the class to execute @@ -71,13 +107,19 @@ public static int execClasspath(Class klass, ProcessIOReceiver ioReceiver, Strin command.addAll(args); } - System.out.println("Executing command: " + command); + int processCount = count; + + logger.info("Executing Java process #{} at \"{}\", main class \"{}\", JVM args \"{}\" and args \"{}\"", processCount, javaBin, className, jvmArgs, args); + + count++; ProcessBuilder builder = new ProcessBuilder(command); if (ioReceiver != null) { Process process = builder.start(); - ioReceiver.receive(process.getInputStream(), process.getErrorStream()); + logger.info("Started #{} with PID {}, inheriting IO to {}", processCount, process.pid(), ioReceiver.getClass().getSimpleName()); + + ioReceiver.receive(process.getInputStream(), process.getErrorStream(), (int) process.pid()); killOnExit(process); process.waitFor(); @@ -85,6 +127,8 @@ public static int execClasspath(Class klass, ProcessIOReceiver ioReceiver, Strin } else { builder.inheritIO(); Process process = builder.start(); + logger.info("Started ${} with PID {}, IO will be inherited to System.out and System.err", processCount, process.pid()); + killOnExit(process); process.waitFor(); diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt index 72cb4e4a..943c81dc 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt @@ -94,7 +94,7 @@ class PluginLoader( /** * Whether the plugin has super access (full system access) */ - val hasSuperAccess get() = eocvSim.config.superAccessPluginHashes.contains(pluginHash) + val hasSuperAccess get() = eocvSim.config.superAccessPluginHashes.contains(pluginFileHash) init { pluginClassLoader = PluginClassLoader(pluginFile, classpath) { @@ -103,12 +103,11 @@ class PluginLoader( } /** - * Load the plugin from the jar file - * @throws InvalidPluginException if the plugin.toml file is not found - * @throws UnsupportedPluginException if the plugin requests an api version higher than the current one + * Fetch the plugin info from the plugin.toml file + * Fills the pluginName, pluginVersion, pluginAuthor and pluginAuthorEmail fields */ - fun load() { - if(loaded) return + fun fetchInfoFromToml() { + if(::pluginToml.isInitialized) return pluginToml = Toml().read(pluginClassLoader.getResourceAsStream("plugin.toml") ?: throw InvalidPluginException("No plugin.toml in the jar file") @@ -119,8 +118,19 @@ class PluginLoader( pluginAuthor = pluginToml.getString("author") ?: throw InvalidPluginException("No author in plugin.toml") pluginAuthorEmail = pluginToml.getString("author-email", "") + } + + /** + * Load the plugin from the jar file + * @throws InvalidPluginException if the plugin.toml file is not found + * @throws UnsupportedPluginException if the plugin requests an api version higher than the current one + */ + fun load() { + if(loaded) return + + fetchInfoFromToml() - logger.info("Loading plugin $pluginName v$pluginVersion by $pluginAuthor") + logger.info("Loading plugin $pluginName v$pluginVersion by $pluginAuthor from ${pluginSource.name}") setupFs() @@ -216,7 +226,7 @@ class PluginLoader( * Get the hash of the plugin file based off the file contents * @return the hash */ - val pluginHash by lazy { + val pluginFileHash by lazy { val messageDigest = MessageDigest.getInstance("SHA-256") messageDigest.update(pluginFile.readBytes()) SysUtil.byteArray2Hex(messageDigest.digest()) diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt index c483efcc..15d1fd5b 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt @@ -49,6 +49,9 @@ class PluginManager(val eocvSim: EOCVSim) { val logger by loggerForThis() + private val _loadedPluginHashes = mutableListOf() + val loadedPluginHashes get() = _loadedPluginHashes.toList() + val repositoryManager = PluginRepositoryManager() private val _pluginFiles = mutableListOf() @@ -91,7 +94,7 @@ class PluginManager(val eocvSim: EOCVSim) { for (pluginFile in pluginFiles) { loaders[pluginFile] = PluginLoader( pluginFile, - repositoryManager.classpath(), + repositoryManager.resolvedFiles, if(pluginFile in repositoryManager.resolvedFiles) PluginSource.REPOSITORY else PluginSource.FILE, eocvSim @@ -108,7 +111,16 @@ class PluginManager(val eocvSim: EOCVSim) { fun loadPlugins() { for ((file, loader) in loaders) { try { + loader.fetchInfoFromToml() + + val hash = loader.hash() + + if(hash in _loadedPluginHashes) { + throw IllegalStateException("Plugin ${loader.pluginName} by ${loader.pluginAuthor} is already loaded. Please delete the duplicate from the plugins folder or from repository.toml !") + } + loader.load() + _loadedPluginHashes.add(hash) } catch (e: Throwable) { logger.error("Failure loading ${file.name}", e) loaders.remove(file) @@ -176,7 +188,7 @@ class PluginManager(val eocvSim: EOCVSim) { val name = "${loader.pluginName} by ${loader.pluginAuthor}".replace(" ", "-") if(JavaProcess.exec(SuperAccessRequestMain::class.java, null, Arrays.asList(name, warning)) == 171) { - eocvSim.config.superAccessPluginHashes.add(loader.pluginHash) + eocvSim.config.superAccessPluginHashes.add(loader.pluginFileHash) eocvSim.configManager.saveToFile() return true } diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt index 59b79769..11007d48 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt @@ -39,8 +39,6 @@ class PluginRepositoryManager { val REPOSITORY_FILE = PluginManager.PLUGIN_FOLDER + File.separator + "repository.toml" val CACHE_FILE = PluginManager.PLUGIN_FOLDER + File.separator + "cache.toml" - val MAVEN_FOLDER = PluginManager.PLUGIN_FOLDER + File.separator + "maven" - val REPOSITORY_TOML_RES = PluginRepositoryManager::class.java.getResourceAsStream("/repository.toml") val CACHE_TOML_RES = PluginRepositoryManager::class.java.getResourceAsStream("/cache.toml") @@ -66,8 +64,6 @@ class PluginRepositoryManager { SysUtil.copyFileIs(CACHE_TOML_RES, CACHE_FILE, false) cacheToml = Toml().read(CACHE_FILE) - MAVEN_FOLDER.mkdir() - SysUtil.copyFileIs(REPOSITORY_TOML_RES, REPOSITORY_FILE, false) pluginsToml = Toml().read(REPOSITORY_FILE) @@ -130,14 +126,10 @@ class PluginRepositoryManager { if(pluginJar == null) { // the first file is the plugin jar pluginJar = file + newCache[pluginDep.hexString] = pluginJar!!.absolutePath } - val destFile = File(MAVEN_FOLDER, file.name) - file.copyTo(destFile, true) - - _resolvedFiles += destFile - - newCache[pluginDep.hexString] = pluginJar!!.absolutePath + _resolvedFiles += file } files += pluginJar!! @@ -156,18 +148,6 @@ class PluginRepositoryManager { return files } - - fun classpath(): List { - val files = mutableListOf() - - for(jar in MAVEN_FOLDER.listFiles() ?: arrayOf()) { - if(jar.extension == "jar") { - files += jar - } - } - - return files - } } From 7ba7bda8669cab95850c04d44bbf289a7ab2c27b Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Sat, 26 Oct 2024 23:50:37 -0600 Subject: [PATCH 12/36] Plugin output GUI and improvements to requested api versions --- .../github/serivesmejia/eocvsim/EOCVSim.kt | 2 + .../eocvsim/gui/DialogFactory.java | 11 +- .../eocvsim/gui/dialog/PluginOutput.kt | 226 ++++++++++++++++++ .../gui/dialog/component/OutputPanel.kt | 9 +- .../eocvsim/util/JavaProcess.java | 2 +- .../eocvsim/plugin/loader/PluginLoader.kt | 16 +- .../eocvsim/plugin/loader/PluginManager.kt | 40 +++- .../repository/PluginRepositoryManager.kt | 47 +++- build.gradle | 2 +- 9 files changed, 331 insertions(+), 24 deletions(-) create mode 100644 EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt index 17d99f2d..924e34b3 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt @@ -282,6 +282,8 @@ class EOCVSim(val params: Parameters = Parameters()) { configManager.init() + configManager.config.simTheme.install() + pluginManager.init() // woah pluginManager.loadPlugins() diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/DialogFactory.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/DialogFactory.java index 80f7f09c..780c980d 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/DialogFactory.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/DialogFactory.java @@ -39,9 +39,6 @@ import java.awt.*; import java.io.File; import java.util.ArrayList; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadPoolExecutor; import java.util.function.IntConsumer; public class DialogFactory { @@ -154,6 +151,14 @@ public static void createPipelineOutput(EOCVSim eocvSim) { }); } + public static AppendDelegate createMavenOutput(Runnable onContinue) { + AppendDelegate delegate = new AppendDelegate(); + + invokeLater(() -> new PluginOutput(delegate, onContinue)); + + return delegate; + } + public static void createSplashScreen(EventHandler closeHandler) { invokeLater(() -> new SplashScreen(closeHandler)); } diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt new file mode 100644 index 00000000..27b77a83 --- /dev/null +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package com.github.serivesmejia.eocvsim.gui.dialog + +import com.github.serivesmejia.eocvsim.gui.dialog.component.BottomButtonsPanel +import com.github.serivesmejia.eocvsim.gui.dialog.component.OutputPanel +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.swing.Swing +import java.awt.Dimension +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection +import javax.swing.* + +class AppendDelegate { + private val appendables = mutableListOf() + + @Synchronized + fun subscribe(appendable: Appendable) { + appendables.add(appendable) + } + + fun subscribe(appendable: (String) -> Unit) { + appendables.add(object : Appendable { + override fun append(csq: CharSequence?): java.lang.Appendable? { + appendable(csq.toString()) + return this + } + + override fun append(csq: CharSequence?, start: Int, end: Int): java.lang.Appendable? { + appendable(csq.toString().substring(start, end)) + return this + } + + override fun append(c: Char): java.lang.Appendable? { + appendable(c.toString()) + return this + } + }) + } + + @Synchronized + fun append(text: String) { + appendables.forEach { it.append(text) } + } + + @Synchronized + fun appendln(text: String) { + appendables.forEach { it.appendLine(text) } + } +} + +class PluginOutput( + appendDelegate: AppendDelegate, + val onContinue: Runnable +) : Appendable { + + companion object { + const val SPECIAL_CLOSE = "[CLOSE]" + const val SPECIAL_CONTINUE = "[CONTINUE]" + const val SPECIAL_SILENT = "[SILENT]" + } + + private val output = JDialog() + + private val mavenBottomButtonsPanel: MavenOutputBottomButtonsPanel = MavenOutputBottomButtonsPanel(::close) { + mavenOutputPanel.outputArea.text + } + + private val mavenOutputPanel = OutputPanel(mavenBottomButtonsPanel) + + init { + output.isModal = true + output.isAlwaysOnTop = true + output.title = "Plugin Output" + + output.add(JTabbedPane().apply { + addTab("Output", mavenOutputPanel) + }) + + registerListeners() + + output.pack() + output.setSize(500, 350) + + appendDelegate.subscribe(this) + + output.setLocationRelativeTo(null) + output.defaultCloseOperation = WindowConstants.HIDE_ON_CLOSE + } + + @OptIn(DelicateCoroutinesApi::class) + private fun registerListeners() = GlobalScope.launch(Dispatchers.Swing) { + mavenBottomButtonsPanel.continueButton.addActionListener { + close() + onContinue.run() + + output.defaultCloseOperation = WindowConstants.HIDE_ON_CLOSE + mavenBottomButtonsPanel.continueButton.isEnabled = false + mavenBottomButtonsPanel.closeButton.isEnabled = true + } + } + + fun close() { + output.isVisible = false + } + + private fun handleSpecials(text: String): Boolean { + when(text) { + SPECIAL_CLOSE -> close() + SPECIAL_CONTINUE -> { + mavenBottomButtonsPanel.continueButton.isEnabled = true + mavenBottomButtonsPanel.closeButton.isEnabled = false + } + } + + if(!text.startsWith(SPECIAL_SILENT) && text != SPECIAL_CLOSE) { + SwingUtilities.invokeLater { + output.isVisible = true + } + } + + return text == SPECIAL_CLOSE || text == SPECIAL_CONTINUE + } + + private fun String.trimSpecials(): String { + return this.replace(SPECIAL_CLOSE, "") + .replace(SPECIAL_CONTINUE, "") + .replace(SPECIAL_SILENT, "") + } + + override fun append(csq: CharSequence?): java.lang.Appendable? { + val text = csq.toString() + + SwingUtilities.invokeLater { + if(handleSpecials(text)) return@invokeLater + + mavenOutputPanel.outputArea.text += text.trimSpecials() + mavenOutputPanel.outputArea.revalidate() + mavenOutputPanel.outputArea.repaint() + } + return this + } + + override fun append(csq: CharSequence?, start: Int, end: Int): java.lang.Appendable? { + val text = csq.toString().substring(start, end) + + SwingUtilities.invokeLater { + if(handleSpecials(text)) return@invokeLater + + mavenOutputPanel.outputArea.text += text.trimSpecials() + mavenOutputPanel.outputArea.revalidate() + mavenOutputPanel.outputArea.repaint() + } + return this + } + + override fun append(c: Char): java.lang.Appendable? { + SwingUtilities.invokeLater { + mavenOutputPanel.outputArea.text += c + mavenOutputPanel.outputArea.revalidate() + mavenOutputPanel.outputArea.repaint() + } + + return this + } + + class MavenOutputBottomButtonsPanel( + override val closeCallback: () -> Unit, + val outputTextSupplier: () -> String + ) : BottomButtonsPanel() { + + val copyButton = JButton("Copy") + val clearButton = JButton("Clear") + val continueButton = JButton("Continue") + val closeButton = JButton("Close") + + override fun create(panel: OutputPanel) { + layout = BoxLayout(this, BoxLayout.LINE_AXIS) + + copyButton.addActionListener { + Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(outputTextSupplier()), null) + } + + add(copyButton) + add(Box.createRigidArea(Dimension(4, 0))) + + clearButton.addActionListener { panel.outputArea.text = "" } + + add(clearButton) + + add(Box.createHorizontalGlue()) + + add(continueButton) + continueButton.isEnabled = false + + add(Box.createRigidArea(Dimension(4, 0))) + + add(closeButton) + closeButton.addActionListener { closeCallback() } + } + } +} diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/component/OutputPanel.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/component/OutputPanel.kt index da348cf8..3fdf25fd 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/component/OutputPanel.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/component/OutputPanel.kt @@ -31,13 +31,11 @@ import java.awt.datatransfer.StringSelection import javax.swing.* class OutputPanel( - private val bottomButtonsPanel: BottomButtonsPanel + bottomButtonsPanel: BottomButtonsPanel ) : JPanel(GridBagLayout()) { val outputArea = JTextArea("") - constructor(closeCallback: () -> Unit) : this(DefaultBottomButtonsPanel(closeCallback)) - init { if(bottomButtonsPanel is DefaultBottomButtonsPanel) { bottomButtonsPanel.outputTextSupplier = { outputArea.text } @@ -45,8 +43,9 @@ class OutputPanel( outputArea.isEditable = false outputArea.highlighter = null - outputArea.lineWrap = true - outputArea.wrapStyleWord = true + + // set the background color to a darker tone + outputArea.background = outputArea.background.darker() val outputScroll = JScrollPane(outputArea) outputScroll.verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_ALWAYS diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java index 58418862..20923ab7 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java @@ -127,7 +127,7 @@ public static int execClasspath(Class klass, ProcessIOReceiver ioReceiver, Strin } else { builder.inheritIO(); Process process = builder.start(); - logger.info("Started ${} with PID {}, IO will be inherited to System.out and System.err", processCount, process.pid()); + logger.info("Started #{} with PID {}, IO will be inherited to System.out and System.err", processCount, process.pid()); killOnExit(process); diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt index 943c81dc..438342d6 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt @@ -134,10 +134,24 @@ class PluginLoader( setupFs() + if(pluginToml.contains("min-api-version")) { + val parsedVersion = ParsedVersion(pluginToml.getString("min-api-version")) + + if(parsedVersion > EOCVSim.PARSED_VERSION) + throw UnsupportedPluginException("Plugin requires a minimum api version of v${parsedVersion}, EOCV-Sim is currently running at v${EOCVSim.PARSED_VERSION}") + } + + if(pluginToml.contains("max-api-version")) { + val parsedVersion = ParsedVersion(pluginToml.getString("max-api-version")) + + if(parsedVersion < EOCVSim.PARSED_VERSION) + throw UnsupportedPluginException("Plugin requires a maximum api version of v${parsedVersion}, EOCV-Sim is currently running at v${EOCVSim.PARSED_VERSION}") + } + if(pluginToml.contains("api-version")) { val parsedVersion = ParsedVersion(pluginToml.getString("api-version")) - if(parsedVersion > EOCVSim.PARSED_VERSION) + if(parsedVersion == EOCVSim.PARSED_VERSION) throw UnsupportedPluginException("Plugin request api version of v${parsedVersion}, EOCV-Sim is currently running at v${EOCVSim.PARSED_VERSION}") } diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt index 15d1fd5b..58570c5f 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt @@ -24,6 +24,8 @@ package io.github.deltacv.eocvsim.plugin.loader import com.github.serivesmejia.eocvsim.EOCVSim +import com.github.serivesmejia.eocvsim.gui.DialogFactory +import com.github.serivesmejia.eocvsim.gui.dialog.PluginOutput import com.github.serivesmejia.eocvsim.util.JavaProcess import com.github.serivesmejia.eocvsim.util.extension.plus import com.github.serivesmejia.eocvsim.util.io.EOCVSimFolder @@ -52,7 +54,29 @@ class PluginManager(val eocvSim: EOCVSim) { private val _loadedPluginHashes = mutableListOf() val loadedPluginHashes get() = _loadedPluginHashes.toList() - val repositoryManager = PluginRepositoryManager() + private val haltLock = Object() + + val appender by lazy { + val appender = DialogFactory.createMavenOutput { + synchronized(haltLock) { + haltLock.notify() + } + } + + appender.subscribe { + if(!it.isBlank()) { + logger.info(it) + } + } + + appender + } + + val repositoryManager by lazy { + PluginRepositoryManager( + appender, haltLock + ) + } private val _pluginFiles = mutableListOf() @@ -87,7 +111,7 @@ class PluginManager(val eocvSim: EOCVSim) { } if(pluginFiles.isEmpty()) { - logger.info("No plugins to load") + appender.appendln(PluginOutput.SPECIAL_SILENT + "No plugins to load") return } @@ -116,13 +140,18 @@ class PluginManager(val eocvSim: EOCVSim) { val hash = loader.hash() if(hash in _loadedPluginHashes) { - throw IllegalStateException("Plugin ${loader.pluginName} by ${loader.pluginAuthor} is already loaded. Please delete the duplicate from the plugins folder or from repository.toml !") + val source = if(loader.pluginSource == PluginSource.REPOSITORY) "repository.toml file" else "plugins folder" + + appender.appendln("Plugin ${loader.pluginName} by ${loader.pluginAuthor} is already loaded. Please delete the duplicate from the $source !") + return } loader.load() _loadedPluginHashes.add(hash) } catch (e: Throwable) { - logger.error("Failure loading ${file.name}", e) + appender.appendln("Failure loading ${loader.pluginName} v${loader.pluginVersion}: ${e.message}") + logger.error("Failure loading ${loader.pluginName} v${loader.pluginVersion}", e) + loaders.remove(file) loader.kill() } @@ -138,6 +167,7 @@ class PluginManager(val eocvSim: EOCVSim) { try { loader.enable() } catch (e: Throwable) { + appender.appendln("Failure enabling ${loader.pluginName} v${loader.pluginVersion}: ${e.message}") logger.error("Failure enabling ${loader.pluginName} v${loader.pluginVersion}", e) loader.kill() } @@ -156,6 +186,7 @@ class PluginManager(val eocvSim: EOCVSim) { try { loader.disable() } catch (e: Throwable) { + appender.appendln("Failure disabling ${loader.pluginName} v${loader.pluginVersion}: ${e.message}") logger.error("Failure disabling ${loader.pluginName} v${loader.pluginVersion}", e) loader.kill() } @@ -174,6 +205,7 @@ class PluginManager(val eocvSim: EOCVSim) { fun requestSuperAccessFor(loader: PluginLoader, reason: String): Boolean { if(loader.hasSuperAccess) return true + appender.appendln(PluginOutput.SPECIAL_SILENT + "Requesting super access for ${loader.pluginName} v${loader.pluginVersion}") logger.info("Requesting super access for ${loader.pluginName} v${loader.pluginVersion}") var warning = "$GENERIC_SUPERACCESS_WARN" diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt index 11007d48..15b966c2 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt @@ -23,6 +23,9 @@ package io.github.deltacv.eocvsim.plugin.repository +import com.github.serivesmejia.eocvsim.gui.DialogFactory +import com.github.serivesmejia.eocvsim.gui.dialog.AppendDelegate +import com.github.serivesmejia.eocvsim.gui.dialog.PluginOutput import com.github.serivesmejia.eocvsim.util.SysUtil import com.github.serivesmejia.eocvsim.util.extension.hexString import com.github.serivesmejia.eocvsim.util.extension.plus @@ -33,7 +36,10 @@ import org.jboss.shrinkwrap.resolver.api.maven.ConfigurableMavenResolverSystem import org.jboss.shrinkwrap.resolver.api.maven.Maven import java.io.File -class PluginRepositoryManager { +class PluginRepositoryManager( + val appender: AppendDelegate, + val haltLock: Object +) { companion object { val REPOSITORY_FILE = PluginManager.PLUGIN_FOLDER + File.separator + "repository.toml" @@ -61,6 +67,8 @@ class PluginRepositoryManager { fun init() { logger.info("Initializing plugin repository manager") + appender // init appender + SysUtil.copyFileIs(CACHE_TOML_RES, CACHE_FILE, false) cacheToml = Toml().read(CACHE_FILE) @@ -90,35 +98,45 @@ class PluginRepositoryManager { val newCache = mutableMapOf() - for(cache in cacheToml.toMap()) { - newCache[cache.key] = cache.value as String - } + var shouldHalt = false for(plugin in plugins.toMap()) { if(plugin.value !is String) throw InvalidFileException("Invalid plugin dependency in repository.toml. $UNSURE_USER_HELP") - logger.info("Resolving plugin dependency ${plugin.key} with ${plugin.value}") - val pluginDep = plugin.value as String var pluginJar: File? = null try { + var foundCache = false + for(cached in cacheToml.toMap()) { if(cached.key == pluginDep.hexString) { val cachedFile = File(cached.value as String) + if(cachedFile.exists()) { - logger.info("Found cached plugin dependency $pluginDep (${pluginDep.hexString})") - files += cachedFile + appender.appendln( + PluginOutput.SPECIAL_SILENT + + "Found cached plugin dependency $pluginDep (${pluginDep.hexString})" + ) + + pluginJar = cachedFile _resolvedFiles += cachedFile - continue + foundCache = true + break } else { newCache.remove(cached.key) } } } + if(foundCache) { + appender.appendln(PluginOutput.SPECIAL_SILENT + "Resolving plugin ${plugin.key} at ${plugin.value}") + } else { + appender.appendln("Resolving plugin ${plugin.key} at ${plugin.value}") + } + resolver.resolve(pluginDep) .withTransitivity() .asFile() @@ -135,6 +153,8 @@ class PluginRepositoryManager { files += pluginJar!! } catch(ex: Exception) { logger.warn("Failed to resolve plugin dependency $pluginDep", ex) + appender.appendln("Failed to resolve plugin ${plugin.key}: ${ex.message}") + shouldHalt = true } } @@ -146,6 +166,15 @@ class PluginRepositoryManager { SysUtil.saveFileStr(CACHE_FILE, cacheBuilder.toString()) + if(shouldHalt) { + appender.append(PluginOutput.SPECIAL_CONTINUE) + synchronized(haltLock) { + haltLock.wait(10000) // wait for user to read the error + } + } else { + appender.append(PluginOutput.SPECIAL_CLOSE) + } + return files } } diff --git a/build.gradle b/build.gradle index 23b53910..9bb7bc17 100644 --- a/build.gradle +++ b/build.gradle @@ -44,7 +44,7 @@ plugins { allprojects { group 'com.github.deltacv' - version '4.0.0' + version '3.8.0' apply plugin: 'java' From 74ec7f7b3ba6860538e03eaab43e7705e1908a4f Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Sun, 27 Oct 2024 01:13:18 -0600 Subject: [PATCH 13/36] Improve plugin cache handling & fix event handler loop/spam in SourceSelectorPanel --- .../eocvsim/gui/DialogFactory.java | 4 - .../gui/component/visualizer/TopMenuBar.kt | 5 +- .../pipeline/SourceSelectorPanel.kt | 65 +++++---- .../eocvsim/gui/dialog/PluginOutput.kt | 37 +++-- .../eocvsim/input/InputSourceManager.java | 4 + .../eocvsim/pipeline/PipelineManager.kt | 14 +- .../serivesmejia/eocvsim/util/SysUtil.java | 10 ++ .../eocvsim/plugin/loader/PluginLoader.kt | 18 ++- .../eocvsim/plugin/loader/PluginManager.kt | 11 +- .../repository/PluginRepositoryManager.kt | 129 +++++++++++++----- 10 files changed, 201 insertions(+), 96 deletions(-) diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/DialogFactory.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/DialogFactory.java index 780c980d..ab4149c0 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/DialogFactory.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/DialogFactory.java @@ -118,10 +118,6 @@ public static void createConfigDialog(EOCVSim eocvSim) { invokeLater(() -> new Configuration(eocvSim.visualizer.frame, eocvSim)); } - public static void createPluginsDialog(EOCVSim eocvSim) { - invokeLater(() -> new Configuration(eocvSim.visualizer.frame, eocvSim)); - } - public static void createAboutDialog(EOCVSim eocvSim) { invokeLater(() -> new About(eocvSim.visualizer.frame, eocvSim)); } diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/component/visualizer/TopMenuBar.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/component/visualizer/TopMenuBar.kt index ca37f44e..f28a4b24 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/component/visualizer/TopMenuBar.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/component/visualizer/TopMenuBar.kt @@ -27,6 +27,7 @@ import com.github.serivesmejia.eocvsim.EOCVSim import com.github.serivesmejia.eocvsim.gui.DialogFactory import com.github.serivesmejia.eocvsim.gui.Visualizer import com.github.serivesmejia.eocvsim.gui.dialog.Output +import com.github.serivesmejia.eocvsim.gui.dialog.PluginOutput import com.github.serivesmejia.eocvsim.gui.util.GuiUtil import com.github.serivesmejia.eocvsim.input.SourceType import com.github.serivesmejia.eocvsim.util.FileFilters @@ -102,7 +103,9 @@ class TopMenuBar(visualizer: Visualizer, eocvSim: EOCVSim) : JMenuBar() { mFileMenu.add(editSettings) val filePlugins = JMenuItem("Plugins") - filePlugins.addActionListener { DialogFactory.createPluginsDialog(eocvSim) } + filePlugins.addActionListener { eocvSim.pluginManager.appender.append(PluginOutput.SPECIAL_OPEN)} + + mFileMenu.add(filePlugins) mFileMenu.addSeparator() diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/component/visualizer/pipeline/SourceSelectorPanel.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/component/visualizer/pipeline/SourceSelectorPanel.kt index cd4d77df..1bc3ebfc 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/component/visualizer/pipeline/SourceSelectorPanel.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/component/visualizer/pipeline/SourceSelectorPanel.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.swing.Swing import java.awt.FlowLayout import java.awt.GridBagConstraints import java.awt.GridBagLayout +import java.awt.event.MouseAdapter import javax.swing.* class SourceSelectorPanel(private val eocvSim: EOCVSim) : JPanel() { @@ -81,6 +82,7 @@ class SourceSelectorPanel(private val eocvSim: EOCVSim) : JPanel() { sourceSelectorButtonsContainer.add(sourceSelectorCreateBtt) sourceSelectorButtonsContainer.add(sourceSelectorDeleteBtt) + sourceSelectorDeleteBtt.isEnabled = false add(sourceSelectorButtonsContainer, GridBagConstraints().apply { gridy = 1 @@ -92,41 +94,46 @@ class SourceSelectorPanel(private val eocvSim: EOCVSim) : JPanel() { private fun registerListeners() { //listener for changing input sources - sourceSelector.addListSelectionListener { evt -> - if(allowSourceSwitching && !evt.valueIsAdjusting) { - try { - if (sourceSelector.selectedIndex != -1) { - val model = sourceSelector.model - val source = model.getElementAt(sourceSelector.selectedIndex) - - //enable or disable source delete button depending if source is default or not - eocvSim.visualizer.sourceSelectorPanel.sourceSelectorDeleteBtt - .isEnabled = !(eocvSim.inputSourceManager.sources[source]?.isDefault ?: true) - - if (!evt.valueIsAdjusting && source != beforeSelectedSource) { - if (!eocvSim.pipelineManager.paused) { - eocvSim.inputSourceManager.requestSetInputSource(source) - beforeSelectedSource = source - beforeSelectedSourceIndex = sourceSelector.selectedIndex - } else { - //check if the user requested the pause or if it was due to one shoot analysis when selecting images - if (eocvSim.pipelineManager.pauseReason !== PipelineManager.PauseReason.IMAGE_ONE_ANALYSIS) { - sourceSelector.setSelectedIndex(beforeSelectedSourceIndex) - } else { //handling pausing - eocvSim.pipelineManager.requestSetPaused(false) - eocvSim.inputSourceManager.requestSetInputSource(source) - beforeSelectedSource = source - beforeSelectedSourceIndex = sourceSelector.selectedIndex + sourceSelector.addMouseListener(object: MouseAdapter() { + override fun mouseClicked(e: java.awt.event.MouseEvent) { + val index = (e.source as JList<*>).locationToIndex(e.point) + + if (index != -1) { + if(allowSourceSwitching) { + try { + if (sourceSelector.selectedIndex != -1) { + val model = sourceSelector.model + val source = model.getElementAt(sourceSelector.selectedIndex) + + //enable or disable source delete button depending if source is default or not + sourceSelectorDeleteBtt.isEnabled = eocvSim.inputSourceManager.sources[source]?.isDefault == false + + if (source != beforeSelectedSource) { + if (!eocvSim.pipelineManager.paused) { + eocvSim.inputSourceManager.requestSetInputSource(source) + beforeSelectedSource = source + beforeSelectedSourceIndex = sourceSelector.selectedIndex + } else { + //check if the user requested the pause or if it was due to one shoot analysis when selecting images + if (eocvSim.pipelineManager.pauseReason !== PipelineManager.PauseReason.IMAGE_ONE_ANALYSIS) { + sourceSelector.setSelectedIndex(beforeSelectedSourceIndex) + } else { //handling pausing + eocvSim.pipelineManager.requestSetPaused(false) + eocvSim.inputSourceManager.requestSetInputSource(source) + beforeSelectedSource = source + beforeSelectedSourceIndex = sourceSelector.selectedIndex + } + } } + } else { + sourceSelector.setSelectedIndex(1) } + } catch (ignored: ArrayIndexOutOfBoundsException) { } - } else { - sourceSelector.setSelectedIndex(1) } - } catch (ignored: ArrayIndexOutOfBoundsException) { } } - } + }) // delete selected input source sourceSelectorDeleteBtt.addActionListener { diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt index 27b77a83..e6b9d8ec 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt @@ -79,9 +79,22 @@ class PluginOutput( ) : Appendable { companion object { - const val SPECIAL_CLOSE = "[CLOSE]" - const val SPECIAL_CONTINUE = "[CONTINUE]" - const val SPECIAL_SILENT = "[SILENT]" + private const val SPECIAL = "13mck" + + const val SPECIAL_OPEN = "$SPECIAL[OPEN]" + const val SPECIAL_CLOSE = "$SPECIAL[CLOSE]" + const val SPECIAL_CONTINUE = "$SPECIAL[CONTINUE]" + const val SPECIAL_FREE = "$SPECIAL[FREE]" + const val SPECIAL_SILENT = "$SPECIAL[SILENT]" + + fun String.trimSpecials(): String { + return this + .replace(SPECIAL_OPEN, "") + .replace(SPECIAL_CLOSE, "") + .replace(SPECIAL_CONTINUE, "") + .replace(SPECIAL_SILENT, "") + .replace(SPECIAL_FREE, "") + } } private val output = JDialog() @@ -130,6 +143,10 @@ class PluginOutput( private fun handleSpecials(text: String): Boolean { when(text) { + SPECIAL_FREE -> { + mavenBottomButtonsPanel.continueButton.isEnabled = false + mavenBottomButtonsPanel.closeButton.isEnabled = true + } SPECIAL_CLOSE -> close() SPECIAL_CONTINUE -> { mavenBottomButtonsPanel.continueButton.isEnabled = true @@ -137,19 +154,13 @@ class PluginOutput( } } - if(!text.startsWith(SPECIAL_SILENT) && text != SPECIAL_CLOSE) { + if(!text.startsWith(SPECIAL_SILENT) && text != SPECIAL_CLOSE && text != SPECIAL_FREE) { SwingUtilities.invokeLater { output.isVisible = true } } - return text == SPECIAL_CLOSE || text == SPECIAL_CONTINUE - } - - private fun String.trimSpecials(): String { - return this.replace(SPECIAL_CLOSE, "") - .replace(SPECIAL_CONTINUE, "") - .replace(SPECIAL_SILENT, "") + return text == SPECIAL_OPEN || text == SPECIAL_CLOSE || text == SPECIAL_CONTINUE || text == SPECIAL_FREE } override fun append(csq: CharSequence?): java.lang.Appendable? { @@ -201,6 +212,8 @@ class PluginOutput( override fun create(panel: OutputPanel) { layout = BoxLayout(this, BoxLayout.LINE_AXIS) + add(Box.createRigidArea(Dimension(4, 0))) + copyButton.addActionListener { Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(outputTextSupplier()), null) } @@ -221,6 +234,8 @@ class PluginOutput( add(closeButton) closeButton.addActionListener { closeCallback() } + + add(Box.createRigidArea(Dimension(4, 0))) } } } diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/input/InputSourceManager.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/input/InputSourceManager.java index 97c08910..77ac0aae 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/input/InputSourceManager.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/input/InputSourceManager.java @@ -208,6 +208,8 @@ public boolean setInputSource(String sourceName, boolean makeDefault) { public boolean setInputSource(String sourceName) { InputSource src = null; + SysUtil.debugLogCalled("setInputSource"); + if(sourceName == null) { src = new NullSource(); } else { @@ -298,6 +300,8 @@ public void pauseIfImageTwoFrames() { } public void requestSetInputSource(String name) { + SysUtil.debugLogCalled("requestSetInputSource"); + eocvSim.onMainUpdate.doOnce(() -> setInputSource(name)); } diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/pipeline/PipelineManager.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/pipeline/PipelineManager.kt index c9a910dd..2547c05a 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/pipeline/PipelineManager.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/pipeline/PipelineManager.kt @@ -34,6 +34,7 @@ import com.github.serivesmejia.eocvsim.pipeline.util.PipelineExceptionTracker import com.github.serivesmejia.eocvsim.pipeline.util.PipelineSnapshot import com.github.serivesmejia.eocvsim.util.ReflectUtil import com.github.serivesmejia.eocvsim.util.StrUtil +import com.github.serivesmejia.eocvsim.util.SysUtil import com.github.serivesmejia.eocvsim.util.event.EventHandler import com.github.serivesmejia.eocvsim.util.fps.FpsCounter import com.github.serivesmejia.eocvsim.util.loggerForThis @@ -572,7 +573,7 @@ class PipelineManager( logger.info("Changing to pipeline ${pipelineClass.name}") - debugLogCalled("forceChangePipeline") + SysUtil.debugLogCalled("forceChangePipeline") val instantiator = getInstantiatorFor(pipelineClass) @@ -664,7 +665,7 @@ class PipelineManager( } fun requestForceChangePipeline(index: Int) { - debugLogCalled("requestForceChangePipeline") + SysUtil.debugLogCalled("requestForceChangePipeline") onUpdate.doOnce { forceChangePipeline(index) } } @@ -769,15 +770,6 @@ class PipelineManager( forceChangePipeline(0) // default pipeline } - private fun debugLogCalled(name: String) { - val builder = StringBuilder() - for (s in Thread.currentThread().stackTrace) { - builder.appendLine(s.toString()) - } - - logger.debug("$name called in: {}", builder.toString().trim()) - } - } enum class PipelineTimeout(val ms: Long, val coolName: String) { diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/SysUtil.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/SysUtil.java index 9022214e..fa70650f 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/SysUtil.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/SysUtil.java @@ -339,6 +339,16 @@ public static CommandResult runShellCommand(String command) { return result; } + + public static void debugLogCalled(String name) { + StringBuilder builder = new StringBuilder(); + for (StackTraceElement s : Thread.currentThread().getStackTrace()) { + builder.append(s.toString()).append("\n"); + } + + logger.debug("{} called in: {}", name, builder.toString().trim()); + } + public enum OperatingSystem { WINDOWS, LINUX, diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt index 438342d6..940367a3 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt @@ -134,11 +134,13 @@ class PluginLoader( setupFs() - if(pluginToml.contains("min-api-version")) { - val parsedVersion = ParsedVersion(pluginToml.getString("min-api-version")) + if(pluginToml.contains("api-version")) { + val parsedVersion = ParsedVersion(pluginToml.getString("api-version")) if(parsedVersion > EOCVSim.PARSED_VERSION) throw UnsupportedPluginException("Plugin requires a minimum api version of v${parsedVersion}, EOCV-Sim is currently running at v${EOCVSim.PARSED_VERSION}") + + logger.info("Plugin $pluginName requests min api version of v${parsedVersion}") } if(pluginToml.contains("max-api-version")) { @@ -146,13 +148,17 @@ class PluginLoader( if(parsedVersion < EOCVSim.PARSED_VERSION) throw UnsupportedPluginException("Plugin requires a maximum api version of v${parsedVersion}, EOCV-Sim is currently running at v${EOCVSim.PARSED_VERSION}") + + logger.info("Plugin $pluginName requests max api version of v${parsedVersion}") } - if(pluginToml.contains("api-version")) { - val parsedVersion = ParsedVersion(pluginToml.getString("api-version")) + if(pluginToml.contains("exact-api-version")) { + val parsedVersion = ParsedVersion(pluginToml.getString("exact-api-version")) + + if(parsedVersion != EOCVSim.PARSED_VERSION) + throw UnsupportedPluginException("Plugin requires an exact api version of v${parsedVersion}, EOCV-Sim is currently running at v${EOCVSim.PARSED_VERSION}") - if(parsedVersion == EOCVSim.PARSED_VERSION) - throw UnsupportedPluginException("Plugin request api version of v${parsedVersion}, EOCV-Sim is currently running at v${EOCVSim.PARSED_VERSION}") + logger.info("Plugin $pluginName requests exact api version of v${parsedVersion}") } if(pluginToml.getBoolean("super-access", false)) { diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt index 58570c5f..8d1d9756 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt @@ -26,6 +26,7 @@ package io.github.deltacv.eocvsim.plugin.loader import com.github.serivesmejia.eocvsim.EOCVSim import com.github.serivesmejia.eocvsim.gui.DialogFactory import com.github.serivesmejia.eocvsim.gui.dialog.PluginOutput +import com.github.serivesmejia.eocvsim.gui.dialog.PluginOutput.Companion.trimSpecials import com.github.serivesmejia.eocvsim.util.JavaProcess import com.github.serivesmejia.eocvsim.util.extension.plus import com.github.serivesmejia.eocvsim.util.io.EOCVSimFolder @@ -65,7 +66,11 @@ class PluginManager(val eocvSim: EOCVSim) { appender.subscribe { if(!it.isBlank()) { - logger.info(it) + val message = it.trimSpecials() + + if(message.isNotBlank()) { + logger.info(message) + } } } @@ -97,6 +102,10 @@ class PluginManager(val eocvSim: EOCVSim) { * @see PluginLoader */ fun init() { + eocvSim.visualizer.onInitFinished { + appender.append(PluginOutput.SPECIAL_FREE) + } + repositoryManager.init() val pluginFiles = mutableListOf() diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt index 15b966c2..71973a7f 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt @@ -35,6 +35,7 @@ import io.github.deltacv.eocvsim.plugin.loader.PluginManager import org.jboss.shrinkwrap.resolver.api.maven.ConfigurableMavenResolverSystem import org.jboss.shrinkwrap.resolver.api.maven.Maven import java.io.File +import kotlin.collections.iterator class PluginRepositoryManager( val appender: AppendDelegate, @@ -56,6 +57,8 @@ class PluginRepositoryManager( private lateinit var plugins: Toml private lateinit var cacheToml: Toml + private lateinit var cachePluginsToml: Toml + private lateinit var cacheTransitiveToml: Toml private lateinit var resolver: ConfigurableMavenResolverSystem @@ -65,13 +68,19 @@ class PluginRepositoryManager( val logger by loggerForThis() fun init() { - logger.info("Initializing plugin repository manager") + logger.info("Initializing...") appender // init appender SysUtil.copyFileIs(CACHE_TOML_RES, CACHE_FILE, false) cacheToml = Toml().read(CACHE_FILE) + cachePluginsToml = cacheToml.getTable("plugins") + ?: Toml() + + cacheTransitiveToml = cacheToml.getTable("transitive") + ?: Toml() + SysUtil.copyFileIs(REPOSITORY_TOML_RES, REPOSITORY_FILE, false) pluginsToml = Toml().read(REPOSITORY_FILE) @@ -97,6 +106,7 @@ class PluginRepositoryManager( val files = mutableListOf() val newCache = mutableMapOf() + val newTransitiveCache = mutableMapOf>() var shouldHalt = false @@ -109,62 +119,83 @@ class PluginRepositoryManager( var pluginJar: File? = null try { - var foundCache = false + var isCached = false - for(cached in cacheToml.toMap()) { + mainCacheLoop@ + for(cached in cachePluginsToml.toMap()) { if(cached.key == pluginDep.hexString) { val cachedFile = File(cached.value as String) if(cachedFile.exists()) { + for(transitive in cacheTransitiveToml.getList(pluginDep.hexString) ?: emptyList()) { + val transitiveFile = File(transitive as String) + + if (!transitiveFile.exists()) { + appender.appendln(PluginOutput.SPECIAL_SILENT + "Transitive dependency $transitive for plugin $pluginDep does not exist. Resolving...") + break@mainCacheLoop + } + + _resolvedFiles += transitiveFile // add transitive dependency to resolved files + + newTransitiveCache[pluginDep.hexString] = newTransitiveCache.getOrDefault( + pluginDep.hexString, + mutableListOf() + ).apply { + add(transitiveFile.absolutePath) + } + } + appender.appendln( PluginOutput.SPECIAL_SILENT + - "Found cached plugin dependency $pluginDep (${pluginDep.hexString})" + "Found cached plugin \"$pluginDep\" (${pluginDep.hexString}). All transitive dependencies OK." ) pluginJar = cachedFile _resolvedFiles += cachedFile - foundCache = true - break - } else { - newCache.remove(cached.key) - } - } - } - if(foundCache) { - appender.appendln(PluginOutput.SPECIAL_SILENT + "Resolving plugin ${plugin.key} at ${plugin.value}") - } else { - appender.appendln("Resolving plugin ${plugin.key} at ${plugin.value}") - } + newCache[pluginDep.hexString] = cachedFile.absolutePath // add plugin to revalidated cache - resolver.resolve(pluginDep) - .withTransitivity() - .asFile() - .forEach { file -> - if(pluginJar == null) { - // the first file is the plugin jar - pluginJar = file - newCache[pluginDep.hexString] = pluginJar!!.absolutePath + isCached = true // skip to next plugin, this one is already resolved by cache } - _resolvedFiles += file + break@mainCacheLoop } + } + + if(!isCached) { + // if we reach this point, the plugin was not found in cache + appender.appendln("Resolving plugin ${plugin.key} at \"${plugin.value}\"...") + + resolver.resolve(pluginDep) + .withTransitivity() + .asFile() + .forEach { file -> + if (pluginJar == null) { + // the first file returned by maven is the plugin jar we want + pluginJar = file + newCache[pluginDep.hexString] = pluginJar!!.absolutePath + } else { + newTransitiveCache[pluginDep.hexString] = newTransitiveCache.getOrDefault( + pluginDep.hexString, + mutableListOf() + ).apply { + add(file.absolutePath) + } // add transitive dependency to cache + } + + _resolvedFiles += file // add file to resolved files to later build a classpath + } + } files += pluginJar!! } catch(ex: Exception) { - logger.warn("Failed to resolve plugin dependency $pluginDep", ex) + logger.warn("Failed to resolve plugin dependency \"$pluginDep\"", ex) appender.appendln("Failed to resolve plugin ${plugin.key}: ${ex.message}") shouldHalt = true } } - val cacheBuilder = StringBuilder() - cacheBuilder.append("# Do not edit this file, it is generated by the application.\n") - for(cached in newCache) { - cacheBuilder.append("${cached.key} = \"${cached.value.replace("\\", "/")}\"\n") - } - - SysUtil.saveFileStr(CACHE_FILE, cacheBuilder.toString()) + writeCacheFile(newCache, newTransitiveCache) if(shouldHalt) { appender.append(PluginOutput.SPECIAL_CONTINUE) @@ -177,6 +208,38 @@ class PluginRepositoryManager( return files } + + private fun writeCacheFile( + cache: Map, + transitiveCache: Map> + ) { + val cacheBuilder = StringBuilder() + + cacheBuilder.appendLine("# Do not edit this file, it is generated by the application.") + + cacheBuilder.appendLine("[plugins]") // add plugins table + + for(cached in cache) { + cacheBuilder.appendLine("${cached.key} = \"${cached.value.replace("\\", "/")}\"") + } + + cacheBuilder.appendLine("[transitive]") // add transitive dependencies table + + for((plugin, deps) in transitiveCache) { + cacheBuilder.appendLine("$plugin = [") // add plugin hash as key + + for((i, dep) in deps.withIndex()) { + cacheBuilder.append("\t\"${dep.replace("\\", "/")}\"") // add dependency path + if(i < deps.size - 1) cacheBuilder.append(",") // add comma if not last + + cacheBuilder.appendLine() + } + + cacheBuilder.appendLine("]") + } + + SysUtil.saveFileStr(CACHE_FILE, cacheBuilder.toString().trim()) + } } From 97b9963679a1ee4bf360a4c3f2a4ea58ef765b3a Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Sun, 27 Oct 2024 02:30:09 -0600 Subject: [PATCH 14/36] Fix plugins to actually log to file & change output text area font to jb mono --- .../serivesmejia/eocvsim/util/LogExt.kt | 25 ++- .../gui/dialog/component/OutputPanel.kt | 15 ++ .../eocvsim/util/JavaProcess.java | 6 + .../plugin/loader/PluginClassLoader.kt | 7 +- .../eocvsim/plugin/loader/PluginContext.kt | 4 +- .../eocvsim/plugin/loader/PluginLoader.kt | 14 +- .../eocvsim/plugin/loader/PluginManager.kt | 19 +- .../repository/PluginRepositoryManager.kt | 193 +++++++++++------- .../resources/fonts/JetBrainsMono-Medium.ttf | Bin 0 -> 273860 bytes 9 files changed, 186 insertions(+), 97 deletions(-) create mode 100644 EOCV-Sim/src/main/resources/fonts/JetBrainsMono-Medium.ttf diff --git a/Common/src/main/java/com/github/serivesmejia/eocvsim/util/LogExt.kt b/Common/src/main/java/com/github/serivesmejia/eocvsim/util/LogExt.kt index 40dac066..d1d9c956 100644 --- a/Common/src/main/java/com/github/serivesmejia/eocvsim/util/LogExt.kt +++ b/Common/src/main/java/com/github/serivesmejia/eocvsim/util/LogExt.kt @@ -1,9 +1,16 @@ -package com.github.serivesmejia.eocvsim.util - -import org.slf4j.LoggerFactory -import kotlin.reflect.KClass - -fun Any.loggerFor(clazz: KClass<*>) = lazy { LoggerFactory.getLogger(clazz.java) } -fun Any.loggerForThis() = lazy { LoggerFactory.getLogger(this::class.java) } - -fun loggerOf(name: String) = lazy { LoggerFactory.getLogger(name) } \ No newline at end of file +package com.github.serivesmejia.eocvsim.util + +import org.slf4j.LoggerFactory +import kotlin.reflect.KClass + +fun Any.loggerFor(clazz: KClass<*>) = lazy { + LoggerFactory.getLogger(clazz.java) +} + +fun Any.loggerForThis() = lazy { + LoggerFactory.getLogger(this::class.java) +} + +fun loggerOf(name: String) = lazy { + LoggerFactory.getLogger(name) +} \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/component/OutputPanel.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/component/OutputPanel.kt index 3fdf25fd..f28e6f49 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/component/OutputPanel.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/component/OutputPanel.kt @@ -28,19 +28,34 @@ import java.awt.GridBagConstraints import java.awt.GridBagLayout import java.awt.Toolkit import java.awt.datatransfer.StringSelection +import java.awt.Font +import java.io.InputStream import javax.swing.* + class OutputPanel( bottomButtonsPanel: BottomButtonsPanel ) : JPanel(GridBagLayout()) { val outputArea = JTextArea("") + companion object { + val monoFont: Font by lazy { + Font.createFont( + Font.TRUETYPE_FONT, + this::class.java.getResourceAsStream("/fonts/JetBrainsMono-Medium.ttf") + ) + } + } + init { if(bottomButtonsPanel is DefaultBottomButtonsPanel) { bottomButtonsPanel.outputTextSupplier = { outputArea.text } } + // JTextArea will use /fonts/JetBrainsMono-Medium.ttf as font + outputArea.font = monoFont.deriveFont(13f) + outputArea.isEditable = false outputArea.highlighter = null diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java index 20923ab7..b7912e69 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java @@ -53,6 +53,8 @@ public SLF4JIOReceiver(Logger logger) { @Override public void receive(InputStream out, InputStream err, int pid) { new Thread(() -> { + Thread.currentThread().setContextClassLoader(SLF4JIOReceiver.class.getClassLoader()); + Scanner sc = new Scanner(out); while (sc.hasNextLine()) { logger.info(sc.nextLine()); @@ -60,11 +62,15 @@ public void receive(InputStream out, InputStream err, int pid) { }, "SLFJ4IOReceiver-out-" + pid).start(); new Thread(() -> { + Thread.currentThread().setContextClassLoader(SLF4JIOReceiver.class.getClassLoader()); + Scanner sc = new Scanner(err); while (sc.hasNextLine()) { logger.error(sc.nextLine()); } }, "SLF4JIOReceiver-err-" + pid).start(); + + logger.debug("SLF4JIOReceiver started for PID: {}", pid); // Debug log to check if the logger is working } } diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt index ec1197dd..5f54b4f9 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt @@ -29,6 +29,7 @@ import io.github.deltacv.eocvsim.sandbox.restrictions.MethodCallByteCodeChecker import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingMethodBlacklist import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingPackageBlacklist import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingPackageWhitelist +import org.apache.logging.log4j.core.LoggerContext import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException @@ -43,7 +44,11 @@ import java.util.zip.ZipFile * @param pluginJar the jar file of the plugin * @param pluginContext the plugin context */ -class PluginClassLoader(private val pluginJar: File, val classpath: List, val pluginContextProvider: () -> PluginContext) : ClassLoader() { +class PluginClassLoader( + private val pluginJar: File, + val classpath: List, + val pluginContextProvider: () -> PluginContext +) : ClassLoader() { private val zipFile = try { ZipFile(pluginJar) diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginContext.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginContext.kt index 7148f300..82c402ef 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginContext.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginContext.kt @@ -28,7 +28,9 @@ import io.github.deltacv.eocvsim.plugin.EOCVSimPlugin import io.github.deltacv.eocvsim.sandbox.nio.SandboxFileSystem class PluginContext( - val eocvSim: EOCVSim, val fileSystem: SandboxFileSystem, val loader: PluginLoader + val eocvSim: EOCVSim, + val fileSystem: SandboxFileSystem, + val loader: PluginLoader ) { companion object { @JvmStatic fun current(plugin: EOCVSimPlugin) = (plugin.javaClass.classLoader as PluginClassLoader).pluginContextProvider() diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt index 940367a3..ace814df 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt @@ -25,15 +25,19 @@ package io.github.deltacv.eocvsim.plugin.loader import com.github.serivesmejia.eocvsim.EOCVSim import com.github.serivesmejia.eocvsim.config.ConfigLoader +import com.github.serivesmejia.eocvsim.gui.dialog.AppendDelegate +import com.github.serivesmejia.eocvsim.gui.dialog.PluginOutput import com.github.serivesmejia.eocvsim.util.SysUtil import com.github.serivesmejia.eocvsim.util.event.EventHandler import com.github.serivesmejia.eocvsim.util.extension.plus import com.github.serivesmejia.eocvsim.util.loggerForThis import com.moandjiezana.toml.Toml +import org.apache.logging.log4j.LogManager import io.github.deltacv.common.util.ParsedVersion import io.github.deltacv.eocvsim.plugin.EOCVSimPlugin import io.github.deltacv.eocvsim.sandbox.nio.SandboxFileSystem import net.lingala.zip4j.ZipFile +import org.apache.logging.log4j.core.LoggerContext import java.io.File import java.security.MessageDigest @@ -51,7 +55,8 @@ class PluginLoader( val pluginFile: File, val classpath: List, val pluginSource: PluginSource, - val eocvSim: EOCVSim + val eocvSim: EOCVSim, + val appender: AppendDelegate ) { val logger by loggerForThis() @@ -97,7 +102,10 @@ class PluginLoader( val hasSuperAccess get() = eocvSim.config.superAccessPluginHashes.contains(pluginFileHash) init { - pluginClassLoader = PluginClassLoader(pluginFile, classpath) { + pluginClassLoader = PluginClassLoader( + pluginFile, + classpath + ) { PluginContext(eocvSim, fileSystem, this) } } @@ -130,7 +138,7 @@ class PluginLoader( fetchInfoFromToml() - logger.info("Loading plugin $pluginName v$pluginVersion by $pluginAuthor from ${pluginSource.name}") + appender.appendln("${PluginOutput.SPECIAL_SILENT}Loading plugin $pluginName v$pluginVersion by $pluginAuthor from ${pluginSource.name}") setupFs() diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt index 8d1d9756..9ce1f49c 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt @@ -31,10 +31,13 @@ import com.github.serivesmejia.eocvsim.util.JavaProcess import com.github.serivesmejia.eocvsim.util.extension.plus import com.github.serivesmejia.eocvsim.util.io.EOCVSimFolder import com.github.serivesmejia.eocvsim.util.loggerForThis +import com.github.serivesmejia.eocvsim.util.loggerOf import io.github.deltacv.eocvsim.gui.dialog.SuperAccessRequestMain import io.github.deltacv.eocvsim.plugin.repository.PluginRepositoryManager import java.io.File import java.util.* +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock /** * Manages the loading, enabling and disabling of plugins @@ -55,15 +58,18 @@ class PluginManager(val eocvSim: EOCVSim) { private val _loadedPluginHashes = mutableListOf() val loadedPluginHashes get() = _loadedPluginHashes.toList() - private val haltLock = Object() + private val haltLock = ReentrantLock() + private val haltCondition = haltLock.newCondition() val appender by lazy { val appender = DialogFactory.createMavenOutput { - synchronized(haltLock) { - haltLock.notify() + haltLock.withLock { + haltCondition.signalAll() } } + val logger by loggerOf("PluginOutput") + appender.subscribe { if(!it.isBlank()) { val message = it.trimSpecials() @@ -78,9 +84,7 @@ class PluginManager(val eocvSim: EOCVSim) { } val repositoryManager by lazy { - PluginRepositoryManager( - appender, haltLock - ) + PluginRepositoryManager(appender, haltLock, haltCondition) } private val _pluginFiles = mutableListOf() @@ -130,7 +134,8 @@ class PluginManager(val eocvSim: EOCVSim) { repositoryManager.resolvedFiles, if(pluginFile in repositoryManager.resolvedFiles) PluginSource.REPOSITORY else PluginSource.FILE, - eocvSim + eocvSim, + appender ) } diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt index 71973a7f..3865d393 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt @@ -23,7 +23,6 @@ package io.github.deltacv.eocvsim.plugin.repository -import com.github.serivesmejia.eocvsim.gui.DialogFactory import com.github.serivesmejia.eocvsim.gui.dialog.AppendDelegate import com.github.serivesmejia.eocvsim.gui.dialog.PluginOutput import com.github.serivesmejia.eocvsim.util.SysUtil @@ -35,11 +34,16 @@ import io.github.deltacv.eocvsim.plugin.loader.PluginManager import org.jboss.shrinkwrap.resolver.api.maven.ConfigurableMavenResolverSystem import org.jboss.shrinkwrap.resolver.api.maven.Maven import java.io.File +import java.util.concurrent.locks.ReentrantLock +import java.util.concurrent.locks.Condition +import java.util.concurrent.TimeUnit import kotlin.collections.iterator +import kotlin.concurrent.withLock class PluginRepositoryManager( val appender: AppendDelegate, - val haltLock: Object + val haltLock: ReentrantLock, + val haltCondition: Condition ) { companion object { @@ -104,109 +108,146 @@ class PluginRepositoryManager( fun resolveAll(): List { val files = mutableListOf() - val newCache = mutableMapOf() val newTransitiveCache = mutableMapOf>() - var shouldHalt = false - for(plugin in plugins.toMap()) { - if(plugin.value !is String) + for (plugin in plugins.toMap()) { + if (plugin.value !is String) { throw InvalidFileException("Invalid plugin dependency in repository.toml. $UNSURE_USER_HELP") + } val pluginDep = plugin.value as String - - var pluginJar: File? = null + var pluginJar: File? try { - var isCached = false + // Attempt to resolve from cache + pluginJar = if (resolveFromCache(pluginDep, newCache, newTransitiveCache)) { + File(newCache[pluginDep.hexString]!!) + } else { + // Resolve from the resolver if not found in cache + resolveFromResolver(pluginDep, newCache, newTransitiveCache) + } - mainCacheLoop@ - for(cached in cachePluginsToml.toMap()) { - if(cached.key == pluginDep.hexString) { - val cachedFile = File(cached.value as String) + files += pluginJar + } catch (ex: Exception) { + handleResolutionError(pluginDep, ex) + shouldHalt = true + } + } - if(cachedFile.exists()) { - for(transitive in cacheTransitiveToml.getList(pluginDep.hexString) ?: emptyList()) { - val transitiveFile = File(transitive as String) + writeCacheFile(newCache, newTransitiveCache) - if (!transitiveFile.exists()) { - appender.appendln(PluginOutput.SPECIAL_SILENT + "Transitive dependency $transitive for plugin $pluginDep does not exist. Resolving...") - break@mainCacheLoop - } + handleResolutionOutcome(shouldHalt) - _resolvedFiles += transitiveFile // add transitive dependency to resolved files + return files + } - newTransitiveCache[pluginDep.hexString] = newTransitiveCache.getOrDefault( - pluginDep.hexString, - mutableListOf() - ).apply { - add(transitiveFile.absolutePath) - } - } + // Function to handle resolution from cache + private fun resolveFromCache( + pluginDep: String, + newCache: MutableMap, + newTransitiveCache: MutableMap> + ): Boolean { + for (cached in cachePluginsToml.toMap()) { + if (cached.key == pluginDep.hexString) { + val cachedFile = File(cached.value as String) + + if (cachedFile.exists() && areAllTransitivesCached(pluginDep)) { + addToResolvedFiles(cachedFile, pluginDep, newCache, newTransitiveCache) + appender.appendln( + PluginOutput.SPECIAL_SILENT + + "Found cached plugin \"$pluginDep\" (${pluginDep.hexString}). All transitive dependencies OK." + ) + return true + } else { + appender.appendln( + PluginOutput.SPECIAL_SILENT + + "Dependency missing for plugin $pluginDep. Resolving..." + ) + } + } + } + return false + } - appender.appendln( - PluginOutput.SPECIAL_SILENT + - "Found cached plugin \"$pluginDep\" (${pluginDep.hexString}). All transitive dependencies OK." - ) + // Function to check if all transitive dependencies are cached + private fun areAllTransitivesCached(pluginDep: String): Boolean { + return cacheTransitiveToml + .getList(pluginDep.hexString).all { + val exists = File(it).exists() + + if(!exists) { + appender.appendln( + PluginOutput.SPECIAL_SILENT + + "Couldn't find file specified in cache for plugin $pluginDep, expected at \"$it\"." + ) + } - pluginJar = cachedFile - _resolvedFiles += cachedFile + exists + } + } - newCache[pluginDep.hexString] = cachedFile.absolutePath // add plugin to revalidated cache + // Function to resolve plugin using the resolver + private fun resolveFromResolver( + pluginDep: String, + newCache: MutableMap, + newTransitiveCache: MutableMap> + ): File { + appender.appendln("Resolving plugin \"$pluginDep\"...") + + var pluginJar: File? = null + resolver.resolve(pluginDep) + .withTransitivity() + .asFile() + .forEach { file -> + if (pluginJar == null) { + pluginJar = file + newCache[pluginDep.hexString] = file.absolutePath + } else { + newTransitiveCache.getOrPut(pluginDep.hexString) { mutableListOf() } + .add(file.absolutePath) + } - isCached = true // skip to next plugin, this one is already resolved by cache - } + _resolvedFiles += file + } - break@mainCacheLoop - } - } + return pluginJar!! + } - if(!isCached) { - // if we reach this point, the plugin was not found in cache - appender.appendln("Resolving plugin ${plugin.key} at \"${plugin.value}\"...") - - resolver.resolve(pluginDep) - .withTransitivity() - .asFile() - .forEach { file -> - if (pluginJar == null) { - // the first file returned by maven is the plugin jar we want - pluginJar = file - newCache[pluginDep.hexString] = pluginJar!!.absolutePath - } else { - newTransitiveCache[pluginDep.hexString] = newTransitiveCache.getOrDefault( - pluginDep.hexString, - mutableListOf() - ).apply { - add(file.absolutePath) - } // add transitive dependency to cache - } - - _resolvedFiles += file // add file to resolved files to later build a classpath - } - } + // Function to add resolved files to the cache and resolved list + private fun addToResolvedFiles( + cachedFile: File, + pluginDep: String, + newCache: MutableMap, + newTransitiveCache: MutableMap> + ) { + _resolvedFiles += cachedFile + newCache[pluginDep.hexString] = cachedFile.absolutePath - files += pluginJar!! - } catch(ex: Exception) { - logger.warn("Failed to resolve plugin dependency \"$pluginDep\"", ex) - appender.appendln("Failed to resolve plugin ${plugin.key}: ${ex.message}") - shouldHalt = true - } + cacheTransitiveToml.getList(pluginDep.hexString)?.forEach { transitive -> + _resolvedFiles += File(transitive) + newTransitiveCache.getOrPut(pluginDep.hexString) { mutableListOf() } + .add(transitive) } + } - writeCacheFile(newCache, newTransitiveCache) + // Function to handle resolution errors + private fun handleResolutionError(pluginDep: String, ex: Exception) { + logger.warn("Failed to resolve plugin dependency \"$pluginDep\"", ex) + appender.appendln("Failed to resolve plugin \"$pluginDep\": ${ex.message}") + } - if(shouldHalt) { + // Function to handle the outcome of the resolution process + private fun handleResolutionOutcome(shouldHalt: Boolean) { + if (shouldHalt) { appender.append(PluginOutput.SPECIAL_CONTINUE) - synchronized(haltLock) { - haltLock.wait(10000) // wait for user to read the error + haltLock.withLock { + haltCondition.await(15, TimeUnit.SECONDS) // wait for user to read the error } } else { appender.append(PluginOutput.SPECIAL_CLOSE) } - - return files } private fun writeCacheFile( diff --git a/EOCV-Sim/src/main/resources/fonts/JetBrainsMono-Medium.ttf b/EOCV-Sim/src/main/resources/fonts/JetBrainsMono-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..97671156df256e850498054fdebcd41d74a65d6b GIT binary patch literal 273860 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%0AmA2ptU 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 literal 0 HcmV?d00001 From fc0ba42c7d4ecfd84195a4a1caae70a47a6904a6 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Sun, 27 Oct 2024 02:40:56 -0600 Subject: [PATCH 15/36] Add comments to the default repository.toml file --- .../eocvsim/plugin/repository/PluginRepositoryManager.kt | 9 +++++++-- EOCV-Sim/src/main/resources/repository.toml | 7 ++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt index 3865d393..5c149a96 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt @@ -100,9 +100,14 @@ class PluginRepositoryManager( if(repo.value !is String) throw InvalidFileException("Invalid repository URL in repository.toml. $UNSURE_USER_HELP") - resolver.withRemoteRepo(repo.key, repo.value as String, "default") + var repoUrl = repo.value as String + if(!repoUrl.endsWith("/")) { + repoUrl += "/" + } + + resolver.withRemoteRepo(repo.key, repoUrl, "default") - logger.info("Added repository ${repo.key} with URL ${repo.value}") + logger.info("Added repository ${repo.key} with URL ${repoUrl}") } } diff --git a/EOCV-Sim/src/main/resources/repository.toml b/EOCV-Sim/src/main/resources/repository.toml index 4091f87d..2a116d88 100644 --- a/EOCV-Sim/src/main/resources/repository.toml +++ b/EOCV-Sim/src/main/resources/repository.toml @@ -1,6 +1,11 @@ [repositories] +# Declare the URL of the Maven repositories to use, with a friendly name. +# The URL must preferably end with a slash to be handled correctly. central = "https://repo.maven.apache.org/maven2/" jitpack = "https://jitpack.io/" [plugins] -PaperVision = "com.github.deltacv:PaperVision:1.0.0" \ No newline at end of file +# Declare the plugin ID and the Maven coordinates of the plugin, similar to how you do it in Gradle. +# (group:artifact:version) which will be used to download the plugin from one of Maven repositories. +# Any dependency that does not have a plugin.toml in its jar will not be considered after download. +PaperVision = "com.github.deltacv.PaperVision:EOCVSimPlugin:40a30a8965" \ No newline at end of file From 19d7a142ea2b69782156843b5d4f9b192c161f8b Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Sun, 27 Oct 2024 13:25:28 -0600 Subject: [PATCH 16/36] Add HintManager from flatlaf demo --- EOCV-Sim/build.gradle | 1 + .../com/formdev/flatlaf/demo/HintManager.java | 357 ++++++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java diff --git a/EOCV-Sim/build.gradle b/EOCV-Sim/build.gradle index 551458e0..90af4332 100644 --- a/EOCV-Sim/build.gradle +++ b/EOCV-Sim/build.gradle @@ -85,6 +85,7 @@ dependencies { implementation 'com.formdev:flatlaf:3.5.1' implementation 'com.formdev:flatlaf-intellij-themes:3.5.1' + implementation 'com.miglayout:miglayout-swing:11.4.2' implementation 'net.lingala.zip4j:zip4j:2.11.3' diff --git a/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java b/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java new file mode 100644 index 00000000..57d90f2b --- /dev/null +++ b/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java @@ -0,0 +1,357 @@ +/* + * Copyright 2020 FormDev Software GmbH + * + * 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 + * + * https://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. + */ + +package com.formdev.flatlaf.demo; + +import java.awt.*; +import java.awt.event.MouseAdapter; +import java.awt.geom.Area; +import java.awt.geom.RoundRectangle2D; +import java.util.ArrayList; +import java.util.List; +import javax.swing.*; +import javax.swing.border.Border; +import com.formdev.flatlaf.FlatLaf; +import com.formdev.flatlaf.ui.FlatDropShadowBorder; +import com.formdev.flatlaf.ui.FlatEmptyBorder; +import com.formdev.flatlaf.ui.FlatUIUtils; +import com.formdev.flatlaf.util.UIScale; +import net.miginfocom.swing.*; + +/** + * @author Karl Tauber + */ +class HintManager +{ + private static final List hintPanels = new ArrayList<>(); + + static void showHint( Hint hint ) { + HintPanel hintPanel = new HintPanel( hint ); + hintPanel.showHint(); + + hintPanels.add( hintPanel ); + } + + static void hideAllHints() { + HintPanel[] hintPanels2 = hintPanels.toArray( new HintPanel[hintPanels.size()] ); + for( HintPanel hintPanel : hintPanels2 ) + hintPanel.hideHint(); + } + + //---- class HintPanel ---------------------------------------------------- + + static class Hint + { + private final String message; + private final Component owner; + private final int position; + private final String prefsKey; + private final Hint nextHint; + + Hint( String message, Component owner, int position, String prefsKey, Hint nextHint ) { + this.message = message; + this.owner = owner; + this.position = position; + this.prefsKey = prefsKey; + this.nextHint = nextHint; + } + } + + //---- class HintPanel ---------------------------------------------------- + + private static class HintPanel + extends JPanel + { + private final Hint hint; + + private JPanel popup; + + private HintPanel( Hint hint ) { + this.hint = hint; + + initComponents(); + + setOpaque( false ); + updateBalloonBorder(); + + hintLabel.setText( "" + hint.message + "" ); + + // grab all mouse events to avoid that components overlapped + // by the hint panel receive them + addMouseListener( new MouseAdapter() {} ); + } + + @Override + public void updateUI() { + super.updateUI(); + + if( UIManager.getLookAndFeel() instanceof FlatLaf ) + setBackground( UIManager.getColor( "HintPanel.backgroundColor" ) ); + else { + // using nonUIResource() because otherwise Nimbus does not fill the background + setBackground( FlatUIUtils.nonUIResource( UIManager.getColor( "info" ) ) ); + } + + if( hint != null ) + updateBalloonBorder(); + } + + private void updateBalloonBorder() { + int direction; + switch( hint.position ) { + case SwingConstants.LEFT: direction = SwingConstants.RIGHT; break; + case SwingConstants.TOP: direction = SwingConstants.BOTTOM; break; + case SwingConstants.RIGHT: direction = SwingConstants.LEFT; break; + case SwingConstants.BOTTOM: direction = SwingConstants.TOP; break; + default: throw new IllegalArgumentException(); + } + + setBorder( new BalloonBorder( direction, FlatUIUtils.getUIColor( "PopupMenu.borderColor", Color.gray ) ) ); + } + + void showHint() { + JRootPane rootPane = SwingUtilities.getRootPane( hint.owner ); + if( rootPane == null ) + return; + + JLayeredPane layeredPane = rootPane.getLayeredPane(); + + // create a popup panel that has a drop shadow + popup = new JPanel( new BorderLayout() ) { + @Override + public void updateUI() { + super.updateUI(); + + // use invokeLater because at this time the UI delegates + // of child components are not yet updated + EventQueue.invokeLater( () -> { + validate(); + setSize( getPreferredSize() ); + } ); + } + }; + popup.setOpaque( false ); + popup.add( this ); + + // calculate x/y location for hint popup + Point pt = SwingUtilities.convertPoint( hint.owner, 0, 0, layeredPane ); + int x = pt.x; + int y = pt.y; + Dimension size = popup.getPreferredSize(); + int gap = UIScale.scale( 6 ); + + switch( hint.position ) { + case SwingConstants.LEFT: + x -= size.width + gap; + break; + + case SwingConstants.TOP: + y -= size.height + gap; + break; + + case SwingConstants.RIGHT: + x += hint.owner.getWidth() + gap; + break; + + case SwingConstants.BOTTOM: + y += hint.owner.getHeight() + gap; + break; + } + + // set hint popup size and show it + popup.setBounds( x, y, size.width, size.height ); + layeredPane.add( popup, JLayeredPane.POPUP_LAYER ); + } + + void hideHint() { + if( popup != null ) { + Container parent = popup.getParent(); + if( parent != null ) { + parent.remove( popup ); + parent.repaint( popup.getX(), popup.getY(), popup.getWidth(), popup.getHeight() ); + } + } + + hintPanels.remove( this ); + } + + private void gotIt() { + // hide hint + hideHint(); + + // remember that user closed the hint + DemoPrefs.getState().putBoolean( hint.prefsKey, true ); + + // show next hint (if any) + if( hint.nextHint != null ) + HintManager.showHint( hint.nextHint ); + } + + private void initComponents() { + // JFormDesigner - Component initialization - DO NOT MODIFY //GEN-BEGIN:initComponents + hintLabel = new JLabel(); + gotItButton = new JButton(); + + //======== this ======== + setLayout(new MigLayout( + "insets dialog,hidemode 3", + // columns + "[::200,fill]", + // rows + "[]para" + + "[]")); + + //---- hintLabel ---- + hintLabel.setText("hint"); + add(hintLabel, "cell 0 0"); + + //---- gotItButton ---- + gotItButton.setText("Got it!"); + gotItButton.setFocusable(false); + gotItButton.addActionListener(e -> gotIt()); + add(gotItButton, "cell 0 1,alignx right,growx 0"); + // JFormDesigner - End of component initialization //GEN-END:initComponents + } + + // JFormDesigner - Variables declaration - DO NOT MODIFY //GEN-BEGIN:variables + private JLabel hintLabel; + private JButton gotItButton; + // JFormDesigner - End of variables declaration //GEN-END:variables + } + + //---- class BalloonBorder ------------------------------------------------ + + private static class BalloonBorder + extends FlatEmptyBorder + { + private static int ARC = 8; + private static int ARROW_XY = 16; + private static int ARROW_SIZE = 8; + private static int SHADOW_SIZE = 6; + private static int SHADOW_TOP_SIZE = 3; + private static int SHADOW_SIZE2 = SHADOW_SIZE + 2; + + private final int direction; + private final Color borderColor; + + private final Border shadowBorder; + + public BalloonBorder( int direction, Color borderColor ) { + super( 1 + SHADOW_TOP_SIZE, 1 + SHADOW_SIZE, 1 + SHADOW_SIZE, 1 + SHADOW_SIZE ); + + this.direction = direction; + this.borderColor = borderColor; + + switch( direction ) { + case SwingConstants.LEFT: left += ARROW_SIZE; break; + case SwingConstants.TOP: top += ARROW_SIZE; break; + case SwingConstants.RIGHT: right += ARROW_SIZE; break; + case SwingConstants.BOTTOM: bottom += ARROW_SIZE; break; + } + + shadowBorder = UIManager.getLookAndFeel() instanceof FlatLaf + ? new FlatDropShadowBorder( + UIManager.getColor( "Popup.dropShadowColor" ), + new Insets( SHADOW_SIZE2, SHADOW_SIZE2, SHADOW_SIZE2, SHADOW_SIZE2 ), + FlatUIUtils.getUIFloat( "Popup.dropShadowOpacity", 0.5f ) ) + : null; + } + + @Override + public void paintBorder( Component c, Graphics g, int x, int y, int width, int height ) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + FlatUIUtils.setRenderingHints( g2 ); + g2.translate( x, y ); + + // shadow coordinates + int sx = 0; + int sy = 0; + int sw = width; + int sh = height; + int arrowSize = UIScale.scale( ARROW_SIZE ); + switch( direction ) { + case SwingConstants.LEFT: sx += arrowSize; sw -= arrowSize; break; + case SwingConstants.TOP: sy += arrowSize; sh -= arrowSize; break; + case SwingConstants.RIGHT: sw -= arrowSize; break; + case SwingConstants.BOTTOM: sh -= arrowSize; break; + } + + // paint shadow + if( shadowBorder != null ) + shadowBorder.paintBorder( c, g2, sx, sy, sw, sh ); + + // create balloon shape + int bx = UIScale.scale( SHADOW_SIZE ); + int by = UIScale.scale( SHADOW_TOP_SIZE ); + int bw = width - UIScale.scale( SHADOW_SIZE + SHADOW_SIZE ); + int bh = height - UIScale.scale( SHADOW_TOP_SIZE + SHADOW_SIZE ); + g2.translate( bx, by ); + Shape shape = createBalloonShape( bw, bh ); + + // fill balloon background + g2.setColor( c.getBackground() ); + g2.fill( shape ); + + // paint balloon border + g2.setColor( borderColor ); + g2.setStroke( new BasicStroke( UIScale.scale( 1f ) ) ); + g2.draw( shape ); + } finally { + g2.dispose(); + } + } + + private Shape createBalloonShape( int width, int height ) { + int arc = UIScale.scale( ARC ); + int xy = UIScale.scale( ARROW_XY ); + int awh = UIScale.scale( ARROW_SIZE ); + + Shape rect; + Shape arrow; + switch( direction ) { + case SwingConstants.LEFT: + rect = new RoundRectangle2D.Float( awh, 0, width - 1 - awh, height - 1, arc, arc ); + arrow = FlatUIUtils.createPath( awh,xy, 0,xy+awh, awh,xy+awh+awh ); + break; + + case SwingConstants.TOP: + rect = new RoundRectangle2D.Float( 0, awh, width - 1, height - 1 - awh, arc, arc ); + arrow = FlatUIUtils.createPath( xy,awh, xy+awh,0, xy+awh+awh,awh ); + break; + + case SwingConstants.RIGHT: + rect = new RoundRectangle2D.Float( 0, 0, width - 1 - awh, height - 1, arc, arc ); + int x = width - 1 - awh; + arrow = FlatUIUtils.createPath( x,xy, x+awh,xy+awh, x,xy+awh+awh ); + break; + + case SwingConstants.BOTTOM: + rect = new RoundRectangle2D.Float( 0, 0, width - 1, height - 1 - awh, arc, arc ); + int y = height - 1 - awh; + arrow = FlatUIUtils.createPath( xy,y, xy+awh,y+awh, xy+awh+awh,y ); + break; + + default: + throw new RuntimeException(); + } + + Area area = new Area( rect ); + area.add( new Area( arrow ) ); + return area; + } + } +} \ No newline at end of file From 9194f660268e51922edf835e57c3afe062f26480 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Sun, 27 Oct 2024 13:26:11 -0600 Subject: [PATCH 17/36] Delete DemoPrefs from HintManager --- .../src/main/java/com/formdev/flatlaf/demo/HintManager.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java b/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java index 57d90f2b..3ed289b2 100644 --- a/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java +++ b/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java @@ -192,9 +192,6 @@ private void gotIt() { // hide hint hideHint(); - // remember that user closed the hint - DemoPrefs.getState().putBoolean( hint.prefsKey, true ); - // show next hint (if any) if( hint.nextHint != null ) HintManager.showHint( hint.nextHint ); From d79822834599941648e7dfb94fd1f9de4190ccb7 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Sun, 27 Oct 2024 13:34:55 -0600 Subject: [PATCH 18/36] Make HintManager public --- .../src/main/java/com/formdev/flatlaf/demo/HintManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java b/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java index 3ed289b2..9c561953 100644 --- a/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java +++ b/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java @@ -34,7 +34,7 @@ /** * @author Karl Tauber */ -class HintManager +public class HintManager { private static final List hintPanels = new ArrayList<>(); From 9ffda440bd551a2dc19eb1ae7f9ea6fa5d5449b1 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Sun, 27 Oct 2024 13:40:25 -0600 Subject: [PATCH 19/36] Make methods public in HintManager --- .../java/com/formdev/flatlaf/demo/HintManager.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java b/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java index 9c561953..4bc22c81 100644 --- a/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java +++ b/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java @@ -38,14 +38,14 @@ public class HintManager { private static final List hintPanels = new ArrayList<>(); - static void showHint( Hint hint ) { + public static void showHint( Hint hint ) { HintPanel hintPanel = new HintPanel( hint ); hintPanel.showHint(); hintPanels.add( hintPanel ); } - static void hideAllHints() { + public static void hideAllHints() { HintPanel[] hintPanels2 = hintPanels.toArray( new HintPanel[hintPanels.size()] ); for( HintPanel hintPanel : hintPanels2 ) hintPanel.hideHint(); @@ -53,7 +53,7 @@ static void hideAllHints() { //---- class HintPanel ---------------------------------------------------- - static class Hint + public static class Hint { private final String message; private final Component owner; @@ -61,7 +61,7 @@ static class Hint private final String prefsKey; private final Hint nextHint; - Hint( String message, Component owner, int position, String prefsKey, Hint nextHint ) { + public Hint( String message, Component owner, int position, String prefsKey, Hint nextHint ) { this.message = message; this.owner = owner; this.position = position; @@ -231,7 +231,7 @@ private void initComponents() { //---- class BalloonBorder ------------------------------------------------ - private static class BalloonBorder + public static class BalloonBorder extends FlatEmptyBorder { private static int ARC = 8; From e972891f17d98113690c314ff243c8a2903aa9fd Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Sun, 27 Oct 2024 14:58:16 -0600 Subject: [PATCH 20/36] Remove prefs from HintManager.Hint --- .../com/formdev/flatlaf/demo/HintManager.java | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java b/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java index 4bc22c81..92fd6552 100644 --- a/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java +++ b/EOCV-Sim/src/main/java/com/formdev/flatlaf/demo/HintManager.java @@ -46,7 +46,7 @@ public static void showHint( Hint hint ) { } public static void hideAllHints() { - HintPanel[] hintPanels2 = hintPanels.toArray( new HintPanel[hintPanels.size()] ); + HintPanel[] hintPanels2 = hintPanels.toArray(new HintPanel[0]); for( HintPanel hintPanel : hintPanels2 ) hintPanel.hideHint(); } @@ -58,14 +58,12 @@ public static class Hint private final String message; private final Component owner; private final int position; - private final String prefsKey; private final Hint nextHint; - public Hint( String message, Component owner, int position, String prefsKey, Hint nextHint ) { + public Hint( String message, Component owner, int position, Hint nextHint ) { this.message = message; this.owner = owner; this.position = position; - this.prefsKey = prefsKey; this.nextHint = nextHint; } } @@ -234,12 +232,12 @@ private void initComponents() { public static class BalloonBorder extends FlatEmptyBorder { - private static int ARC = 8; - private static int ARROW_XY = 16; - private static int ARROW_SIZE = 8; - private static int SHADOW_SIZE = 6; - private static int SHADOW_TOP_SIZE = 3; - private static int SHADOW_SIZE2 = SHADOW_SIZE + 2; + private static final int ARC = 8; + private static final int ARROW_XY = 16; + private static final int ARROW_SIZE = 8; + private static final int SHADOW_SIZE = 6; + private static final int SHADOW_TOP_SIZE = 3; + private static final int SHADOW_SIZE2 = SHADOW_SIZE + 2; private final int direction; private final Color borderColor; From 4bb17a06195373f9fa5d15fae0b176b853fa8334 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Sun, 27 Oct 2024 16:53:37 -0600 Subject: [PATCH 21/36] Improving plugin output GUI --- .../eocvsim/config/ConfigManager.java | 5 +++++ .../eocvsim/gui/dialog/PluginOutput.kt | 15 +++++++++++++-- .../deltacv/eocvsim/plugin/loader/PluginLoader.kt | 4 ++-- .../eocvsim/plugin/loader/PluginManager.kt | 3 ++- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/config/ConfigManager.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/config/ConfigManager.java index 5777ada9..d8c1605a 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/config/ConfigManager.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/config/ConfigManager.java @@ -49,6 +49,11 @@ public void init() { logger.info("Creating config file..."); configLoader.saveToFile(config); } + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + logger.info("SHUTDOWN - Saving config to file..."); + saveToFile(); + })); } public void saveToFile() { diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt index e6b9d8ec..41ba0042 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt @@ -98,6 +98,7 @@ class PluginOutput( } private val output = JDialog() + private val tabbedPane: JTabbedPane private val mavenBottomButtonsPanel: MavenOutputBottomButtonsPanel = MavenOutputBottomButtonsPanel(::close) { mavenOutputPanel.outputArea.text @@ -108,9 +109,16 @@ class PluginOutput( init { output.isModal = true output.isAlwaysOnTop = true - output.title = "Plugin Output" + output.title = "Plugin Manager" - output.add(JTabbedPane().apply { + tabbedPane = JTabbedPane() + + output.add(tabbedPane.apply { + addTab("Plugins", JPanel().apply { + layout = BoxLayout(this, BoxLayout.PAGE_AXIS) + + add(JLabel("Plugins output will be shown here")) + }) addTab("Output", mavenOutputPanel) }) @@ -156,6 +164,9 @@ class PluginOutput( if(!text.startsWith(SPECIAL_SILENT) && text != SPECIAL_CLOSE && text != SPECIAL_FREE) { SwingUtilities.invokeLater { + SwingUtilities.invokeLater { + tabbedPane.selectedIndex = tabbedPane.indexOfTab("Output") // focus on output tab + } output.isVisible = true } } diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt index ace814df..183ced53 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt @@ -198,7 +198,7 @@ class PluginLoader( fun enable() { if(enabled || !loaded) return - logger.info("Enabling plugin $pluginName v$pluginVersion") + appender.appendln("${PluginOutput.SPECIAL_SILENT}Enabling plugin $pluginName v$pluginVersion") plugin.enabled = true plugin.onEnable() @@ -212,7 +212,7 @@ class PluginLoader( fun disable() { if(!enabled || !loaded) return - logger.info("Disabling plugin $pluginName v$pluginVersion") + appender.appendln("${PluginOutput.SPECIAL_SILENT}Disabling plugin $pluginName v$pluginVersion") plugin.enabled = false plugin.onDisable() diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt index 9ce1f49c..9ce1a5aa 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt @@ -163,7 +163,8 @@ class PluginManager(val eocvSim: EOCVSim) { loader.load() _loadedPluginHashes.add(hash) } catch (e: Throwable) { - appender.appendln("Failure loading ${loader.pluginName} v${loader.pluginVersion}: ${e.message}") + appender.appendln("Failure loading ${loader.pluginName} v${loader.pluginVersion}:") + appender.appendln(e.message ?: "Unknown error") logger.error("Failure loading ${loader.pluginName} v${loader.pluginVersion}", e) loaders.remove(file) From dc6c8fba65b7da931e141ddf032d27f7bff2b28d Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Sun, 27 Oct 2024 22:37:19 -0600 Subject: [PATCH 22/36] Fixing PluginClassLoader to handle blacklisting correctly --- .../eocvsim/gui/DialogFactory.java | 5 +- .../gui/component/visualizer/TopMenuBar.kt | 4 +- .../eocvsim/gui/dialog/PluginOutput.kt | 222 +++++++++++++++++- .../plugin/loader/PluginClassLoader.kt | 45 ++-- .../eocvsim/plugin/loader/PluginLoader.kt | 33 ++- .../eocvsim/plugin/loader/PluginManager.kt | 15 +- .../DynamicLoadingClassRestrictions.kt | 7 +- EOCV-Sim/src/main/resources/repository.toml | 3 +- 8 files changed, 290 insertions(+), 44 deletions(-) diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/DialogFactory.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/DialogFactory.java index ab4149c0..a1cd2f17 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/DialogFactory.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/DialogFactory.java @@ -32,6 +32,7 @@ import com.github.serivesmejia.eocvsim.gui.dialog.source.CreateVideoSource; import com.github.serivesmejia.eocvsim.input.SourceType; import com.github.serivesmejia.eocvsim.util.event.EventHandler; +import io.github.deltacv.eocvsim.plugin.loader.PluginManager; import javax.swing.*; import javax.swing.filechooser.FileFilter; @@ -147,10 +148,10 @@ public static void createPipelineOutput(EOCVSim eocvSim) { }); } - public static AppendDelegate createMavenOutput(Runnable onContinue) { + public static AppendDelegate createMavenOutput(PluginManager manager, Runnable onContinue) { AppendDelegate delegate = new AppendDelegate(); - invokeLater(() -> new PluginOutput(delegate, onContinue)); + invokeLater(() -> new PluginOutput(delegate, manager, manager.getEocvSim(), onContinue)); return delegate; } diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/component/visualizer/TopMenuBar.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/component/visualizer/TopMenuBar.kt index f28a4b24..81af4fa4 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/component/visualizer/TopMenuBar.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/component/visualizer/TopMenuBar.kt @@ -102,8 +102,8 @@ class TopMenuBar(visualizer: Visualizer, eocvSim: EOCVSim) : JMenuBar() { mFileMenu.add(editSettings) - val filePlugins = JMenuItem("Plugins") - filePlugins.addActionListener { eocvSim.pluginManager.appender.append(PluginOutput.SPECIAL_OPEN)} + val filePlugins = JMenuItem("Manage Plugins") + filePlugins.addActionListener { eocvSim.pluginManager.appender.append(PluginOutput.SPECIAL_OPEN_MGR)} mFileMenu.add(filePlugins) diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt index 41ba0042..65ce7b75 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt @@ -23,8 +23,11 @@ package com.github.serivesmejia.eocvsim.gui.dialog +import com.github.serivesmejia.eocvsim.EOCVSim import com.github.serivesmejia.eocvsim.gui.dialog.component.BottomButtonsPanel import com.github.serivesmejia.eocvsim.gui.dialog.component.OutputPanel +import io.github.deltacv.eocvsim.plugin.loader.PluginManager +import io.github.deltacv.eocvsim.plugin.loader.PluginSource import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -34,6 +37,12 @@ import java.awt.Dimension import java.awt.Toolkit import java.awt.datatransfer.StringSelection import javax.swing.* +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import java.awt.event.ActionEvent +import java.awt.event.ActionListener +import javax.swing.event.ChangeEvent +import javax.swing.event.ChangeListener class AppendDelegate { private val appendables = mutableListOf() @@ -75,13 +84,16 @@ class AppendDelegate { class PluginOutput( appendDelegate: AppendDelegate, + val pluginManager: PluginManager, + val eocvSim: EOCVSim? = null, val onContinue: Runnable ) : Appendable { companion object { - private const val SPECIAL = "13mck" + private const val SPECIAL = "[13mck]" const val SPECIAL_OPEN = "$SPECIAL[OPEN]" + const val SPECIAL_OPEN_MGR = "$SPECIAL[OPEN_MGR]" const val SPECIAL_CLOSE = "$SPECIAL[CLOSE]" const val SPECIAL_CONTINUE = "$SPECIAL[CONTINUE]" const val SPECIAL_FREE = "$SPECIAL[FREE]" @@ -90,6 +102,7 @@ class PluginOutput( fun String.trimSpecials(): String { return this .replace(SPECIAL_OPEN, "") + .replace(SPECIAL_OPEN_MGR, "") .replace(SPECIAL_CLOSE, "") .replace(SPECIAL_CONTINUE, "") .replace(SPECIAL_SILENT, "") @@ -100,6 +113,8 @@ class PluginOutput( private val output = JDialog() private val tabbedPane: JTabbedPane + private var shouldAskForRestart = false + private val mavenBottomButtonsPanel: MavenOutputBottomButtonsPanel = MavenOutputBottomButtonsPanel(::close) { mavenOutputPanel.outputArea.text } @@ -114,11 +129,7 @@ class PluginOutput( tabbedPane = JTabbedPane() output.add(tabbedPane.apply { - addTab("Plugins", JPanel().apply { - layout = BoxLayout(this, BoxLayout.PAGE_AXIS) - - add(JLabel("Plugins output will be shown here")) - }) + addTab("Plugins", makePluginManagerPanel()) addTab("Output", mavenOutputPanel) }) @@ -135,14 +146,201 @@ class PluginOutput( @OptIn(DelicateCoroutinesApi::class) private fun registerListeners() = GlobalScope.launch(Dispatchers.Swing) { + output.addWindowListener(object : java.awt.event.WindowAdapter() { + override fun windowClosing(e: java.awt.event.WindowEvent?) { + if(mavenBottomButtonsPanel.continueButton.isEnabled) { + mavenBottomButtonsPanel.continueButton.doClick() + } + + checkShouldAskForRestart() + } + }) + mavenBottomButtonsPanel.continueButton.addActionListener { close() onContinue.run() - - output.defaultCloseOperation = WindowConstants.HIDE_ON_CLOSE mavenBottomButtonsPanel.continueButton.isEnabled = false mavenBottomButtonsPanel.closeButton.isEnabled = true } + + tabbedPane.addChangeListener(object: ChangeListener { + override fun stateChanged(e: ChangeEvent?) { + // remake plugin manager panel + if(tabbedPane.selectedIndex == tabbedPane.indexOfTab("Plugins")) { + tabbedPane.setComponentAt(0, makePluginManagerPanel()) + } + } + }) + } + + private fun checkShouldAskForRestart() { + if(shouldAskForRestart && eocvSim != null) { + val dialogResult = JOptionPane.showConfirmDialog( + output, + "You need to restart the application to apply the changes. Do you want to restart now?", + "Restart required", + JOptionPane.YES_NO_OPTION + ) + + if(dialogResult == JOptionPane.YES_OPTION) { + eocvSim.restart() + } + + shouldAskForRestart = false + } + } + + private fun makePluginManagerPanel(): JPanel { + val panel = JPanel() + panel.layout = GridBagLayout() + + if(pluginManager.loaders.isEmpty()) { + // center vertically and horizontally + val noPluginsLabel = JLabel("

Nothing to see here...

") + noPluginsLabel.horizontalAlignment = SwingConstants.CENTER + noPluginsLabel.verticalAlignment = SwingConstants.CENTER + + // Use GridBagConstraints to center the label + val constraints = GridBagConstraints().apply { + gridx = 0 // Center horizontally + gridy = 0 // Center vertically + weightx = 1.0 // Take up the entire horizontal space + weighty = 1.0 // Take up the entire vertical space + anchor = GridBagConstraints.CENTER // Center alignment + } + + // Add the label to the panel with the constraints + panel.add(noPluginsLabel, constraints) + } else { + val tabbedPane = JTabbedPane(JTabbedPane.LEFT) + + for((_, loader) in pluginManager.loaders) { + val pluginPanel = JPanel() + pluginPanel.layout = GridBagLayout() + + val pluginNameLabel = JLabel( + "

${loader.pluginName} v${loader.pluginVersion} by ${loader.pluginAuthor}

", + SwingConstants.CENTER + ) + + pluginPanel.add(pluginNameLabel, GridBagConstraints().apply { + gridx = 0 + gridy = 0 + weightx = 1.0 + fill = GridBagConstraints.HORIZONTAL + anchor = GridBagConstraints.CENTER + }) + + val authorEmail = if(loader.pluginAuthorEmail.isBlank()) + "The author did not provide contact information." + else "Contact the author at ${loader.pluginAuthorEmail}" + + val description = if(loader.pluginDescription.isBlank()) + "No description available." + else loader.pluginDescription + + val source = if(loader.pluginSource == PluginSource.REPOSITORY) + "Maven repository" + else "local file" + + val superAccess = if(loader.hasSuperAccess) + "This plugin has super access." + else "This plugin does not have super access." + + val wantsSuperAccess = if(loader.pluginToml.getBoolean("super-access", false)) + "It requests super access in its manifest." + else "It does not request super access in its manifest." + + val font = pluginNameLabel.font.deriveFont(13.0f) + + // add a text area for the plugin description + val pluginDescriptionArea = JTextArea(""" + $authorEmail + + ${if(loader.shouldEnable) "This plugin was loaded from a $source." else "This plugin is disabled, it comes from a $source."} + $superAccess $wantsSuperAccess + + $description + """.trimIndent()) + + pluginDescriptionArea.font = font + pluginDescriptionArea.isEditable = false + pluginDescriptionArea.lineWrap = true + pluginDescriptionArea.wrapStyleWord = true + pluginDescriptionArea.background = pluginPanel.background + + pluginPanel.add(JScrollPane(pluginDescriptionArea), GridBagConstraints().apply { + gridx = 0 + gridy = 1 + weightx = 1.0 + weighty = 1.0 + fill = GridBagConstraints.BOTH + }) + + val restartWarnLabel = JLabel("Restart to apply changes.") + restartWarnLabel.isVisible = false + + val disableButton = JButton("Disable") + val enableButton = JButton("Enable") + + fun refreshButtons() { + disableButton.isEnabled = loader.shouldEnable + enableButton.isEnabled = !loader.shouldEnable + + } + + refreshButtons() + + enableButton.addActionListener { + loader.shouldEnable = true + restartWarnLabel.isVisible = true + shouldAskForRestart = true + + refreshButtons() + } + disableButton.addActionListener { + loader.shouldEnable = false + restartWarnLabel.isVisible = true + shouldAskForRestart = true + + refreshButtons() + } + + // center buttons + val buttonsPanel = JPanel() + buttonsPanel.border = BorderFactory.createEmptyBorder(10, 0, 0, 0) + + buttonsPanel.layout = BoxLayout(buttonsPanel, BoxLayout.LINE_AXIS) + + buttonsPanel.add(restartWarnLabel) + buttonsPanel.add(Box.createHorizontalGlue()) + buttonsPanel.add(disableButton) + buttonsPanel.add(Box.createRigidArea(Dimension(5, 0))) + buttonsPanel.add(enableButton) + + pluginPanel.add(buttonsPanel, GridBagConstraints().apply { + gridx = 0 + gridy = 2 + weightx = 1.0 + fill = GridBagConstraints.HORIZONTAL + anchor = GridBagConstraints.CENTER + }) + + pluginPanel.border = BorderFactory.createEmptyBorder(10, 10, 10, 10) + + tabbedPane.addTab(loader.pluginName, pluginPanel) + } + + panel.add(tabbedPane, GridBagConstraints().apply { + gridx = 0 + gridy = 0 + weightx = 1.0 + weighty = 1.0 + fill = GridBagConstraints.BOTH + }) + } + + return panel } fun close() { @@ -165,8 +363,14 @@ class PluginOutput( if(!text.startsWith(SPECIAL_SILENT) && text != SPECIAL_CLOSE && text != SPECIAL_FREE) { SwingUtilities.invokeLater { SwingUtilities.invokeLater { - tabbedPane.selectedIndex = tabbedPane.indexOfTab("Output") // focus on output tab + if(text == SPECIAL_OPEN_MGR) { + tabbedPane.selectedIndex = tabbedPane.indexOfTab("Plugins") // focus on plugins tab + } else { + tabbedPane.selectedIndex = tabbedPane.indexOfTab("Output") // focus on output tab + } } + + tabbedPane.setComponentAt(0, makePluginManagerPanel()) output.isVisible = true } } diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt index 5f54b4f9..f22231aa 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt @@ -26,6 +26,7 @@ package io.github.deltacv.eocvsim.plugin.loader import com.github.serivesmejia.eocvsim.util.SysUtil import com.github.serivesmejia.eocvsim.util.extension.removeFromEnd import io.github.deltacv.eocvsim.sandbox.restrictions.MethodCallByteCodeChecker +import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingExactMatchBlacklist import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingMethodBlacklist import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingPackageBlacklist import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingPackageWhitelist @@ -92,10 +93,21 @@ class PluginClassLoader( fun loadClassStrict(name: String): Class<*> { if(!pluginContextProvider().hasSuperAccess) { for (blacklistedPackage in dynamicLoadingPackageBlacklist) { + for(whiteListedPackage in dynamicLoadingPackageWhitelist) { + // If the class is whitelisted, skip the blacklist check + if(name.contains(whiteListedPackage)) continue + } + if (name.contains(blacklistedPackage)) { throw IllegalAccessError("Plugins are blacklisted to use $name") } } + + for(blacklistedClass in dynamicLoadingExactMatchBlacklist) { + if(name == blacklistedClass) { + throw IllegalAccessError("Plugins are blacklisted to use $name") + } + } } return loadClass(zipFile.getEntry(name.replace('.', '/') + ".class") ?: throw ClassNotFoundException(name)) @@ -105,29 +117,30 @@ class PluginClassLoader( var clazz = loadedClasses[name] if(clazz == null) { - var inWhitelist = false - - for(whiteListedPackage in dynamicLoadingPackageWhitelist) { - if(name.contains(whiteListedPackage)) { - inWhitelist = true - break + try { + clazz = loadClassStrict(name) + } catch(_: Exception) { + val classpathClass = classFromClasspath(name) + if(classpathClass != null) { + clazz = classpathClass } } - if(!inWhitelist && !pluginContextProvider().hasSuperAccess) { - throw IllegalAccessError("Plugins are not whitelisted to use $name") - } + if(clazz == null) { + var inWhitelist = false - clazz = try { - Class.forName(name) - } catch (_: ClassNotFoundException) { - val classpathClass = classFromClasspath(name) + for (whiteListedPackage in dynamicLoadingPackageWhitelist) { + if (name.contains(whiteListedPackage)) { + inWhitelist = true + break + } + } - if(classpathClass != null) { - return classpathClass + if (!inWhitelist && !pluginContextProvider().hasSuperAccess) { + throw IllegalAccessError("Plugins are not whitelisted to use $name") } - loadClassStrict(name) + clazz = Class.forName(name) } if(resolve) resolveClass(clazz) diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt index 183ced53..f8174fd3 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt @@ -32,12 +32,10 @@ import com.github.serivesmejia.eocvsim.util.event.EventHandler import com.github.serivesmejia.eocvsim.util.extension.plus import com.github.serivesmejia.eocvsim.util.loggerForThis import com.moandjiezana.toml.Toml -import org.apache.logging.log4j.LogManager import io.github.deltacv.common.util.ParsedVersion import io.github.deltacv.eocvsim.plugin.EOCVSimPlugin import io.github.deltacv.eocvsim.sandbox.nio.SandboxFileSystem import net.lingala.zip4j.ZipFile -import org.apache.logging.log4j.core.LoggerContext import java.io.File import java.security.MessageDigest @@ -69,6 +67,15 @@ class PluginLoader( val pluginClassLoader: PluginClassLoader + var shouldEnable: Boolean + get() { + return eocvSim.config.flags.getOrDefault(hash(), true) + } + set(value) { + eocvSim.config.flags[hash()] = value + eocvSim.configManager.saveToFile() + } + lateinit var pluginToml: Toml private set @@ -77,6 +84,9 @@ class PluginLoader( lateinit var pluginVersion: String private set + lateinit var pluginDescription: String + private set + lateinit var pluginAuthor: String private set lateinit var pluginAuthorEmail: String @@ -124,6 +134,8 @@ class PluginLoader( pluginName = pluginToml.getString("name") ?: throw InvalidPluginException("No name in plugin.toml") pluginVersion = pluginToml.getString("version") ?: throw InvalidPluginException("No version in plugin.toml") + pluginDescription = pluginToml.getString("description", "") + pluginAuthor = pluginToml.getString("author") ?: throw InvalidPluginException("No author in plugin.toml") pluginAuthorEmail = pluginToml.getString("author-email", "") } @@ -138,12 +150,19 @@ class PluginLoader( fetchInfoFromToml() + if(!shouldEnable) { + appender.appendln("${PluginOutput.SPECIAL_SILENT}Plugin $pluginName v$pluginVersion is disabled") + return + } + appender.appendln("${PluginOutput.SPECIAL_SILENT}Loading plugin $pluginName v$pluginVersion by $pluginAuthor from ${pluginSource.name}") setupFs() - if(pluginToml.contains("api-version")) { - val parsedVersion = ParsedVersion(pluginToml.getString("api-version")) + if(pluginToml.contains("api-version") || pluginToml.contains("min-api-version")) { + // default to api-version if min-api-version is not present + val apiVersionKey = if(pluginToml.contains("api-version")) "api-version" else "min-api-version" + val parsedVersion = ParsedVersion(pluginToml.getString(apiVersionKey)) if(parsedVersion > EOCVSim.PARSED_VERSION) throw UnsupportedPluginException("Plugin requires a minimum api version of v${parsedVersion}, EOCV-Sim is currently running at v${EOCVSim.PARSED_VERSION}") @@ -155,7 +174,7 @@ class PluginLoader( val parsedVersion = ParsedVersion(pluginToml.getString("max-api-version")) if(parsedVersion < EOCVSim.PARSED_VERSION) - throw UnsupportedPluginException("Plugin requires a maximum api version of v${parsedVersion}, EOCV-Sim is currently running at v${EOCVSim.PARSED_VERSION}") + throw UnsupportedPluginException("Plugin requires a max api version of v${parsedVersion}, EOCV-Sim is currently running at v${EOCVSim.PARSED_VERSION}") logger.info("Plugin $pluginName requests max api version of v${parsedVersion}") } @@ -198,6 +217,8 @@ class PluginLoader( fun enable() { if(enabled || !loaded) return + if(!shouldEnable) return + appender.appendln("${PluginOutput.SPECIAL_SILENT}Enabling plugin $pluginName v$pluginVersion") plugin.enabled = true @@ -226,6 +247,7 @@ class PluginLoader( * @see EventHandler.banClassLoader */ fun kill() { + if(!loaded) return fileSystem.close() enabled = false EventHandler.banClassLoader(pluginClassLoader) @@ -236,6 +258,7 @@ class PluginLoader( * @param reason the reason for requesting super access */ fun requestSuperAccess(reason: String): Boolean { + if(!loaded) return false if(hasSuperAccess) return true return eocvSim.pluginManager.requestSuperAccessFor(this, reason) } diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt index 9ce1a5aa..0456e24c 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt @@ -62,7 +62,7 @@ class PluginManager(val eocvSim: EOCVSim) { private val haltCondition = haltLock.newCondition() val appender by lazy { - val appender = DialogFactory.createMavenOutput { + val appender = DialogFactory.createMavenOutput(this) { haltLock.withLock { haltCondition.signalAll() } @@ -94,7 +94,8 @@ class PluginManager(val eocvSim: EOCVSim) { */ val pluginFiles get() = _pluginFiles.toList() - private val loaders = mutableMapOf() + private val _loaders = mutableMapOf() + val loaders get() = _loaders.toMap() private var isEnabled = false @@ -129,7 +130,7 @@ class PluginManager(val eocvSim: EOCVSim) { } for (pluginFile in pluginFiles) { - loaders[pluginFile] = PluginLoader( + _loaders[pluginFile] = PluginLoader( pluginFile, repositoryManager.resolvedFiles, if(pluginFile in repositoryManager.resolvedFiles) @@ -147,7 +148,7 @@ class PluginManager(val eocvSim: EOCVSim) { * @see PluginLoader.load */ fun loadPlugins() { - for ((file, loader) in loaders) { + for ((file, loader) in _loaders) { try { loader.fetchInfoFromToml() @@ -167,7 +168,7 @@ class PluginManager(val eocvSim: EOCVSim) { appender.appendln(e.message ?: "Unknown error") logger.error("Failure loading ${loader.pluginName} v${loader.pluginVersion}", e) - loaders.remove(file) + _loaders.remove(file) loader.kill() } } @@ -178,7 +179,7 @@ class PluginManager(val eocvSim: EOCVSim) { * @see PluginLoader.enable */ fun enablePlugins() { - for (loader in loaders.values) { + for (loader in _loaders.values) { try { loader.enable() } catch (e: Throwable) { @@ -197,7 +198,7 @@ class PluginManager(val eocvSim: EOCVSim) { fun disablePlugins() { if(!isEnabled) return - for (loader in loaders.values) { + for (loader in _loaders.values) { try { loader.disable() } catch (e: Throwable) { diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/sandbox/restrictions/DynamicLoadingClassRestrictions.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/sandbox/restrictions/DynamicLoadingClassRestrictions.kt index fb8b3c5e..dde92ba3 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/sandbox/restrictions/DynamicLoadingClassRestrictions.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/sandbox/restrictions/DynamicLoadingClassRestrictions.kt @@ -25,10 +25,12 @@ package io.github.deltacv.eocvsim.sandbox.restrictions val dynamicLoadingPackageWhitelist = setOf( "java.lang", + "java.lang.RuntimeException", "java.util", "java.awt", "javax.swing", "java.nio", + "java.io.IOException", "java.io.File", "java.io.PrintStream", @@ -51,9 +53,12 @@ val dynamicLoadingPackageWhitelist = setOf( "com.formdev.flatlaf" ) +val dynamicLoadingExactMatchBlacklist = setOf( + "java.lang.Runtime" +) + val dynamicLoadingPackageBlacklist = setOf( // System and Runtime Classes - "java.lang.Runtime", "java.lang.ProcessBuilder", "java.lang.reflect", diff --git a/EOCV-Sim/src/main/resources/repository.toml b/EOCV-Sim/src/main/resources/repository.toml index 2a116d88..79e1b2ae 100644 --- a/EOCV-Sim/src/main/resources/repository.toml +++ b/EOCV-Sim/src/main/resources/repository.toml @@ -7,5 +7,4 @@ jitpack = "https://jitpack.io/" [plugins] # Declare the plugin ID and the Maven coordinates of the plugin, similar to how you do it in Gradle. # (group:artifact:version) which will be used to download the plugin from one of Maven repositories. -# Any dependency that does not have a plugin.toml in its jar will not be considered after download. -PaperVision = "com.github.deltacv.PaperVision:EOCVSimPlugin:40a30a8965" \ No newline at end of file +# Any dependency that does not have a plugin.toml in its jar will not be considered after download. \ No newline at end of file From 0bdb81838d658afd34ca8f50408fd4454545fab7 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Sun, 27 Oct 2024 22:37:51 -0600 Subject: [PATCH 23/36] Correctly handle the continue on blacklistLoop --- .../github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt index f22231aa..452de952 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt @@ -92,10 +92,11 @@ class PluginClassLoader( */ fun loadClassStrict(name: String): Class<*> { if(!pluginContextProvider().hasSuperAccess) { + blacklistLoop@ for (blacklistedPackage in dynamicLoadingPackageBlacklist) { for(whiteListedPackage in dynamicLoadingPackageWhitelist) { // If the class is whitelisted, skip the blacklist check - if(name.contains(whiteListedPackage)) continue + if(name.contains(whiteListedPackage)) continue@blacklistLoop } if (name.contains(blacklistedPackage)) { From 2ea93051a802bc181e626f72a31410a3fcd862b6 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Sun, 27 Oct 2024 23:04:59 -0600 Subject: [PATCH 24/36] Fix check that prevented calling super access request & improve PluginClassLoader black/white list handling --- .../plugin/loader/PluginClassLoader.kt | 120 +++++++++--------- .../eocvsim/plugin/loader/PluginLoader.kt | 1 - .../DynamicLoadingClassRestrictions.kt | 2 - 3 files changed, 61 insertions(+), 62 deletions(-) diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt index 452de952..3060a3fb 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt @@ -30,19 +30,18 @@ import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingExactMatchBl import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingMethodBlacklist import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingPackageBlacklist import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingPackageWhitelist -import org.apache.logging.log4j.core.LoggerContext import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException import java.io.InputStream import java.net.URL -import java.nio.file.FileSystems import java.util.zip.ZipEntry import java.util.zip.ZipFile /** * ClassLoader for loading classes from a plugin jar file * @param pluginJar the jar file of the plugin + * @param classpath additional classpath that the plugin may require * @param pluginContext the plugin context */ class PluginClassLoader( @@ -53,16 +52,12 @@ class PluginClassLoader( private val zipFile = try { ZipFile(pluginJar) - } catch(e: Exception) { + } catch (e: Exception) { throw IOException("Failed to open plugin JAR file", e) } private val loadedClasses = mutableMapOf>() - init { - FileSystems.getDefault() - } - private fun loadClass(entry: ZipEntry, zipFile: ZipFile = this.zipFile): Class<*> { val name = entry.name.removeFromEnd(".class").replace('/', '.') @@ -71,7 +66,7 @@ class PluginClassLoader( SysUtil.copyStream(inStream, outStream) val bytes = outStream.toByteArray() - if(!pluginContextProvider().hasSuperAccess) + if (!pluginContextProvider().hasSuperAccess) MethodCallByteCodeChecker(bytes, dynamicLoadingMethodBlacklist) val clazz = defineClass(name, bytes, 0, bytes.size) @@ -88,86 +83,91 @@ class PluginClassLoader( * Load a class from the plugin jar file * @param name the name of the class to load * @return the loaded class - * @throws IllegalAccessError if the class is blacklisted */ fun loadClassStrict(name: String): Class<*> { - if(!pluginContextProvider().hasSuperAccess) { - blacklistLoop@ - for (blacklistedPackage in dynamicLoadingPackageBlacklist) { - for(whiteListedPackage in dynamicLoadingPackageWhitelist) { - // If the class is whitelisted, skip the blacklist check - if(name.contains(whiteListedPackage)) continue@blacklistLoop - } - - if (name.contains(blacklistedPackage)) { - throw IllegalAccessError("Plugins are blacklisted to use $name") - } - } - - for(blacklistedClass in dynamicLoadingExactMatchBlacklist) { - if(name == blacklistedClass) { - throw IllegalAccessError("Plugins are blacklisted to use $name") - } - } - } - return loadClass(zipFile.getEntry(name.replace('.', '/') + ".class") ?: throw ClassNotFoundException(name)) } override fun loadClass(name: String, resolve: Boolean): Class<*> { var clazz = loadedClasses[name] - if(clazz == null) { - try { - clazz = loadClassStrict(name) - } catch(_: Exception) { - val classpathClass = classFromClasspath(name) - if(classpathClass != null) { - clazz = classpathClass - } - } + try { + if (clazz == null) { + var canForName = true - if(clazz == null) { - var inWhitelist = false + if (!pluginContextProvider().hasSuperAccess) { + var inWhitelist = false - for (whiteListedPackage in dynamicLoadingPackageWhitelist) { - if (name.contains(whiteListedPackage)) { - inWhitelist = true - break + for (whiteListedPackage in dynamicLoadingPackageWhitelist) { + if (name.contains(whiteListedPackage)) { + inWhitelist = true + break + } } - } - if (!inWhitelist && !pluginContextProvider().hasSuperAccess) { - throw IllegalAccessError("Plugins are not whitelisted to use $name") + if (!inWhitelist && !pluginContextProvider().hasSuperAccess) { + canForName = false + throw IllegalAccessError("Plugins are not whitelisted to use $name") + } + + blacklistLoop@ + for (blacklistedPackage in dynamicLoadingPackageBlacklist) { + // If the class is whitelisted, skip the blacklist check + if (inWhitelist) continue@blacklistLoop + + if (name.contains(blacklistedPackage)) { + canForName = false + throw IllegalAccessError("Plugins are blacklisted to use $name") + } + } + + for (blacklistedClass in dynamicLoadingExactMatchBlacklist) { + if (name == blacklistedClass) { + canForName = false + throw IllegalAccessError("Plugins are blacklisted to use $name") + } + } } - clazz = Class.forName(name) + if (canForName) { + clazz = Class.forName(name) + } } + } catch(e: Throwable) { + try { + clazz = loadClassStrict(name) + } catch(_: Throwable) { + val classpathClass = classFromClasspath(name) - if(resolve) resolveClass(clazz) + if(classpathClass != null) { + clazz = classpathClass + } else { + throw e + } + } } + if (resolve) resolveClass(clazz) return clazz!! } override fun getResourceAsStream(name: String): InputStream? { - // Try to find the resource inside the classpath - resourceAsStreamFromClasspath(name)?.let { return it } - val entry = zipFile.getEntry(name) - if(entry != null) { + if (entry != null) { try { return zipFile.getInputStream(entry) - } catch (e: IOException) { } + } catch (_: IOException) { + } + } else { + // Try to find the resource inside the classpath + resourceAsStreamFromClasspath(name)?.let { return it } } return super.getResourceAsStream(name) } override fun getResource(name: String): URL? { - resourceFromClasspath(name)?.let { return it } - // Try to find the resource inside the plugin JAR val entry = zipFile.getEntry(name) @@ -177,6 +177,8 @@ class PluginClassLoader( return URL("jar:file:${pluginJar.absolutePath}!/$name") } catch (e: Exception) { } + } else { + resourceFromClasspath(name)?.let { return it } } // Fallback to the parent classloader if not found in the plugin JAR @@ -188,7 +190,7 @@ class PluginClassLoader( */ fun resourceAsStreamFromClasspath(name: String): InputStream? { for (file in classpath) { - if(file == pluginJar) continue + if (file == pluginJar) continue val zipFile = ZipFile(file) @@ -210,7 +212,7 @@ class PluginClassLoader( */ fun resourceFromClasspath(name: String): URL? { for (file in classpath) { - if(file == pluginJar) continue + if (file == pluginJar) continue val zipFile = ZipFile(file) @@ -236,7 +238,7 @@ class PluginClassLoader( */ fun classFromClasspath(className: String): Class<*>? { for (file in classpath) { - if(file == pluginJar) continue + if (file == pluginJar) continue val zipFile = ZipFile(file) diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt index f8174fd3..96cc7b48 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt @@ -258,7 +258,6 @@ class PluginLoader( * @param reason the reason for requesting super access */ fun requestSuperAccess(reason: String): Boolean { - if(!loaded) return false if(hasSuperAccess) return true return eocvSim.pluginManager.requestSuperAccessFor(this, reason) } diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/sandbox/restrictions/DynamicLoadingClassRestrictions.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/sandbox/restrictions/DynamicLoadingClassRestrictions.kt index dde92ba3..8f8af2df 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/sandbox/restrictions/DynamicLoadingClassRestrictions.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/sandbox/restrictions/DynamicLoadingClassRestrictions.kt @@ -80,8 +80,6 @@ val dynamicLoadingPackageBlacklist = setOf( "com.github.serivesmejia.eocvsim.util.FileExtKt", "com.github.serivesmejia.eocvsim.util.compiler", "com.github.serivesmejia.eocvsim.config", - - "io.github.deltacv.eocvsim.plugin.sandbox.nio.JimfsWatcher" ) val dynamicLoadingMethodBlacklist = setOf( From c195227ed386c75751073d50358b8838c4d764af Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Mon, 28 Oct 2024 06:46:35 -0600 Subject: [PATCH 25/36] Handle superaccess requests in separate process & implement plugin signing and authority verifying --- .../eocvsim/util/event/EventHandler.kt | 481 +++++++++--------- EOCV-Sim/build.gradle | 4 +- .../github/serivesmejia/eocvsim/EOCVSim.kt | 2 +- .../eocvsim/gui/dialog/PluginOutput.kt | 2 +- .../eocvsim/util/JavaProcess.java | 15 + .../eocvsim/util/extension/StrExt.kt | 11 +- .../util/serialization/PolymorphicAdapter.kt | 50 ++ .../gui/dialog/SuperAccessRequestMain.java | 66 ++- .../eocvsim/plugin/loader/PluginLoader.kt | 49 +- .../eocvsim/plugin/loader/PluginManager.kt | 50 +- .../repository/PluginRepositoryManager.kt | 50 +- .../eocvsim/plugin/security/Authority.kt | 153 ++++++ .../plugin/security/KeyGeneratorTool.kt | 59 +++ .../security/PluginSignatureVerifier.kt | 181 +++++++ .../plugin/security/PluginSigningTool.kt | 216 ++++++++ .../security/superaccess/SuperAccessDaemon.kt | 221 ++++++++ .../superaccess/SuperAccessDaemonClient.kt | 197 +++++++ .../DynamicLoadingClassRestrictions.kt | 19 + build.gradle | 4 +- 19 files changed, 1505 insertions(+), 325 deletions(-) create mode 100644 EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/serialization/PolymorphicAdapter.kt create mode 100644 EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/Authority.kt create mode 100644 EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/KeyGeneratorTool.kt create mode 100644 EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSignatureVerifier.kt create mode 100644 EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSigningTool.kt create mode 100644 EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemon.kt create mode 100644 EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemonClient.kt diff --git a/Common/src/main/java/com/github/serivesmejia/eocvsim/util/event/EventHandler.kt b/Common/src/main/java/com/github/serivesmejia/eocvsim/util/event/EventHandler.kt index a004239d..870be586 100644 --- a/Common/src/main/java/com/github/serivesmejia/eocvsim/util/event/EventHandler.kt +++ b/Common/src/main/java/com/github/serivesmejia/eocvsim/util/event/EventHandler.kt @@ -1,241 +1,240 @@ -/* - * Copyright (c) 2021 Sebastian Erives - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -package com.github.serivesmejia.eocvsim.util.event - -import com.github.serivesmejia.eocvsim.util.loggerOf -import java.lang.ref.WeakReference - -/** - * Event handler class to manage event listeners - * @param name the name of the event handler - */ -class EventHandler(val name: String) : Runnable { - - companion object { - private val bannedClassLoaders = mutableListOf>() - - /** - * Ban a class loader from being able to add listeners - * and lazily remove all listeners from that class loader - */ - fun banClassLoader(loader: ClassLoader) = bannedClassLoaders.add(WeakReference(loader)) - - /** - * Check if a class loader is banned - */ - fun isBanned(classLoader: ClassLoader): Boolean { - for(bannedClassLoader in bannedClassLoaders) { - if(bannedClassLoader.get() == classLoader) return true - } - - return false - } - } - - val logger by loggerOf("${name}-EventHandler") - - private val lock = Any() - private val onceLock = Any() - - /** - * Get all the listeners in this event handler - * thread-safe operation (synchronized) - */ - val listeners: Array - get() { - synchronized(lock) { - return internalListeners.toTypedArray() - } - } - - /** - * Get all the "doOnce" listeners in this event handler - * thread-safe operation (synchronized) - */ - val onceListeners: Array - get() { - synchronized(onceLock) { - return internalOnceListeners.toTypedArray() - } - } - - var callRightAway = false - - private val internalListeners = ArrayList() - private val internalOnceListeners = ArrayList() - - /** - * Run all the listeners in this event handler - */ - override fun run() { - for(listener in listeners) { - try { - runListener(listener, false) - } catch (ex: Exception) { - if(ex is InterruptedException) { - logger.warn("Rethrowing InterruptedException...") - throw ex - } else { - logger.error("Error while running listener ${listener.javaClass.name}", ex) - } - } - } - - val toRemoveOnceListeners = mutableListOf() - - //executing "doOnce" listeners - for(listener in onceListeners) { - try { - runListener(listener, true) - } catch (ex: Exception) { - if(ex is InterruptedException) { - logger.warn("Rethrowing InterruptedException...") - throw ex - } else { - logger.error("Error while running \"once\" ${listener.javaClass.name}", ex) - removeOnceListener(listener) - } - } - - toRemoveOnceListeners.add(listener) - } - - synchronized(onceLock) { - for(listener in toRemoveOnceListeners) { - internalOnceListeners.remove(listener) - } - } - } - - /** - * Add a listener to this event handler to only be run once - * @param listener the listener to add - */ - fun doOnce(listener: EventListener) { - if(callRightAway) - runListener(listener, true) - else synchronized(onceLock) { - internalOnceListeners.add(listener) - } - } - - /** - * Add a runnable as a listener to this event handler to only be run once - * @param runnable the runnable to add - */ - fun doOnce(runnable: Runnable) = doOnce { runnable.run() } - - /** - * Add a listener to this event handler to be run every time - * @param listener the listener to add - */ - fun doPersistent(listener: EventListener): EventListenerRemover { - synchronized(lock) { - internalListeners.add(listener) - } - - if(callRightAway) runListener(listener, false) - - return EventListenerRemover(this, listener, false) - } - - /** - * Add a runnable as a listener to this event handler to be run every time - * @param runnable the runnable to add - */ - fun doPersistent(runnable: Runnable) = doPersistent { runnable.run() } - - /** - * Remove a listener from this event handler - * @param listener the listener to remove - */ - fun removePersistentListener(listener: EventListener) { - if(internalListeners.contains(listener)) { - synchronized(lock) { internalListeners.remove(listener) } - } - } - - /** - * Remove a "doOnce" listener from this event handler - * @param listener the listener to remove - */ - fun removeOnceListener(listener: EventListener) { - if(internalOnceListeners.contains(listener)) { - synchronized(onceLock) { internalOnceListeners.remove(listener) } - } - } - - /** - * Remove all listeners from this event handler - */ - fun removeAllListeners() { - removeAllPersistentListeners() - removeAllOnceListeners() - } - - /** - * Remove all persistent listeners from this event handler - */ - fun removeAllPersistentListeners() = synchronized(lock) { - internalListeners.clear() - } - - /** - * Remove all "doOnce" listeners from this event handler - */ - fun removeAllOnceListeners() = synchronized(onceLock) { - internalOnceListeners.clear() - } - - /** - * Add a listener to this event handler to only be run once - * with kotlin operator overloading - * @param listener the listener to add - */ - operator fun invoke(listener: EventListener) = doPersistent(listener) - - private fun runListener(listener: EventListener, isOnce: Boolean) { - if(isBanned(listener.javaClass.classLoader)) { - removeBannedListeners() - return - } - - listener.run(EventListenerRemover(this, listener, isOnce)) - } - - private fun removeBannedListeners() { - for(listener in internalListeners.toArray()) { - if(isBanned(listener.javaClass.classLoader)) { - internalListeners.remove(listener) - logger.warn("Removed banned listener from ${listener.javaClass.classLoader}") - } - } - - for(listener in internalOnceListeners.toArray()) { - if(isBanned(listener.javaClass.classLoader)) internalOnceListeners.remove(listener) - logger.warn("Removed banned listener from ${listener.javaClass.classLoader}") - } - } - -} +/* + * Copyright (c) 2021 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package com.github.serivesmejia.eocvsim.util.event + +import com.github.serivesmejia.eocvsim.util.loggerOf +import java.lang.ref.WeakReference + +/** + * Event handler class to manage event listeners + * @param name the name of the event handler + */ +class EventHandler(val name: String) : Runnable { + + companion object { + private val bannedClassLoaders = mutableListOf>() + + /** + * Ban a class loader from being able to add listeners + * and lazily remove all listeners from that class loader + */ + fun banClassLoader(loader: ClassLoader) = bannedClassLoaders.add(WeakReference(loader)) + + /** + * Check if a class loader is banned + */ + fun isBanned(classLoader: ClassLoader): Boolean { + for(bannedClassLoader in bannedClassLoaders) { + if(bannedClassLoader.get() == classLoader) return true + } + + return false + } + } + + val logger by loggerOf("${name}-EventHandler") + + private val lock = Any() + private val onceLock = Any() + + /** + * Get all the listeners in this event handler + * thread-safe operation (synchronized) + */ + val listeners: Array + get() { + synchronized(lock) { + return internalListeners.toTypedArray() + } + } + + /** + * Get all the "doOnce" listeners in this event handler + * thread-safe operation (synchronized) + */ + val onceListeners: Array + get() { + synchronized(onceLock) { + return internalOnceListeners.toTypedArray() + } + } + + var callRightAway = false + + private val internalListeners = ArrayList() + private val internalOnceListeners = ArrayList() + + /** + * Run all the listeners in this event handler + */ + override fun run() { + for(listener in listeners) { + try { + runListener(listener, false) + } catch (ex: Exception) { + if(ex is InterruptedException) { + logger.warn("Rethrowing InterruptedException...") + throw ex + } else { + logger.error("Error while running listener ${listener.javaClass.name}", ex) + } + } + } + + val toRemoveOnceListeners = mutableListOf() + + //executing "doOnce" listeners + for(listener in onceListeners) { + try { + runListener(listener, true) + } catch (ex: Exception) { + if(ex is InterruptedException) { + logger.warn("Rethrowing InterruptedException...") + throw ex + } else { + logger.error("Error while running \"once\" ${listener.javaClass.name}", ex) + removeOnceListener(listener) + } + } + + toRemoveOnceListeners.add(listener) + } + + synchronized(onceLock) { + for(listener in toRemoveOnceListeners) { + internalOnceListeners.remove(listener) + } + } + } + + /** + * Add a listener to this event handler to only be run once + * @param listener the listener to add + */ + fun doOnce(listener: EventListener) { + if(callRightAway) + runListener(listener, true) + else synchronized(onceLock) { + internalOnceListeners.add(listener) + } + } + + /** + * Add a runnable as a listener to this event handler to only be run once + * @param runnable the runnable to add + */ + fun doOnce(runnable: Runnable) = doOnce { runnable.run() } + + /** + * Add a listener to this event handler to be run every time + * @param listener the listener to add + */ + fun doPersistent(listener: EventListener): EventListenerRemover { + synchronized(lock) { + internalListeners.add(listener) + } + + if(callRightAway) runListener(listener, false) + + return EventListenerRemover(this, listener, false) + } + + /** + * Add a runnable as a listener to this event handler to be run every time + * @param runnable the runnable to add + */ + fun doPersistent(runnable: Runnable) = doPersistent { runnable.run() } + + /** + * Remove a listener from this event handler + * @param listener the listener to remove + */ + fun removePersistentListener(listener: EventListener) { + if(internalListeners.contains(listener)) { + synchronized(lock) { internalListeners.remove(listener) } + } + } + + /** + * Remove a "doOnce" listener from this event handler + * @param listener the listener to remove + */ + fun removeOnceListener(listener: EventListener) { + if(internalOnceListeners.contains(listener)) { + synchronized(onceLock) { internalOnceListeners.remove(listener) } + } + } + + /** + * Remove all listeners from this event handler + */ + fun removeAllListeners() { + removeAllPersistentListeners() + removeAllOnceListeners() + } + + /** + * Remove all persistent listeners from this event handler + */ + fun removeAllPersistentListeners() = synchronized(lock) { + internalListeners.clear() + } + + /** + * Remove all "doOnce" listeners from this event handler + */ + fun removeAllOnceListeners() = synchronized(onceLock) { + internalOnceListeners.clear() + } + + /** + * Add a listener to this event handler + * @param listener the listener to add + */ + operator fun invoke(listener: EventListener) = doPersistent(listener) + + private fun runListener(listener: EventListener, isOnce: Boolean) { + if(isBanned(listener.javaClass.classLoader)) { + removeBannedListeners() + return + } + + listener.run(EventListenerRemover(this, listener, isOnce)) + } + + private fun removeBannedListeners() { + for(listener in internalListeners.toArray()) { + if(isBanned(listener.javaClass.classLoader)) { + internalListeners.remove(listener) + logger.warn("Removed banned listener from ${listener.javaClass.classLoader}") + } + } + + for(listener in internalOnceListeners.toArray()) { + if(isBanned(listener.javaClass.classLoader)) internalOnceListeners.remove(listener) + logger.warn("Removed banned listener from ${listener.javaClass.classLoader}") + } + } + +} diff --git a/EOCV-Sim/build.gradle b/EOCV-Sim/build.gradle index 90af4332..dfa77efa 100644 --- a/EOCV-Sim/build.gradle +++ b/EOCV-Sim/build.gradle @@ -72,7 +72,7 @@ dependencies { implementation "org.slf4j:slf4j-api:$slf4j_version" implementation "org.apache.logging.log4j:log4j-api:$log4j_version" implementation "org.apache.logging.log4j:log4j-core:$log4j_version" - implementation "org.apache.logging.log4j:log4j-slf4j-impl:$log4j_version" + implementation "org.apache.logging.log4j:log4j-slf4j2-impl:$log4j_version" implementation "com.github.deltacv:steve:1.1.1" api("com.github.deltacv:AprilTagDesktop:$apriltag_plugin_version") { @@ -87,6 +87,8 @@ dependencies { implementation 'com.formdev:flatlaf-intellij-themes:3.5.1' implementation 'com.miglayout:miglayout-swing:11.4.2' + implementation("org.java-websocket:Java-WebSocket:1.5.2") + implementation 'net.lingala.zip4j:zip4j:2.11.3' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version" diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt index 924e34b3..86fe763e 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt @@ -39,7 +39,6 @@ import com.github.serivesmejia.eocvsim.util.FileFilters import com.github.serivesmejia.eocvsim.util.JavaProcess import com.github.serivesmejia.eocvsim.util.SysUtil import com.github.serivesmejia.eocvsim.util.event.EventHandler -import com.github.serivesmejia.eocvsim.util.exception.MaxActiveContextsException import com.github.serivesmejia.eocvsim.util.exception.handling.CrashReport import com.github.serivesmejia.eocvsim.util.exception.handling.EOCVSimUncaughtExceptionHandler import com.github.serivesmejia.eocvsim.util.fps.FpsLimiter @@ -50,6 +49,7 @@ import com.qualcomm.robotcore.eventloop.opmode.OpMode import com.qualcomm.robotcore.eventloop.opmode.OpModePipelineHandler import io.github.deltacv.common.pipeline.util.PipelineStatisticsCalculator import io.github.deltacv.common.util.ParsedVersion +import io.github.deltacv.eocvsim.plugin.security.AuthorityFetcher import io.github.deltacv.eocvsim.plugin.loader.PluginManager import io.github.deltacv.vision.external.PipelineRenderHook import nu.pattern.OpenCV diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt index 65ce7b75..7d21fd3c 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt @@ -90,7 +90,7 @@ class PluginOutput( ) : Appendable { companion object { - private const val SPECIAL = "[13mck]" + const val SPECIAL = "[13mck]" const val SPECIAL_OPEN = "$SPECIAL[OPEN]" const val SPECIAL_OPEN_MGR = "$SPECIAL[OPEN_MGR]" diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java index b7912e69..a7c7806a 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java @@ -159,4 +159,19 @@ public static int exec(Class klass, List jvmArgs, List args) thr return execClasspath(klass, null, System.getProperty("java.class.path"), jvmArgs, args); } + /** + * Executes a Java process with the given class and arguments. + * @param klass the class to execute + * @param ioReceiver the receiver for the process' input and error streams (will use inheritIO if null) + * @param jvmArgs the JVM arguments to pass to the process + * @param args the arguments to pass to the class + * @return the exit value of the process + * @throws InterruptedException if the process is interrupted + * @throws IOException if an I/O error occurs + */ + public static int exec(Class klass, ProcessIOReceiver ioReceiver, List jvmArgs, List args) throws InterruptedException, IOException { + return execClasspath(klass, ioReceiver, System.getProperty("java.class.path"), jvmArgs, args); + } + + } \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/extension/StrExt.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/extension/StrExt.kt index 9c08fa77..34f4e756 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/extension/StrExt.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/extension/StrExt.kt @@ -1,5 +1,8 @@ package com.github.serivesmejia.eocvsim.util.extension +import com.github.serivesmejia.eocvsim.util.SysUtil +import java.security.MessageDigest + /** * Remove a string from the end of this string * @param rem the string to remove @@ -12,4 +15,10 @@ fun String.removeFromEnd(rem: String): String { return trim() } -val Any.hexString get() = Integer.toHexString(hashCode())!! \ No newline at end of file +val Any.hashString get() = Integer.toHexString(hashCode())!! + +val String.hashString: String get() { + val messageDigest = MessageDigest.getInstance("SHA-256") + val hash = messageDigest.digest(toByteArray()) + return SysUtil.byteArray2Hex(hash) +} \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/serialization/PolymorphicAdapter.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/serialization/PolymorphicAdapter.kt new file mode 100644 index 00000000..04d4cced --- /dev/null +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/serialization/PolymorphicAdapter.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package com.github.serivesmejia.eocvsim.util.serialization + +import com.google.gson.* +import java.lang.reflect.Type + +private val gson = Gson() + +open class PolymorphicAdapter(val name: String) : JsonSerializer, JsonDeserializer { + + override fun serialize(src: T, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { + val obj = JsonObject() + + obj.addProperty("${name}Class", src!!::class.java.name) + obj.add(name, gson.toJsonTree(src)) + + return obj + } + + @Suppress("UNCHECKED_CAST") + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): T { + val className = json.asJsonObject.get("${name}Class").asString + val clazz = Class.forName(className) + + return gson.fromJson(json.asJsonObject.get(name), clazz) as T + } + +} \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/gui/dialog/SuperAccessRequestMain.java b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/gui/dialog/SuperAccessRequestMain.java index 6d733f60..27d76e41 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/gui/dialog/SuperAccessRequestMain.java +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/gui/dialog/SuperAccessRequestMain.java @@ -1,34 +1,32 @@ -/* - * Copyright (c) 2024 Sebastian Erives - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -package io.github.deltacv.eocvsim.gui.dialog; - -import javax.swing.*; - -public class SuperAccessRequestMain { - public static void main(String[] args) { - SwingUtilities.invokeLater(() -> { - new SuperAccessRequest(args[0].replace("-", " "), args[1].replace("-", " ")); - }); - } -} +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package io.github.deltacv.eocvsim.gui.dialog; + +import javax.swing.*; + +public class SuperAccessRequestMain { + public static void main(String[] args) { + SwingUtilities.invokeLater(() -> new SuperAccessRequest(args[0].replace("-", " "), args[1].replace("-", " "))); + } +} diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt index 96cc7b48..37938b4b 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt @@ -29,11 +29,14 @@ import com.github.serivesmejia.eocvsim.gui.dialog.AppendDelegate import com.github.serivesmejia.eocvsim.gui.dialog.PluginOutput import com.github.serivesmejia.eocvsim.util.SysUtil import com.github.serivesmejia.eocvsim.util.event.EventHandler +import com.github.serivesmejia.eocvsim.util.extension.hashString import com.github.serivesmejia.eocvsim.util.extension.plus import com.github.serivesmejia.eocvsim.util.loggerForThis import com.moandjiezana.toml.Toml import io.github.deltacv.common.util.ParsedVersion import io.github.deltacv.eocvsim.plugin.EOCVSimPlugin +import io.github.deltacv.eocvsim.plugin.security.PluginSignature +import io.github.deltacv.eocvsim.plugin.security.PluginSignatureVerifier import io.github.deltacv.eocvsim.sandbox.nio.SandboxFileSystem import net.lingala.zip4j.ZipFile import java.io.File @@ -44,6 +47,24 @@ enum class PluginSource { FILE } +class PluginParser(pluginToml: Toml) { + val pluginName = pluginToml.getString("name") ?: throw InvalidPluginException("No name in plugin.toml") + val pluginVersion = pluginToml.getString("version") ?: throw InvalidPluginException("No version in plugin.toml") + + val pluginAuthor = pluginToml.getString("author") ?: throw InvalidPluginException("No author in plugin.toml") + val pluginAuthorEmail = pluginToml.getString("author-email", "") + + val pluginMain = pluginToml.getString("main") ?: throw InvalidPluginException("No main in plugin.toml") + + val pluginDescription = pluginToml.getString("description", "") + + /** + * Get the hash of the plugin based off the plugin name and author + * @return the hash + */ + fun hash() = "${pluginName}${PluginOutput.SPECIAL}${pluginAuthor}".hashString +} + /** * Loads a plugin from a jar file * @param pluginFile the jar file of the plugin @@ -103,13 +124,18 @@ class PluginLoader( lateinit var fileSystem: SandboxFileSystem private set + /** + * The signature of the plugin, issued by a verified authority + */ + val signature by lazy { PluginSignatureVerifier.verify(pluginFile) } + val fileSystemZip by lazy { PluginManager.FILESYSTEMS_FOLDER + File.separator + "${hash()}-fs" } val fileSystemZipPath by lazy { fileSystemZip.toPath() } /** * Whether the plugin has super access (full system access) */ - val hasSuperAccess get() = eocvSim.config.superAccessPluginHashes.contains(pluginFileHash) + val hasSuperAccess get() = eocvSim.pluginManager.superAccessDaemonClient.checkAccess(pluginFile) init { pluginClassLoader = PluginClassLoader( @@ -131,13 +157,13 @@ class PluginLoader( ?: throw InvalidPluginException("No plugin.toml in the jar file") ) - pluginName = pluginToml.getString("name") ?: throw InvalidPluginException("No name in plugin.toml") - pluginVersion = pluginToml.getString("version") ?: throw InvalidPluginException("No version in plugin.toml") + val parser = PluginParser(pluginToml) - pluginDescription = pluginToml.getString("description", "") - - pluginAuthor = pluginToml.getString("author") ?: throw InvalidPluginException("No author in plugin.toml") - pluginAuthorEmail = pluginToml.getString("author-email", "") + pluginName = parser.pluginName + pluginVersion = parser.pluginVersion + pluginAuthor = parser.pluginAuthor + pluginAuthorEmail = parser.pluginAuthorEmail + pluginDescription = parser.pluginDescription } /** @@ -258,19 +284,14 @@ class PluginLoader( * @param reason the reason for requesting super access */ fun requestSuperAccess(reason: String): Boolean { - if(hasSuperAccess) return true return eocvSim.pluginManager.requestSuperAccessFor(this, reason) } /** - * Get the hash of the plugin file based off the plugin name and author + * Get the hash of the plugin based off the plugin name and author * @return the hash */ - fun hash(): String { - val messageDigest = MessageDigest.getInstance("SHA-256") - messageDigest.update("${pluginName} by ${pluginAuthor}".toByteArray()) - return SysUtil.byteArray2Hex(messageDigest.digest()) - } + fun hash() = "${pluginName}${PluginOutput.SPECIAL}${pluginAuthor}".hashString /** * Get the hash of the plugin file based off the file contents diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt index 0456e24c..526ed1cc 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt @@ -27,17 +27,18 @@ import com.github.serivesmejia.eocvsim.EOCVSim import com.github.serivesmejia.eocvsim.gui.DialogFactory import com.github.serivesmejia.eocvsim.gui.dialog.PluginOutput import com.github.serivesmejia.eocvsim.gui.dialog.PluginOutput.Companion.trimSpecials -import com.github.serivesmejia.eocvsim.util.JavaProcess import com.github.serivesmejia.eocvsim.util.extension.plus import com.github.serivesmejia.eocvsim.util.io.EOCVSimFolder import com.github.serivesmejia.eocvsim.util.loggerForThis import com.github.serivesmejia.eocvsim.util.loggerOf -import io.github.deltacv.eocvsim.gui.dialog.SuperAccessRequestMain import io.github.deltacv.eocvsim.plugin.repository.PluginRepositoryManager +import io.github.deltacv.eocvsim.plugin.security.superaccess.SuperAccessDaemon +import io.github.deltacv.eocvsim.plugin.security.superaccess.SuperAccessDaemonClient +import io.github.deltacv.eocvsim.plugin.security.toMutable import java.io.File -import java.util.* import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock +import kotlin.properties.Delegates /** * Manages the loading, enabling and disabling of plugins @@ -55,6 +56,8 @@ class PluginManager(val eocvSim: EOCVSim) { val logger by loggerForThis() + val superAccessDaemonClient = SuperAccessDaemonClient() + private val _loadedPluginHashes = mutableListOf() val loadedPluginHashes get() = _loadedPluginHashes.toList() @@ -97,6 +100,7 @@ class PluginManager(val eocvSim: EOCVSim) { private val _loaders = mutableMapOf() val loaders get() = _loaders.toMap() + private var enableTimestamp by Delegates.notNull() private var isEnabled = false /** @@ -111,6 +115,8 @@ class PluginManager(val eocvSim: EOCVSim) { appender.append(PluginOutput.SPECIAL_FREE) } + superAccessDaemonClient.init() + repositoryManager.init() val pluginFiles = mutableListOf() @@ -140,6 +146,7 @@ class PluginManager(val eocvSim: EOCVSim) { ) } + enableTimestamp = System.currentTimeMillis() isEnabled = true } @@ -219,28 +226,35 @@ class PluginManager(val eocvSim: EOCVSim) { * @return true if super access was granted, false otherwise */ fun requestSuperAccessFor(loader: PluginLoader, reason: String): Boolean { - if(loader.hasSuperAccess) return true + if(loader.hasSuperAccess) { + appender.appendln(PluginOutput.SPECIAL_SILENT + "Plugin ${loader.pluginName} v${loader.pluginVersion} already has super access") + return true + } - appender.appendln(PluginOutput.SPECIAL_SILENT + "Requesting super access for ${loader.pluginName} v${loader.pluginVersion}") - logger.info("Requesting super access for ${loader.pluginName} v${loader.pluginVersion}") + val signature = loader.signature - var warning = "$GENERIC_SUPERACCESS_WARN" - if(reason.trim().isNotBlank()) { - warning += "

$reason" - } + appender.appendln(PluginOutput.SPECIAL_SILENT + "Requesting super access for ${loader.pluginName} v${loader.pluginVersion}") - warning += GENERIC_LAWYER_YEET + var access = false - warning += "" + superAccessDaemonClient.sendRequest(SuperAccessDaemon.SuperAccessMessage.Request( + loader.pluginFile.absolutePath, + signature.toMutable(), + reason + )) { + if(it) { + access = true + } - val name = "${loader.pluginName} by ${loader.pluginAuthor}".replace(" ", "-") + haltLock.withLock { + haltCondition.signalAll() + } + } - if(JavaProcess.exec(SuperAccessRequestMain::class.java, null, Arrays.asList(name, warning)) == 171) { - eocvSim.config.superAccessPluginHashes.add(loader.pluginFileHash) - eocvSim.configManager.saveToFile() - return true + haltLock.withLock { + haltCondition.await() } - return false + return access } } \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt index 5c149a96..1672abd5 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/repository/PluginRepositoryManager.kt @@ -26,7 +26,7 @@ package io.github.deltacv.eocvsim.plugin.repository import com.github.serivesmejia.eocvsim.gui.dialog.AppendDelegate import com.github.serivesmejia.eocvsim.gui.dialog.PluginOutput import com.github.serivesmejia.eocvsim.util.SysUtil -import com.github.serivesmejia.eocvsim.util.extension.hexString +import com.github.serivesmejia.eocvsim.util.extension.hashString import com.github.serivesmejia.eocvsim.util.extension.plus import com.github.serivesmejia.eocvsim.util.loggerForThis import com.moandjiezana.toml.Toml @@ -107,7 +107,7 @@ class PluginRepositoryManager( resolver.withRemoteRepo(repo.key, repoUrl, "default") - logger.info("Added repository ${repo.key} with URL ${repoUrl}") + logger.info("Added repository ${repo.key} with URL $repoUrl") } } @@ -128,7 +128,7 @@ class PluginRepositoryManager( try { // Attempt to resolve from cache pluginJar = if (resolveFromCache(pluginDep, newCache, newTransitiveCache)) { - File(newCache[pluginDep.hexString]!!) + File(newCache[pluginDep.hashString]!!) } else { // Resolve from the resolver if not found in cache resolveFromResolver(pluginDep, newCache, newTransitiveCache) @@ -155,14 +155,14 @@ class PluginRepositoryManager( newTransitiveCache: MutableMap> ): Boolean { for (cached in cachePluginsToml.toMap()) { - if (cached.key == pluginDep.hexString) { + if (cached.key == pluginDep.hashString) { val cachedFile = File(cached.value as String) if (cachedFile.exists() && areAllTransitivesCached(pluginDep)) { addToResolvedFiles(cachedFile, pluginDep, newCache, newTransitiveCache) appender.appendln( PluginOutput.SPECIAL_SILENT + - "Found cached plugin \"$pluginDep\" (${pluginDep.hexString}). All transitive dependencies OK." + "Found cached plugin \"$pluginDep\" (${pluginDep.hashString}). All transitive dependencies OK." ) return true } else { @@ -178,8 +178,29 @@ class PluginRepositoryManager( // Function to check if all transitive dependencies are cached private fun areAllTransitivesCached(pluginDep: String): Boolean { - return cacheTransitiveToml - .getList(pluginDep.hexString).all { + val deps = cacheTransitiveToml.getList(pluginDep.hashString) + + val depsHash = depsToHash(deps) + val tomlHash = cacheTransitiveToml.getString("${pluginDep.hashString}_hash", "") + + val matchesDepsHash = depsHash == tomlHash + + if(!matchesDepsHash) { + appender.appendln( + PluginOutput.SPECIAL_SILENT + + "Mismatch, $depsHash != $tomlHash" + ) + + appender.appendln( + PluginOutput.SPECIAL_SILENT + + "Transitive dependencies hash mismatch for plugin $pluginDep. Resolving..." + ) + } + + return matchesDepsHash && + deps != null && + deps.isNotEmpty() && + deps.all { val exists = File(it).exists() if(!exists) { @@ -208,9 +229,9 @@ class PluginRepositoryManager( .forEach { file -> if (pluginJar == null) { pluginJar = file - newCache[pluginDep.hexString] = file.absolutePath + newCache[pluginDep.hashString] = file.absolutePath } else { - newTransitiveCache.getOrPut(pluginDep.hexString) { mutableListOf() } + newTransitiveCache.getOrPut(pluginDep.hashString) { mutableListOf() } .add(file.absolutePath) } @@ -228,11 +249,11 @@ class PluginRepositoryManager( newTransitiveCache: MutableMap> ) { _resolvedFiles += cachedFile - newCache[pluginDep.hexString] = cachedFile.absolutePath + newCache[pluginDep.hashString] = cachedFile.absolutePath - cacheTransitiveToml.getList(pluginDep.hexString)?.forEach { transitive -> + cacheTransitiveToml.getList(pluginDep.hashString)?.forEach { transitive -> _resolvedFiles += File(transitive) - newTransitiveCache.getOrPut(pluginDep.hexString) { mutableListOf() } + newTransitiveCache.getOrPut(pluginDep.hashString) { mutableListOf() } .add(transitive) } } @@ -282,10 +303,15 @@ class PluginRepositoryManager( } cacheBuilder.appendLine("]") + + cacheBuilder.appendLine("${plugin}_hash=\"${depsToHash(deps)}\"") } SysUtil.saveFileStr(CACHE_FILE, cacheBuilder.toString().trim()) } + + private fun depsToHash(deps: List) = + deps.joinToString(File.pathSeparator).replace("\\", "/").trimEnd(File.pathSeparatorChar).hashString } diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/Authority.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/Authority.kt new file mode 100644 index 00000000..53cec9b5 --- /dev/null +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/Authority.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package io.github.deltacv.eocvsim.plugin.security + +import com.github.serivesmejia.eocvsim.util.io.EOCVSimFolder +import com.github.serivesmejia.eocvsim.util.loggerForThis +import com.moandjiezana.toml.Toml +import java.io.File +import java.net.URL +import java.security.KeyFactory +import java.security.PublicKey +import java.security.spec.X509EncodedKeySpec +import java.util.Base64 +import java.util.concurrent.TimeUnit + +data class Authority( + val name: String, + val publicKey: PublicKey +) + +data class MutableAuthority( + var name: String, + var publicKey: ByteArray +) + +fun Authority.toMutable() = MutableAuthority(name, publicKey.encoded) + +data class CachedAuthority( + val authority: Authority, + val fetchedTime: Long // Timestamp when the key was fetched +) + +object AuthorityFetcher { + + const val AUTHORITY_SERVER_URL = "https://raw.githubusercontent.com/deltacv/Authorities/refs/heads/master" + + private val AUTHORITIES_FILE = File(EOCVSimFolder, "authorities.toml") + + private val TTL_DURATION_MS = TimeUnit.HOURS.toMillis(8) + + private val logger by loggerForThis() + private val cache = mutableMapOf() + + fun fetchAuthority(name: String): Authority? { + // Check if the authority is cached and valid + cache[name]?.let { cachedAuthority -> + if (System.currentTimeMillis() - cachedAuthority.fetchedTime < TTL_DURATION_MS) { + logger.info("Returning cached authority for $name") + return cachedAuthority.authority + } else { + logger.info("Cached authority for $name has expired") + } + } + + // Load authorities from file if it exists + if (AUTHORITIES_FILE.exists()) { + try { + val authoritiesToml = Toml().read(AUTHORITIES_FILE) + val authorityData = authoritiesToml.getTable(name) + val authorityPublicKey = authorityData?.getString("public") + val fetchedTime = authorityData?.getLong("timestamp") + + // Check if the fetched time is valid and within the TTL + if (authorityPublicKey != null && fetchedTime != null && + System.currentTimeMillis() - fetchedTime < TTL_DURATION_MS) { + + val authority = Authority(name, parsePublicKey(authorityPublicKey)) + cache[name] = CachedAuthority(authority, fetchedTime) + + return authority + } + } catch (e: Exception) { + logger.error("Failed to read authorities file", e) + } + } + + // Fetch the authority from the server + val authorityUrl = "${AUTHORITY_SERVER_URL.trim('/')}/$name" + return try { + val authorityPublicKey = URL(authorityUrl).readText() + val pem = parsePem(authorityPublicKey) + + val authority = Authority(name, parsePublicKey(pem)) + + // Cache the fetched authority + cache[name] = CachedAuthority(authority, System.currentTimeMillis()) + + // write to the authorities file + saveAuthorityToFile(name, pem) + + authority + } catch (e: Exception) { + logger.error("Failed to fetch authority from server", e) + null + } + } + + private fun saveAuthorityToFile(name: String, publicKey: String) { + try { + val sb = StringBuilder() + + // Load existing authorities if the file exists + if (AUTHORITIES_FILE.exists()) { + val existingToml = AUTHORITIES_FILE.readText() + sb.append(existingToml) + } + + // Append new authority information + sb.appendLine("[$name]") + sb.appendLine("public = \"$publicKey\"") + sb.appendLine("timestamp = ${System.currentTimeMillis()}") + + // Write the updated content to the file + AUTHORITIES_FILE.appendText(sb.toString()) + } catch (e: Exception) { + logger.error("Failed to save authority to file", e) + } + } + + fun parsePem(pem: String) = + pem.trim().lines().drop(1).dropLast(1).joinToString("") + + private fun parsePublicKey(keyPEM: String): PublicKey { + // Decode the Base64 encoded string + val encoded = Base64.getDecoder().decode(keyPEM) + + // Generate the public key + val keySpec = X509EncodedKeySpec(encoded) + val keyFactory = KeyFactory.getInstance("RSA") + return keyFactory.generatePublic(keySpec) + } +} \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/KeyGeneratorTool.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/KeyGeneratorTool.kt new file mode 100644 index 00000000..97a6f916 --- /dev/null +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/KeyGeneratorTool.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package io.github.deltacv.eocvsim.plugin.security + +import java.io.File +import java.io.FileWriter +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.PrivateKey +import java.util.Base64 + +fun main() { + // Generate RSA key pair + val keyPair: KeyPair = generateKeyPair() + + // Save keys to files + saveKeyToFile("private_key.pem", keyPair.private) + saveKeyToFile("public_key.pem", keyPair.public) + + println("Keys generated and saved to files.") +} + +fun generateKeyPair(): KeyPair { + val keyPairGenerator = KeyPairGenerator.getInstance("RSA") + keyPairGenerator.initialize(2048) // Use 2048 bits for key size + return keyPairGenerator.generateKeyPair() +} + +fun saveKeyToFile(filename: String, key: java.security.Key) { + val encodedKey = Base64.getEncoder().encodeToString(key.encoded) + val keyType = if (key is PrivateKey) "PRIVATE" else "PUBLIC" + + val pemFormat = "-----BEGIN ${keyType} KEY-----\n$encodedKey\n-----END ${keyType} KEY-----" + + FileWriter(File(filename)).use { writer -> + writer.write(pemFormat) + } +} \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSignatureVerifier.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSignatureVerifier.kt new file mode 100644 index 00000000..c1e84a54 --- /dev/null +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSignatureVerifier.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package io.github.deltacv.eocvsim.plugin.security + +import com.github.serivesmejia.eocvsim.util.extension.hashString +import com.github.serivesmejia.eocvsim.util.loggerForThis +import com.moandjiezana.toml.Toml +import java.io.File +import java.security.PublicKey +import java.security.Signature +import java.util.Base64 +import java.util.zip.ZipFile + +data class PluginSignature( + val verified: Boolean, + val authority: Authority?, + val timestamp: Long +) + +data class MutablePluginSignature( + var verified: Boolean, + var authority: MutableAuthority?, + var timestamp: Long +) + +fun PluginSignature.toMutable() = MutablePluginSignature(verified, authority?.toMutable(), timestamp) + +object PluginSignatureVerifier { + private val emptyResult = PluginSignature(false, null, 0L) + + val logger by loggerForThis() + + fun verify(pluginFile: File): PluginSignature { + logger.info("Verifying plugin signature of ${pluginFile.name}") + + ZipFile(pluginFile).use { zip -> + val signatureEntry = zip.getEntry("signature.toml") + val pluginEntry = zip.getEntry("plugin.toml") + + if (signatureEntry == null) { + logger.warn("Plugin JAR does not contain a signature.toml file") + return emptyResult + } + + if(pluginEntry == null) { + logger.warn("Plugin JAR does not contain a plugin.toml file") + return emptyResult + } + + val signatureToml = zip.getInputStream(signatureEntry).bufferedReader() + val signature = Toml().read(signatureToml) + + val authorityName = signature.getString("authority") + if (authorityName == null) { + logger.warn("signature.toml does not contain an authority field") + return emptyResult + } + + val authorityPublic = signature.getString("public") + if (authorityPublic == null) { + logger.warn("signature.toml does not contain a public (key) field") + return emptyResult + } + + val authority = AuthorityFetcher.fetchAuthority(authorityName) + if (authority == null) { + logger.warn("Failed to fetch authority $authorityName") + return emptyResult + } + + if(Base64.getEncoder().encodeToString(authority.publicKey.encoded) != authorityPublic) { + logger.warn("Authority public key does not match the one in the signature") + return emptyResult + } + + val signatureData = signature.getTable("signatures") ?: run { + logger.warn("signature.toml does not contain a signatures table") + return emptyResult + } + + // Get the public key from the authority + val publicKey = authority.publicKey + + val signatures = mutableMapOf() + + // Verify each signature + for ((path, sign) in signatureData.toMap()) { + if(sign !is String) { + logger.warn("Signature for class $path is not a string") + return emptyResult + } + + signatures[path] = sign + } + + // Now, verify each class in the JAR file + val classEntries = zip.entries().toList().filter { entry -> entry.name.endsWith(".class") } + + if(classEntries.count() != signatures.count()) { + logger.warn("Amount of classes in the JAR does not match the amount of signatures") + return emptyResult + } + + val signatureStatus = mutableMapOf() + + for(signature in signatures) { + signatureStatus[signature.key] = false + } + + for (classEntry in classEntries) { + val className = classEntry.name.removeSuffix(".class").replace('/', '.') // Convert to fully qualified class name + + // Fetch the class bytecode + val classData = zip.getInputStream(classEntry).readBytes() + + // Hash the class name to get the signature key + val classHash = className.hashString + + // Verify the signature of the class + if (!verifySignature(classData, signatures[classHash]!!, publicKey)) { + logger.warn("Signature verification failed for class $className") + return emptyResult + } else { + signatureStatus[classHash] = true + } + } + + if(signatureStatus.containsValue(false)) { + logger.warn("Not all classes were signed") + return emptyResult + } + + logger.info("Plugin signature of ${pluginFile.absolutePath} verified, signed by $authorityName") + + return PluginSignature(true, authority, System.currentTimeMillis(), ) + } + } + + private fun verifySignature(classData: ByteArray, signatureString: String, publicKey: PublicKey): Boolean { + // Extract the actual signature bytes from the string (you'll need to implement this) + val signatureBytes = decodeSignature(signatureString) // Implement this method + return try { + val signature = Signature.getInstance("SHA256withRSA") + signature.initVerify(publicKey) + signature.update(classData) + signature.verify(signatureBytes) + } catch (e: Exception) { + logger.error("Error during signature verification", e) + false + } + } + + private fun decodeSignature(signatureString: String): ByteArray { + // Remove any whitespace or newline characters that may exist in the string + val cleanSignature = signatureString.replace("\\s".toRegex(), "") + + // Decode the Base64-encoded string into a byte array + return Base64.getDecoder().decode(cleanSignature) + } +} \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSigningTool.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSigningTool.kt new file mode 100644 index 00000000..ce8cbb72 --- /dev/null +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSigningTool.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package io.github.deltacv.eocvsim.plugin.security + +import com.github.serivesmejia.eocvsim.gui.dialog.PluginOutput +import com.github.serivesmejia.eocvsim.util.extension.hashString +import com.github.serivesmejia.eocvsim.util.loggerForThis +import com.moandjiezana.toml.Toml +import io.github.deltacv.eocvsim.plugin.loader.PluginParser +import picocli.CommandLine +import java.io.File +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.PublicKey +import java.security.Signature +import java.security.spec.PKCS8EncodedKeySpec +import javax.crypto.Cipher +import java.util.Base64 +import java.util.Scanner +import java.util.zip.ZipFile +import java.util.zip.ZipEntry +import java.io.FileOutputStream +import java.io.IOException +import java.util.zip.ZipOutputStream +import kotlin.math.log +import kotlin.system.exitProcess + +class PluginSigningTool : Runnable { + + val logger by loggerForThis() + + @CommandLine.Option(names = ["-p", "--plugin"], description = ["The plugin JAR file to sign"], required = true) + var pluginFile: String? = null + + @CommandLine.Option(names = ["-a", "--authority"], description = ["The authority to sign the plugin with"], required = true) + var authority: String? = null + + @CommandLine.Option(names = ["-k", "--key"], description = ["The private key file to sign the plugin with"], required = true) + var privateKeyFile: String? = null + + override fun run() { + logger.info("Signing plugin $pluginFile with authority $authority") + + val authority = AuthorityFetcher.fetchAuthority(authority ?: throw IllegalArgumentException("Authority is required")) + if (authority == null) { + exitProcess(1) + } + + // Load the private key + val privateKey = loadPrivateKey(privateKeyFile ?: throw IllegalArgumentException("Private key file is required")) + + val publicKey = authority.publicKey // Assuming Authority has a publicKey property + if (!isPrivateKeyMatchingAuthority(publicKey, privateKey)) { + logger.error("Private key does not match to the authority's public key.") + exitProcess(1) + } else { + logger.info("Private key matches to the authority's public key.") + } + + signPlugin(File(pluginFile), privateKey, authority) + + logger.info("Plugin signed successfully and saved") + } + + private fun loadPrivateKey(privateKeyPath: String): PrivateKey { + val keyBytes = File(privateKeyPath).readBytes() + val keyString = String(keyBytes) + val decodedKey = Base64.getDecoder().decode(AuthorityFetcher.parsePem(keyString)) + + val keySpec = PKCS8EncodedKeySpec(decodedKey) + val keyFactory = KeyFactory.getInstance("RSA") + + return keyFactory.generatePrivate(keySpec) + } + + private fun signPlugin(jarFile: File, privateKey: PrivateKey, authority: Authority) { + // Generate signatures for each class in the JAR + val signatures = mutableMapOf() + + ZipFile(jarFile).use { zip -> + val classEntries = zip.entries().asSequence().filter { it.name.endsWith(".class") } + + for (classEntry in classEntries) { + val className = classEntry.name.removeSuffix(".class").replace('/', '.') + + val classData = zip.getInputStream(classEntry).readBytes() + + // Sign the class data + val signature = signClass(classData, privateKey) + signatures[className] = signature + + logger.info("Signed class $className") + } + } + + logger.info("Signed all classes, creating signature.toml in jar") + + // Create signature.toml + createSignatureToml(signatures, authority, jarFile) + } + + private fun signClass(classData: ByteArray, privateKey: PrivateKey): String { + val signature = Signature.getInstance("SHA256withRSA") + signature.initSign(privateKey) + signature.update(classData) + val signatureBytes = signature.sign() + return Base64.getEncoder().encodeToString(signatureBytes) + } + + private fun createSignatureToml( + signatures: Map, + authority: Authority, jarFile: File + ) { + val toml = StringBuilder() + toml.appendLine("authority = \"${authority.name}\"") + toml.appendLine("public = \"${Base64.getEncoder().encodeToString(authority.publicKey.encoded)}\"") + toml.appendLine("timestamp = ${System.currentTimeMillis()}") + toml.appendLine("[signatures]") + + for ((className, signature) in signatures) { + toml.appendLine("${className.hashString} = \"$signature\"") + } + + saveTomlToJar(toml.toString(), jarFile) + } + + + private fun saveTomlToJar(tomlContent: String, jarFile: File) { + // Create a temporary file for the new JAR + val tempJarFile = File(jarFile.parentFile, "${jarFile.name}.tmp") + + ZipOutputStream(FileOutputStream(tempJarFile)).use { zipOut -> + // First, copy existing entries from the original JAR + jarFile.inputStream().use { jarIn -> + val jarInput = java.util.zip.ZipInputStream(jarIn) + var entry: ZipEntry? + while (jarInput.nextEntry.also { entry = it } != null) { + if(entry!!.name == "signature.toml") { + continue + } + + // Write each existing entry to the new JAR + zipOut.putNextEntry(ZipEntry(entry!!.name)) + jarInput.copyTo(zipOut) + zipOut.closeEntry() + } + } + + // Now add the new signature.toml file + val tomlEntry = ZipEntry("signature.toml") + zipOut.putNextEntry(tomlEntry) + zipOut.write(tomlContent.toByteArray(Charsets.UTF_8)) + zipOut.closeEntry() + } + + // Replace the original JAR with the new one + if (!jarFile.delete()) { + throw IOException("Failed to delete original JAR file: ${jarFile.absolutePath}") + } + if (!tempJarFile.renameTo(jarFile)) { + throw IOException("Failed to rename temporary JAR file to original name: ${tempJarFile.absolutePath}") + } + } + + private fun isPrivateKeyMatchingAuthority(publicKey: PublicKey, privateKey: PrivateKey): Boolean { + // encrypt and decrypt a sample message to check if the keys match + val sampleMessage = PluginOutput.SPECIAL + + val cipher = Cipher.getInstance("RSA") + cipher.init(Cipher.ENCRYPT_MODE, publicKey) + val encrypted = cipher.doFinal(sampleMessage.toByteArray()) + + cipher.init(Cipher.DECRYPT_MODE, privateKey) + val decrypted = cipher.doFinal(encrypted) + + return sampleMessage == String(decrypted) + } +} + +// Use System.in +fun main() { + val scanner = Scanner(System.`in`) + + val tool = PluginSigningTool() + println("Enter the plugin JAR file path:") + tool.pluginFile = scanner.next() + + println("Enter the authority to sign the plugin with:") + tool.authority = scanner.next() + + println("Enter the private key file path:") + tool.privateKeyFile = scanner.next() + + tool.run() +} \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemon.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemon.kt new file mode 100644 index 00000000..3b7441cc --- /dev/null +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemon.kt @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package io.github.deltacv.eocvsim.plugin.security.superaccess + +import com.github.serivesmejia.eocvsim.util.JavaProcess +import com.github.serivesmejia.eocvsim.util.io.EOCVSimFolder +import com.github.serivesmejia.eocvsim.util.loggerForThis +import com.github.serivesmejia.eocvsim.util.extension.plus +import com.github.serivesmejia.eocvsim.util.serialization.PolymorphicAdapter +import com.google.gson.GsonBuilder +import com.moandjiezana.toml.Toml +import io.github.deltacv.eocvsim.gui.dialog.SuperAccessRequestMain +import io.github.deltacv.eocvsim.plugin.loader.PluginManager.Companion.GENERIC_LAWYER_YEET +import io.github.deltacv.eocvsim.plugin.loader.PluginManager.Companion.GENERIC_SUPERACCESS_WARN +import io.github.deltacv.eocvsim.plugin.loader.PluginParser +import io.github.deltacv.eocvsim.plugin.security.Authority +import io.github.deltacv.eocvsim.plugin.security.AuthorityFetcher +import io.github.deltacv.eocvsim.plugin.security.MutablePluginSignature +import org.java_websocket.client.WebSocketClient +import java.net.URI +import org.java_websocket.handshake.ServerHandshake +import java.util.zip.ZipFile +import java.io.File +import java.lang.Exception +import kotlin.system.exitProcess + +object SuperAccessDaemon { + val logger by loggerForThis() + + val gson = GsonBuilder() + .registerTypeHierarchyAdapter(SuperAccessMessage::class.java, PolymorphicAdapter("message")) + .registerTypeHierarchyAdapter(SuperAccessResponse::class.java, PolymorphicAdapter("response")) + .create() + + @get:Synchronized + @set:Synchronized + private var uniqueId = 0 + + sealed class SuperAccessMessage { + data class Request(var pluginPath: String, var signature: MutablePluginSignature, var reason: String) : SuperAccessMessage() + data class Check(var pluginPath: String) : SuperAccessMessage() + + var id = uniqueId++ + } + + sealed class SuperAccessResponse(val id: Int) { + class Success(id: Int) : SuperAccessResponse(id) + class Failure(id: Int) : SuperAccessResponse(id) + } + + val SUPERACCESS_FILE = EOCVSimFolder + File.separator + "superaccess.txt" + + private val access = mutableMapOf() + + @JvmStatic + fun main(args: Array) { + if(args.isEmpty()) { + logger.error("Port is required.") + return + } + if(args.size > 1) { + logger.warn("Ignoring extra arguments.") + } + + WsClient(args[0].toIntOrNull() ?: throw IllegalArgumentException("Port is not a valid int")).connect() + } + + class WsClient(port: Int) : WebSocketClient(URI("ws://localhost:$port")) { + + override fun onOpen(p0: ServerHandshake?) { + logger.info("SuperAccessDaemon connection opened") + } + + override fun onMessage(msg: String) { + val message = gson.fromJson(msg, SuperAccessMessage::class.java) + + when(message) { + is SuperAccessMessage.Request -> { + handleRequest(message) + } + + is SuperAccessMessage.Check -> { + handleCheck(message) + } + } + } + + private fun handleRequest(message: SuperAccessMessage.Request) { + logger.info("Requesting SuperAccess for ${message.pluginPath}") + + var validAuthority: Authority? = null + + if(message.signature.authority != null) { + val declaredAuthority = message.signature.authority!! + val authorityName = declaredAuthority.name + + val fetchedAuthorityKey = AuthorityFetcher.fetchAuthority(authorityName)?.publicKey + + if(fetchedAuthorityKey == null) { + logger.error("Failed to fetch authority $authorityName") + } else { + if(fetchedAuthorityKey.encoded.contentEquals(declaredAuthority.publicKey)) { + logger.info("Authority key matches the fetched key for $authorityName") + validAuthority = Authority(authorityName, fetchedAuthorityKey) + } else { + logger.warn("Authority key does not match the fetched key for $authorityName") + } + } + } + + val parser = parsePlugin(File(message.pluginPath)) ?: run { + logger.error("Failed to parse plugin at ${message.pluginPath}") + (gson.toJson(SuperAccessResponse.Failure(message.id))) + return@handleRequest + } + + val reason = message.reason + + val name = "${parser.pluginName} v${parser.pluginVersion} by ${parser.pluginAuthor}".replace(" ", "-") + + var warning = "$GENERIC_SUPERACCESS_WARN" + if(reason.trim().isNotBlank()) { + warning += "

$reason" + } + + warning += if(validAuthority != null) { + "

This plugin is signed by the trusted authority ${validAuthority.name}." + } else { + GENERIC_LAWYER_YEET + } + + warning += "" + + if(JavaProcess.exec(SuperAccessRequestMain::class.java, null, listOf(name, warning)) == 171) { + logger.info("SuperAccess granted to ${message.pluginPath}") + send(gson.toJson(SuperAccessResponse.Success(message.id))) + + SUPERACCESS_FILE.appendText("${parser.hash()}\n") + access[message.pluginPath] = true + } else { + logger.info("SuperAccess denied to ${message.pluginPath}") + send(gson.toJson(SuperAccessResponse.Failure(message.id))) + } + } + + private fun handleCheck(message: SuperAccessMessage.Check) { + if(access.containsKey(message.pluginPath)) { + if(access[message.pluginPath] == true) { + accessGranted(message.id, message.pluginPath) + return + } else { + accessDenied(message.id, message.pluginPath) + return + } + } + + val parser = parsePlugin(File(message.pluginPath)) ?: run { + logger.error("Failed to parse plugin at ${message.pluginPath}") + send(gson.toJson(SuperAccessResponse.Failure(message.id))) + return + } + + if(SUPERACCESS_FILE.readLines().contains(parser.hash())) { + accessGranted(message.id, message.pluginPath) + } else { + accessDenied(message.id, message.pluginPath) + } + } + + private fun accessGranted(id: Int, pluginPath: String) { + access[pluginPath] = true + send(gson.toJson(SuperAccessResponse.Success(id))) + } + + private fun accessDenied(id: Int, pluginPath: String) { + access[pluginPath] = false + send(gson.toJson(SuperAccessResponse.Failure(id))) + } + + override fun onClose(p0: Int, p1: String?, p2: Boolean) { + logger.info("SuperAccessDaemon connection closed: $p0, $p1, $p2") + exitProcess(-1) + } + + override fun onError(p0: Exception?) { + logger.error("Error in SuperAccessDaemon", p0) + } + } + + private fun parsePlugin(file: File): PluginParser? { + ZipFile(file).use { + val pluginToml = it.getEntry("plugin.toml") + if(pluginToml != null) { + return PluginParser(Toml().read(it.getInputStream(pluginToml))) + } + } + + return null + } +} \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemonClient.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemonClient.kt new file mode 100644 index 00000000..538d2821 --- /dev/null +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemonClient.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package io.github.deltacv.eocvsim.plugin.security.superaccess + +import com.github.serivesmejia.eocvsim.util.JavaProcess +import com.github.serivesmejia.eocvsim.util.event.EventHandler +import com.github.serivesmejia.eocvsim.util.event.EventListenerRemover +import com.github.serivesmejia.eocvsim.util.loggerForThis +import org.firstinspires.ftc.robotcore.internal.collections.EvictingBlockingQueue +import java.util.concurrent.ArrayBlockingQueue +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import java.lang.Exception +import java.io.File +import java.net.InetSocketAddress +import java.util.concurrent.Executors +import java.util.concurrent.locks.ReentrantLock +import java.util.concurrent.locks.Condition +import kotlin.concurrent.withLock + +typealias ResponseReceiver = (SuperAccessDaemon.SuperAccessResponse) -> Unit +typealias ResponseCondition = (SuperAccessDaemon.SuperAccessResponse) -> Boolean + +class SuperAccessDaemonClient { + + val logger by loggerForThis() + + private val startLock = ReentrantLock() + val startCondition = startLock.newCondition() + + // create a new WebSocket server + private val server = WsServer(startLock, startCondition) + + fun init() { + server.start() + + startLock.withLock { + startCondition.await() + } + + logger.info("SuperAccessDaemonClient initialized") + } + + fun sendRequest(request: SuperAccessDaemon.SuperAccessMessage.Request, onResponse: (Boolean) -> Unit) { + server.broadcast(SuperAccessDaemon.gson.toJson(request)) + + server.addResponseReceiver(request.id) { response -> + if(response is SuperAccessDaemon.SuperAccessResponse.Success) { + onResponse(true) + } else if(response is SuperAccessDaemon.SuperAccessResponse.Failure) { + onResponse(false) + } + } + } + + fun checkAccess(file: File): Boolean { + val lock = ReentrantLock() + val condition = lock.newCondition() + + val check = SuperAccessDaemon.SuperAccessMessage.Check(file.absolutePath) + server.broadcast(SuperAccessDaemon.gson.toJson(check)) + + var hasAccess = false + + server.addResponseReceiver(check.id) { response -> + if(response is SuperAccessDaemon.SuperAccessResponse.Success) { + hasAccess = true + + lock.withLock { + condition.signalAll() + } + } else if(response is SuperAccessDaemon.SuperAccessResponse.Failure) { + hasAccess = false + + lock.withLock { + condition.signalAll() + } + } + } + + lock.withLock { + condition.await(2, java.util.concurrent.TimeUnit.SECONDS) + } + + return hasAccess + } + + private class WsServer( + val startLock: ReentrantLock, + val startCondition: Condition, + ) : WebSocketServer(InetSocketAddress(0)) { + // create an executor with 1 thread + private val executor = Executors.newSingleThreadExecutor() + + val logger by loggerForThis() + + val responseReceiver = mutableMapOf() + + private var processRestarts = 0 + + override fun onOpen(conn: WebSocket, p1: ClientHandshake?) { + val hostString = conn.localSocketAddress.hostString + if(hostString != "127.0.0.1" && hostString != "localhost" && hostString != "0.0.0.0") { + logger.warn("Connection from ${conn.remoteSocketAddress} refused, only localhost connections are allowed") + conn.close(1013, "Ipc does not allow connections incoming from non-localhost addresses") + } + + logger.info("SuperAccessDaemon is here.") + + processRestarts = 0 + + startLock.withLock { + startCondition.signalAll() + } + } + + override fun onClose( + p0: WebSocket?, + p1: Int, + p2: String?, + p3: Boolean + ) { + logger.info("SuperAccessDaemon is gone.") + } + + override fun onMessage(ws: WebSocket, msg: String) { + val response = SuperAccessDaemon.gson.fromJson(msg, SuperAccessDaemon.SuperAccessResponse::class.java) + + for((condition, receiver) in responseReceiver.toMap()) { + if(condition(response)) { + receiver(response) + } + } + } + + override fun onError(p0: WebSocket?, p1: Exception?) { + logger.error("SuperAccessDaemon error", p1) + } + + override fun onStart() { + fun startProcess() { + executor.submit { + logger.info("Starting SuperAccessDaemon on port $port") + + val exitCode = JavaProcess.exec( + SuperAccessDaemon::class.java, + JavaProcess.SLF4JIOReceiver(logger), + null, listOf(port.toString()) + ) + + if(processRestarts == 6) { + logger.error("SuperAccessDaemon restarted too many times, aborting.") + return@submit + } + + logger.error("SuperAccessDaemon exited with code $exitCode, restarting...") + startProcess() // restart the process + + processRestarts++ + } + } + + startProcess() + } + + fun addResponseReceiver(id: Int, receiver: ResponseReceiver) { + responseReceiver[{ it.id == id }] = receiver + } + + fun addResponseReceiver(condition: ResponseCondition, receiver: ResponseReceiver) { + responseReceiver[condition] = receiver + } + } + +} \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/sandbox/restrictions/DynamicLoadingClassRestrictions.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/sandbox/restrictions/DynamicLoadingClassRestrictions.kt index 8f8af2df..f1fd0948 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/sandbox/restrictions/DynamicLoadingClassRestrictions.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/sandbox/restrictions/DynamicLoadingClassRestrictions.kt @@ -94,6 +94,7 @@ val dynamicLoadingMethodBlacklist = setOf( "java.lang.Class#newInstance", "java.lang.Class#forName", + // Additional java.io.File methods to blacklist "java.io.File#delete", "java.io.File#createNewFile", "java.io.File#mkdirs", @@ -107,4 +108,22 @@ val dynamicLoadingMethodBlacklist = setOf( "java.io.File#setWritable", "java.io.File#setReadable", "java.io.File#setExecutable", + "java.io.File#list", + "java.io.File#listFiles", + "java.io.File#listRoots", + "java.io.File#canRead", + "java.io.File#canWrite", + "java.io.File#canExecute", + "java.io.File#length", + "java.io.File#getTotalSpace", + "java.io.File#getFreeSpace", + "java.io.File#getUsableSpace", + "java.io.File#getAbsolutePath", + "java.io.File#getCanonicalPath", + "java.io.File#getParentFile", + "java.io.File#exists", + "java.io.File#isDirectory", + "java.io.File#isFile", + "java.io.File#isHidden", + "java.io.File#lastModified" ) \ No newline at end of file diff --git a/build.gradle b/build.gradle index 9bb7bc17..cf644dbd 100644 --- a/build.gradle +++ b/build.gradle @@ -6,8 +6,8 @@ buildscript { ext { kotlin_version = "1.9.0" kotlinx_coroutines_version = "1.5.0-native-mt" - slf4j_version = "1.7.32" - log4j_version = "2.17.1" + slf4j_version = "2.0.16" + log4j_version = "2.24.1" opencv_version = "4.7.0-0" apriltag_plugin_version = "2.1.0-B" From 4b7b18d8df7d6f665231b6b89b87c679ad76c9c1 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Mon, 28 Oct 2024 14:50:26 -0600 Subject: [PATCH 26/36] Ignore Common transitive dependency from AprilTagDesktop --- EOCV-Sim/build.gradle | 3 - .../eocvsim/gui/dialog/SuperAccessRequest.kt | 332 +++++++++--------- .../gui/dialog/SuperAccessRequestMain.java | 32 -- .../security/PluginSignatureVerifier.kt | 8 +- .../plugin/security/PluginSigningTool.kt | 25 +- .../security/superaccess/SuperAccessDaemon.kt | 83 +++-- .../superaccess/SuperAccessDaemonClient.kt | 8 +- Vision/build.gradle | 2 +- 8 files changed, 246 insertions(+), 247 deletions(-) delete mode 100644 EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/gui/dialog/SuperAccessRequestMain.java diff --git a/EOCV-Sim/build.gradle b/EOCV-Sim/build.gradle index dfa77efa..b014a1eb 100644 --- a/EOCV-Sim/build.gradle +++ b/EOCV-Sim/build.gradle @@ -75,9 +75,6 @@ dependencies { implementation "org.apache.logging.log4j:log4j-slf4j2-impl:$log4j_version" implementation "com.github.deltacv:steve:1.1.1" - api("com.github.deltacv:AprilTagDesktop:$apriltag_plugin_version") { - exclude group: 'com.github.deltacv.EOCVSim', module: 'Common' - } implementation 'info.picocli:picocli:4.6.1' implementation 'com.google.code.gson:gson:2.8.9' diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/gui/dialog/SuperAccessRequest.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/gui/dialog/SuperAccessRequest.kt index 6b8d8074..58d98dc8 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/gui/dialog/SuperAccessRequest.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/gui/dialog/SuperAccessRequest.kt @@ -1,165 +1,169 @@ -/* - * Copyright (c) 2024 Sebastian Erives - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -package io.github.deltacv.eocvsim.gui.dialog - -import com.formdev.flatlaf.FlatLightLaf -import com.formdev.flatlaf.intellijthemes.FlatArcDarkIJTheme -import com.formdev.flatlaf.intellijthemes.FlatArcOrangeIJTheme -import com.formdev.flatlaf.intellijthemes.FlatCobalt2IJTheme -import com.github.serivesmejia.eocvsim.gui.EOCVSimIconLibrary -import com.github.serivesmejia.eocvsim.gui.Icons -import com.github.serivesmejia.eocvsim.gui.Icons.getImage -import java.awt.BorderLayout -import java.awt.Dimension -import java.awt.Font -import java.awt.event.WindowEvent -import java.awt.event.WindowListener -import javax.swing.* -import kotlin.system.exitProcess - -class SuperAccessRequest(sourceName: String, reason: String) { - - init { - FlatArcDarkIJTheme.setup() - - val panel = JPanel(BorderLayout()) - val frame = JDialog() - - frame.title = "EOCV-Sim SuperAccess Request" - - // Create UI components - val titleLabel = JLabel("SuperAccess request from $sourceName", JLabel.CENTER) - - // Set the font of the JLabel to bold - titleLabel.font = titleLabel.font.deriveFont(Font.BOLD, 21f) - - // Text Area for Reason Input - val reasonTextArea = JTextPane().apply { - isEditable = false - contentType = "text/html" - - font = font.deriveFont(18f) - - var formattedReason = "" - - for(line in reason.split("
")) { - formattedReason += wrapText(line, 80) + "
" - } - - text = formattedReason - } - val scrollPane = JScrollPane(reasonTextArea) - - // Create a panel for buttons - val buttonPanel = JPanel().apply { - layout = BoxLayout(this, BoxLayout.X_AXIS) - val acceptButton = JButton("Accept").apply { - preferredSize = Dimension(100, 30) - addActionListener { - exitProcess(171) - } - } - - val rejectButton = JButton("Reject").apply { - preferredSize = Dimension(100, 30) - addActionListener { - exitProcess(-172) - } - } - - add(Box.createHorizontalGlue()) // Push buttons to the center - add(acceptButton) - add(Box.createRigidArea(Dimension(10, 0))) // Space between buttons - add(rejectButton) - add(Box.createHorizontalGlue()) // Push buttons to the center - } - - // Add components to the panel - panel.add(titleLabel, BorderLayout.NORTH) - panel.add(scrollPane, BorderLayout.CENTER) - panel.add(buttonPanel, BorderLayout.SOUTH) - - // Setup the frame - frame.contentPane = panel - frame.isAlwaysOnTop = true - frame.defaultCloseOperation = JDialog.HIDE_ON_CLOSE - frame.isResizable = false - - frame.addWindowListener(object: WindowListener { - override fun windowOpened(e: WindowEvent?) {} - override fun windowClosing(e: WindowEvent?) {} - override fun windowIconified(e: WindowEvent?) {} - override fun windowDeiconified(e: WindowEvent?) {} - override fun windowActivated(e: WindowEvent?) {} - override fun windowDeactivated(e: WindowEvent?) {} - - override fun windowClosed(e: WindowEvent?) { - exitProcess(-129) - } - }) - - frame.setIconImage(EOCVSimIconLibrary.icoEOCVSim.image) - - frame.pack() - - frame.setLocationRelativeTo(null) // Center the window on the screen - frame.isVisible = true - } - -} - -private fun wrapText(text: String, maxLineLength: Int): String { - val words = text.split(" ") - val wrappedLines = mutableListOf() - var currentLine = StringBuilder() - val breakTag = "
" // HTML break tag - - for (word in words) { - // Calculate the length of the line with a break tag - val lineWithWord = if (currentLine.isEmpty()) { - word - } else { - "$currentLine $word" - } - val lineLengthWithBreak = lineWithWord.length + (if (currentLine.isEmpty()) 0 else breakTag.length) - - // Check if adding the next word exceeds the maxLineLength - if (lineLengthWithBreak > maxLineLength) { - // If it does, add the current line to the list and start a new line - wrappedLines.add(currentLine.toString()) - currentLine = StringBuilder(word) - } else { - // Append the word to the current line - currentLine.append(" ").append(word) - } - } - - // Add the last line if it's not empty - if (currentLine.isNotEmpty()) { - wrappedLines.add(currentLine.toString()) - } - - // Join all lines with
for HTML - return wrappedLines.joinToString(breakTag) +/* + * Copyright (c) 2024 Sebastian Erives + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package io.github.deltacv.eocvsim.gui.dialog + +import com.formdev.flatlaf.FlatLightLaf +import com.formdev.flatlaf.intellijthemes.FlatArcDarkIJTheme +import com.formdev.flatlaf.intellijthemes.FlatArcOrangeIJTheme +import com.formdev.flatlaf.intellijthemes.FlatCobalt2IJTheme +import com.github.serivesmejia.eocvsim.gui.EOCVSimIconLibrary +import com.github.serivesmejia.eocvsim.gui.Icons +import com.github.serivesmejia.eocvsim.gui.Icons.getImage +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.Font +import java.awt.event.WindowEvent +import java.awt.event.WindowListener +import javax.swing.* + +class SuperAccessRequest(sourceName: String, reason: String, val callback: (Boolean) -> Unit) { + + init { + FlatArcDarkIJTheme.setup() + + val panel = JPanel(BorderLayout()) + val frame = JDialog() + + frame.title = "EOCV-Sim SuperAccess Request" + + // Create UI components + val titleLabel = JLabel("SuperAccess request from $sourceName", JLabel.CENTER) + + // Set the font of the JLabel to bold + titleLabel.font = titleLabel.font.deriveFont(Font.BOLD, 21f) + + // Text Area for Reason Input + val reasonTextArea = JTextPane().apply { + isEditable = false + contentType = "text/html" + + font = font.deriveFont(18f) + + var formattedReason = "" + + for(line in reason.split("
")) { + formattedReason += wrapText(line, 80) + "
" + } + + text = formattedReason + } + val scrollPane = JScrollPane(reasonTextArea) + + // Create a panel for buttons + val buttonPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.X_AXIS) + val acceptButton = JButton("Accept").apply { + preferredSize = Dimension(100, 30) + addActionListener { + callback(true) + + frame.isVisible = false + frame.dispose() + } + } + + val rejectButton = JButton("Reject").apply { + preferredSize = Dimension(100, 30) + addActionListener { + callback(false) + + frame.isVisible = false + frame.dispose() + } + } + + add(Box.createHorizontalGlue()) // Push buttons to the center + add(acceptButton) + add(Box.createRigidArea(Dimension(10, 0))) // Space between buttons + add(rejectButton) + add(Box.createHorizontalGlue()) // Push buttons to the center + } + + // Add components to the panel + panel.add(titleLabel, BorderLayout.NORTH) + panel.add(scrollPane, BorderLayout.CENTER) + panel.add(buttonPanel, BorderLayout.SOUTH) + + // Setup the frame + frame.contentPane = panel + frame.isAlwaysOnTop = true + frame.defaultCloseOperation = JDialog.HIDE_ON_CLOSE + frame.isResizable = false + + frame.addWindowListener(object: WindowListener { + override fun windowOpened(e: WindowEvent?) {} + override fun windowClosing(e: WindowEvent?) {} + override fun windowIconified(e: WindowEvent?) {} + override fun windowDeiconified(e: WindowEvent?) {} + override fun windowActivated(e: WindowEvent?) {} + override fun windowDeactivated(e: WindowEvent?) {} + + override fun windowClosed(e: WindowEvent?) { + } + }) + + frame.setIconImage(EOCVSimIconLibrary.icoEOCVSim.image) + + frame.pack() + + frame.setLocationRelativeTo(null) // Center the window on the screen + frame.isVisible = true + } + +} + +private fun wrapText(text: String, maxLineLength: Int): String { + val words = text.split(" ") + val wrappedLines = mutableListOf() + var currentLine = StringBuilder() + val breakTag = "
" // HTML break tag + + for (word in words) { + // Calculate the length of the line with a break tag + val lineWithWord = if (currentLine.isEmpty()) { + word + } else { + "$currentLine $word" + } + val lineLengthWithBreak = lineWithWord.length + (if (currentLine.isEmpty()) 0 else breakTag.length) + + // Check if adding the next word exceeds the maxLineLength + if (lineLengthWithBreak > maxLineLength) { + // If it does, add the current line to the list and start a new line + wrappedLines.add(currentLine.toString()) + currentLine = StringBuilder(word) + } else { + // Append the word to the current line + currentLine.append(" ").append(word) + } + } + + // Add the last line if it's not empty + if (currentLine.isNotEmpty()) { + wrappedLines.add(currentLine.toString()) + } + + // Join all lines with
for HTML + return wrappedLines.joinToString(breakTag) } \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/gui/dialog/SuperAccessRequestMain.java b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/gui/dialog/SuperAccessRequestMain.java deleted file mode 100644 index 27d76e41..00000000 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/gui/dialog/SuperAccessRequestMain.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (c) 2024 Sebastian Erives - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -package io.github.deltacv.eocvsim.gui.dialog; - -import javax.swing.*; - -public class SuperAccessRequestMain { - public static void main(String[] args) { - SwingUtilities.invokeLater(() -> new SuperAccessRequest(args[0].replace("-", " "), args[1].replace("-", " "))); - } -} diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSignatureVerifier.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSignatureVerifier.kt index c1e84a54..4b2960c1 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSignatureVerifier.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSignatureVerifier.kt @@ -26,6 +26,7 @@ package io.github.deltacv.eocvsim.plugin.security import com.github.serivesmejia.eocvsim.util.extension.hashString import com.github.serivesmejia.eocvsim.util.loggerForThis import com.moandjiezana.toml.Toml +import io.github.deltacv.eocvsim.plugin.loader.InvalidPluginException import java.io.File import java.security.PublicKey import java.security.Signature @@ -124,8 +125,8 @@ object PluginSignatureVerifier { val signatureStatus = mutableMapOf() - for(signature in signatures) { - signatureStatus[signature.key] = false + for(sign in signatures) { + signatureStatus[sign.key] = false } for (classEntry in classEntries) { @@ -139,8 +140,7 @@ object PluginSignatureVerifier { // Verify the signature of the class if (!verifySignature(classData, signatures[classHash]!!, publicKey)) { - logger.warn("Signature verification failed for class $className") - return emptyResult + throw InvalidPluginException("Signature verification failed for class $className. Please try to re-download the plugin or discard it immediately.") } else { signatureStatus[classHash] = true } diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSigningTool.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSigningTool.kt index ce8cbb72..b6a4ee74 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSigningTool.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSigningTool.kt @@ -198,19 +198,22 @@ class PluginSigningTool : Runnable { } } -// Use System.in -fun main() { - val scanner = Scanner(System.`in`) +fun main(args: Array) { + if(args.isEmpty()) { + val scanner = Scanner(System.`in`) - val tool = PluginSigningTool() - println("Enter the plugin JAR file path:") - tool.pluginFile = scanner.next() + val tool = PluginSigningTool() + println("Enter the plugin JAR file path:") + tool.pluginFile = scanner.next() - println("Enter the authority to sign the plugin with:") - tool.authority = scanner.next() + println("Enter the authority to sign the plugin with:") + tool.authority = scanner.next() - println("Enter the private key file path:") - tool.privateKeyFile = scanner.next() + println("Enter the private key file path:") + tool.privateKeyFile = scanner.next() - tool.run() + tool.run() + } else { + CommandLine(PluginSigningTool()).execute(*args) + } } \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemon.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemon.kt index 3b7441cc..4a23d9cf 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemon.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemon.kt @@ -24,25 +24,30 @@ package io.github.deltacv.eocvsim.plugin.security.superaccess import com.github.serivesmejia.eocvsim.util.JavaProcess +import com.github.serivesmejia.eocvsim.util.SysUtil import com.github.serivesmejia.eocvsim.util.io.EOCVSimFolder import com.github.serivesmejia.eocvsim.util.loggerForThis import com.github.serivesmejia.eocvsim.util.extension.plus import com.github.serivesmejia.eocvsim.util.serialization.PolymorphicAdapter import com.google.gson.GsonBuilder import com.moandjiezana.toml.Toml -import io.github.deltacv.eocvsim.gui.dialog.SuperAccessRequestMain +import io.github.deltacv.eocvsim.gui.dialog.SuperAccessRequest +import javax.swing.SwingUtilities import io.github.deltacv.eocvsim.plugin.loader.PluginManager.Companion.GENERIC_LAWYER_YEET import io.github.deltacv.eocvsim.plugin.loader.PluginManager.Companion.GENERIC_SUPERACCESS_WARN import io.github.deltacv.eocvsim.plugin.loader.PluginParser import io.github.deltacv.eocvsim.plugin.security.Authority import io.github.deltacv.eocvsim.plugin.security.AuthorityFetcher import io.github.deltacv.eocvsim.plugin.security.MutablePluginSignature +import kotlinx.coroutines.newFixedThreadPoolContext import org.java_websocket.client.WebSocketClient import java.net.URI import org.java_websocket.handshake.ServerHandshake import java.util.zip.ZipFile import java.io.File import java.lang.Exception +import java.security.MessageDigest +import java.util.concurrent.Executors import kotlin.system.exitProcess object SuperAccessDaemon { @@ -88,6 +93,8 @@ object SuperAccessDaemon { class WsClient(port: Int) : WebSocketClient(URI("ws://localhost:$port")) { + private val executor = Executors.newFixedThreadPool(6) + override fun onOpen(p0: ServerHandshake?) { logger.info("SuperAccessDaemon connection opened") } @@ -95,21 +102,37 @@ object SuperAccessDaemon { override fun onMessage(msg: String) { val message = gson.fromJson(msg, SuperAccessMessage::class.java) - when(message) { - is SuperAccessMessage.Request -> { - handleRequest(message) - } + executor.submit { + when (message) { + is SuperAccessMessage.Request -> { + handleRequest(message) + } - is SuperAccessMessage.Check -> { - handleCheck(message) + is SuperAccessMessage.Check -> { + handleCheck(message) + } } } } private fun handleRequest(message: SuperAccessMessage.Request) { + val pluginFile = File(message.pluginPath) + + val parser = parsePlugin(pluginFile) ?: run { + logger.error("Failed to parse plugin at ${message.pluginPath}") + (gson.toJson(SuperAccessResponse.Failure(message.id))) + return@handleRequest + } + + if(SUPERACCESS_FILE.exists() && SUPERACCESS_FILE.readLines().contains(pluginFile.fileHash())) { + accessGranted(message.id, message.pluginPath) + return + } + logger.info("Requesting SuperAccess for ${message.pluginPath}") var validAuthority: Authority? = null + var untrusted = false if(message.signature.authority != null) { val declaredAuthority = message.signature.authority!! @@ -125,19 +148,17 @@ object SuperAccessDaemon { validAuthority = Authority(authorityName, fetchedAuthorityKey) } else { logger.warn("Authority key does not match the fetched key for $authorityName") + untrusted = true } } - } - - val parser = parsePlugin(File(message.pluginPath)) ?: run { - logger.error("Failed to parse plugin at ${message.pluginPath}") - (gson.toJson(SuperAccessResponse.Failure(message.id))) - return@handleRequest + } else { + val fetch = AuthorityFetcher.fetchAuthority(parser.pluginAuthor) + untrusted = fetch != null // the plugin is claiming to be made by the authority but it's not signed by them } val reason = message.reason - val name = "${parser.pluginName} v${parser.pluginVersion} by ${parser.pluginAuthor}".replace(" ", "-") + val name = "${parser.pluginName} v${parser.pluginVersion} by ${parser.pluginAuthor}" var warning = "$GENERIC_SUPERACCESS_WARN" if(reason.trim().isNotBlank()) { @@ -145,22 +166,24 @@ object SuperAccessDaemon { } warning += if(validAuthority != null) { - "

This plugin is signed by the trusted authority ${validAuthority.name}." + "

This plugin has been digitally signed by ${validAuthority.name}, ensuring its integrity and authenticity.
${validAuthority.name} is a trusted authority in the EOCV-Sim ecosystem." + } else if(untrusted) { + "

This plugin claims to be made by trusted authority ${parser.pluginAuthor}, but it has not been digitally signed by them.

Beware of potential security risks.

" } else { GENERIC_LAWYER_YEET } warning += "" - if(JavaProcess.exec(SuperAccessRequestMain::class.java, null, listOf(name, warning)) == 171) { - logger.info("SuperAccess granted to ${message.pluginPath}") - send(gson.toJson(SuperAccessResponse.Success(message.id))) - - SUPERACCESS_FILE.appendText("${parser.hash()}\n") - access[message.pluginPath] = true - } else { - logger.info("SuperAccess denied to ${message.pluginPath}") - send(gson.toJson(SuperAccessResponse.Failure(message.id))) + SwingUtilities.invokeLater { + SuperAccessRequest(name, warning) { granted -> + if(granted) { + SUPERACCESS_FILE.appendText(pluginFile.fileHash() + "\n") + accessGranted(message.id, message.pluginPath) + } else { + accessDenied(message.id, message.pluginPath) + } + } } } @@ -175,19 +198,27 @@ object SuperAccessDaemon { } } - val parser = parsePlugin(File(message.pluginPath)) ?: run { + val pluginFile = File(message.pluginPath) + + val parser = parsePlugin(pluginFile) ?: run { logger.error("Failed to parse plugin at ${message.pluginPath}") send(gson.toJson(SuperAccessResponse.Failure(message.id))) return } - if(SUPERACCESS_FILE.readLines().contains(parser.hash())) { + if(SUPERACCESS_FILE.exists() && SUPERACCESS_FILE.readLines().contains(pluginFile.fileHash())) { accessGranted(message.id, message.pluginPath) } else { accessDenied(message.id, message.pluginPath) } } + private fun File.fileHash(): String { + val messageDigest = MessageDigest.getInstance("SHA-256") + messageDigest.update(readBytes()) + return SysUtil.byteArray2Hex(messageDigest.digest()) + } + private fun accessGranted(id: Int, pluginPath: String) { access[pluginPath] = true send(gson.toJson(SuperAccessResponse.Success(id))) diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemonClient.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemonClient.kt index 538d2821..dac20eab 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemonClient.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemonClient.kt @@ -24,20 +24,16 @@ package io.github.deltacv.eocvsim.plugin.security.superaccess import com.github.serivesmejia.eocvsim.util.JavaProcess -import com.github.serivesmejia.eocvsim.util.event.EventHandler -import com.github.serivesmejia.eocvsim.util.event.EventListenerRemover import com.github.serivesmejia.eocvsim.util.loggerForThis -import org.firstinspires.ftc.robotcore.internal.collections.EvictingBlockingQueue -import java.util.concurrent.ArrayBlockingQueue import org.java_websocket.WebSocket import org.java_websocket.handshake.ClientHandshake import org.java_websocket.server.WebSocketServer -import java.lang.Exception import java.io.File +import java.lang.Exception import java.net.InetSocketAddress import java.util.concurrent.Executors -import java.util.concurrent.locks.ReentrantLock import java.util.concurrent.locks.Condition +import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock typealias ResponseReceiver = (SuperAccessDaemon.SuperAccessResponse) -> Unit diff --git a/Vision/build.gradle b/Vision/build.gradle index d62ff8ce..ad38e4b1 100644 --- a/Vision/build.gradle +++ b/Vision/build.gradle @@ -28,7 +28,7 @@ configurations.all { dependencies { implementation project(':Common') - implementation("com.github.deltacv:AprilTagDesktop:$apriltag_plugin_version") { + api("com.github.deltacv:AprilTagDesktop:$apriltag_plugin_version") { exclude group: 'com.github.deltacv.EOCVSim', module: 'Common' } From e95c53d55d4faac0236c2a4a76a58a9f0e373915 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Mon, 28 Oct 2024 15:09:34 -0600 Subject: [PATCH 27/36] Add global exclusion to Common remote dependency --- build.gradle | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/build.gradle b/build.gradle index cf644dbd..0116df38 100644 --- a/build.gradle +++ b/build.gradle @@ -48,6 +48,11 @@ allprojects { apply plugin: 'java' + configurations { + // do not pull in EOCV-Sim's Common module from a repository, it's already in the project + runtime.exclude group: "com.github.deltacv.EOCV-Sim", module: "Common" + } + ext { standardVersion = version } From 84705096943b35fc8efdbdc4b0be6a83daa12d99 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Mon, 28 Oct 2024 18:19:44 -0600 Subject: [PATCH 28/36] Add double confirmation on superaccess request for untrusted --- .gitignore | 2 ++ .../serivesmejia/eocvsim/config/Config.java | 2 ++ .../eocvsim/gui/dialog/PluginOutput.kt | 28 +++++++++------- .../eocvsim/util/extension/FileExt.kt | 9 +++++ .../eocvsim/gui/dialog/SuperAccessRequest.kt | 20 +++++++++-- .../eocvsim/plugin/loader/PluginLoader.kt | 29 +++++++--------- .../eocvsim/plugin/security/Authority.kt | 13 ++++++++ .../security/PluginSignatureVerifier.kt | 8 ++--- .../security/superaccess/SuperAccessDaemon.kt | 33 ++++++------------- README.md | 17 ++++++++++ 10 files changed, 102 insertions(+), 59 deletions(-) diff --git a/.gitignore b/.gitignore index 1c4b3d23..c77a1310 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,8 @@ fabric.properties *.log +*.pem + **/Build.java **/build/* diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/config/Config.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/config/Config.java index f50fe1f8..0bc7f0d6 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/config/Config.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/config/Config.java @@ -40,6 +40,7 @@ public class Config { public volatile Theme simTheme = Theme.Light; + @Deprecated public volatile double zoom = 1; public volatile PipelineFps pipelineMaxFps = PipelineFps.MEDIUM; @@ -63,6 +64,7 @@ public class Config { public volatile HashMap specificTunableFieldConfig = new HashMap<>(); + @Deprecated public volatile List superAccessPluginHashes = new ArrayList<>(); public volatile HashMap flags = new HashMap<>(); diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt index 7d21fd3c..647eb5d2 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt @@ -34,13 +34,11 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.swing.Swing import java.awt.Dimension +import java.awt.GridBagConstraints +import java.awt.GridBagLayout import java.awt.Toolkit import java.awt.datatransfer.StringSelection import javax.swing.* -import java.awt.GridBagConstraints -import java.awt.GridBagLayout -import java.awt.event.ActionEvent -import java.awt.event.ActionListener import javax.swing.event.ChangeEvent import javax.swing.event.ChangeListener @@ -231,34 +229,40 @@ class PluginOutput( anchor = GridBagConstraints.CENTER }) + val signatureStatus = if(loader.signature.verified) + "This plugin has been verified and was signed by ${loader.signature.authority!!.name}." + else "This plugin is not signed." + val authorEmail = if(loader.pluginAuthorEmail.isBlank()) "The author did not provide contact information." else "Contact the author at ${loader.pluginAuthorEmail}" - val description = if(loader.pluginDescription.isBlank()) - "No description available." - else loader.pluginDescription - val source = if(loader.pluginSource == PluginSource.REPOSITORY) "Maven repository" else "local file" + val sourceEnabled = if(loader.shouldEnable) "It was loaded from a $source." else "It is disabled, it comes from a $source." + val superAccess = if(loader.hasSuperAccess) - "This plugin has super access." - else "This plugin does not have super access." + "It has super access." + else "It does not have super access." val wantsSuperAccess = if(loader.pluginToml.getBoolean("super-access", false)) "It requests super access in its manifest." else "It does not request super access in its manifest." + val description = if(loader.pluginDescription.isBlank()) + "No description available." + else loader.pluginDescription + val font = pluginNameLabel.font.deriveFont(13.0f) // add a text area for the plugin description val pluginDescriptionArea = JTextArea(""" + $signatureStatus $authorEmail - ${if(loader.shouldEnable) "This plugin was loaded from a $source." else "This plugin is disabled, it comes from a $source."} - $superAccess $wantsSuperAccess + ** $sourceEnabled $superAccess $wantsSuperAccess ** $description """.trimIndent()) diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/extension/FileExt.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/extension/FileExt.kt index d2264bef..8ef35ac0 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/extension/FileExt.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/extension/FileExt.kt @@ -23,7 +23,9 @@ package com.github.serivesmejia.eocvsim.util.extension +import com.github.serivesmejia.eocvsim.util.SysUtil import java.io.File +import java.security.MessageDigest /** * Operator function to concatenate a string to a file path @@ -31,3 +33,10 @@ import java.io.File operator fun File.plus(str: String): File { return File(this.absolutePath, str) } + + +fun File.fileHash(algorithm: String = "SHA-256"): String { + val messageDigest = MessageDigest.getInstance(algorithm) + messageDigest.update(readBytes()) + return SysUtil.byteArray2Hex(messageDigest.digest()) +} \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/gui/dialog/SuperAccessRequest.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/gui/dialog/SuperAccessRequest.kt index 58d98dc8..1b5b083e 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/gui/dialog/SuperAccessRequest.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/gui/dialog/SuperAccessRequest.kt @@ -37,7 +37,7 @@ import java.awt.event.WindowEvent import java.awt.event.WindowListener import javax.swing.* -class SuperAccessRequest(sourceName: String, reason: String, val callback: (Boolean) -> Unit) { +class SuperAccessRequest(sourceName: String, reason: String, val untrusted: Boolean, val callback: (Boolean) -> Unit) { init { FlatArcDarkIJTheme.setup() @@ -76,7 +76,23 @@ class SuperAccessRequest(sourceName: String, reason: String, val callback: (Bool val acceptButton = JButton("Accept").apply { preferredSize = Dimension(100, 30) addActionListener { - callback(true) + if(untrusted) { + JOptionPane.showConfirmDialog( + frame, + "We were unable to verify the integrity and origin of the plugin. Are you sure you want to accept this request? Do so at your own risk.", + "Confirm", + JOptionPane.YES_NO_OPTION, + JOptionPane.QUESTION_MESSAGE + ).let { + if(it == JOptionPane.YES_OPTION) { + callback(true) + } else { + callback(false) + } + } + } else { + callback(true) + } frame.isVisible = false frame.dispose() diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt index 37938b4b..dcb36cc7 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt @@ -29,6 +29,7 @@ import com.github.serivesmejia.eocvsim.gui.dialog.AppendDelegate import com.github.serivesmejia.eocvsim.gui.dialog.PluginOutput import com.github.serivesmejia.eocvsim.util.SysUtil import com.github.serivesmejia.eocvsim.util.event.EventHandler +import com.github.serivesmejia.eocvsim.util.extension.fileHash import com.github.serivesmejia.eocvsim.util.extension.hashString import com.github.serivesmejia.eocvsim.util.extension.plus import com.github.serivesmejia.eocvsim.util.loggerForThis @@ -48,15 +49,15 @@ enum class PluginSource { } class PluginParser(pluginToml: Toml) { - val pluginName = pluginToml.getString("name") ?: throw InvalidPluginException("No name in plugin.toml") - val pluginVersion = pluginToml.getString("version") ?: throw InvalidPluginException("No version in plugin.toml") + val pluginName = pluginToml.getString("name")?.trim() ?: throw InvalidPluginException("No name in plugin.toml") + val pluginVersion = pluginToml.getString("version")?.trim() ?: throw InvalidPluginException("No version in plugin.toml") - val pluginAuthor = pluginToml.getString("author") ?: throw InvalidPluginException("No author in plugin.toml") - val pluginAuthorEmail = pluginToml.getString("author-email", "") + val pluginAuthor = pluginToml.getString("author")?.trim() ?: throw InvalidPluginException("No author in plugin.toml") + val pluginAuthorEmail = pluginToml.getString("author-email", "")?.trim() - val pluginMain = pluginToml.getString("main") ?: throw InvalidPluginException("No main in plugin.toml") + val pluginMain = pluginToml.getString("main")?.trim() ?: throw InvalidPluginException("No main in plugin.toml") - val pluginDescription = pluginToml.getString("description", "") + val pluginDescription = pluginToml.getString("description", "")?.trim() /** * Get the hash of the plugin based off the plugin name and author @@ -162,8 +163,8 @@ class PluginLoader( pluginName = parser.pluginName pluginVersion = parser.pluginVersion pluginAuthor = parser.pluginAuthor - pluginAuthorEmail = parser.pluginAuthorEmail - pluginDescription = parser.pluginDescription + pluginAuthorEmail = parser.pluginAuthorEmail ?: "" + pluginDescription = parser.pluginDescription ?: "" } /** @@ -183,6 +184,8 @@ class PluginLoader( appender.appendln("${PluginOutput.SPECIAL_SILENT}Loading plugin $pluginName v$pluginVersion by $pluginAuthor from ${pluginSource.name}") + signature + setupFs() if(pluginToml.contains("api-version") || pluginToml.contains("min-api-version")) { @@ -293,14 +296,4 @@ class PluginLoader( */ fun hash() = "${pluginName}${PluginOutput.SPECIAL}${pluginAuthor}".hashString - /** - * Get the hash of the plugin file based off the file contents - * @return the hash - */ - val pluginFileHash by lazy { - val messageDigest = MessageDigest.getInstance("SHA-256") - messageDigest.update(pluginFile.readBytes()) - SysUtil.byteArray2Hex(messageDigest.digest()) - } - } \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/Authority.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/Authority.kt index 53cec9b5..edc11020 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/Authority.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/Authority.kt @@ -123,6 +123,19 @@ object AuthorityFetcher { // Load existing authorities if the file exists if (AUTHORITIES_FILE.exists()) { val existingToml = AUTHORITIES_FILE.readText() + if(existingToml.contains("[$name]")) { + // remove the existing authority. we need to delete the [$name] and the next two lines + val lines = existingToml.lines().toMutableList() + val index = lines.indexOfFirst { it == "[$name]" } + + if(index != -1) { + lines.removeAt(index) + lines.removeAt(index) + lines.removeAt(index) + } + sb.append(lines.joinToString("\n")) + } + sb.append(existingToml) } diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSignatureVerifier.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSignatureVerifier.kt index 4b2960c1..63d33eb9 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSignatureVerifier.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/PluginSignatureVerifier.kt @@ -147,13 +147,13 @@ object PluginSignatureVerifier { } if(signatureStatus.containsValue(false)) { - logger.warn("Not all classes were signed") - return emptyResult + throw InvalidPluginException("Some classes in the plugin are not signed. Please try to re-download the plugin or discard it immediately.") } - logger.info("Plugin signature of ${pluginFile.absolutePath} verified, signed by $authorityName") + logger.info("Plugin ${pluginFile.absolutePath} has been verified, signed by $authorityName") + - return PluginSignature(true, authority, System.currentTimeMillis(), ) + return PluginSignature(true, authority, System.currentTimeMillis()) } } diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemon.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemon.kt index 4a23d9cf..c4194121 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemon.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemon.kt @@ -23,31 +23,28 @@ package io.github.deltacv.eocvsim.plugin.security.superaccess -import com.github.serivesmejia.eocvsim.util.JavaProcess -import com.github.serivesmejia.eocvsim.util.SysUtil +import com.github.serivesmejia.eocvsim.util.extension.plus +import com.github.serivesmejia.eocvsim.util.extension.fileHash import com.github.serivesmejia.eocvsim.util.io.EOCVSimFolder import com.github.serivesmejia.eocvsim.util.loggerForThis -import com.github.serivesmejia.eocvsim.util.extension.plus import com.github.serivesmejia.eocvsim.util.serialization.PolymorphicAdapter import com.google.gson.GsonBuilder import com.moandjiezana.toml.Toml import io.github.deltacv.eocvsim.gui.dialog.SuperAccessRequest -import javax.swing.SwingUtilities import io.github.deltacv.eocvsim.plugin.loader.PluginManager.Companion.GENERIC_LAWYER_YEET import io.github.deltacv.eocvsim.plugin.loader.PluginManager.Companion.GENERIC_SUPERACCESS_WARN import io.github.deltacv.eocvsim.plugin.loader.PluginParser import io.github.deltacv.eocvsim.plugin.security.Authority import io.github.deltacv.eocvsim.plugin.security.AuthorityFetcher import io.github.deltacv.eocvsim.plugin.security.MutablePluginSignature -import kotlinx.coroutines.newFixedThreadPoolContext import org.java_websocket.client.WebSocketClient -import java.net.URI import org.java_websocket.handshake.ServerHandshake -import java.util.zip.ZipFile import java.io.File import java.lang.Exception -import java.security.MessageDigest +import java.net.URI import java.util.concurrent.Executors +import java.util.zip.ZipFile +import javax.swing.SwingUtilities import kotlin.system.exitProcess object SuperAccessDaemon { @@ -88,12 +85,14 @@ object SuperAccessDaemon { logger.warn("Ignoring extra arguments.") } + System.setProperty("sun.java2d.d3d", "false") + WsClient(args[0].toIntOrNull() ?: throw IllegalArgumentException("Port is not a valid int")).connect() } class WsClient(port: Int) : WebSocketClient(URI("ws://localhost:$port")) { - private val executor = Executors.newFixedThreadPool(6) + private val executor = Executors.newFixedThreadPool(4) override fun onOpen(p0: ServerHandshake?) { logger.info("SuperAccessDaemon connection opened") @@ -153,7 +152,7 @@ object SuperAccessDaemon { } } else { val fetch = AuthorityFetcher.fetchAuthority(parser.pluginAuthor) - untrusted = fetch != null // the plugin is claiming to be made by the authority but it's not signed by them + untrusted = fetch != null // the plugin is claiming to be made by the authority, but it's not signed by them } val reason = message.reason @@ -176,7 +175,7 @@ object SuperAccessDaemon { warning += "" SwingUtilities.invokeLater { - SuperAccessRequest(name, warning) { granted -> + SuperAccessRequest(name, warning, validAuthority == null || untrusted) { granted -> if(granted) { SUPERACCESS_FILE.appendText(pluginFile.fileHash() + "\n") accessGranted(message.id, message.pluginPath) @@ -200,12 +199,6 @@ object SuperAccessDaemon { val pluginFile = File(message.pluginPath) - val parser = parsePlugin(pluginFile) ?: run { - logger.error("Failed to parse plugin at ${message.pluginPath}") - send(gson.toJson(SuperAccessResponse.Failure(message.id))) - return - } - if(SUPERACCESS_FILE.exists() && SUPERACCESS_FILE.readLines().contains(pluginFile.fileHash())) { accessGranted(message.id, message.pluginPath) } else { @@ -213,12 +206,6 @@ object SuperAccessDaemon { } } - private fun File.fileHash(): String { - val messageDigest = MessageDigest.getInstance("SHA-256") - messageDigest.update(readBytes()) - return SysUtil.byteArray2Hex(messageDigest.digest()) - } - private fun accessGranted(id: Int, pluginPath: String) { access[pluginPath] = true send(gson.toJson(SuperAccessResponse.Success(id))) diff --git a/README.md b/README.md index 7c2cb944..b54af870 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,23 @@ Join the [deltacv discord server](https://discord.gg/A3RMYzf6DA) ! ### Formerly, EOCV-Sim was hosted on a [personal account repo](https://github.com/serivesmejia/EOCV-Sim/). Released prior to 3.0.0 can be found there for historic purposes. + +## [v3.8.0 - Better FTC VisionPortal support & Plugin System Fixes](https://github.com/deltacv/EOCV-Sim/releases/tag/v3.8.0) +- This is the 25th release for EOCV-Sim + - Changelog + - Update skiko to 0.8.15 + - Fixes Typeface.DEFAULT_BOLD and Typeface.DEFAULT_ITALIC to actually work + - Adds a stub no-op implementation for the FTC SDK Gamepad class into OpMode + - Adds android.opengl.Matrix implementation and matrices from the FTC SDK + - Adds navigation classes from the FTC SDK (AngleUnit, AxesOrder, AxesReference, etc) + - Adds the ConceptAprilTagLocalization, ConceptVisionColorLocator and ConceptVisionColorSensor samples + - Reimplements Telemetry to EOCVSimTelemetryImpl with stubs for Telemetry#talk + - Internal changes: + - Plugin virtual filesystems now use the name and author in the TOML to generate the fs name + - Allows to specify JVM args in JavaExec + - Rename some internal classes + - Better handling of Pipeline/OpMode tab switching + ## [v3.7.1 - Better FTC VisionPortal support & Plugin System Fixes](https://github.com/deltacv/EOCV-Sim/releases/tag/v3.7.1) - This is the 24th release for EOCV-Sim - Changelog From 47bd2c734bb8a773ab5a6f96f8627773d7f57741 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Mon, 28 Oct 2024 18:39:56 -0600 Subject: [PATCH 29/36] Add classloader parameter to PolymorphicAdapter --- .../github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt | 2 +- .../eocvsim/util/serialization/PolymorphicAdapter.kt | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt index 647eb5d2..a879c513 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt @@ -241,7 +241,7 @@ class PluginOutput( "Maven repository" else "local file" - val sourceEnabled = if(loader.shouldEnable) "It was loaded from a $source." else "It is disabled, it comes from a $source." + val sourceEnabled = if(loader.shouldEnable) "It was LOADED from a $source." else "It is DISABLED, it comes from a $source." val superAccess = if(loader.hasSuperAccess) "It has super access." diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/serialization/PolymorphicAdapter.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/serialization/PolymorphicAdapter.kt index 04d4cced..2528bcc5 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/serialization/PolymorphicAdapter.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/serialization/PolymorphicAdapter.kt @@ -28,7 +28,10 @@ import java.lang.reflect.Type private val gson = Gson() -open class PolymorphicAdapter(val name: String) : JsonSerializer, JsonDeserializer { +open class PolymorphicAdapter( + val name: String, + val classloader: ClassLoader = PolymorphicAdapter::class.java.classLoader +) : JsonSerializer, JsonDeserializer { override fun serialize(src: T, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { val obj = JsonObject() @@ -42,7 +45,7 @@ open class PolymorphicAdapter(val name: String) : JsonSerializer, JsonDese @Suppress("UNCHECKED_CAST") override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): T { val className = json.asJsonObject.get("${name}Class").asString - val clazz = Class.forName(className) + val clazz = classloader.loadClass(className) return gson.fromJson(json.asJsonObject.get(name), clazz) as T } From 1a1b58f37e0731a211e1a121242bc1b61c26bc9d Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Mon, 28 Oct 2024 22:43:59 -0600 Subject: [PATCH 30/36] Allow IPC messages to persist after response --- .../eocvsim/util/serialization/PolymorphicAdapter.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/serialization/PolymorphicAdapter.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/serialization/PolymorphicAdapter.kt index 2528bcc5..2c788bb7 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/serialization/PolymorphicAdapter.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/serialization/PolymorphicAdapter.kt @@ -45,6 +45,7 @@ open class PolymorphicAdapter( @Suppress("UNCHECKED_CAST") override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): T { val className = json.asJsonObject.get("${name}Class").asString + val clazz = classloader.loadClass(className) return gson.fromJson(json.asJsonObject.get(name), clazz) as T From 13867684243ada270ab0603df8f6cc005bb54087 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Mon, 28 Oct 2024 23:08:08 -0600 Subject: [PATCH 31/36] Add changelog for v3.8.0 --- .../eocvsim/gui/dialog/PluginOutput.kt | 1 + README.md | 24 +++++++++---------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt index a879c513..c0c194c2 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.swing.Swing import java.awt.Dimension import java.awt.GridBagConstraints import java.awt.GridBagLayout +import java.awt.GridLayout import java.awt.Toolkit import java.awt.datatransfer.StringSelection import javax.swing.* diff --git a/README.md b/README.md index b54af870..6c0ffe28 100644 --- a/README.md +++ b/README.md @@ -92,22 +92,20 @@ Join the [deltacv discord server](https://discord.gg/A3RMYzf6DA) ! ### Formerly, EOCV-Sim was hosted on a [personal account repo](https://github.com/serivesmejia/EOCV-Sim/). Released prior to 3.0.0 can be found there for historic purposes. - -## [v3.8.0 - Better FTC VisionPortal support & Plugin System Fixes](https://github.com/deltacv/EOCV-Sim/releases/tag/v3.8.0) +## [v3.8.0 - Major Plugin System Rework](https://github.com/deltacv/EOCV-Sim/releases/tag/v3.8.0) - This is the 25th release for EOCV-Sim - Changelog - - Update skiko to 0.8.15 - - Fixes Typeface.DEFAULT_BOLD and Typeface.DEFAULT_ITALIC to actually work - - Adds a stub no-op implementation for the FTC SDK Gamepad class into OpMode - - Adds android.opengl.Matrix implementation and matrices from the FTC SDK - - Adds navigation classes from the FTC SDK (AngleUnit, AxesOrder, AxesReference, etc) - - Adds the ConceptAprilTagLocalization, ConceptVisionColorLocator and ConceptVisionColorSensor samples - - Reimplements Telemetry to EOCVSimTelemetryImpl with stubs for Telemetry#talk + - Implements a system that allows for plugins to be downloaded from a maven repository and loaded into the simulator. + - New repository.toml file that contains the list of repositories to download plugins from and the maven coordinates to download. + - Adds a new dialog to manage the loaded plugins and check the output of the plugin system. + - Rewrites SuperAccess verification to be handled by a separate JVM process, ensuring that the main process is not compromised by malicious code + - Implements a plugin signature verification system to allow for authors to sign their plugins and ensure that they are not tampered with. + - The signature is stored on a custom format in the plugin jar, the signing authorities are pulled from a public key database + - When making a SuperAccess request, the user is warned if the plugin is not signed or if the signature is invalid. + - Adds a KeyGeneratorTool and PluginSigningTool to allow for easy generation of keys and signing of plugins respectively. - Internal changes: - - Plugin virtual filesystems now use the name and author in the TOML to generate the fs name - - Allows to specify JVM args in JavaExec - - Rename some internal classes - - Better handling of Pipeline/OpMode tab switching + - Adds a PolymorphicAdapter class to allow for easy serialization while retaining type information + - Improvements to the handling of JavaProcess ## [v3.7.1 - Better FTC VisionPortal support & Plugin System Fixes](https://github.com/deltacv/EOCV-Sim/releases/tag/v3.7.1) - This is the 24th release for EOCV-Sim From 57942a46c056300aef66c5c28972b1cd4e7fefcc Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Tue, 29 Oct 2024 01:03:27 -0600 Subject: [PATCH 32/36] Improve restart routine & add more options to PluginOutput --- .../github/serivesmejia/eocvsim/EOCVSim.kt | 40 +++++-- .../eocvsim/gui/dialog/PluginOutput.kt | 103 ++++++++++++++++-- .../gui/dialog/component/OutputPanel.kt | 13 ++- .../eocvsim/util/JavaProcess.java | 3 + .../plugin/loader/PluginClassLoader.kt | 21 +++- .../eocvsim/plugin/loader/PluginLoader.kt | 2 + .../eocvsim/plugin/loader/PluginManager.kt | 20 +++- .../eocvsim/plugin/security/Authority.kt | 21 ++-- 8 files changed, 181 insertions(+), 42 deletions(-) diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt index 86fe763e..2ba19617 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/EOCVSim.kt @@ -58,9 +58,11 @@ import org.opencv.core.Size import org.openftc.easyopencv.TimestampedPipelineHandler import java.awt.Dimension import java.io.File +import java.lang.Thread.sleep import javax.swing.SwingUtilities import javax.swing.filechooser.FileFilter import javax.swing.filechooser.FileNameExtensionFilter +import kotlin.jvm.Throws import kotlin.system.exitProcess /** @@ -361,7 +363,30 @@ class EOCVSim(val params: Parameters = Parameters()) { pluginManager.enablePlugins() - start() + try { + start() + } catch (e: InterruptedException) { + logger.warn("Main thread interrupted ($hexCode)", e) + } + + if(!destroying) { + destroy(DestroyReason.THREAD_EXIT) + } + + if (isRestarting) { + Thread.interrupted() //clear interrupted flag + EOCVSimFolder.lock?.lock?.close() + + JavaProcess.killSubprocessesOnExit = false + Thread { + JavaProcess.exec(Main::class.java, null, null) + }.start() + + sleep(1000) + } + + logger.info("-- End of EasyOpenCV Simulator v$VERSION ($hexCode) --") + exitProcess(0) } /** @@ -375,6 +400,7 @@ class EOCVSim(val params: Parameters = Parameters()) { * are explicitly updated within this method * @see init */ + @Throws(InterruptedException::class) private fun start() { if(Thread.currentThread() != eocvSimThread) { throw IllegalStateException("start() must be called from the EOCVSim thread") @@ -397,7 +423,7 @@ class EOCVSim(val params: Parameters = Parameters()) { } else null ) - //limit FPG + //limit FPS fpsLimiter.maxFPS = config.pipelineMaxFps.fps.toDouble() try { fpsLimiter.sync() @@ -407,16 +433,6 @@ class EOCVSim(val params: Parameters = Parameters()) { } logger.warn("Main thread interrupted ($hexCode)") - - if(!destroying) { - destroy(DestroyReason.THREAD_EXIT) - } - - if (isRestarting) { - Thread.interrupted() //clear interrupted flag - EOCVSimFolder.lock?.lock?.close() - JavaProcess.exec(Main::class.java, null, null) - } } /** diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt index c0c194c2..81780730 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt @@ -28,6 +28,7 @@ import com.github.serivesmejia.eocvsim.gui.dialog.component.BottomButtonsPanel import com.github.serivesmejia.eocvsim.gui.dialog.component.OutputPanel import io.github.deltacv.eocvsim.plugin.loader.PluginManager import io.github.deltacv.eocvsim.plugin.loader.PluginSource +import io.github.deltacv.eocvsim.plugin.repository.PluginRepositoryManager import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -36,10 +37,11 @@ import kotlinx.coroutines.swing.Swing import java.awt.Dimension import java.awt.GridBagConstraints import java.awt.GridBagLayout -import java.awt.GridLayout import java.awt.Toolkit +import java.awt.Color import java.awt.datatransfer.StringSelection import javax.swing.* +import java.awt.Desktop import javax.swing.event.ChangeEvent import javax.swing.event.ChangeListener @@ -135,7 +137,7 @@ class PluginOutput( registerListeners() output.pack() - output.setSize(500, 350) + output.setSize(500, 365) appendDelegate.subscribe(this) @@ -182,6 +184,7 @@ class PluginOutput( ) if(dialogResult == JOptionPane.YES_OPTION) { + output.isVisible = false eocvSim.restart() } @@ -190,8 +193,8 @@ class PluginOutput( } private fun makePluginManagerPanel(): JPanel { - val panel = JPanel() - panel.layout = GridBagLayout() + val pluginsPanel = JPanel() + pluginsPanel.layout = GridBagLayout() if(pluginManager.loaders.isEmpty()) { // center vertically and horizontally @@ -209,7 +212,7 @@ class PluginOutput( } // Add the label to the panel with the constraints - panel.add(noPluginsLabel, constraints) + pluginsPanel.add(noPluginsLabel, constraints) } else { val tabbedPane = JTabbedPane(JTabbedPane.LEFT) @@ -291,7 +294,6 @@ class PluginOutput( fun refreshButtons() { disableButton.isEnabled = loader.shouldEnable enableButton.isEnabled = !loader.shouldEnable - } refreshButtons() @@ -336,7 +338,7 @@ class PluginOutput( tabbedPane.addTab(loader.pluginName, pluginPanel) } - panel.add(tabbedPane, GridBagConstraints().apply { + pluginsPanel.add(tabbedPane, GridBagConstraints().apply { gridx = 0 gridy = 0 weightx = 1.0 @@ -345,6 +347,93 @@ class PluginOutput( }) } + val panel = JPanel() + + panel.layout = GridBagLayout() + panel.add(pluginsPanel, GridBagConstraints().apply { + gridx = 0 + gridy = 0 + weightx = 1.0 + weighty = 1.0 + fill = GridBagConstraints.BOTH + }) + + val bottomButtonsPanel = JPanel() + bottomButtonsPanel.layout = BoxLayout(bottomButtonsPanel, BoxLayout.PAGE_AXIS) + + val openPluginsFolderButton = JButton("Open plugins folder") + + openPluginsFolderButton.addActionListener { + val pluginsFolder = PluginManager.PLUGIN_FOLDER + + if(pluginsFolder.exists() && Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(pluginsFolder) + } else { + JOptionPane.showMessageDialog( + output, + "Unable to open plugins folder, the folder does not exist or the operation is unsupported.", + "Operation failed", + JOptionPane.ERROR_MESSAGE + ) + } + } + + val startFreshButton = JButton("Start fresh") + + startFreshButton.addActionListener { + val dialogResult = JOptionPane.showConfirmDialog( + output, + "Are you sure you want to start fresh? This will remove all plugins from all sources.", + "Start fresh", + JOptionPane.YES_NO_OPTION + ) + + if(dialogResult == JOptionPane.YES_OPTION) { + eocvSim?.config?.flags?.set("startFresh", true) + + PluginRepositoryManager.REPOSITORY_FILE.delete() + PluginRepositoryManager.CACHE_FILE.delete() + + shouldAskForRestart = true + checkShouldAskForRestart() + } + } + + val closeButton = JButton("Close") + + closeButton.addActionListener { + close() + } + + val buttonsPanel = JPanel() + buttonsPanel.layout = BoxLayout(buttonsPanel, BoxLayout.LINE_AXIS) + buttonsPanel.border = BorderFactory.createEmptyBorder(5, 0, 5, 0) + + buttonsPanel.add(Box.createHorizontalGlue()) + + buttonsPanel.add(openPluginsFolderButton) + buttonsPanel.add(Box.createRigidArea(Dimension(5, 0))) + buttonsPanel.add(startFreshButton) + buttonsPanel.add(Box.createRigidArea(Dimension(5, 0))) + buttonsPanel.add(closeButton) + + buttonsPanel.add(Box.createHorizontalGlue()) + + bottomButtonsPanel.add(buttonsPanel) + + // Set a thin light gray line border with padding + bottomButtonsPanel.border = BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(10, 10, 10, 10), // Padding inside the border + JScrollPane().border + ) + + panel.add(bottomButtonsPanel, GridBagConstraints().apply { + gridx = 0 + gridy = 2 + weightx = 1.0 + fill = GridBagConstraints.HORIZONTAL + }) + return panel } diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/component/OutputPanel.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/component/OutputPanel.kt index f28e6f49..b80fe5cf 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/component/OutputPanel.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/component/OutputPanel.kt @@ -23,6 +23,7 @@ package com.github.serivesmejia.eocvsim.gui.dialog.component +import com.formdev.flatlaf.FlatLaf import java.awt.Dimension import java.awt.GridBagConstraints import java.awt.GridBagLayout @@ -31,6 +32,7 @@ import java.awt.datatransfer.StringSelection import java.awt.Font import java.io.InputStream import javax.swing.* +import kotlin.math.roundToInt class OutputPanel( @@ -60,7 +62,16 @@ class OutputPanel( outputArea.highlighter = null // set the background color to a darker tone - outputArea.background = outputArea.background.darker() + outputArea.background = if(FlatLaf.isLafDark()) { + outputArea.background.darker() + } else { + java.awt.Color( + (outputArea.background.red * 0.95).roundToInt(), + (outputArea.background.green * 0.95).roundToInt(), + (outputArea.background.blue * 0.95).roundToInt(), + 255 + ) + } val outputScroll = JScrollPane(outputArea) outputScroll.verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_ALWAYS diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java index a7c7806a..4b4451a5 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/util/JavaProcess.java @@ -80,6 +80,8 @@ private JavaProcess() {} private static int count; + public static boolean killSubprocessesOnExit = true; + private static final Logger logger = LoggerFactory.getLogger(JavaProcess.class); /** @@ -143,6 +145,7 @@ public static int execClasspath(Class klass, ProcessIOReceiver ioReceiver, Strin } private static void killOnExit(Process process) { + if(!killSubprocessesOnExit) return; Runtime.getRuntime().addShutdownHook(new Thread(process::destroy)); } diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt index 3060a3fb..bc557d69 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginClassLoader.kt @@ -31,6 +31,7 @@ import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingMethodBlackl import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingPackageBlacklist import io.github.deltacv.eocvsim.sandbox.restrictions.dynamicLoadingPackageWhitelist import java.io.ByteArrayOutputStream +import java.lang.ref.WeakReference import java.io.File import java.io.IOException import java.io.InputStream @@ -50,6 +51,8 @@ class PluginClassLoader( val pluginContextProvider: () -> PluginContext ) : ClassLoader() { + private var additionalZipFiles = mutableListOf>() + private val zipFile = try { ZipFile(pluginJar) } catch (e: Exception) { @@ -172,11 +175,8 @@ class PluginClassLoader( val entry = zipFile.getEntry(name) if (entry != null) { - try { - // Construct a URL for the resource inside the plugin JAR - return URL("jar:file:${pluginJar.absolutePath}!/$name") - } catch (e: Exception) { - } + // Construct a URL for the resource inside the plugin JAR + return URL("jar:file:${pluginJar.absolutePath}!/$name") } else { resourceFromClasspath(name)?.let { return it } } @@ -198,9 +198,13 @@ class PluginClassLoader( if (entry != null) { try { + additionalZipFiles.add(WeakReference(zipFile)) return zipFile.getInputStream(entry) } catch (e: Exception) { + zipFile.close() } + } else { + zipFile.close() } } @@ -256,6 +260,13 @@ class PluginClassLoader( return null } + fun close() { + zipFile.close() + for(ref in additionalZipFiles) { + ref.get()?.close() + } + } + override fun toString() = "PluginClassLoader@\"${pluginJar.name}\"" } \ No newline at end of file diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt index dcb36cc7..2ae9631a 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginLoader.kt @@ -280,6 +280,8 @@ class PluginLoader( fileSystem.close() enabled = false EventHandler.banClassLoader(pluginClassLoader) + + pluginClassLoader.close() } /** diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt index 526ed1cc..3e9fb974 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/loader/PluginManager.kt @@ -119,11 +119,23 @@ class PluginManager(val eocvSim: EOCVSim) { repositoryManager.init() - val pluginFiles = mutableListOf() - pluginFiles.addAll(repositoryManager.resolveAll()) + val pluginFilesInFolder = PLUGIN_FOLDER.listFiles()?.let { + it.filter { file -> file.extension == "jar" } + } ?: emptyList() - PLUGIN_FOLDER.listFiles()?.let { - pluginFiles.addAll(it.filter { it.extension == "jar" }) + _pluginFiles.addAll(repositoryManager.resolveAll()) + + if(eocvSim.config.flags.getOrDefault("startFresh", false)) { + logger.warn("startFresh = true, deleting all plugins in the plugins folder") + + for (file in pluginFilesInFolder) { + file.delete() + } + + eocvSim.config.flags["startFresh"] = false + eocvSim.configManager.saveToFile() + } else { + _pluginFiles.addAll(pluginFilesInFolder) } for (file in pluginFiles) { diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/Authority.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/Authority.kt index edc11020..20d71294 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/Authority.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/Authority.kt @@ -55,6 +55,8 @@ object AuthorityFetcher { const val AUTHORITY_SERVER_URL = "https://raw.githubusercontent.com/deltacv/Authorities/refs/heads/master" + fun sectionRegex(name: String) = Regex("\\[\\Q$name\\E](?:\\n[^\\[]+)*") + private val AUTHORITIES_FILE = File(EOCVSimFolder, "authorities.toml") private val TTL_DURATION_MS = TimeUnit.HOURS.toMillis(8) @@ -92,6 +94,7 @@ object AuthorityFetcher { } } catch (e: Exception) { logger.error("Failed to read authorities file", e) + AUTHORITIES_FILE.delete() } } @@ -123,20 +126,12 @@ object AuthorityFetcher { // Load existing authorities if the file exists if (AUTHORITIES_FILE.exists()) { val existingToml = AUTHORITIES_FILE.readText() - if(existingToml.contains("[$name]")) { - // remove the existing authority. we need to delete the [$name] and the next two lines - val lines = existingToml.lines().toMutableList() - val index = lines.indexOfFirst { it == "[$name]" } - - if(index != -1) { - lines.removeAt(index) - lines.removeAt(index) - lines.removeAt(index) - } - sb.append(lines.joinToString("\n")) - } - sb.append(existingToml) + sb.append(if(existingToml.contains("[$name]")) { + existingToml.replace(sectionRegex(name), "") + } else { + existingToml + }) } // Append new authority information From 2986052dd678e377ac1edd654a9e8b2d985a01c2 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Tue, 29 Oct 2024 01:44:32 -0600 Subject: [PATCH 33/36] Add deadlock prevention in SuperAccessDaemonClient --- .../superaccess/SuperAccessDaemonClient.kt | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemonClient.kt b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemonClient.kt index dac20eab..d92156df 100644 --- a/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemonClient.kt +++ b/EOCV-Sim/src/main/java/io/github/deltacv/eocvsim/plugin/security/superaccess/SuperAccessDaemonClient.kt @@ -60,6 +60,11 @@ class SuperAccessDaemonClient { } fun sendRequest(request: SuperAccessDaemon.SuperAccessMessage.Request, onResponse: (Boolean) -> Unit) { + if(server.connections.isEmpty()) { + onResponse(false) + return + } + server.broadcast(SuperAccessDaemon.gson.toJson(request)) server.addResponseReceiver(request.id) { response -> @@ -97,7 +102,7 @@ class SuperAccessDaemonClient { } lock.withLock { - condition.await(2, java.util.concurrent.TimeUnit.SECONDS) + condition.await(3, java.util.concurrent.TimeUnit.SECONDS) } return hasAccess @@ -112,10 +117,19 @@ class SuperAccessDaemonClient { val logger by loggerForThis() - val responseReceiver = mutableMapOf() + private val responseReceiver = mutableMapOf() + private val pendingRequests = mutableMapOf() private var processRestarts = 0 + // Notify all pending requests if the process dies + private fun notifyPendingRequestsOfFailure() { + pendingRequests.forEach { key, value -> + value(SuperAccessDaemon.SuperAccessResponse.Failure(key)) + } + pendingRequests.clear() + } + override fun onOpen(conn: WebSocket, p1: ClientHandshake?) { val hostString = conn.localSocketAddress.hostString if(hostString != "127.0.0.1" && hostString != "localhost" && hostString != "0.0.0.0") { @@ -139,6 +153,7 @@ class SuperAccessDaemonClient { p3: Boolean ) { logger.info("SuperAccessDaemon is gone.") + notifyPendingRequestsOfFailure() // Notify all waiting clients } override fun onMessage(ws: WebSocket, msg: String) { @@ -149,6 +164,8 @@ class SuperAccessDaemonClient { receiver(response) } } + + pendingRequests.remove(response.id) } override fun onError(p0: WebSocket?, p1: Exception?) { @@ -182,12 +199,9 @@ class SuperAccessDaemonClient { } fun addResponseReceiver(id: Int, receiver: ResponseReceiver) { + pendingRequests[id] = receiver responseReceiver[{ it.id == id }] = receiver } - - fun addResponseReceiver(condition: ResponseCondition, receiver: ResponseReceiver) { - responseReceiver[condition] = receiver - } } } \ No newline at end of file From e84daf6026286197a42522cd6527d8ab7a4a78d8 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Tue, 29 Oct 2024 02:06:21 -0600 Subject: [PATCH 34/36] Close PluginOutput on open plugins folder --- .../com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt index 81780730..a602d223 100644 --- a/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt +++ b/EOCV-Sim/src/main/java/com/github/serivesmejia/eocvsim/gui/dialog/PluginOutput.kt @@ -367,6 +367,7 @@ class PluginOutput( val pluginsFolder = PluginManager.PLUGIN_FOLDER if(pluginsFolder.exists() && Desktop.isDesktopSupported()) { + output.isVisible = false Desktop.getDesktop().open(pluginsFolder) } else { JOptionPane.showMessageDialog( From e1376f11e408fc75966d9bb70429d98656d9d437 Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Tue, 29 Oct 2024 02:11:46 -0600 Subject: [PATCH 35/36] Update apriltag plugin to 2.1.0-C --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 0116df38..e5d86bef 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ buildscript { slf4j_version = "2.0.16" log4j_version = "2.24.1" opencv_version = "4.7.0-0" - apriltag_plugin_version = "2.1.0-B" + apriltag_plugin_version = "2.1.0-C" skiko_version = "0.8.15" From 6b3285bfc212b2eadf66ba023cfcd0f996d7a94a Mon Sep 17 00:00:00 2001 From: Sebastian Erives Date: Tue, 29 Oct 2024 02:13:04 -0600 Subject: [PATCH 36/36] Finish up changelog for v3.8.0 --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1d18c1be..61511494 100644 --- a/README.md +++ b/README.md @@ -96,17 +96,18 @@ Join the [deltacv discord server](https://discord.gg/A3RMYzf6DA) ! ## [v3.8.0 - Major Plugin System Rework](https://github.com/deltacv/EOCV-Sim/releases/tag/v3.8.0) - This is the 25th release for EOCV-Sim - Changelog + - Updates AprilTagDesktop to 2.1.0-C, enabling support for Linux AARCH64. - Implements a system that allows for plugins to be downloaded from a maven repository and loaded into the simulator. - New repository.toml file that contains the list of repositories to download plugins from and the maven coordinates to download. - Adds a new dialog to manage the loaded plugins and check the output of the plugin system. - - Rewrites SuperAccess verification to be handled by a separate JVM process, ensuring that the main process is not compromised by malicious code + - Rewrites SuperAccess verification to be handled by a separate JVM process, ensuring that the main process is not compromised by malicious code. - Implements a plugin signature verification system to allow for authors to sign their plugins and ensure that they are not tampered with. - - The signature is stored on a custom format in the plugin jar, the signing authorities are pulled from a public key database + - The signature is stored on a custom format in the plugin jar, the signing authorities are pulled from a public key database. - When making a SuperAccess request, the user is warned if the plugin is not signed or if the signature is invalid. - Adds a KeyGeneratorTool and PluginSigningTool to allow for easy generation of keys and signing of plugins respectively. - Internal changes: - - Adds a PolymorphicAdapter class to allow for easy serialization while retaining type information - - Improvements to the handling of JavaProcess + - Adds a PolymorphicAdapter class to allow for easy serialization while retaining type information. + - Improvements to the handling of JavaProcess. ## [v3.7.1 - Better FTC VisionPortal support & Plugin System Fixes](https://github.com/deltacv/EOCV-Sim/releases/tag/v3.7.1) - This is the 24th release for EOCV-Sim