Directly using VirtualBox from Java #5
8 changed files with 85 additions and 40 deletions
|
@ -2,13 +2,13 @@ package sznp.virtualcomputer;
|
||||||
|
|
||||||
import com.google.common.collect.Lists;
|
import com.google.common.collect.Lists;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.val;
|
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.ChatColor;
|
import org.bukkit.ChatColor;
|
||||||
import org.bukkit.command.CommandSender;
|
import org.bukkit.command.CommandSender;
|
||||||
import org.virtualbox_6_0.*;
|
import org.virtualbox_6_0.*;
|
||||||
import sznp.virtualcomputer.events.MachineEventHandler;
|
import sznp.virtualcomputer.events.MachineEventHandler;
|
||||||
import sznp.virtualcomputer.events.VBoxEventHandler;
|
import sznp.virtualcomputer.events.VBoxEventHandler;
|
||||||
|
import sznp.virtualcomputer.renderer.GPURendererInternal;
|
||||||
import sznp.virtualcomputer.renderer.MCFrameBuffer;
|
import sznp.virtualcomputer.renderer.MCFrameBuffer;
|
||||||
import sznp.virtualcomputer.util.Scancode;
|
import sznp.virtualcomputer.util.Scancode;
|
||||||
|
|
||||||
|
@ -23,6 +23,8 @@ public final class Computer {
|
||||||
private ISession session;
|
private ISession session;
|
||||||
private IVirtualBox vbox;
|
private IVirtualBox vbox;
|
||||||
private IMachine machine;
|
private IMachine machine;
|
||||||
|
private MachineEventHandler handler;
|
||||||
|
private IEventListener listener;
|
||||||
|
|
||||||
@java.beans.ConstructorProperties({"plugin"})
|
@java.beans.ConstructorProperties({"plugin"})
|
||||||
public Computer(PluginMain plugin, ISession session, IVirtualBox vbox) {
|
public Computer(PluginMain plugin, ISession session, IVirtualBox vbox) {
|
||||||
|
@ -49,13 +51,13 @@ public final class Computer {
|
||||||
session.setName("minecraft");
|
session.setName("minecraft");
|
||||||
// machine.launchVMProcess(session, "headless", "").waitForCompletion(10000); - This creates a *process*, we don't want that anymore
|
// machine.launchVMProcess(session, "headless", "").waitForCompletion(10000); - This creates a *process*, we don't want that anymore
|
||||||
machine.lockMachine(session, LockType.VM); // We want the machine inside *our* process <-- Need the VM type to have console access
|
machine.lockMachine(session, LockType.VM); // We want the machine inside *our* process <-- Need the VM type to have console access
|
||||||
VBoxEventHandler.getInstance().setup(machine.getId(), sender);
|
VBoxEventHandler.getInstance().setup(machine.getId(), sender); //TODO: Sometimes null
|
||||||
} catch (VBoxException e) {
|
} catch (VBoxException e) {
|
||||||
if (e.getResultCode() == 0x80070005) { //lockMachine: "The object functionality is limited"
|
if (e.getResultCode() == 0x80070005) { //lockMachine: "The object functionality is limited"
|
||||||
sendMessage(sender, "§6Cannot start computer, the machine may be inaccessible");
|
sendMessage(sender, "§6Cannot start computer, the machine may be inaccessible");
|
||||||
|
sendMessage(sender, "§6Make sure that the server is running as root (sudo)");
|
||||||
//TODO: If we have VirtualBox open, it won't close the server's port
|
//TODO: If we have VirtualBox open, it won't close the server's port
|
||||||
//TODO: Can't detect if machine fails to start because of hardening issues
|
//TODO: "The object in question already exists." on second start
|
||||||
//TODO: This error also occurs if the machine has failed to start at least once (always reassign the machine?)
|
|
||||||
//machine.launchVMProcess(session, "headless", "").waitForCompletion(10000); //No privileges, start the 'old' way
|
//machine.launchVMProcess(session, "headless", "").waitForCompletion(10000); //No privileges, start the 'old' way
|
||||||
//session.getConsole().getDisplay().attachFramebuffer(0L, new IFramebuffer(new MCFrameBuffer(session.getConsole().getDisplay(), false)));
|
//session.getConsole().getDisplay().attachFramebuffer(0L, new IFramebuffer(new MCFrameBuffer(session.getConsole().getDisplay(), false)));
|
||||||
//sendMessage(sender, "§6Computer started with slower screen. Run as root to use a faster method.");
|
//sendMessage(sender, "§6Computer started with slower screen. Run as root to use a faster method.");
|
||||||
|
@ -74,8 +76,8 @@ public final class Computer {
|
||||||
public void onLock(CommandSender sender) {
|
public void onLock(CommandSender sender) {
|
||||||
machine = session.getMachine(); // This is the Machine object we can work with
|
machine = session.getMachine(); // This is the Machine object we can work with
|
||||||
final IConsole console = session.getConsole();
|
final IConsole console = session.getConsole();
|
||||||
val handler = new MachineEventHandler(Computer.this);
|
handler = new MachineEventHandler(Computer.this);
|
||||||
handler.registerTo(console.getEventSource());
|
listener = handler.registerTo(console.getEventSource());
|
||||||
IProgress progress = console.powerUp(); // https://marc.info/?l=vbox-dev&m=142780789819967&w=2
|
IProgress progress = console.powerUp(); // https://marc.info/?l=vbox-dev&m=142780789819967&w=2
|
||||||
handler.registerTo(progress.getEventSource()); //TODO: Show progress bar some way?
|
handler.registerTo(progress.getEventSource()); //TODO: Show progress bar some way?
|
||||||
console.getDisplay().attachFramebuffer(0L,
|
console.getDisplay().attachFramebuffer(0L,
|
||||||
|
@ -99,7 +101,6 @@ public final class Computer {
|
||||||
}
|
}
|
||||||
sendMessage(sender, "§eStopping computer...");
|
sendMessage(sender, "§eStopping computer...");
|
||||||
session.getConsole().powerDown().waitForCompletion(2000);
|
session.getConsole().powerDown().waitForCompletion(2000);
|
||||||
session.unlockMachine();
|
|
||||||
sendMessage(sender, "§eComputer stopped.");
|
sendMessage(sender, "§eComputer stopped.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -192,7 +193,26 @@ public final class Computer {
|
||||||
}
|
}
|
||||||
|
|
||||||
public void onMachineStop() {
|
public void onMachineStop() {
|
||||||
session.unlockMachine();
|
System.out.println("Unlocking machine...");
|
||||||
plugin.getLogger().info("Computer powered off.");
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
if (session.getState() == SessionState.Locked) {
|
||||||
|
System.out.println("Unlocking...");
|
||||||
|
session.unlockMachine(); //Needs to be outside of the event handler
|
||||||
|
System.out.println("Machine unlocked.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
System.out.println("Setting pixels...");
|
||||||
|
GPURendererInternal.setPixels(new byte[1], 0, 0); //Black screen
|
||||||
|
System.out.println("Stopping events...");
|
||||||
|
stopEvents();
|
||||||
|
plugin.getLogger().info("Computer powered off.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stopEvents() {
|
||||||
|
/*if(session!=null && listener!=null)
|
||||||
|
session.getConsole().getEventSource().unregisterListener(listener);*/
|
||||||
|
if (listener != null)
|
||||||
|
handler.disable();
|
||||||
|
listener = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,6 +27,8 @@ public class PluginMain extends JavaPlugin {
|
||||||
private IMachine machine;
|
private IMachine machine;
|
||||||
private BukkitTask screenupdatetask;
|
private BukkitTask screenupdatetask;
|
||||||
private BukkitTask mousetask;
|
private BukkitTask mousetask;
|
||||||
|
private IEventListener listener;
|
||||||
|
private IEventSource source;
|
||||||
|
|
||||||
public static PluginMain Instance;
|
public static PluginMain Instance;
|
||||||
//public static ByteBuffer allpixels = ByteBuffer.allocate(640 * 480 * 4); // It's set on each change
|
//public static ByteBuffer allpixels = ByteBuffer.allocate(640 * 480 * 4); // It's set on each change
|
||||||
|
@ -70,7 +72,7 @@ public class PluginMain extends JavaPlugin {
|
||||||
VBoxLib vbl = LibraryLoader.create(VBoxLib.class).load("vboxjxpcom");
|
VBoxLib vbl = LibraryLoader.create(VBoxLib.class).load("vboxjxpcom");
|
||||||
vbl.RTR3InitExe(0, "", 0);
|
vbl.RTR3InitExe(0, "", 0);
|
||||||
vbox = manager.getVBox();
|
vbox = manager.getVBox();
|
||||||
new VBoxEventHandler().registerTo(vbox.getEventSource());
|
listener = new VBoxEventHandler().registerTo(source = vbox.getEventSource());
|
||||||
session = manager.getSessionObject();
|
session = manager.getSessionObject();
|
||||||
new Computer(this, session, vbox); //Saves itself
|
new Computer(this, session, vbox); //Saves itself
|
||||||
ccs.sendMessage("§bLoading Screen...");
|
ccs.sendMessage("§bLoading Screen...");
|
||||||
|
@ -109,6 +111,14 @@ public class PluginMain extends JavaPlugin {
|
||||||
public void onDisable() {
|
public void onDisable() {
|
||||||
ConsoleCommandSender ccs = getServer().getConsoleSender();
|
ConsoleCommandSender ccs = getServer().getConsoleSender();
|
||||||
mousetask.cancel();
|
mousetask.cancel();
|
||||||
|
/*try {
|
||||||
|
source.unregisterListener(listener);
|
||||||
|
} catch (VBoxException e) { //"Listener was never registered"
|
||||||
|
e.printStackTrace(); - VBox claims the listener was never registered (can double register as well)
|
||||||
|
}*/
|
||||||
|
((VBoxEventHandler) listener.getTypedWrapped()).disable(); //The save progress wait locks with the event
|
||||||
|
if (Computer.getInstance() != null)
|
||||||
|
Computer.getInstance().stopEvents();
|
||||||
if (session.getState() == SessionState.Locked) {
|
if (session.getState() == SessionState.Locked) {
|
||||||
if (session.getMachine().getState().equals(MachineState.Running)) {
|
if (session.getMachine().getState().equals(MachineState.Running)) {
|
||||||
ccs.sendMessage("§aSaving machine state...");
|
ccs.sendMessage("§aSaving machine state...");
|
||||||
|
|
|
@ -22,6 +22,7 @@ public abstract class EventHandlerBase extends COMObjectBase implements IEventLi
|
||||||
* The events to listen for. It will only look for these handlers.
|
* The events to listen for. It will only look for these handlers.
|
||||||
*/
|
*/
|
||||||
private final Map<VBoxEventType, Class<? extends org.virtualbox_6_0.IEvent>> eventMap;
|
private final Map<VBoxEventType, Class<? extends org.virtualbox_6_0.IEvent>> eventMap;
|
||||||
|
private boolean enabled = true;
|
||||||
|
|
||||||
protected EventHandlerBase(Map<VBoxEventType, Class<? extends org.virtualbox_6_0.IEvent>> eventMap) {
|
protected EventHandlerBase(Map<VBoxEventType, Class<? extends org.virtualbox_6_0.IEvent>> eventMap) {
|
||||||
//this.eventMap = eventMap.entrySet().stream().collect(Collectors.toMap(e -> e.getKey().value(), Map.Entry::getValue));
|
//this.eventMap = eventMap.entrySet().stream().collect(Collectors.toMap(e -> e.getKey().value(), Map.Entry::getValue));
|
||||||
|
@ -31,6 +32,8 @@ public abstract class EventHandlerBase extends COMObjectBase implements IEventLi
|
||||||
@Override
|
@Override
|
||||||
public final void handleEvent(IEvent iEvent) {
|
public final void handleEvent(IEvent iEvent) {
|
||||||
//val cl=eventMap.get((int)iEvent.getType()); - We can afford to search through the events for this handler
|
//val cl=eventMap.get((int)iEvent.getType()); - We can afford to search through the events for this handler
|
||||||
|
if (!enabled)
|
||||||
|
return;
|
||||||
val kv = eventMap.entrySet().stream().filter(e -> e.getKey().value() == iEvent.getType()).findAny();
|
val kv = eventMap.entrySet().stream().filter(e -> e.getKey().value() == iEvent.getType()).findAny();
|
||||||
if (!kv.isPresent()) return; //Event not supported
|
if (!kv.isPresent()) return; //Event not supported
|
||||||
val cl = kv.get().getValue();
|
val cl = kv.get().getValue();
|
||||||
|
@ -50,7 +53,11 @@ public abstract class EventHandlerBase extends COMObjectBase implements IEventLi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public <T extends EventHandlerBase> void registerTo(IEventSource source) {
|
public <T extends EventHandlerBase> org.virtualbox_6_0.IEventListener registerTo(IEventSource source) {
|
||||||
Utils.registerListener(source, this, new ArrayList<>(eventMap.keySet()));
|
return Utils.registerListener(source, this, new ArrayList<>(eventMap.keySet()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void disable() {
|
||||||
|
enabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import sznp.virtualcomputer.Computer;
|
||||||
|
|
||||||
public class MachineEventHandler extends EventHandlerBase {
|
public class MachineEventHandler extends EventHandlerBase {
|
||||||
private final Computer computer;
|
private final Computer computer;
|
||||||
|
private boolean starting = false;
|
||||||
|
|
||||||
public MachineEventHandler(Computer computer) {
|
public MachineEventHandler(Computer computer) {
|
||||||
super(ImmutableMap.of(VBoxEventType.OnStateChanged, IStateChangedEvent.class,
|
super(ImmutableMap.of(VBoxEventType.OnStateChanged, IStateChangedEvent.class,
|
||||||
|
@ -18,13 +19,26 @@ public class MachineEventHandler extends EventHandlerBase {
|
||||||
|
|
||||||
@EventHandler
|
@EventHandler
|
||||||
public void handleStateChange(IStateChangedEvent event) { //https://www.virtualbox.org/sdkref/_virtual_box_8idl.html#a80b08f71210afe16038e904a656ed9eb
|
public void handleStateChange(IStateChangedEvent event) { //https://www.virtualbox.org/sdkref/_virtual_box_8idl.html#a80b08f71210afe16038e904a656ed9eb
|
||||||
|
System.out.println("State event: " + event.getState());
|
||||||
switch (event.getState()) {
|
switch (event.getState()) {
|
||||||
case Stuck:
|
case Stuck:
|
||||||
computer.Stop(null);
|
computer.Stop(null);
|
||||||
break;
|
break;
|
||||||
case PoweredOff:
|
case PoweredOff:
|
||||||
case Saved:
|
case Saved:
|
||||||
|
if (starting) {
|
||||||
|
System.out.println("Failed to start computer! See the VM's log for more details.");
|
||||||
|
starting = false; //TODO: Sender
|
||||||
|
}
|
||||||
computer.onMachineStop();
|
computer.onMachineStop();
|
||||||
}
|
break;
|
||||||
|
case Starting:
|
||||||
|
starting = true;
|
||||||
|
break;
|
||||||
|
case Running:
|
||||||
|
System.out.println("Computer is running.");
|
||||||
|
starting = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,10 +22,7 @@ public class VBoxEventHandler extends EventHandlerBase {
|
||||||
|
|
||||||
@EventHandler
|
@EventHandler
|
||||||
public void onSessionStateChange(ISessionStateChangedEvent event) {
|
public void onSessionStateChange(ISessionStateChangedEvent event) {
|
||||||
System.out.println("Session change event: " + event);
|
|
||||||
System.out.println("ID1: " + event.getMachineId() + " - ID2: " + machineID);
|
|
||||||
if (!event.getMachineId().equals(machineID)) return;
|
if (!event.getMachineId().equals(machineID)) return;
|
||||||
System.out.println("State: " + event.getState());
|
|
||||||
if (event.getState() == SessionState.Locked) //Need to check here, because we can't access the console yet
|
if (event.getState() == SessionState.Locked) //Need to check here, because we can't access the console yet
|
||||||
Computer.getInstance().onLock(sender);
|
Computer.getInstance().onLock(sender);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
package sznp.virtualcomputer.renderer;
|
package sznp.virtualcomputer.renderer;
|
||||||
|
|
||||||
import lombok.val;
|
|
||||||
import net.minecraft.server.v1_12_R1.WorldMap;
|
import net.minecraft.server.v1_12_R1.WorldMap;
|
||||||
import org.bukkit.World;
|
import org.bukkit.World;
|
||||||
import org.bukkit.craftbukkit.v1_12_R1.entity.CraftPlayer;
|
|
||||||
import org.bukkit.craftbukkit.v1_12_R1.map.RenderData;
|
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.map.MapCanvas;
|
import org.bukkit.map.MapCanvas;
|
||||||
import org.bukkit.map.MapPalette;
|
import org.bukkit.map.MapPalette;
|
||||||
|
@ -14,23 +11,20 @@ import sznp.virtualcomputer.util.Timing;
|
||||||
|
|
||||||
import java.awt.*;
|
import java.awt.*;
|
||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public class GPURenderer extends MapRenderer implements IRenderer {
|
public class GPURenderer extends MapRenderer implements IRenderer {
|
||||||
private byte[] buffer;
|
private byte[] buffer;
|
||||||
private GPURendererInternal kernel;
|
private GPURendererInternal kernel;
|
||||||
|
private WorldMap wmap;
|
||||||
//Store at central location after conversion
|
//Store at central location after conversion
|
||||||
private static int[] colors_;
|
private static int[] colors_;
|
||||||
|
|
||||||
public GPURenderer(short id, World world, int mapx, int mapy) throws Exception {
|
public GPURenderer(short id, World world, int mapx, int mapy) throws Exception {
|
||||||
MapView map = IRenderer.prepare(id, world);
|
MapView map = IRenderer.prepare(id, world);
|
||||||
if (map == null) return; //Testing
|
if (map == null) return; //Testing
|
||||||
Field field = map.getClass().getDeclaredField("renderCache");
|
|
||||||
field.setAccessible(true);
|
|
||||||
@SuppressWarnings("unchecked") val renderCache = (Map<CraftPlayer, RenderData>) field.get(map);
|
|
||||||
|
|
||||||
if (colors_ == null) {
|
if (colors_ == null) {
|
||||||
field = MapPalette.class.getDeclaredField("colors");
|
Field field = MapPalette.class.getDeclaredField("colors");
|
||||||
field.setAccessible(true);
|
field.setAccessible(true);
|
||||||
Color[] cs = (Color[]) field.get(null);
|
Color[] cs = (Color[]) field.get(null);
|
||||||
colors_ = new int[cs.length];
|
colors_ = new int[cs.length];
|
||||||
|
@ -38,15 +32,11 @@ public class GPURenderer extends MapRenderer implements IRenderer {
|
||||||
colors_[i] = cs[i].getRGB();
|
colors_[i] = cs[i].getRGB();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Field field = map.getClass().getDeclaredField("worldMap");
|
||||||
|
field.setAccessible(true);
|
||||||
|
wmap = (WorldMap) field.get(map);
|
||||||
kernel = new GPURendererInternal(mapx, mapy, colors_);
|
kernel = new GPURendererInternal(mapx, mapy, colors_);
|
||||||
|
|
||||||
RenderData render = renderCache.get(null);
|
|
||||||
|
|
||||||
if (render == null)
|
|
||||||
renderCache.put(null, render = new RenderData());
|
|
||||||
|
|
||||||
this.buffer = render.buffer;
|
|
||||||
|
|
||||||
//System.setProperty("com.codegen.config.enable.NEW", "true");
|
//System.setProperty("com.codegen.config.enable.NEW", "true");
|
||||||
|
|
||||||
map.addRenderer(this);
|
map.addRenderer(this);
|
||||||
|
@ -57,13 +47,12 @@ public class GPURenderer extends MapRenderer implements IRenderer {
|
||||||
Timing t = new Timing();
|
Timing t = new Timing();
|
||||||
try {
|
try {
|
||||||
if (kernel.isRendered()) return; //TODO: Stop rendering after computer is stopped
|
if (kernel.isRendered()) return; //TODO: Stop rendering after computer is stopped
|
||||||
Field field = canvas.getClass().getDeclaredField("buffer");
|
if (buffer == null) { //The buffer remains the same, as the canvas remains the same
|
||||||
field.setAccessible(true);
|
Field field = canvas.getClass().getDeclaredField("buffer");
|
||||||
buffer = (byte[]) field.get(canvas);
|
field.setAccessible(true);
|
||||||
|
buffer = (byte[]) field.get(canvas);
|
||||||
|
}
|
||||||
kernel.render(buffer);
|
kernel.render(buffer);
|
||||||
field = map.getClass().getDeclaredField("worldMap");
|
|
||||||
field.setAccessible(true);
|
|
||||||
WorldMap wmap = (WorldMap) field.get(map);
|
|
||||||
wmap.flagDirty(0, 0);
|
wmap.flagDirty(0, 0);
|
||||||
wmap.flagDirty(127, 127); // Send the whole image - TODO: Only send changes
|
wmap.flagDirty(127, 127); // Send the whole image - TODO: Only send changes
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|
|
@ -20,7 +20,7 @@ public class GPURendererInternal extends Kernel {
|
||||||
private byte[] buffer; //References the map buffer
|
private byte[] buffer; //References the map buffer
|
||||||
private Range range;
|
private Range range;
|
||||||
@Getter
|
@Getter
|
||||||
private boolean rendered = true;
|
private boolean rendered;
|
||||||
private static ArrayList<GPURendererInternal> renderers = new ArrayList<>();
|
private static ArrayList<GPURendererInternal> renderers = new ArrayList<>();
|
||||||
|
|
||||||
//public static byte[] test=new byte[1]; - LAMBDAS
|
//public static byte[] test=new byte[1]; - LAMBDAS
|
||||||
|
@ -29,6 +29,12 @@ public class GPURendererInternal extends Kernel {
|
||||||
this.mapy = mapy;
|
this.mapy = mapy;
|
||||||
this.colors = colors;
|
this.colors = colors;
|
||||||
range = Range.create2D(128, 128);
|
range = Range.create2D(128, 128);
|
||||||
|
|
||||||
|
//Do an intial draw of a black screen with Aparapi so it doesn't lag at start
|
||||||
|
pixels = new byte[1];
|
||||||
|
width = height = 0;
|
||||||
|
rendered = false;
|
||||||
|
|
||||||
renderers.add(this);
|
renderers.add(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -40,8 +40,10 @@ public class Utils {
|
||||||
}
|
}
|
||||||
|
|
||||||
//public static void registerListener(IEventSource source, IEventListener listener, VBoxEventType... types) {
|
//public static void registerListener(IEventSource source, IEventListener listener, VBoxEventType... types) {
|
||||||
public static void registerListener(IEventSource source, IEventListener listener, List<VBoxEventType> types) {
|
public static org.virtualbox_6_0.IEventListener registerListener(IEventSource source, IEventListener listener, List<VBoxEventType> types) {
|
||||||
source.registerListener(new org.virtualbox_6_0.IEventListener(listener), types, true);
|
val ret = new org.virtualbox_6_0.IEventListener(listener);
|
||||||
|
source.registerListener(ret, types, true);
|
||||||
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
|
|
Loading…
Reference in a new issue