initial work
This commit is contained in:
parent
7e61c5bc61
commit
b95f882956
5 changed files with 450 additions and 1 deletions
|
@ -228,7 +228,8 @@ object AutomationPlugin : PluginBase(PluginDescription()
|
||||||
TriggerLocation(),
|
TriggerLocation(),
|
||||||
TriggerAutosensValue(),
|
TriggerAutosensValue(),
|
||||||
TriggerBolusAgo(),
|
TriggerBolusAgo(),
|
||||||
TriggerPumpLastConnection()
|
TriggerPumpLastConnection(),
|
||||||
|
TriggerBTDevice()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,95 @@
|
||||||
|
package info.nightscout.androidaps.plugins.general.automation.elements;
|
||||||
|
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.AdapterView;
|
||||||
|
import android.widget.ArrayAdapter;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.Spinner;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
import info.nightscout.androidaps.MainApp;
|
||||||
|
import info.nightscout.androidaps.R;
|
||||||
|
import info.nightscout.androidaps.logging.L;
|
||||||
|
|
||||||
|
public class DropdownMenu extends Element {
|
||||||
|
private static Logger log = LoggerFactory.getLogger(L.AUTOMATION);
|
||||||
|
private ArrayList<CharSequence> itemList;
|
||||||
|
private String selected;
|
||||||
|
|
||||||
|
public DropdownMenu(String name) {
|
||||||
|
super();
|
||||||
|
this.selected = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DropdownMenu(DropdownMenu another) {
|
||||||
|
super();
|
||||||
|
selected = another.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addToLayout(LinearLayout root) {
|
||||||
|
if (itemList == null) {
|
||||||
|
log.error("ItemList is empty!");
|
||||||
|
itemList = new ArrayList<>();
|
||||||
|
}
|
||||||
|
ArrayAdapter<CharSequence> adapter = new ArrayAdapter<>(root.getContext(),
|
||||||
|
R.layout.spinner_centered, itemList);
|
||||||
|
Spinner spinner = new Spinner(root.getContext());
|
||||||
|
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||||
|
spinner.setAdapter(adapter);
|
||||||
|
LinearLayout.LayoutParams spinnerParams = new LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
);
|
||||||
|
spinnerParams.setMargins(0, MainApp.dpToPx(4), 0, MainApp.dpToPx(4));
|
||||||
|
spinner.setLayoutParams(spinnerParams);
|
||||||
|
spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
||||||
|
@Override
|
||||||
|
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
|
||||||
|
setValue(itemList.get(position).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNothingSelected(AdapterView<?> parent) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
spinner.setSelection(0);
|
||||||
|
LinearLayout l = new LinearLayout(root.getContext());
|
||||||
|
l.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
l.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||||
|
l.addView(spinner);
|
||||||
|
root.addView(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DropdownMenu setValue(String name) {
|
||||||
|
this.selected = name;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getValue() {
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setList(ArrayList<CharSequence> values){
|
||||||
|
if (itemList == null)
|
||||||
|
itemList = new ArrayList<>();
|
||||||
|
log.debug("values size is "+values.size());
|
||||||
|
itemList = new ArrayList<>(values);
|
||||||
|
log.debug("items size is "+itemList.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// For testing only
|
||||||
|
public void add(String item){
|
||||||
|
if (itemList == null)
|
||||||
|
itemList = new ArrayList<>();
|
||||||
|
itemList.add(item);
|
||||||
|
log.debug("Added " + item + "("+itemList.size()+")");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,227 @@
|
||||||
|
package info.nightscout.androidaps.plugins.general.automation.triggers;
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothAdapter;
|
||||||
|
import android.bluetooth.BluetoothDevice;
|
||||||
|
import android.bluetooth.BluetoothProfile;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
|
||||||
|
import androidx.fragment.app.FragmentManager;
|
||||||
|
|
||||||
|
import com.google.common.base.Optional;
|
||||||
|
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import info.nightscout.androidaps.MainApp;
|
||||||
|
import info.nightscout.androidaps.R;
|
||||||
|
import info.nightscout.androidaps.logging.L;
|
||||||
|
import info.nightscout.androidaps.plugins.general.automation.elements.ComparatorExists;
|
||||||
|
import info.nightscout.androidaps.plugins.general.automation.elements.InputString;
|
||||||
|
import info.nightscout.androidaps.plugins.general.automation.elements.LayoutBuilder;
|
||||||
|
import info.nightscout.androidaps.plugins.general.automation.elements.StaticLabel;
|
||||||
|
import info.nightscout.androidaps.plugins.general.automation.elements.DropdownMenu;
|
||||||
|
import info.nightscout.androidaps.utils.DateUtil;
|
||||||
|
import info.nightscout.androidaps.utils.JsonHelper;
|
||||||
|
import info.nightscout.androidaps.utils.T;
|
||||||
|
|
||||||
|
public class TriggerBTDevice extends Trigger {
|
||||||
|
private static Logger log = LoggerFactory.getLogger(L.AUTOMATION);
|
||||||
|
|
||||||
|
private InputString deviceName = new InputString();
|
||||||
|
DropdownMenu listOfDevices = new DropdownMenu("");
|
||||||
|
ComparatorExists comparator = new ComparatorExists();
|
||||||
|
private boolean connectedToDevice = false;
|
||||||
|
|
||||||
|
public TriggerBTDevice() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
private TriggerBTDevice(TriggerBTDevice TriggerBTDevice) {
|
||||||
|
super();
|
||||||
|
deviceName.setValue(TriggerBTDevice.deviceName.getValue());
|
||||||
|
comparator = new ComparatorExists(TriggerBTDevice.comparator);
|
||||||
|
connectedToDevice = TriggerBTDevice.connectedToDevice;
|
||||||
|
listOfDevices.setList(devicesPaired());
|
||||||
|
lastRun = TriggerBTDevice.lastRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComparatorExists getComparator() {
|
||||||
|
return comparator;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized boolean shouldRun() {
|
||||||
|
log.debug("Connected "+connectedToDevice+"! Time left "+ (5 - T.msecs(DateUtil.now()-lastRun).mins()));
|
||||||
|
if (lastRun > DateUtil.now() - T.mins(5).msecs())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
checkConnected();
|
||||||
|
|
||||||
|
if (connectedToDevice && comparator.getValue() == ComparatorExists.Compare.EXISTS) {
|
||||||
|
if (L.isEnabled(L.AUTOMATION))
|
||||||
|
log.debug("Ready for execution: " + friendlyDescription());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized String toJSON() {
|
||||||
|
JSONObject o = new JSONObject();
|
||||||
|
try {
|
||||||
|
o.put("type", TriggerBTDevice.class.getName());
|
||||||
|
JSONObject data = new JSONObject();
|
||||||
|
data.put("lastRun", lastRun);
|
||||||
|
data.put("comparator", comparator.getValue().toString());
|
||||||
|
if (!deviceName.getValue().equals(""))
|
||||||
|
data.put("name", deviceName.getValue());
|
||||||
|
else
|
||||||
|
data.put("name", listOfDevices.getValue());
|
||||||
|
|
||||||
|
o.put("data", data);
|
||||||
|
} catch (JSONException e) {
|
||||||
|
log.error("Unhandled exception", e);
|
||||||
|
}
|
||||||
|
log.debug("JSON saved "+o.toString());
|
||||||
|
return o.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
Trigger fromJSON(String data) {
|
||||||
|
try {
|
||||||
|
JSONObject d = new JSONObject(data);
|
||||||
|
lastRun = JsonHelper.safeGetLong(d, "lastRun");
|
||||||
|
deviceName.setValue(JsonHelper.safeGetString(d, "name"));
|
||||||
|
listOfDevices.setList(devicesPaired());
|
||||||
|
comparator.setValue(ComparatorExists.Compare.valueOf(JsonHelper.safeGetString(d, "comparator")));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Unhandled exception", e);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int friendlyName() {
|
||||||
|
return R.string.btdevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String friendlyDescription() {
|
||||||
|
return MainApp.gs(R.string.btdevicecompared, deviceName.getValue(), MainApp.gs(comparator.getValue().getStringRes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Integer> icon() {
|
||||||
|
return Optional.of(R.drawable.ic_bluetooth_white_48dp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Trigger duplicate() {
|
||||||
|
return new TriggerBTDevice(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
TriggerBTDevice lastRun(long lastRun) {
|
||||||
|
this.lastRun = lastRun;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TriggerBTDevice comparator(ComparatorExists.Compare compare) {
|
||||||
|
this.comparator = new ComparatorExists().setValue(compare);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void generateDialog(LinearLayout root, FragmentManager fragmentManager) {
|
||||||
|
ArrayList<CharSequence> pairedDevices = devicesPaired();
|
||||||
|
if (pairedDevices.size() == 0) {
|
||||||
|
// No list of paired devices comming from BT adapter -> show a text input
|
||||||
|
new LayoutBuilder()
|
||||||
|
.add(new StaticLabel(R.string.btdevice))
|
||||||
|
.add(deviceName)
|
||||||
|
.add(comparator)
|
||||||
|
.build(root);
|
||||||
|
} else {
|
||||||
|
listOfDevices.setList(pairedDevices);
|
||||||
|
new LayoutBuilder()
|
||||||
|
.add(new StaticLabel(R.string.btdevice))
|
||||||
|
.add(listOfDevices)
|
||||||
|
.add(comparator)
|
||||||
|
.build(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the list of paired BT devices to use in dropdown menu
|
||||||
|
private ArrayList<CharSequence> devicesPaired(){
|
||||||
|
BluetoothAdapter mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
|
||||||
|
ArrayList<CharSequence> s = new ArrayList<>();
|
||||||
|
if (mBluetoothAdapter == null)
|
||||||
|
return s;
|
||||||
|
Set<BluetoothDevice> pairedDevices = mBluetoothAdapter.getBondedDevices();
|
||||||
|
|
||||||
|
for(BluetoothDevice bt : pairedDevices) {
|
||||||
|
s.add(bt.getName());
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void checkConnected() {
|
||||||
|
BluetoothAdapter mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
|
||||||
|
|
||||||
|
if (mBluetoothAdapter == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
int state = mBluetoothAdapter.getProfileConnectionState(BluetoothProfile.HEADSET); // Checks only for connected HEADSET, no other type of BT devices
|
||||||
|
if (state != BluetoothProfile.STATE_CONNECTED) {
|
||||||
|
connectedToDevice = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Context context = MainApp.instance().getApplicationContext();
|
||||||
|
mBluetoothAdapter.getProfileProxy(context, serviceListener, BluetoothProfile.STATE_CONNECTED);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BluetoothProfile.ServiceListener serviceListener = new BluetoothProfile.ServiceListener()
|
||||||
|
{
|
||||||
|
@Override
|
||||||
|
public void onServiceDisconnected(int profile)
|
||||||
|
{ }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServiceConnected(int profile, BluetoothProfile proxy)
|
||||||
|
{
|
||||||
|
|
||||||
|
for (BluetoothDevice device : proxy.getConnectedDevices())
|
||||||
|
{
|
||||||
|
connectedToDevice = deviceName.getValue().equals(device.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
BluetoothAdapter.getDefaultAdapter().closeProfileProxy(profile, proxy);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public void setDeviceName(String newName) {
|
||||||
|
deviceName.setValue(newName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeviceName() {
|
||||||
|
return deviceName.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConnectedState(boolean newstate){
|
||||||
|
this.connectedToDevice = newstate;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1390,6 +1390,8 @@
|
||||||
<string name="exists">exists</string>
|
<string name="exists">exists</string>
|
||||||
<string name="notexists">not exists</string>
|
<string name="notexists">not exists</string>
|
||||||
<string name="temptargetcompared">Temp target %1$s</string>
|
<string name="temptargetcompared">Temp target %1$s</string>
|
||||||
|
<string name="btdevicecompared">Bluetooth connection to device %1$s %2$s</string>
|
||||||
|
<string name="btdevice">Connection to Bluetooth device </string>
|
||||||
<string name="wifissidcompared">WiFi SSID %1$s %2$s</string>
|
<string name="wifissidcompared">WiFi SSID %1$s %2$s</string>
|
||||||
<string name="autosenscompared">Autosens %1$s %2$s %%</string>
|
<string name="autosenscompared">Autosens %1$s %2$s %%</string>
|
||||||
<string name="autosenslabel">Autosens %</string>
|
<string name="autosenslabel">Autosens %</string>
|
||||||
|
|
|
@ -0,0 +1,124 @@
|
||||||
|
package info.nightscout.androidaps.plugins.general.automation.triggers;
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothAdapter;
|
||||||
|
import android.bluetooth.BluetoothProfile;
|
||||||
|
|
||||||
|
import com.google.common.base.Optional;
|
||||||
|
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
import org.powermock.api.mockito.PowerMockito;
|
||||||
|
import org.powermock.core.classloader.annotations.PrepareForTest;
|
||||||
|
import org.powermock.modules.junit4.PowerMockRunner;
|
||||||
|
|
||||||
|
import info.AAPSMocker;
|
||||||
|
import info.nightscout.androidaps.MainApp;
|
||||||
|
import info.nightscout.androidaps.R;
|
||||||
|
import info.nightscout.androidaps.logging.L;
|
||||||
|
import info.nightscout.androidaps.plugins.configBuilder.ProfileFunctions;
|
||||||
|
import info.nightscout.androidaps.plugins.general.automation.elements.ComparatorExists;
|
||||||
|
import info.nightscout.androidaps.plugins.iob.iobCobCalculator.IobCobCalculatorPlugin;
|
||||||
|
import info.nightscout.androidaps.utils.DateUtil;
|
||||||
|
import info.nightscout.androidaps.utils.SP;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.powermock.api.mockito.PowerMockito.when;
|
||||||
|
|
||||||
|
@RunWith(PowerMockRunner.class)
|
||||||
|
@PrepareForTest({MainApp.class, ProfileFunctions.class, DateUtil.class, IobCobCalculatorPlugin.class, SP.class, L.class, BluetoothAdapter.class, BluetoothProfile.class})
|
||||||
|
public class TriggerBTDeviceTest {
|
||||||
|
|
||||||
|
long now = 1514766900000L;
|
||||||
|
String someName = "Headset";
|
||||||
|
String btJson = "{\"data\":{\"comparator\":\"EXISTS\",\"lastRun\":0,\"name\":\"Headset\"},\"type\":\"info.nightscout.androidaps.plugins.general.automation.triggers.TriggerBTDevice\"}";
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void mock() {
|
||||||
|
AAPSMocker.mockMainApp();
|
||||||
|
AAPSMocker.mockProfileFunctions();
|
||||||
|
AAPSMocker.mockSP();
|
||||||
|
AAPSMocker.mockL();
|
||||||
|
|
||||||
|
PowerMockito.mockStatic(DateUtil.class);
|
||||||
|
when(DateUtil.now()).thenReturn(now);
|
||||||
|
when(SP.getInt(any(), any())).thenReturn(48);
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void comparator() {
|
||||||
|
TriggerBTDevice t = new TriggerBTDevice().comparator(ComparatorExists.Compare.EXISTS);
|
||||||
|
Assert.assertEquals(t.comparator.getValue(), ComparatorExists.Compare.EXISTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldRun() {
|
||||||
|
BluetoothAdapter btAdapter = PowerMockito.mock(BluetoothAdapter.class);
|
||||||
|
PowerMockito.mockStatic(BluetoothAdapter.class);
|
||||||
|
PowerMockito.mockStatic(BluetoothProfile.class);
|
||||||
|
TriggerBTDevice t = new TriggerBTDevice().comparator(ComparatorExists.Compare.EXISTS);
|
||||||
|
Assert.assertFalse(t.shouldRun()); // no bluetooth adapter
|
||||||
|
when(BluetoothAdapter.getDefaultAdapter()).thenReturn(btAdapter);
|
||||||
|
when(btAdapter.isEnabled()).thenReturn(true);
|
||||||
|
Assert.assertFalse(t.shouldRun()); // no device connected
|
||||||
|
when(btAdapter.getProfileConnectionState(BluetoothProfile.HEADSET)).thenReturn(BluetoothProfile.STATE_CONNECTED);
|
||||||
|
t.setDeviceName(someName);
|
||||||
|
Assert.assertFalse(t.shouldRun()); // no mocked connection
|
||||||
|
t.setConnectedState(true);
|
||||||
|
Assert.assertTrue(t.shouldRun());
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void toJSON() {
|
||||||
|
TriggerBTDevice t = new TriggerBTDevice().comparator(ComparatorExists.Compare.EXISTS);
|
||||||
|
t.setDeviceName(someName);
|
||||||
|
Assert.assertEquals(btJson, t.toJSON());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void fromJSON() throws JSONException {
|
||||||
|
TriggerBTDevice t = new TriggerBTDevice().comparator(ComparatorExists.Compare.EXISTS);
|
||||||
|
t.setDeviceName(someName);
|
||||||
|
TriggerBTDevice t2 = (TriggerBTDevice) Trigger.instantiate(new JSONObject(t.toJSON()));
|
||||||
|
Assert.assertEquals(ComparatorExists.Compare.EXISTS, t2.getComparator().getValue());
|
||||||
|
Assert.assertEquals("Headset", t2.getDeviceName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void friendlyName() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void friendlyDescription() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void icon() {
|
||||||
|
Assert.assertEquals(Optional.of(R.drawable.ic_bluetooth_white_48dp), new TriggerBTDevice().icon());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void duplicate() {
|
||||||
|
TriggerBTDevice t = new TriggerBTDevice().comparator(ComparatorExists.Compare.EXISTS);
|
||||||
|
t.setDeviceName(someName);
|
||||||
|
TriggerBTDevice t1 = (TriggerBTDevice) t.duplicate();
|
||||||
|
Assert.assertEquals("Headset", t1.getDeviceName());
|
||||||
|
Assert.assertEquals(ComparatorExists.Compare.EXISTS, t.getComparator().getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void lastRun() {
|
||||||
|
TriggerBTDevice t = new TriggerBTDevice().lastRun(now);
|
||||||
|
Assert.assertEquals(now, t.lastRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void generateDialog() {
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue