parent
10af945f36
commit
625264b6d8
@ -0,0 +1,203 @@ |
||||
package com.jme3.input.ios; |
||||
|
||||
import com.jme3.input.RawInputListener; |
||||
import com.jme3.input.TouchInput; |
||||
import com.jme3.input.event.InputEvent; |
||||
import com.jme3.input.event.KeyInputEvent; |
||||
import com.jme3.input.event.MouseButtonEvent; |
||||
import com.jme3.input.event.MouseMotionEvent; |
||||
import com.jme3.input.event.TouchEvent; |
||||
import com.jme3.system.AppSettings; |
||||
import java.util.concurrent.ConcurrentLinkedQueue; |
||||
import java.util.logging.Level; |
||||
import java.util.logging.Logger; |
||||
|
||||
public class IosInputHandler implements TouchInput { |
||||
private static final Logger logger = Logger.getLogger(IosInputHandler.class.getName()); |
||||
|
||||
private final static int MAX_TOUCH_EVENTS = 1024; |
||||
|
||||
// Custom settings
|
||||
private boolean mouseEventsEnabled = true; |
||||
private boolean mouseEventsInvertX = false; |
||||
private boolean mouseEventsInvertY = false; |
||||
private boolean keyboardEventsEnabled = false; |
||||
private boolean dontSendHistory = false; |
||||
|
||||
// Internal
|
||||
private boolean initialized = false; |
||||
private RawInputListener listener = null; |
||||
private ConcurrentLinkedQueue<InputEvent> inputEventQueue = new ConcurrentLinkedQueue<InputEvent>(); |
||||
private final TouchEventPool touchEventPool = new TouchEventPool(MAX_TOUCH_EVENTS); |
||||
private IosTouchHandler touchHandler; |
||||
private float scaleX = 1f; |
||||
private float scaleY = 1f; |
||||
private int width = 0; |
||||
private int height = 0; |
||||
|
||||
public IosInputHandler() { |
||||
touchHandler = new IosTouchHandler(this); |
||||
} |
||||
@Override |
||||
public void initialize() { |
||||
touchEventPool.initialize(); |
||||
if (touchHandler != null) { |
||||
touchHandler.initialize(); |
||||
} |
||||
initialized = true; |
||||
} |
||||
|
||||
@Override |
||||
public void update() { |
||||
logger.log(Level.FINE, "InputEvent update : {0}", |
||||
new Object[]{listener}); |
||||
if (listener != null) { |
||||
InputEvent inputEvent; |
||||
|
||||
while ((inputEvent = inputEventQueue.poll()) != null) { |
||||
if (inputEvent instanceof TouchEvent) { |
||||
listener.onTouchEvent((TouchEvent)inputEvent); |
||||
} else if (inputEvent instanceof MouseButtonEvent) { |
||||
listener.onMouseButtonEvent((MouseButtonEvent)inputEvent); |
||||
} else if (inputEvent instanceof MouseMotionEvent) { |
||||
listener.onMouseMotionEvent((MouseMotionEvent)inputEvent); |
||||
} else if (inputEvent instanceof KeyInputEvent) { |
||||
listener.onKeyEvent((KeyInputEvent)inputEvent); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void destroy() { |
||||
initialized = false; |
||||
touchEventPool.destroy(); |
||||
if (touchHandler != null) { |
||||
touchHandler.destroy(); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public boolean isInitialized() { |
||||
return initialized; |
||||
} |
||||
|
||||
@Override |
||||
public void setInputListener(RawInputListener listener) { |
||||
this.listener = listener; |
||||
} |
||||
|
||||
@Override |
||||
public long getInputTimeNanos() { |
||||
return System.nanoTime(); |
||||
} |
||||
|
||||
@Override |
||||
public void setSimulateMouse(boolean simulate) { |
||||
this.mouseEventsEnabled = simulate; |
||||
} |
||||
|
||||
@Override |
||||
public boolean getSimulateMouse() { |
||||
return mouseEventsEnabled; |
||||
} |
||||
|
||||
@Override |
||||
public boolean isSimulateMouse() { |
||||
return mouseEventsEnabled; |
||||
} |
||||
|
||||
@Override |
||||
public void setSimulateKeyboard(boolean simulate) { |
||||
this.keyboardEventsEnabled = simulate; |
||||
} |
||||
|
||||
@Override |
||||
public void setOmitHistoricEvents(boolean dontSendHistory) { |
||||
this.dontSendHistory = dontSendHistory; |
||||
} |
||||
|
||||
@Override |
||||
public void showVirtualKeyboard(boolean visible) { |
||||
throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates.
|
||||
} |
||||
|
||||
// ----------------
|
||||
|
||||
public void loadSettings(AppSettings settings) { |
||||
// TODO: add simulate keyboard to settings
|
||||
// keyboardEventsEnabled = true;
|
||||
mouseEventsEnabled = true;//settings.isEmulateMouse();
|
||||
mouseEventsInvertX = settings.isEmulateMouseFlipX(); |
||||
mouseEventsInvertY = settings.isEmulateMouseFlipY(); |
||||
|
||||
// view width and height are 0 until the view is displayed on the screen
|
||||
//if (view.getWidth() != 0 && view.getHeight() != 0) {
|
||||
// scaleX = (float)settings.getWidth() / (float)view.getWidth();
|
||||
// scaleY = (float)settings.getHeight() / (float)view.getHeight();
|
||||
//}
|
||||
scaleX = 1.0f; |
||||
scaleY = 1.0f; |
||||
width = settings.getWidth(); |
||||
height = settings.getHeight(); |
||||
logger.log(Level.FINE, "Setting input scaling, scaleX: {0}, scaleY: {1}, width: {2}, height: {3}", |
||||
new Object[]{scaleX, scaleY, width, height}); |
||||
} |
||||
|
||||
public boolean isMouseEventsInvertX() { |
||||
return mouseEventsInvertX; |
||||
} |
||||
|
||||
public boolean isMouseEventsInvertY() { |
||||
return mouseEventsInvertY; |
||||
} |
||||
|
||||
public float invertX(float origX) { |
||||
return getJmeX(width) - origX; |
||||
} |
||||
|
||||
public float invertY(float origY) { |
||||
return getJmeY(height) - origY; |
||||
} |
||||
|
||||
public float getJmeX(float origX) { |
||||
return origX * scaleX; |
||||
} |
||||
|
||||
public float getJmeY(float origY) { |
||||
return origY * scaleY; |
||||
} |
||||
|
||||
public TouchEvent getFreeTouchEvent() { |
||||
return touchEventPool.getNextFreeEvent(); |
||||
} |
||||
|
||||
public void addEvent(InputEvent event) { |
||||
inputEventQueue.add(event); |
||||
if (event instanceof TouchEvent) { |
||||
touchEventPool.storeEvent((TouchEvent)event); |
||||
} |
||||
} |
||||
|
||||
// ----------------
|
||||
|
||||
public void injectTouchDown(int pointerId, long time, float x, float y) { |
||||
logger.log(Level.FINE, "Using input scaling, scaleX: {0}, scaleY: {1}, width: {2}, height: {3}", |
||||
new Object[]{scaleX, scaleY, width, height}); |
||||
if (touchHandler != null) { |
||||
touchHandler.actionDown(pointerId, time, x, y); |
||||
} |
||||
} |
||||
|
||||
public void injectTouchUp(int pointerId, long time, float x, float y) { |
||||
if (touchHandler != null) { |
||||
touchHandler.actionUp(pointerId, time, x, y); |
||||
} |
||||
} |
||||
|
||||
public void injectTouchMove(int pointerId, long time, float x, float y) { |
||||
if (touchHandler != null) { |
||||
touchHandler.actionMove(pointerId, time, x, y); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,191 @@ |
||||
/* |
||||
* Copyright (c) 2009-2012 jMonkeyEngine |
||||
* All rights reserved. |
||||
* |
||||
* Redistribution and use in source and binary forms, with or without |
||||
* modification, are permitted 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 'jMonkeyEngine' nor the names of its contributors |
||||
* may be used to endorse or promote products derived from this software |
||||
* without specific prior written permission. |
||||
* |
||||
* 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 com.jme3.input.ios; |
||||
|
||||
import com.jme3.input.event.InputEvent; |
||||
import com.jme3.input.event.MouseButtonEvent; |
||||
import com.jme3.input.event.MouseMotionEvent; |
||||
import com.jme3.input.event.TouchEvent; |
||||
import static com.jme3.input.event.TouchEvent.Type.DOWN; |
||||
import static com.jme3.input.event.TouchEvent.Type.MOVE; |
||||
import static com.jme3.input.event.TouchEvent.Type.UP; |
||||
import com.jme3.math.Vector2f; |
||||
import java.util.HashMap; |
||||
import java.util.logging.Level; |
||||
import java.util.logging.Logger; |
||||
|
||||
/** |
||||
* AndroidTouchHandler is the base class that receives touch inputs from the |
||||
* Android system and creates the TouchEvents for jME. This class is designed |
||||
* to handle the base touch events for Android rev 9 (Android 2.3). This is |
||||
* extended by other classes to add features that were introducted after |
||||
* Android rev 9. |
||||
* |
||||
* @author iwgeric |
||||
*/ |
||||
public class IosTouchHandler { |
||||
private static final Logger logger = Logger.getLogger(IosTouchHandler.class.getName()); |
||||
|
||||
final private HashMap<Integer, Vector2f> lastPositions = new HashMap<Integer, Vector2f>(); |
||||
|
||||
protected int numPointers = 1; |
||||
|
||||
protected IosInputHandler iosInput; |
||||
|
||||
public IosTouchHandler(IosInputHandler iosInput) { |
||||
this.iosInput = iosInput; |
||||
} |
||||
|
||||
public void initialize() { |
||||
} |
||||
|
||||
public void destroy() { |
||||
} |
||||
|
||||
public void actionDown(int pointerId, long time, float x, float y) { |
||||
logger.log(Level.FINE, "Inject input pointer: {0}, time: {1}, x: {2}, y: {3}", |
||||
new Object[]{pointerId, time, x, y}); |
||||
float jmeX = iosInput.getJmeX(x); |
||||
float jmeY = iosInput.invertY(iosInput.getJmeY(y)); |
||||
TouchEvent touch = iosInput.getFreeTouchEvent(); |
||||
touch.set(TouchEvent.Type.DOWN, jmeX, jmeY, 0, 0); |
||||
touch.setPointerId(pointerId);//TODO: pointer ID
|
||||
touch.setTime(time); |
||||
touch.setPressure(1.0f); |
||||
//touch.setPressure(event.getPressure(pointerIndex)); //TODO: preassure
|
||||
|
||||
lastPositions.put(pointerId, new Vector2f(jmeX, jmeY)); |
||||
|
||||
processEvent(touch); |
||||
} |
||||
|
||||
public void actionUp(int pointerId, long time, float x, float y) { |
||||
float jmeX = iosInput.getJmeX(x); |
||||
float jmeY = iosInput.invertY(iosInput.getJmeY(y)); |
||||
TouchEvent touch = iosInput.getFreeTouchEvent(); |
||||
touch.set(TouchEvent.Type.UP, jmeX, jmeY, 0, 0); |
||||
touch.setPointerId(pointerId);//TODO: pointer ID
|
||||
touch.setTime(time); |
||||
touch.setPressure(1.0f); |
||||
//touch.setPressure(event.getPressure(pointerIndex)); //TODO: preassure
|
||||
lastPositions.remove(pointerId); |
||||
|
||||
processEvent(touch); |
||||
} |
||||
|
||||
public void actionMove(int pointerId, long time, float x, float y) { |
||||
float jmeX = iosInput.getJmeX(x); |
||||
float jmeY = iosInput.invertY(iosInput.getJmeY(y)); |
||||
Vector2f lastPos = lastPositions.get(pointerId); |
||||
if (lastPos == null) { |
||||
lastPos = new Vector2f(jmeX, jmeY); |
||||
lastPositions.put(pointerId, lastPos); |
||||
} |
||||
|
||||
float dX = jmeX - lastPos.x; |
||||
float dY = jmeY - lastPos.y; |
||||
if (dX != 0 || dY != 0) { |
||||
TouchEvent touch = iosInput.getFreeTouchEvent(); |
||||
touch.set(TouchEvent.Type.MOVE, jmeX, jmeY, dX, dY); |
||||
touch.setPointerId(pointerId); |
||||
touch.setTime(time); |
||||
touch.setPressure(1.0f); |
||||
//touch.setPressure(event.getPressure(p));
|
||||
lastPos.set(jmeX, jmeY); |
||||
|
||||
processEvent(touch); |
||||
} |
||||
} |
||||
|
||||
protected void processEvent(TouchEvent event) { |
||||
// Add the touch event
|
||||
iosInput.addEvent(event); |
||||
// MouseEvents do not support multi-touch, so only evaluate 1 finger pointer events
|
||||
if (iosInput.isSimulateMouse() && numPointers == 1) { |
||||
InputEvent mouseEvent = generateMouseEvent(event); |
||||
if (mouseEvent != null) { |
||||
// Add the mouse event
|
||||
iosInput.addEvent(mouseEvent); |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
// TODO: Ring Buffer for mouse events?
|
||||
protected InputEvent generateMouseEvent(TouchEvent event) { |
||||
InputEvent inputEvent = null; |
||||
int newX; |
||||
int newY; |
||||
int newDX; |
||||
int newDY; |
||||
|
||||
if (iosInput.isMouseEventsInvertX()) { |
||||
newX = (int) (iosInput.invertX(event.getX())); |
||||
newDX = (int)event.getDeltaX() * -1; |
||||
} else { |
||||
newX = (int) event.getX(); |
||||
newDX = (int)event.getDeltaX(); |
||||
} |
||||
|
||||
if (iosInput.isMouseEventsInvertY()) { |
||||
newY = (int) (iosInput.invertY(event.getY())); |
||||
newDY = (int)event.getDeltaY() * -1; |
||||
} else { |
||||
newY = (int) event.getY(); |
||||
newDY = (int)event.getDeltaY(); |
||||
} |
||||
|
||||
switch (event.getType()) { |
||||
case DOWN: |
||||
// Handle mouse down event
|
||||
inputEvent = new MouseButtonEvent(0, true, newX, newY); |
||||
inputEvent.setTime(event.getTime()); |
||||
break; |
||||
|
||||
case UP: |
||||
// Handle mouse up event
|
||||
inputEvent = new MouseButtonEvent(0, false, newX, newY); |
||||
inputEvent.setTime(event.getTime()); |
||||
break; |
||||
|
||||
case HOVER_MOVE: |
||||
case MOVE: |
||||
inputEvent = new MouseMotionEvent(newX, newY, newDX, newDY, (int)event.getScaleSpan(), (int)event.getDeltaScaleSpan()); |
||||
inputEvent.setTime(event.getTime()); |
||||
break; |
||||
} |
||||
|
||||
return inputEvent; |
||||
} |
||||
|
||||
} |
@ -0,0 +1,121 @@ |
||||
/* |
||||
* Copyright (c) 2009-2012 jMonkeyEngine |
||||
* All rights reserved. |
||||
* |
||||
* Redistribution and use in source and binary forms, with or without |
||||
* modification, are permitted 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 'jMonkeyEngine' nor the names of its contributors |
||||
* may be used to endorse or promote products derived from this software |
||||
* without specific prior written permission. |
||||
* |
||||
* 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 com.jme3.input.ios; |
||||
|
||||
import com.jme3.input.event.TouchEvent; |
||||
import com.jme3.util.RingBuffer; |
||||
import java.util.logging.Logger; |
||||
|
||||
/** |
||||
* TouchEventPool provides a RingBuffer of jME TouchEvents to help with garbage |
||||
* collection on Android. Each TouchEvent is stored in the RingBuffer and is |
||||
* reused if the TouchEvent has been consumed. |
||||
* |
||||
* If a TouchEvent has not been consumed, it is placed back into the pool at the |
||||
* end for later use. If a TouchEvent has been consumed, it is reused to avoid |
||||
* creating lots of little objects. |
||||
* |
||||
* If the pool is full of unconsumed events, then a new event is created and provided. |
||||
* |
||||
* |
||||
* @author iwgeric |
||||
*/ |
||||
public class TouchEventPool { |
||||
private static final Logger logger = Logger.getLogger(TouchEventPool.class.getName()); |
||||
private final RingBuffer<TouchEvent> eventPool; |
||||
private final int maxEvents; |
||||
|
||||
public TouchEventPool (int maxEvents) { |
||||
eventPool = new RingBuffer<TouchEvent>(maxEvents); |
||||
this.maxEvents = maxEvents; |
||||
} |
||||
|
||||
public void initialize() { |
||||
TouchEvent newEvent; |
||||
while (!eventPool.isEmpty()) { |
||||
eventPool.pop(); |
||||
} |
||||
for (int i = 0; i < maxEvents; i++) { |
||||
newEvent = new TouchEvent(); |
||||
newEvent.setConsumed(); |
||||
eventPool.push(newEvent); |
||||
} |
||||
} |
||||
|
||||
public void destroy() { |
||||
// Clean up queues
|
||||
while (!eventPool.isEmpty()) { |
||||
eventPool.pop(); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Fetches a touch event from the reuse pool |
||||
* |
||||
* @return a usable TouchEvent |
||||
*/ |
||||
public TouchEvent getNextFreeEvent() { |
||||
TouchEvent evt = null; |
||||
int curSize = eventPool.size(); |
||||
while (curSize > 0) { |
||||
evt = (TouchEvent)eventPool.pop(); |
||||
if (evt.isConsumed()) { |
||||
break; |
||||
} else { |
||||
eventPool.push(evt); |
||||
evt = null; |
||||
} |
||||
curSize--; |
||||
} |
||||
|
||||
if (evt == null) { |
||||
logger.warning("eventPool full of unconsumed events"); |
||||
evt = new TouchEvent(); |
||||
} |
||||
return evt; |
||||
} |
||||
|
||||
/** |
||||
* Stores the TouchEvent back in the pool for later reuse. It is only reused |
||||
* if the TouchEvent has been consumed. |
||||
* |
||||
* @param event TouchEvent to store for later use if consumed. |
||||
*/ |
||||
public void storeEvent(TouchEvent event) { |
||||
if (eventPool.size() < maxEvents) { |
||||
eventPool.push(event); |
||||
} else { |
||||
logger.warning("eventPool full"); |
||||
} |
||||
} |
||||
|
||||
} |
@ -0,0 +1,76 @@ |
||||
package com.jme3.util; |
||||
|
||||
import java.util.Iterator; |
||||
import java.util.NoSuchElementException; |
||||
|
||||
/** |
||||
* Ring buffer (fixed size queue) implementation using a circular array (array |
||||
* with wrap-around). |
||||
*/ |
||||
// suppress unchecked warnings in Java 1.5.0_6 and later
|
||||
@SuppressWarnings("unchecked") |
||||
public class RingBuffer<T> implements Iterable<T> { |
||||
|
||||
private T[] buffer; // queue elements
|
||||
private int count = 0; // number of elements on queue
|
||||
private int indexOut = 0; // index of first element of queue
|
||||
private int indexIn = 0; // index of next available slot
|
||||
|
||||
// cast needed since no generic array creation in Java
|
||||
public RingBuffer(int capacity) { |
||||
buffer = (T[]) new Object[capacity]; |
||||
} |
||||
|
||||
public boolean isEmpty() { |
||||
return count == 0; |
||||
} |
||||
|
||||
public int size() { |
||||
return count; |
||||
} |
||||
|
||||
public void push(T item) { |
||||
if (count == buffer.length) { |
||||
throw new RuntimeException("Ring buffer overflow"); |
||||
} |
||||
buffer[indexIn] = item; |
||||
indexIn = (indexIn + 1) % buffer.length; // wrap-around
|
||||
count++; |
||||
} |
||||
|
||||
public T pop() { |
||||
if (isEmpty()) { |
||||
throw new RuntimeException("Ring buffer underflow"); |
||||
} |
||||
T item = buffer[indexOut]; |
||||
buffer[indexOut] = null; // to help with garbage collection
|
||||
count--; |
||||
indexOut = (indexOut + 1) % buffer.length; // wrap-around
|
||||
return item; |
||||
} |
||||
|
||||
public Iterator<T> iterator() { |
||||
return new RingBufferIterator(); |
||||
} |
||||
|
||||
// an iterator, doesn't implement remove() since it's optional
|
||||
private class RingBufferIterator implements Iterator<T> { |
||||
|
||||
private int i = 0; |
||||
|
||||
public boolean hasNext() { |
||||
return i < count; |
||||
} |
||||
|
||||
public void remove() { |
||||
throw new UnsupportedOperationException(); |
||||
} |
||||
|
||||
public T next() { |
||||
if (!hasNext()) { |
||||
throw new NoSuchElementException(); |
||||
} |
||||
return buffer[i++]; |
||||
} |
||||
} |
||||
} |
Loading…
Reference in new issue